Skip to content
Draft
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
20 changes: 20 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const totalInput = last.tokens.input + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
cacheHitPercent: totalInput > 0 ? ((last.tokens.cache.read / totalInput) * 100).toFixed(3) : null,
cacheRead: last.tokens.cache.read,
cacheWrite: last.tokens.cache.write,
cacheNew: last.tokens.input,
cacheInput: totalInput,
cacheOutput: last.tokens.output,
}
})

Expand Down Expand Up @@ -106,6 +113,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={process.env["OPENCODE_CACHE_AUDIT"] && context()?.cacheHitPercent != null}>
<box>
<text fg={theme.text}>
<b>Cache Audit</b>
</text>
<text fg={theme.textMuted}>{context()!.cacheInput.toLocaleString()} input tokens</text>
<text fg={theme.textMuted}> {context()!.cacheNew.toLocaleString()} new</text>
<text fg={theme.textMuted}> {context()!.cacheRead.toLocaleString()} cache read</text>
<text fg={theme.textMuted}> {context()!.cacheWrite.toLocaleString()} cache write</text>
<text fg={theme.textMuted}>{context()!.cacheHitPercent}% hit rate</text>
<text fg={theme.textMuted}>{context()!.cacheOutput.toLocaleString()} output tokens</text>
</box>
</Show>
<Show when={mcpEntries().length > 0}>
<box>
<box
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION = truthy("OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION")
export const OPENCODE_EXPERIMENTAL_CACHE_1H_TTL = truthy("OPENCODE_EXPERIMENTAL_CACHE_1H_TTL")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
Expand Down
13 changes: 9 additions & 4 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,12 @@ export namespace ProviderTransform {
return msgs
}

function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
function applyCaching(msgs: ModelMessage[], model: Provider.Model, extendedTTL?: boolean): ModelMessage[] {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)

// Use 1h cache TTL on first system block (2x write cost vs 1.25x for default 5-min)
const cache = extendedTTL ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" }
const providerOptions = {
anthropic: {
cacheControl: { type: "ephemeral" },
Expand All @@ -194,18 +196,21 @@ export namespace ProviderTransform {
}

for (const msg of unique([...system, ...final])) {
const options = msg === system[0]
? { ...providerOptions, anthropic: { cacheControl: cache } }
: providerOptions
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0

if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
if (lastContent && typeof lastContent === "object") {
lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, options)
continue
}
}

msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, options)
}

return msgs
Expand Down Expand Up @@ -261,7 +266,7 @@ export namespace ProviderTransform {
model.api.npm === "@ai-sdk/anthropic") &&
model.api.npm !== "@ai-sdk/gateway"
) {
msgs = applyCaching(msgs, model)
msgs = applyCaching(msgs, model, (options.extendedTTL as boolean) ?? Flag.OPENCODE_EXPERIMENTAL_CACHE_1H_TTL)
}

// Remap providerOptions keys from stored providerID to expected SDK key
Expand Down
42 changes: 29 additions & 13 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,15 @@ export namespace InstructionPrompt {

export async function systemPaths() {
const config = await Config.get()
const paths = new Set<string>()
const global = new Set<string>()
const project = 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))
project.add(path.resolve(p))
})
break
}
Expand All @@ -87,7 +88,7 @@ export namespace InstructionPrompt {

for (const file of globalFiles()) {
if (await Filesystem.exists(file)) {
paths.add(path.resolve(file))
global.add(path.resolve(file))
break
}
}
Expand All @@ -106,22 +107,29 @@ export namespace InstructionPrompt {
}).catch(() => [])
: await resolveRelative(instruction)
matches.forEach((p) => {
paths.add(path.resolve(p))
project.add(path.resolve(p))
})
}
}

return paths
return { global, project }
}

