summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-07-11 17:37:41 -0400
committerDax Raad <[email protected]>2025-07-11 17:37:41 -0400
commit57ac8f2741b65cc3f4e8d79c5e2d28ae81430035 (patch)
tree5c44fe3687daf527442096c4902a247c9217addd /packages
parent2f1acee5a12b3e60b66cd337690d46f0e36b46be (diff)
downloadopencode-57ac8f2741b65cc3f4e8d79c5e2d28ae81430035.tar.gz
opencode-57ac8f2741b65cc3f4e8d79c5e2d28ae81430035.zip
wip: stats
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/cli/cmd/stats.ts179
-rw-r--r--packages/opencode/src/index.ts2
2 files changed, 181 insertions, 0 deletions
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
new file mode 100644
index 000000000..0db41651a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/stats.ts
@@ -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()
+}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 6206f230c..26129c10c 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -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")