Compare commits

...

7 Commits

Author SHA1 Message Date
Dax Raad 1729c310d9 switch global config to ~/.config/opencode/opencode.json 2025-07-11 20:51:23 -04:00
Dax Raad 0130190bbd docs: add model docs 2025-07-11 20:33:06 -04:00
Aiden Cline 97a31ddffc tweak: plan interactions should match web (TUI) (#895) 2025-07-11 18:03:22 -04:00
zWing 3249420ad1 fix: avoid overwriting the provider.option.baseURL (#880) 2025-07-11 18:01:28 -04:00
Dax Raad 4bb8536d34 introduce cache version concept for auto cleanup when breaking cache changes happen 2025-07-11 17:50:49 -04:00
Jay c73d4a137e docs: Update troubleshooting.mdx 2025-07-11 17:50:25 -04:00
Dax Raad 57ac8f2741 wip: stats 2025-07-11 17:37:41 -04:00
11 changed files with 262 additions and 23 deletions
+6 -4
View File
@@ -65,10 +65,12 @@ export namespace BunProc {
}))
if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
await BunProc.run(
["add", "--exact", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org", pkg + "@" + version],
{
cwd: Global.Path.cache,
},
).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
+179
View File
@@ -0,0 +1,179 @@
import { Storage } from "../../storage/storage"
import { MessageV2 } from "../../session/message-v2"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
interface SessionStats {
totalSessions: number
totalMessages: number
totalCost: number
totalTokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
toolUsage: Record<string, number>
dateRange: {
earliest: number
latest: number
}
days: number
costPerDay: number
}
export const StatsCommand = cmd({
command: "stats",
describe: "analyze and display statistics from message-v2 format",
handler: async () => {
await bootstrap({ cwd: process.cwd() }, async () => {
const stats: SessionStats = {
totalSessions: 0,
totalMessages: 0,
totalCost: 0,
totalTokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
toolUsage: {},
dateRange: {
earliest: Date.now(),
latest: 0,
},
days: 0,
costPerDay: 0,
}
const sessionMap = new Map<string, number>()
try {
for await (const messagePath of Storage.list("session/message")) {
try {
const message = await Storage.readJSON<MessageV2.Info>(messagePath)
if (!message.parts.find((part) => part.type === "step-finish")) continue
stats.totalMessages++
const sessionId = message.sessionID
sessionMap.set(sessionId, (sessionMap.get(sessionId) || 0) + 1)
if (message.time.created < stats.dateRange.earliest) {
stats.dateRange.earliest = message.time.created
}
if (message.time.created > stats.dateRange.latest) {
stats.dateRange.latest = message.time.created
}
if (message.role === "assistant") {
stats.totalCost += message.cost
stats.totalTokens.input += message.tokens.input
stats.totalTokens.output += message.tokens.output
stats.totalTokens.reasoning += message.tokens.reasoning
stats.totalTokens.cache.read += message.tokens.cache.read
stats.totalTokens.cache.write += message.tokens.cache.write
for (const part of message.parts) {
if (part.type === "tool") {
stats.toolUsage[part.tool] = (stats.toolUsage[part.tool] || 0) + 1
}
}
}
} catch (e) {
continue
}
}
} catch (e) {
console.error("Failed to read storage:", e)
return
}
stats.totalSessions = sessionMap.size
if (stats.dateRange.latest > 0) {
const daysDiff = (stats.dateRange.latest - stats.dateRange.earliest) / (1000 * 60 * 60 * 24)
stats.days = Math.max(1, Math.ceil(daysDiff))
stats.costPerDay = stats.totalCost / stats.days
}
displayStats(stats)
})
},
})
function displayStats(stats: SessionStats) {
const width = 56
function renderRow(label: string, value: string): string {
const availableWidth = width - 1
const paddingNeeded = availableWidth - label.length - value.length
const padding = Math.max(0, paddingNeeded)
return `${label}${" ".repeat(padding)}${value}`
}
// Overview section
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ OVERVIEW │")
console.log("├────────────────────────────────────────────────────────┤")
console.log(renderRow("Sessions", stats.totalSessions.toLocaleString()))
console.log(renderRow("Messages", stats.totalMessages.toLocaleString()))
console.log(renderRow("Days", stats.days.toString()))
console.log("└────────────────────────────────────────────────────────┘")
console.log()
// Cost & Tokens section
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ COST & TOKENS │")
console.log("├────────────────────────────────────────────────────────┤")
const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost
const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay
console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`))
console.log(renderRow("Cost/Day", `$${costPerDay.toFixed(2)}`))
console.log(renderRow("Input", formatNumber(stats.totalTokens.input)))
console.log(renderRow("Output", formatNumber(stats.totalTokens.output)))
console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read)))
console.log(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write)))
console.log("└────────────────────────────────────────────────────────┘")
console.log()
// Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ TOOL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
const maxCount = Math.max(...sortedTools.map(([, count]) => count))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
for (const [tool, count] of sortedTools) {
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
const bar = "█".repeat(barLength)
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
const content = ` ${tool.padEnd(10)} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
const padding = Math.max(0, width - content.length)
console.log(`${content}${" ".repeat(padding)}`)
}
console.log("└────────────────────────────────────────────────────────┘")
}
console.log()
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
+15 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod"
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep } from "remeda"
import { mergeDeep, pipe } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
@@ -175,7 +175,11 @@ export namespace Config {
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result = await load(path.join(Global.Path.config, "config.json"))
let result = pipe(
{},
mergeDeep(await load(path.join(Global.Path.config, "config.json"))),
mergeDeep(await load(path.join(Global.Path.config, "opencode.json"))),
)
await import(path.join(Global.Path.config, "config"), {
with: {
@@ -199,9 +203,10 @@ export namespace Config {
let text = await Bun.file(configPath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return "{}"
if (err.code === "ENOENT") return
throw new JsonError({ path: configPath }, { cause: err })
})
if (!text) return {}
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
@@ -226,7 +231,13 @@ export namespace Config {
}
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
await Bun.write(configPath, JSON.stringify(parsed.data, null, 2))
}
return parsed.data
}
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
+11 -1
View File
@@ -23,7 +23,17 @@ export namespace Global {
await Promise.all([
fs.mkdir(Global.Path.data, { recursive: true }),
fs.mkdir(Global.Path.config, { recursive: true }),
fs.mkdir(Global.Path.cache, { recursive: true }),
fs.mkdir(Global.Path.providers, { recursive: true }),
fs.mkdir(Global.Path.state, { recursive: true }),
])
const CACHE_VERSION = "1"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
.catch(() => "0")
if (version !== CACHE_VERSION) {
await fs.rm(Global.Path.cache, { recursive: true, force: true })
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
}
+2
View File
@@ -14,6 +14,7 @@ import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { TuiCommand } from "./cli/cmd/tui"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
const cancel = new AbortController()
@@ -72,6 +73,7 @@ const cli = yargs(hideBin(process.argv))
.command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)
.command(StatsCommand)
.fail((msg) => {
if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
cli.showHelp("log")
+1 -1
View File
@@ -234,7 +234,7 @@ export namespace Provider {
if (!provider) {
const info = database[id]
if (!info) return
if (info.api) options["baseURL"] = info.api
if (info.api && !options["baseURL"]) options["baseURL"] = info.api
providers[id] = {
source,
info,
@@ -423,9 +423,11 @@ func renderToolDetails(
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
body += fmt.Sprintf("- [~] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
// strike through cancelled todo
body += fmt.Sprintf("- [~] ~~%s~~\n", content)
case "in_progress":
// highlight in progress todo
body += fmt.Sprintf("- [ ] `%s`\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
@@ -489,8 +491,6 @@ func renderToolName(name string) string {
switch name {
case "webfetch":
return "Fetch"
case "todowrite", "todoread":
return "Plan"
default:
normalizedName := name
if after, ok := strings.CutPrefix(name, "opencode_"); ok {
@@ -500,6 +500,41 @@ func renderToolName(name string) string {
}
}
func getTodoPhase(metadata map[string]any) string {
todos, ok := metadata["todos"].([]any)
if !ok || len(todos) == 0 {
return "Plan"
}
counts := map[string]int{"pending": 0, "completed": 0}
for _, item := range todos {
if todo, ok := item.(map[string]any); ok {
if status, ok := todo["status"].(string); ok {
counts[status]++
}
}
}
total := len(todos)
switch {
case counts["pending"] == total:
return "Creating plan"
case counts["completed"] == total:
return "Completing plan"
default:
return "Updating plan"
}
}
func getTodoTitle(toolCall opencode.ToolPart) string {
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
return getTodoPhase(metadata)
}
}
return "Plan"
}
func renderToolTitle(
toolCall opencode.ToolPart,
width int,
@@ -547,8 +582,10 @@ func renderToolTitle(
case "webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("%s %s", title, toolArgs)
case "todowrite", "todoread":
// title is just the tool name
case "todowrite":
title = getTodoTitle(toolCall)
case "todoread":
return "Plan"
default:
toolName := renderToolName(toolCall.Tool)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
@@ -21,7 +21,7 @@ This can be used to configure opencode globally or for a specific project.
### Global
Place your global opencode config in `~/.config/opencode/config.json`. You'll want to use the global config for things like themes, providers, or keybinds.
Place your global opencode config in `~/.config/opencode/opencode.json`. You'll want to use the global config for things like themes, providers, or keybinds.
---
@@ -28,11 +28,9 @@ You can add custom providers by specifying the npm package for the provider and
"$schema": "https://opencode.ai/config.json",
"provider": {
"openrouter": {
"npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter",
"options": {},
"models": {
"anthropic/claude-3.5-sonnet": {
"weirdo/some-weird-model": {
"name": "Claude 3.5 Sonnet"
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ If you have both global and project-specific rules, opencode will combine them t
## Custom Instructions
You can specify custom instruction files in your `opencode.json` or the global `~/.config/opencode/config.json`. This allows you and your team to reuse existing rules rather than having to duplicate them to AGENTS.md.
You can specify custom instruction files in your `opencode.json` or the global `~/.config/opencode/opencode.json`. This allows you and your team to reuse existing rules rather than having to duplicate them to AGENTS.md.
Example:
@@ -26,7 +26,7 @@ You can configure the log level in your [config file](/docs/config#logging) to g
opencode stores session data and other application data on disk at:
- **macOS/Linux**: `~/.local/share/opencode/`
- **Windows**: `%APPDATA%\opencode\`
- **Windows**: `%USERPROFILE%\.local\share\opencode`
This directory contains: