Skip to content
120 changes: 120 additions & 0 deletions src/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js'
import { CATEGORY_LABELS } from './types.js'
import { getShortModelName } from './models.js'

export const TOP_OUTLIER_COUNT = 5
export const OUTLIER_MULTIPLIER = 2

export type OutlierSession = {
rank: number
project: string
sessionId: string
date: string
totalCostUSD: number
dominantActivity: string
isOutlier: boolean
}

export type ModelOneShotRow = {
model: string
sessions: number
oneShotTurns: number
editTurns: number
oneShotRate: number | null
costUSD: number
}

export function dominantActivity(session: SessionSummary): string {
let best: TaskCategory | null = null
let bestCost = 0
for (const [cat, data] of Object.entries(session.categoryBreakdown)) {
if (data.costUSD > bestCost) {
bestCost = data.costUSD
best = cat as TaskCategory
}
}
return best ? (CATEGORY_LABELS[best] ?? best) : 'General'
}

export function computeOutlierSessions(projects: ProjectSummary[]): OutlierSession[] {
const projectAvg = new Map<string, number>()
for (const p of projects) {
const avg = p.sessions.length > 0 ? p.totalCostUSD / p.sessions.length : 0
projectAvg.set(p.project, avg)
}

const all = projects.flatMap(p =>
p.sessions.map(s => ({ session: s, project: p.project }))
)
const sorted = [...all].sort((a, b) => b.session.totalCostUSD - a.session.totalCostUSD)
const top = sorted.slice(0, TOP_OUTLIER_COUNT)

return top.map(({ session, project }, i) => {
const avg = projectAvg.get(project) ?? 0
return {
rank: i + 1,
project,
sessionId: session.sessionId,
date: session.firstTimestamp ? session.firstTimestamp.slice(0, 10) : '----------',
totalCostUSD: session.totalCostUSD,
dominantActivity: dominantActivity(session),
isOutlier: avg > 0 && session.totalCostUSD > OUTLIER_MULTIPLIER * avg,
}
})
}

export function computeModelOneShotRates(projects: ProjectSummary[]): ModelOneShotRow[] {
const modelData = new Map<string, { oneShotTurns: number; editTurns: number; sessions: Set<string>; costUSD: number }>()

for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
const primaryModel = turn.assistantCalls[0]
? getShortModelName(turn.assistantCalls[0].model)
: null
if (!primaryModel) continue
const entry = modelData.get(primaryModel) ?? {
oneShotTurns: 0,
editTurns: 0,
sessions: new Set<string>(),
costUSD: 0,
}
if (turn.hasEdits) {
entry.editTurns++
if (turn.retries === 0) entry.oneShotTurns++
}
modelData.set(primaryModel, entry)
}
for (const [model, data] of Object.entries(session.modelBreakdown)) {
const entry = modelData.get(model) ?? {
oneShotTurns: 0,
editTurns: 0,
sessions: new Set<string>(),
costUSD: 0,
}
entry.costUSD += data.costUSD
entry.sessions.add(session.sessionId)
modelData.set(model, entry)
}
}
}

const rows: ModelOneShotRow[] = []
for (const [model, data] of modelData) {
rows.push({
model,
sessions: data.sessions.size,
oneShotTurns: data.oneShotTurns,
editTurns: data.editTurns,
oneShotRate: data.editTurns > 0 ? data.oneShotTurns / data.editTurns : null,
costUSD: data.costUSD,
})
}

return rows.sort((a, b) => {
if (a.oneShotRate === null && b.oneShotRate === null) return b.costUSD - a.costUSD
if (a.oneShotRate === null) return 1
if (b.oneShotRate === null) return -1
return b.oneShotRate - a.oneShotRate || b.costUSD - a.costUSD
})
}
22 changes: 22 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { buildMenubarPayload } from './menubar-json.js'
import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { computeOutlierSessions, computeModelOneShotRates } from './analytics.js'
import { renderDashboard } from './dashboard.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { getAllProviders } from './providers/index.js'
Expand Down Expand Up @@ -204,6 +205,25 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
.sort((a, b) => b.cost - a.cost)
.slice(0, 5)

const outlierSessions = computeOutlierSessions(projects).map(r => ({
rank: r.rank,
project: r.project,
sessionId: r.sessionId,
date: r.date,
cost: convertCost(r.totalCostUSD),
dominantActivity: r.dominantActivity,
isOutlier: r.isOutlier,
}))

const modelOneShotRates = computeModelOneShotRates(projects).map(r => ({
model: r.model,
sessions: r.sessions,
oneShotRate: r.oneShotRate !== null ? Math.round(r.oneShotRate * 1000) / 10 : null,
editTurns: r.editTurns,
oneShotTurns: r.oneShotTurns,
cost: convertCost(r.costUSD),
}))

return {
generated: new Date().toISOString(),
currency: code,
Expand All @@ -229,6 +249,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
topSessions,
outlierSessions,
modelOneShotRates,
}
}

