summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorkaanmertkoc <[email protected]>2025-11-04 03:41:30 +0300
committerGitHub <[email protected]>2025-11-03 18:41:30 -0600
commit63862b1609c0ef28a3b79892ffaa24f412076f3d (patch)
tree4b0e54d7fced35e56df0d00cc7ff8cb9f3cbbab4
parent1cf1e88b529587b373353560f4544815f3cc2059 (diff)
downloadopencode-63862b1609c0ef28a3b79892ffaa24f412076f3d.tar.gz
opencode-63862b1609c0ef28a3b79892ffaa24f412076f3d.zip
feat: implement stats command (#3832)
-rw-r--r--packages/opencode/src/cli/cmd/stats.ts205
1 files changed, 196 insertions, 9 deletions
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
index 39ae86ba0..5abee45f4 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<Project.Info> {
+ return Instance.project
+}
+
+async function getAllSessions(): Promise<Session.Info[]> {
+ const sessions: Session.Info[] = []
+
+ const projectKeys = await Storage.list(["project"])
+ const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(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<Session.Info>(key)),
+ )
+
+ for (const session of projectSessions) {
+ if (session) {
+ sessions.push(session)
+ }
+ }
+ }
+
+ return sessions
+}
+
+async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
+ 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<string, number> = {}
+
+ 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"