Files
oh-my-pi/packages/coding-agent/src/cli/stats-cli.ts
T
2026-08-17 09:46:49 +03:00

165 lines
5.6 KiB
TypeScript

/**
* Stats CLI command handlers.
*
* Handles `omp stats` subcommand for viewing AI usage statistics.
*/
import { truncateToWidth } from "@oh-my-pi/pi-tui/utils";
import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
import chalk from "@oh-my-pi/pi-utils/chalk";
import { openPath } from "../utils/open";
/**
* Single-line TTY progress bar. On a non-TTY stream we just stay quiet -
* the final "Synced ..." summary still prints either way.
*/
function createSyncProgressReporter(): {
onProgress: (event: { current: number; total: number; sessionFile: string }) => void;
finish: () => void;
} {
const stream = process.stderr;
const isTty = stream.isTTY === true;
let lastWidth = 0;
let lastRender = 0;
return {
onProgress(event) {
if (!isTty) return;
const now = Date.now();
// Throttle to ~30 fps and always force a render for the last file.
if (event.current < event.total && now - lastRender < 33) return;
lastRender = now;
const label = chalk.dim(shortenSessionFile(event.sessionFile));
const pct = ((event.current / event.total) * 100).toFixed(0).padStart(3, " ");
const counter = chalk.cyan(`[${event.current}/${event.total}]`);
const line = `${counter} ${pct}% ${label}`;
const columns = stream.columns ?? 120;
const trimmed = truncateToWidth(line, columns - 1);
stream.write(`\r${trimmed.padEnd(lastWidth)}`);
lastWidth = trimmed.length;
},
finish() {
if (!isTty || lastWidth === 0) return;
stream.write(`\r${" ".repeat(lastWidth)}\r`);
lastWidth = 0;
},
};
}
function shortenSessionFile(p: string): string {
const marker = "/sessions/";
const idx = p.indexOf(marker);
return idx >= 0 ? p.slice(idx + marker.length) : p;
}
// =============================================================================
// Types
// =============================================================================
export interface StatsCommandArgs {
port: number;
host: string;
json: boolean;
summary: boolean;
}
function formatCost(n: number): string {
if (n < 0.01) return `$${n.toFixed(4)}`;
if (n < 1) return `$${n.toFixed(3)}`;
return `$${n.toFixed(2)}`;
}
function normalizePremiumRequests(n: number): number {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
// =============================================================================
// Command Handler
// =============================================================================
export async function runStatsCommand(cmd: StatsCommandArgs): Promise<void> {
// Lazy import to avoid loading stats module when not needed
const { closeDb, formatStatsDashboardUrl, getDashboardStats, getTotalMessageCount, startServer, syncAllSessions } =
await import("@oh-my-pi/omp-stats");
// Sync session files first
const progress = createSyncProgressReporter();
process.stderr.write("Syncing session files...\n");
const { processed, files } = await syncAllSessions({ onProgress: progress.onProgress });
progress.finish();
const total = await getTotalMessageCount();
console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
if (cmd.json) {
const stats = await getDashboardStats();
console.log(JSON.stringify(stats, null, 2));
return;
}
if (cmd.summary) {
await printStatsSummary();
return;
}
// Start the dashboard server
const { hostname, port } = await startServer(cmd.port, cmd.host);
const url = formatStatsDashboardUrl(hostname, port);
console.log(chalk.green(`Dashboard available at: ${url}`));
// Open browser
openPath(url);
console.log("Press Ctrl+C to stop\n");
// Keep process running
process.on("SIGINT", () => {
console.log("\nShutting down...");
closeDb();
process.exit(0);
});
// Keep the process alive
await new Promise(() => {});
}
async function printStatsSummary(): Promise<void> {
const { getDashboardStats } = await import("@oh-my-pi/omp-stats");
const stats = await getDashboardStats();
const { overall, byModel, byFolder } = stats;
console.log(chalk.bold("\n=== AI Usage Statistics ===\n"));
console.log(chalk.bold("Overall:"));
console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
console.log(` Cache Savings: ${formatPercent(overall.cacheSavings)}`);
console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`);
if (overall.avgTokensPerSecond !== null) {
console.log(` Avg Tokens/s: ${overall.avgTokensPerSecond.toFixed(1)}`);
}
if (byModel.length > 0) {
console.log(chalk.bold("\nBy Model:"));
for (const m of byModel.slice(0, 10)) {
console.log(
` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache rate, ${formatPercent(m.cacheSavings)} cache savings`,
);
}
}
if (byFolder.length > 0) {
console.log(chalk.bold("\nBy Folder:"));
for (const f of byFolder.slice(0, 10)) {
console.log(` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost)}`);
}
}
console.log("");
}