diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 39ae86ba..5abee45f 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,4 +1,10 @@ +import type { Argv } from "yargs" import { cmd } from "./cmd" +import { Session } from "../../session" +import { bootstrap } from "../bootstrap" +import { Storage } from "../../storage/storage" +import { Project } from "../../project/project" +import { Instance } from "../../project/instance" interface SessionStats { totalSessions: number @@ -24,10 +30,186 @@ interface SessionStats { export const StatsCommand = cmd({ command: "stats", - handler: async () => {}, + describe: "show token usage and cost statistics", + builder: (yargs: Argv) => { + return yargs + .option("days", { + describe: "show stats for the last N days (default: all time)", + type: "number", + }) + .option("tools", { + describe: "number of tools to show (default: all)", + type: "number", + }) + .option("project", { + describe: "filter by project (default: all projects, empty string: current project)", + type: "string", + }) + }, + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const stats = await aggregateSessionStats(args.days, args.project) + displayStats(stats, args.tools) + }) + }, }) -export function displayStats(stats: SessionStats) { +async function getCurrentProject(): Promise { + return Instance.project +} + +async function getAllSessions(): Promise { + const sessions: Session.Info[] = [] + + const projectKeys = await Storage.list(["project"]) + const projects = await Promise.all(projectKeys.map((key) => Storage.read(key))) + + for (const project of projects) { + if (!project) continue + + const sessionKeys = await Storage.list(["session", project.id]) + const projectSessions = await Promise.all( + sessionKeys.map((key) => Storage.read(key)), + ) + + for (const session of projectSessions) { + if (session) { + sessions.push(session) + } + } + } + + return sessions +} + +async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { + const sessions = await getAllSessions() + const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 + const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0 + + let filteredSessions = days + ? sessions.filter((session) => session.time.updated >= cutoffTime) + : sessions + + if (projectFilter !== undefined) { + if (projectFilter === "") { + const currentProject = await getCurrentProject() + filteredSessions = filteredSessions.filter( + (session) => session.projectID === currentProject.id, + ) + } else { + filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter) + } + } + + const stats: SessionStats = { + totalSessions: filteredSessions.length, + totalMessages: 0, + totalCost: 0, + totalTokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + toolUsage: {}, + dateRange: { + earliest: Date.now(), + latest: Date.now(), + }, + days: 0, + costPerDay: 0, + } + + if (filteredSessions.length > 1000) { + console.log( + `Large dataset detected (${filteredSessions.length} sessions). This may take a while...`, + ) + } + + if (filteredSessions.length === 0) { + return stats + } + + let earliestTime = Date.now() + let latestTime = 0 + + const BATCH_SIZE = 20 + for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { + const batch = filteredSessions.slice(i, i + BATCH_SIZE) + + const batchPromises = batch.map(async (session) => { + const messages = await Session.messages(session.id) + + let sessionCost = 0 + let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + let sessionToolUsage: Record = {} + + for (const message of messages) { + if (message.info.role === "assistant") { + sessionCost += message.info.cost || 0 + + if (message.info.tokens) { + sessionTokens.input += message.info.tokens.input || 0 + sessionTokens.output += message.info.tokens.output || 0 + sessionTokens.reasoning += message.info.tokens.reasoning || 0 + sessionTokens.cache.read += message.info.tokens.cache?.read || 0 + sessionTokens.cache.write += message.info.tokens.cache?.write || 0 + } + } + + for (const part of message.parts) { + if (part.type === "tool" && part.tool) { + sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 + } + } + } + + return { + messageCount: messages.length, + sessionCost, + sessionTokens, + sessionToolUsage, + earliestTime: session.time.created, + latestTime: session.time.updated, + } + }) + + const batchResults = await Promise.all(batchPromises) + + for (const result of batchResults) { + earliestTime = Math.min(earliestTime, result.earliestTime) + latestTime = Math.max(latestTime, result.latestTime) + + stats.totalMessages += result.messageCount + stats.totalCost += result.sessionCost + stats.totalTokens.input += result.sessionTokens.input + stats.totalTokens.output += result.sessionTokens.output + stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.cache.read += result.sessionTokens.cache.read + stats.totalTokens.cache.write += result.sessionTokens.cache.write + + for (const [tool, count] of Object.entries(result.sessionToolUsage)) { + stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count + } + } + } + + const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND)) + stats.dateRange = { + earliest: earliestTime, + latest: latestTime, + } + stats.days = actualDays + stats.costPerDay = stats.totalCost / actualDays + + return stats +} + +export function displayStats(stats: SessionStats, toolLimit?: number) { const width = 56 function renderRow(label: string, value: string): string { @@ -64,30 +246,35 @@ export function displayStats(stats: SessionStats) { // Tool Usage section if (Object.keys(stats.toolUsage).length > 0) { - const sortedTools = Object.entries(stats.toolUsage) - .sort(([, a], [, b]) => b - a) - .slice(0, 10) + const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a) + const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools console.log("┌────────────────────────────────────────────────────────┐") console.log("│ TOOL USAGE │") console.log("├────────────────────────────────────────────────────────┤") - const maxCount = Math.max(...sortedTools.map(([, count]) => count)) + const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count)) const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0) - for (const [tool, count] of sortedTools) { + for (const [tool, count] of toolsToDisplay) { 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) + const maxToolLength = 18 + const truncatedTool = + tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool + const toolName = truncatedTool.padEnd(maxToolLength) + + const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)` + const padding = Math.max(0, width - content.length - 1) console.log(`│${content}${" ".repeat(padding)} │`) } console.log("└────────────────────────────────────────────────────────┘") } console.log() } + function formatNumber(num: number): string { if (num >= 1000000) { return (num / 1000000).toFixed(1) + "M"