Merge dev into sqlite2

This commit is contained in:
Dax Raad
2026-01-27 16:44:06 -05:00
213 changed files with 8960 additions and 3116 deletions
-2
View File
@@ -3,5 +3,3 @@ preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]
timeout = 10000 # 10 seconds (default is 5000ms)
# Enable code coverage
coverage = true
+2 -2
View File
@@ -85,8 +85,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.74",
"@opentui/solid": "0.1.74",
"@opentui/core": "0.1.75",
"@opentui/solid": "0.1.75",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
+2
View File
@@ -73,6 +73,7 @@ export namespace Agent {
const result: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: PermissionNext.merge(
defaults,
@@ -87,6 +88,7 @@ export namespace Agent {
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: PermissionNext.merge(
defaults,
@@ -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) {
+10
View File
@@ -570,6 +570,16 @@ function App() {
dialog.clear()
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
dialog.clear()
},
},
])
createEffect(() => {
@@ -127,7 +127,7 @@ function AutoMethod(props: AutoMethodProps) {
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.url
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -145,7 +145,7 @@ export function Session() {
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const wide = createMemo(() => dimensions().width > 120)
@@ -538,18 +538,6 @@ export function Session() {
dialog.clear()
},
},
{
title: diffWrapMode() === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "session.toggle.diffwrap",
category: "Session",
slash: {
name: "diffwrap",
},
onSelect: (dialog) => {
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
dialog.clear()
},
},
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
@@ -1705,10 +1693,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>
</>
)
}
+7 -3
View File
@@ -206,7 +206,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const changedFiles: Info[] = []
@@ -223,7 +227,7 @@ export namespace File {
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
@@ -248,7 +252,7 @@ export namespace File {
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
+21 -6
View File
@@ -26,6 +26,9 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const info = await getAuth()
if (!info || info.type !== "oauth") return {}
const enterpriseUrl = info.enterpriseUrl
const baseURL = enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : undefined
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
@@ -36,16 +39,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
write: 0,
},
}
// TODO: move some of this hacky-ness to models.dev presets once we have better grasp of things here...
const base = baseURL ?? model.api.url
const claude = model.id.includes("claude")
const url = iife(() => {
if (!claude) return base
if (base.endsWith("/v1")) return base
if (base.endsWith("/")) return `${base}v1`
return `${base}/v1`
})
model.api.url = url
model.api.npm = claude ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"
}
}
const enterpriseUrl = info.enterpriseUrl
const baseURL = enterpriseUrl
? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
: "https://api.githubcopilot.com"
return {
baseURL,
apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth()
@@ -267,6 +277,11 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
},
"chat.headers": async (input, output) => {
if (!input.model.providerID.includes("github-copilot")) return
if (input.model.api.npm === "@ai-sdk/anthropic") {
output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
}
const session = await sdk.session
.get({
path: {
+5 -4
View File
@@ -132,6 +132,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
@@ -141,6 +142,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
@@ -601,10 +603,7 @@ export namespace Provider {
api: {
id: model.id,
url: provider.api!,
npm: iife(() => {
if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot"
return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"
}),
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
headers: model.headers ?? {},
@@ -924,6 +923,8 @@ export namespace Provider {
)
delete provider.models[modelID]
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
// Filter out disabled variants from config
const configVariants = configProvider?.models?.[modelID]?.variants
if (configVariants && model.variants) {
+5 -3
View File
@@ -284,7 +284,10 @@ export namespace ProviderTransform {
if (id.includes("glm-4.7")) return 1.0
if (id.includes("minimax-m2")) return 1.0
if (id.includes("kimi-k2")) {
if (id.includes("thinking")) return 1.0
// kimi-k2-thinking & kimi-k2.5
if (id.includes("thinking") || id.includes("k2.")) {
return 1.0
}
return 0.6
}
return undefined
@@ -293,10 +296,9 @@ export namespace ProviderTransform {
export function topP(model: Provider.Model) {
const id = model.id.toLowerCase()
if (id.includes("qwen")) return 1
if (id.includes("minimax-m2")) {
if (id.includes("minimax-m2") || id.includes("kimi-k2.5") || id.includes("gemini")) {
return 0.95
}
if (id.includes("gemini")) return 0.95
return undefined
}
+11 -9
View File
@@ -634,11 +634,18 @@ export namespace Session {
metadata: z.custom<ProviderMetadata>().optional(),
}),
(input) => {
const cachedInputTokens = input.usage.cachedInputTokens ?? 0
const cacheReadInputTokens = input.usage.cachedInputTokens ?? 0
const cacheWriteInputTokens = (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = excludesCachedTokens
? (input.usage.inputTokens ?? 0)
: (input.usage.inputTokens ?? 0) - cachedInputTokens
: (input.usage.inputTokens ?? 0) - cacheReadInputTokens - cacheWriteInputTokens
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return value
@@ -649,13 +656,8 @@ export namespace Session {
output: safe(input.usage.outputTokens ?? 0),
reasoning: safe(input.usage?.reasoningTokens ?? 0),
cache: {
write: safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0) as number,
),
read: safe(cachedInputTokens),
write: safe(cacheWriteInputTokens),
read: safe(cacheReadInputTokens),
},
}
@@ -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
}
}
+14 -8
View File
@@ -150,14 +150,20 @@ export namespace LLM {
},
)
const maxOutputTokens = isCodex
? undefined
: ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
)
const maxOutputTokens = isCodex ? undefined : undefined
log.info("max_output_tokens", {
tokens: ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
),
modelOptions: params.options,
outputLimit: input.model.limit.output,
})
// tokens = 32000
// outputLimit = 64000
// modelOptions={"reasoningEffort":"minimal"}
const tools = await resolveTools(input)
+9 -2
View File
@@ -674,7 +674,7 @@ export namespace MessageV2 {
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
}),
async (input) => {
async (input): Promise<WithParts> => {
const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get())
if (!row) throw new Error(`Message not found: ${input.messageID}`)
const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
@@ -702,6 +702,13 @@ export namespace MessageV2 {
return result
}
const isOpenAiErrorRetryable = (e: APICallError) => {
const status = e.statusCode
if (!status) return e.isRetryable
// openai sometimes returns 404 for models that are actually available
return status === 404 || e.isRetryable
}
export function fromError(e: unknown, ctx: { providerID: string }) {
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
@@ -770,7 +777,7 @@ export namespace MessageV2 {
{
message,
statusCode: e.statusCode,
isRetryable: e.isRetryable,
isRetryable: ctx.providerID.startsWith("openai") ? isOpenAiErrorRetryable(e) : e.isRetryable,
responseHeaders: e.responseHeaders,
responseBody: e.responseBody,
metadata,
+8 -1
View File
@@ -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"
@@ -384,6 +385,7 @@ export namespace SessionPrompt {
abort,
callID: part.callID,
extra: { bypassAgentCheck: true },
messages: msgs,
async metadata(input) {
await Session.updatePart({
...part,
@@ -559,6 +561,7 @@ export namespace SessionPrompt {
tools: lastUser.tools,
processor,
bypassAgentCheck,
messages: msgs,
})
if (step === 1) {
@@ -596,7 +599,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
@@ -648,6 +651,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> = {}
@@ -659,6 +663,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") {
@@ -1006,6 +1011,7 @@ export namespace SessionPrompt {
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, model },
messages: [],
metadata: async () => {},
ask: async () => {},
}
@@ -1067,6 +1073,7 @@ export namespace SessionPrompt {
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true },
messages: [],
metadata: async () => {},
ask: async () => {},
}
+33 -20
View File
@@ -1,5 +1,6 @@
import type { NamedError } from "@opencode-ai/util/error"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
export namespace SessionRetry {
export const RETRY_INITIAL_DELAY = 2000
@@ -63,28 +64,40 @@ export namespace SessionRetry {
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}
if (typeof error.data?.message === "string") {
const json = iife(() => {
try {
const json = JSON.parse(error.data.message)
if (json.type === "error" && json.error?.type === "too_many_requests") {
return "Too Many Requests"
if (typeof error.data?.message === "string") {
const parsed = JSON.parse(error.data.message)
return parsed
}
if (json.code.includes("exhausted") || json.code.includes("unavailable")) {
return "Provider is overloaded"
}
if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
return "Rate Limited"
}
if (
json.error?.message?.includes("no_kv_space") ||
(json.type === "error" && json.error?.type === "server_error") ||
!!json.error
) {
return "Provider Server Error"
}
} catch {}
}
return undefined
return JSON.parse(error.data.message)
} catch {
return undefined
}
})
try {
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
if (json.type === "error" && json.error?.type === "too_many_requests") {
return "Too Many Requests"
}
if (code.includes("exhausted") || code.includes("unavailable")) {
return "Provider is overloaded"
}
if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
return "Rate Limited"
}
if (
json.error?.message?.includes("no_kv_space") ||
(json.type === "error" && json.error?.type === "server_error") ||
!!json.error
) {
return "Provider Server Error"
}
} catch {
return undefined
}
}
}
+68 -5
View File
@@ -20,6 +20,62 @@ import { Agent } from "@/agent/agent"
export namespace SessionSummary {
const log = Log.create({ service: "session.summary" })
function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
for (let i = 0; i < body.length; i++) {
const char = body[i]!
if (char !== "\\") {
bytes.push(char.charCodeAt(0))
continue
}
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
}
return Buffer.from(bytes).toString()
}
export const summarize = fn(
z.object({
sessionID: z.string(),
@@ -117,11 +173,18 @@ export namespace SessionSummary {
messageID: Identifier.schema("message").optional(),
}),
async (input) => {
try {
return await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
} catch {
return []
}
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
const next = diffs.map((item) => {
const file = unquoteGitPath(item.file)
if (file === item.file) return item
return {
...item,
file,
}
})
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {})
return next
},
)
-100
View File
@@ -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() {
@@ -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))
}
}
+2 -2
View File
@@ -163,7 +163,7 @@ export namespace Snapshot {
const git = gitdir()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const result =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
@@ -196,7 +196,7 @@ export namespace Snapshot {
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []
for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
+8 -7
View File
@@ -185,7 +185,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
})
// Apply the changes
const changedFiles: string[] = []
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
@@ -194,12 +194,12 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
updates.push({ file: change.filePath, event: "add" })
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
updates.push({ file: change.filePath, event: "change" })
break
case "move":
@@ -208,13 +208,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
await fs.writeFile(change.movePath, change.newContent, "utf-8")
await fs.unlink(change.filePath)
changedFiles.push(change.movePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
}
break
case "delete":
await fs.unlink(change.filePath)
changedFiles.push(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
break
}
@@ -226,8 +227,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
}
// Publish file change events
for (const filePath of changedFiles) {
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
for (const update of updates) {
await Bus.publish(FileWatcher.Event.Updated, update)
}
// Notify LSP of file changes and collect diagnostics
+10
View File
@@ -10,6 +10,7 @@ import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
@@ -48,6 +49,7 @@ export const EditTool = Tool.define("edit", {
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Bun.file(filePath).exists()
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
@@ -63,6 +65,10 @@ export const EditTool = Tool.define("edit", {
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
FileTime.read(ctx.sessionID, filePath)
return
}
@@ -92,6 +98,10 @@ export const EditTool = Tool.define("edit", {
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await file.text()
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
+10 -1
View File
@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -23,7 +24,7 @@ export const ReadTool = Tool.define("read", {
async execute(params, ctx) {
let filepath = params.filePath
if (!path.isAbsolute(filepath)) {
filepath = path.join(process.cwd(), filepath)
filepath = path.resolve(Instance.directory, filepath)
}
const title = path.relative(Instance.worktree, filepath)
@@ -59,6 +60,8 @@ export const ReadTool = Tool.define("read", {
throw new Error(`File not found: ${filepath}`)
}
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const isImage =
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
@@ -72,6 +75,7 @@ export const ReadTool = Tool.define("read", {
metadata: {
preview: msg,
truncated: false,
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
},
attachments: [
{
@@ -133,12 +137,17 @@ export const ReadTool = Tool.define("read", {
LSP.touchFile(filepath, false)
FileTime.read(ctx.sessionID, filepath)
if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
}
return {
title,
output,
metadata: {
preview,
truncated,
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
},
}
},
+7 -2
View File
@@ -16,7 +16,7 @@ import { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Config } from "../config/config"
import path from "path"
import { type ToolDefinition } from "@opencode-ai/plugin"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
import { WebSearchTool } from "./websearch"
@@ -67,7 +67,12 @@ export namespace ToolRegistry {
parameters: z.object(def.args),
description: def.description,
execute: async (args, ctx) => {
const result = await def.execute(args as any, ctx)
const pluginCtx = {
...ctx,
directory: Instance.directory,
worktree: Instance.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
+1
View File
@@ -20,6 +20,7 @@ export namespace Tool {
abort: AbortSignal
callID?: string
extra?: { [key: string]: any }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
}
+5
View File
@@ -6,6 +6,7 @@ import { createTwoFilesPatch } from "diff"
import DESCRIPTION from "./write.txt"
import { Bus } from "../bus"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
@@ -45,6 +46,10 @@ export const WriteTool = Tool.define("write", {
await Bus.publish(File.Event.Edited, {
file: filepath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
})
FileTime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { InstructionPrompt } from "../../src/session/instruction"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("InstructionPrompt.resolve", () => {
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await InstructionPrompt.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
expect(results).toEqual([])
},
})
})
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await InstructionPrompt.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
})
})
})
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import { APICallError } from "ai"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -10,6 +12,10 @@ function apiError(headers?: Record<string, string>): MessageV2.APIError {
}).toObject() as MessageV2.APIError
}
function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
return { data: { message } } as ReturnType<NamedError["toObject"]>
}
describe("session.retry.delay", () => {
test("caps delay at 30 seconds when headers missing", () => {
const error = apiError()
@@ -80,6 +86,34 @@ describe("session.retry.delay", () => {
})
})
describe("session.retry.retryable", () => {
test("maps too_many_requests json messages", () => {
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
expect(SessionRetry.retryable(error)).toBe("Too Many Requests")
})
test("maps overloaded provider codes", () => {
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
})
test("handles json messages without code", () => {
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
expect(SessionRetry.retryable(error)).toBe("Provider Server Error")
})
test("does not throw on numeric error codes", () => {
const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
const result = SessionRetry.retryable(error)
expect(result).toBeUndefined()
})
test("returns undefined for non-json message", () => {
const error = wrap("not-json")
expect(SessionRetry.retryable(error)).toBeUndefined()
})
})
describe("session.message-v2.fromError", () => {
test.concurrent(
"converts ECONNRESET socket errors to retryable APIError",
@@ -128,4 +162,18 @@ describe("session.message-v2.fromError", () => {
expect(retryable).toBeDefined()
expect(retryable).toBe("Connection reset by server")
})
test("marks OpenAI 404 status codes as retryable", () => {
const error = new APICallError({
message: "boom",
url: "https://api.openai.com/v1/chat/completions",
requestBodyValues: {},
statusCode: 404,
responseHeaders: { "content-type": "application/json" },
responseBody: '{"error":"boom"}',
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID: "openai" }) as MessageV2.APIError
expect(result.data.isRetryable).toBe(true)
})
})
@@ -11,6 +11,7 @@ const baseCtx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
}
+1
View File
@@ -12,6 +12,7 @@ const ctx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
@@ -11,6 +11,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
}
+1
View File
@@ -10,6 +10,7 @@ const ctx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
@@ -9,6 +9,7 @@ const ctx = {
callID: "test-call",
agent: "test-agent",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
+24
View File
@@ -14,6 +14,7 @@ const ctx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
@@ -330,3 +331,26 @@ root_type Monster;`
})
})
})
describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
},
})
})
})