Expand Down
80 changes: 59 additions & 21 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { homedir } from 'os'
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { computeOutlierSessions, computeModelOneShotRates, TOP_OUTLIER_COUNT, OUTLIER_MULTIPLIER } from './analytics.js'
import { formatCost, formatTokens } from './format.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
import { loadPricing } from './models.js'
Expand Down Expand Up @@ -374,37 +375,73 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
)
}

const TOP_SESSIONS_DATE_LEN = 10
const TOP_SESSIONS_COST_COL = 8
const TOP_SESSIONS_CALLS_COL = 6
const TOP_SESSIONS_ACT_COL = 13

const MODEL_ONESHOT_NAME_WIDTH = 14
const MODEL_ONESHOT_RATE_COL = 7
const MODEL_ONESHOT_SESS_COL = 6
const MODEL_ONESHOT_COST_COL = 8
const MODEL_ONESHOT_HIGH_THRESHOLD = 0.9
const MODEL_ONESHOT_MID_THRESHOLD = 0.7

function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const allSessions = projects.flatMap(p =>
p.sessions.map(s => ({ ...s, projectName: p.project }))
)
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
const rows = computeOutlierSessions(projects)

if (top.length === 0) {
return <Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}><Text dimColor>No sessions</Text></Panel>
if (rows.length === 0) {
return <Panel title={`Top ${TOP_OUTLIER_COUNT} Sessions by Cost`} color={PANEL_COLORS.sessions} width={pw}><Text dimColor>No sessions</Text></Panel>
}

const maxCost = top[0].totalCostUSD
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME)
const maxCost = rows[0].totalCostUSD
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_ACT_COL - 2 - PANEL_CHROME)

return (
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)}</Text>
{top.map((session, i) => {
const date = session.firstTimestamp
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
: '----------'
const label = `${date} ${shortProject(session.projectName)}`
<Panel title={`Top ${TOP_OUTLIER_COUNT} Sessions by Cost`} color={PANEL_COLORS.sessions} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{' activity'.padEnd(TOP_SESSIONS_ACT_COL + 1)}</Text>
{rows.map((row, i) => {
const label = `${row.date} ${shortProject(row.project)}`
return (
<Text key={`${session.sessionId}-${i}`} wrap="truncate-end">
<HBar value={session.totalCostUSD} max={maxCost} width={bw} />
<Text key={`${row.sessionId}-${i}`} wrap="truncate-end">
<HBar value={row.totalCostUSD} max={maxCost} width={bw} />
<Text dimColor> {fit(label, nw - 1)}</Text>
<Text color={GOLD}>{formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)}</Text>
<Text>{String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)}</Text>
<Text color={row.isOutlier ? '#F55B5B' : GOLD}>{formatCost(row.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)}</Text>
<Text dimColor> {fit(row.dominantActivity, TOP_SESSIONS_ACT_COL)}</Text>
</Text>
)
})}
<Text dimColor wrap="truncate-end">red cost = outlier ({'>'}{OUTLIER_MULTIPLIER}x project avg)</Text>
</Panel>
)
}

function ModelOneShotBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const rows = computeModelOneShotRates(projects)

if (rows.length === 0) {
return <Panel title="One-Shot Rate by Model" color={PANEL_COLORS.model} width={pw}><Text dimColor>No model data</Text></Panel>
}

const maxCost = Math.max(...rows.map(r => r.costUSD))

return (
<Panel title="One-Shot Rate by Model" color={PANEL_COLORS.model} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_ONESHOT_NAME_WIDTH)}{'cost'.padStart(MODEL_ONESHOT_COST_COL)}{'sess'.padStart(MODEL_ONESHOT_SESS_COL)}{'1-shot'.padStart(MODEL_ONESHOT_RATE_COL)}</Text>
{rows.map((row, i) => {
const rateLabel = row.oneShotRate !== null ? `${Math.round(row.oneShotRate * 100)}%` : '-'
const rateColor = row.oneShotRate === null
? DIM
: row.oneShotRate >= MODEL_ONESHOT_HIGH_THRESHOLD
? '#5BF58C'
: row.oneShotRate >= MODEL_ONESHOT_MID_THRESHOLD
? ORANGE
: '#F55B5B'
return (
<Text key={`${row.model}-${i}`} wrap="truncate-end">
<HBar value={row.costUSD} max={maxCost} width={bw} />
<Text> {fit(row.model, MODEL_ONESHOT_NAME_WIDTH)}</Text>
<Text color={GOLD}>{formatCost(row.costUSD).padStart(MODEL_ONESHOT_COST_COL)}</Text>
<Text>{String(row.sessions).padStart(MODEL_ONESHOT_SESS_COL)}</Text>
<Text color={rateColor}>{rateLabel.padStart(MODEL_ONESHOT_RATE_COL)}</Text>
</Text>
)
})}
Expand Down Expand Up @@ -565,6 +602,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
<ModelOneShotBreakdown projects={projects} pw={dashWidth} bw={barWidth} />
{isCursor ? (
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
) : (
Expand Down
Loading