| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- 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
- 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
- tokensPerSession: number
- medianTokensPerSession: number
- }
- export const StatsCommand = cmd({
- command: "stats",
- 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)
- })
- },
- })
- 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,
- tokensPerSession: 0,
- medianTokensPerSession: 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 sessionTotalTokens: number[] = []
- 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({ sessionID: 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,
- sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
- 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)
- sessionTotalTokens.push(result.sessionTotalTokens)
- 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
- const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning
- stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
- sessionTotalTokens.sort((a, b) => a - b)
- const mid = Math.floor(sessionTotalTokens.length / 2)
- stats.medianTokensPerSession =
- sessionTotalTokens.length === 0
- ? 0
- : sessionTotalTokens.length % 2 === 0
- ? (sessionTotalTokens[mid - 1] + sessionTotalTokens[mid]) / 2
- : sessionTotalTokens[mid]
- return stats
- }
- export function displayStats(stats: SessionStats, toolLimit?: number) {
- 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
- const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession
- console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`))
- console.log(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`))
- console.log(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession))))
- const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession
- console.log(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession))))
- 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)
- const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
- console.log("┌────────────────────────────────────────────────────────┐")
- console.log("│ TOOL USAGE │")
- console.log("├────────────────────────────────────────────────────────┤")
- 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 toolsToDisplay) {
- const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
- const bar = "█".repeat(barLength)
- const percentage = ((count / totalToolUsage) * 100).toFixed(1)
- 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"
- } else if (num >= 1000) {
- return (num / 1000).toFixed(1) + "K"
- }
- return num.toString()
- }
|