export async function system() {
const config = await Config.get()
export type SystemInstructions = { global: string[]; project: string[] }

let cached: SystemInstructions | undefined

export async function system(): Promise<SystemInstructions> {
if (Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION && cached) return cached

const paths = await systemPaths()
const config = await Config.get()

const files = Array.from(paths).map(async (p) => {
const content = await Filesystem.readText(p).catch(() => "")
return content ? "Instructions from: " + p + "\n" + content : ""
})
const readPaths = (set: Set<string>) =>
Array.from(set).map(async (p) => {
const content = await Filesystem.readText(p).catch(() => "")
return content ? "Instructions from: " + p + "\n" + content : ""
})

const urls: string[] = []
if (config.instructions) {
Expand All @@ -138,7 +146,14 @@ export namespace InstructionPrompt {
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
)

return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
const [global, project] = await Promise.all([
Promise.all(readPaths(paths.global)).then((r) => r.filter(Boolean)),
Promise.all([...readPaths(paths.project), ...fetches]).then((r) => r.filter(Boolean)),
])

const result = { global, project }
if (Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION) cached = result
return result
}

export function loaded(messages: MessageV2.WithParts[]) {
Expand Down Expand Up @@ -166,7 +181,8 @@ export namespace InstructionPrompt {
}

export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
const system = await systemPaths()
const paths = await systemPaths()
const system = new Set([...paths.global, ...paths.project])
const already = loaded(messages)
const results: { filepath: string; content: string }[] = []

Expand Down
36 changes: 22 additions & 14 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export namespace LLM {
model: Provider.Model
agent: Agent.Info
system: string[]
systemSplit?: number
abort: AbortSignal
messages: ModelMessage[]
small?: boolean
Expand Down Expand Up @@ -64,20 +65,27 @@ export namespace LLM {
])
const isCodex = provider.id === "openai" && auth?.type === "oauth"

const system = []
system.push(
[
// use agent prompt otherwise provider prompt
// For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
// use agent prompt otherwise provider prompt
// For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
const prompt = input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)
const split = input.systemSplit ?? input.system.length
const system = [
// block 1: provider/agent prompt + global instructions (stable across repos)
[...prompt, ...input.system.slice(0, split)].filter(Boolean).join("\n"),
// block 2: env + project instructions + any custom prompt from last user message (dynamic)
[...input.system.slice(split), ...(input.user.system ? [input.user.system] : [])].filter(Boolean).join("\n"),
].filter(Boolean)

// For non-Anthropic native API providers (OpenAI, OpenAI-compatible, llama-server, etc.),
// join system blocks into a single message to avoid "system message must be at the beginning"
// errors. Only Anthropic native API benefits from the 2-block split for cache marker placement.
const native = input.model.api.npm === "@ai-sdk/anthropic" ||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
if (!native && system.length > 1) {
const joined = system.join("\n")
system.length = 0
system.push(joined)
}

const header = system[0]
await Plugin.trigger(
Expand Down
12 changes: 5 additions & 7 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,13 +650,10 @@ export namespace SessionPrompt {

await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })

// Build system prompt, adding structured output instruction if needed
const skills = await SystemPrompt.skills(agent)
const system = [
...(await SystemPrompt.environment(model)),
...(skills ? [skills] : []),
...(await InstructionPrompt.system()),
]
// Build system prompt: global instructions first (stable), then env + project (dynamic)
const instructions = await InstructionPrompt.system()
const system = [...instructions.global, ...(await SystemPrompt.environment(model)), ...instructions.project]
const systemSplit = instructions.global.length
const format = lastUser.format ?? { type: "text" }
if (format.type === "json_schema") {
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
Expand All @@ -668,6 +665,7 @@ export namespace SessionPrompt {
abort,
sessionID,
system,
systemSplit,
messages: [
...MessageV2.toModelMessages(msgs, model),
...(isLastStep
Expand Down
26 changes: 7 additions & 19 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next"
import { Skill } from "@/skill"
import { Flag } from "@/flag/flag"

export namespace SystemPrompt {
export function instructions() {
Expand All @@ -29,18 +27,22 @@ export namespace SystemPrompt {
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}

let frozen: Date | undefined

export async function environment(model: Provider.Model) {
const project = Instance.project
const date = Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION
? (frozen ??= new Date())
: new Date()
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
` Today's date: ${date.toDateString()}`,
`</env>`,
`<directories>`,
` ${
Expand All @@ -55,18 +57,4 @@ export namespace SystemPrompt {
].join("\n"),
]
}

export async function skills(agent: Agent.Info) {
if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return

const list = await Skill.available(agent)

return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}
}
29 changes: 0 additions & 29 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ import { Bus } from "@/bus"
import { Session } from "@/session"
import { Discovery } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next"

export namespace Skill {
const log = Log.create({ service: "skill" })
Expand Down Expand Up @@ -189,30 +186,4 @@ export namespace Skill {
export async function dirs() {
return state().then((x) => x.dirs)
}

export async function available(agent?: Agent.Info) {
const list = await all()
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
}

export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) {
return "No skills are currently available."
}
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`,
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
}
9 changes: 5 additions & 4 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,17 @@ export const BashTool = Tool.define("bash", async () => {
log.info("bash tool using shell", { shell })

return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
description: DESCRIPTION.replaceAll("${maxLines}", String(Truncate.MAX_LINES)).replaceAll(
"${maxBytes}",
String(Truncate.MAX_BYTES),
),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
`The working directory to run the command in. Defaults to the current working directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/bash.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.

All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.
All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.

IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.

Expand Down
Loading