Procházet zdrojové kódy

feat: implement stats command (#3832)

kaanmertkoc před 3 měsíci
rodič
revize
63862b1609
1 změnil soubory, kde provedl 196 přidání a 9 odebrání
  1. 196 9
      packages/opencode/src/cli/cmd/stats.ts

+ 196 - 9
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"