mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1729c310d9 | |||
| 0130190bbd | |||
| 97a31ddffc | |||
| 3249420ad1 | |||
| 4bb8536d34 | |||
| c73d4a137e | |||
| 57ac8f2741 |
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user