@oh-my-pi/omp-stats 6.9.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Client-side type definitions.
3
+ * Duplicated from ../types.ts to avoid pulling in server dependencies.
4
+ */
5
+
6
+ export interface Usage {
7
+ input: number;
8
+ output: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ totalTokens: number;
12
+ cost: {
13
+ input: number;
14
+ output: number;
15
+ cacheRead: number;
16
+ cacheWrite: number;
17
+ total: number;
18
+ };
19
+ }
20
+
21
+ export interface MessageStats {
22
+ id?: number;
23
+ sessionFile: string;
24
+ entryId: string;
25
+ folder: string;
26
+ model: string;
27
+ provider: string;
28
+ api: string;
29
+ timestamp: number;
30
+ duration: number | null;
31
+ ttft: number | null;
32
+ stopReason: string;
33
+ errorMessage: string | null;
34
+ usage: Usage;
35
+ }
36
+
37
+ export interface RequestDetails extends MessageStats {
38
+ messages: unknown[];
39
+ output: unknown;
40
+ }
41
+
42
+ export interface AggregatedStats {
43
+ totalRequests: number;
44
+ successfulRequests: number;
45
+ failedRequests: number;
46
+ errorRate: number;
47
+ totalInputTokens: number;
48
+ totalOutputTokens: number;
49
+ totalCacheReadTokens: number;
50
+ totalCacheWriteTokens: number;
51
+ cacheRate: number;
52
+ totalCost: number;
53
+ avgDuration: number | null;
54
+ avgTtft: number | null;
55
+ avgTokensPerSecond: number | null;
56
+ firstTimestamp: number;
57
+ lastTimestamp: number;
58
+ }
59
+
60
+ export interface ModelStats extends AggregatedStats {
61
+ model: string;
62
+ provider: string;
63
+ }
64
+
65
+ export interface FolderStats extends AggregatedStats {
66
+ folder: string;
67
+ }
68
+
69
+ export interface TimeSeriesPoint {
70
+ timestamp: number;
71
+ requests: number;
72
+ errors: number;
73
+ tokens: number;
74
+ cost: number;
75
+ }
76
+
77
+ export interface DashboardStats {
78
+ overall: AggregatedStats;
79
+ byModel: ModelStats[];
80
+ byFolder: FolderStats[];
81
+ timeSeries: TimeSeriesPoint[];
82
+ }
package/src/db.ts ADDED
@@ -0,0 +1,396 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { AggregatedStats, FolderStats, MessageStats, ModelStats, TimeSeriesPoint } from "./types";
6
+
7
+ const DB_PATH = join(homedir(), ".omp", "stats.db");
8
+
9
+ let db: Database | null = null;
10
+
11
+ /**
12
+ * Initialize the database and create tables.
13
+ */
14
+ export async function initDb(): Promise<Database> {
15
+ if (db) return db;
16
+
17
+ // Ensure directory exists
18
+ await mkdir(join(homedir(), ".omp"), { recursive: true });
19
+
20
+ db = new Database(DB_PATH);
21
+ db.exec("PRAGMA journal_mode = WAL");
22
+
23
+ // Create tables
24
+ db.exec(`
25
+ CREATE TABLE IF NOT EXISTS messages (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ session_file TEXT NOT NULL,
28
+ entry_id TEXT NOT NULL,
29
+ folder TEXT NOT NULL,
30
+ model TEXT NOT NULL,
31
+ provider TEXT NOT NULL,
32
+ api TEXT NOT NULL,
33
+ timestamp INTEGER NOT NULL,
34
+ duration INTEGER,
35
+ ttft INTEGER,
36
+ stop_reason TEXT NOT NULL,
37
+ error_message TEXT,
38
+ input_tokens INTEGER NOT NULL,
39
+ output_tokens INTEGER NOT NULL,
40
+ cache_read_tokens INTEGER NOT NULL,
41
+ cache_write_tokens INTEGER NOT NULL,
42
+ total_tokens INTEGER NOT NULL,
43
+ cost_input REAL NOT NULL,
44
+ cost_output REAL NOT NULL,
45
+ cost_cache_read REAL NOT NULL,
46
+ cost_cache_write REAL NOT NULL,
47
+ cost_total REAL NOT NULL,
48
+ UNIQUE(session_file, entry_id)
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
52
+ CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
53
+ CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
54
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
55
+
56
+ CREATE TABLE IF NOT EXISTS file_offsets (
57
+ session_file TEXT PRIMARY KEY,
58
+ offset INTEGER NOT NULL,
59
+ last_modified INTEGER NOT NULL
60
+ );
61
+ `);
62
+
63
+ return db;
64
+ }
65
+
66
+ /**
67
+ * Get the stored offset for a session file.
68
+ */
69
+ export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
70
+ if (!db) return null;
71
+
72
+ const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
73
+ const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
74
+
75
+ return row ? { offset: row.offset, lastModified: row.last_modified } : null;
76
+ }
77
+
78
+ /**
79
+ * Update the stored offset for a session file.
80
+ */
81
+ export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
82
+ if (!db) return;
83
+
84
+ const stmt = db.prepare(`
85
+ INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
86
+ VALUES (?, ?, ?)
87
+ `);
88
+ stmt.run(sessionFile, offset, lastModified);
89
+ }
90
+
91
+ /**
92
+ * Insert message stats into the database.
93
+ */
94
+ export function insertMessageStats(stats: MessageStats[]): number {
95
+ if (!db || stats.length === 0) return 0;
96
+
97
+ const stmt = db.prepare(`
98
+ INSERT OR IGNORE INTO messages (
99
+ session_file, entry_id, folder, model, provider, api, timestamp,
100
+ duration, ttft, stop_reason, error_message,
101
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
102
+ cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
103
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
104
+ `);
105
+
106
+ let inserted = 0;
107
+ const insert = db.transaction(() => {
108
+ for (const s of stats) {
109
+ const result = stmt.run(
110
+ s.sessionFile,
111
+ s.entryId,
112
+ s.folder,
113
+ s.model,
114
+ s.provider,
115
+ s.api,
116
+ s.timestamp,
117
+ s.duration,
118
+ s.ttft,
119
+ s.stopReason,
120
+ s.errorMessage,
121
+ s.usage.input,
122
+ s.usage.output,
123
+ s.usage.cacheRead,
124
+ s.usage.cacheWrite,
125
+ s.usage.totalTokens,
126
+ s.usage.cost.input,
127
+ s.usage.cost.output,
128
+ s.usage.cost.cacheRead,
129
+ s.usage.cost.cacheWrite,
130
+ s.usage.cost.total,
131
+ );
132
+ if (result.changes > 0) inserted++;
133
+ }
134
+ });
135
+
136
+ insert();
137
+ return inserted;
138
+ }
139
+
140
+ /**
141
+ * Build aggregated stats from query results.
142
+ */
143
+ function buildAggregatedStats(rows: any[]): AggregatedStats {
144
+ if (rows.length === 0) {
145
+ return {
146
+ totalRequests: 0,
147
+ successfulRequests: 0,
148
+ failedRequests: 0,
149
+ errorRate: 0,
150
+ totalInputTokens: 0,
151
+ totalOutputTokens: 0,
152
+ totalCacheReadTokens: 0,
153
+ totalCacheWriteTokens: 0,
154
+ cacheRate: 0,
155
+ totalCost: 0,
156
+ avgDuration: null,
157
+ avgTtft: null,
158
+ avgTokensPerSecond: null,
159
+ firstTimestamp: 0,
160
+ lastTimestamp: 0,
161
+ };
162
+ }
163
+
164
+ const row = rows[0];
165
+ const totalRequests = row.total_requests || 0;
166
+ const failedRequests = row.failed_requests || 0;
167
+ const successfulRequests = totalRequests - failedRequests;
168
+ const totalInputTokens = row.total_input_tokens || 0;
169
+ const totalCacheReadTokens = row.total_cache_read_tokens || 0;
170
+
171
+ return {
172
+ totalRequests,
173
+ successfulRequests,
174
+ failedRequests,
175
+ errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
176
+ totalInputTokens,
177
+ totalOutputTokens: row.total_output_tokens || 0,
178
+ totalCacheReadTokens,
179
+ totalCacheWriteTokens: row.total_cache_write_tokens || 0,
180
+ cacheRate:
181
+ totalInputTokens + totalCacheReadTokens > 0
182
+ ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
183
+ : 0,
184
+ totalCost: row.total_cost || 0,
185
+ avgDuration: row.avg_duration,
186
+ avgTtft: row.avg_ttft,
187
+ avgTokensPerSecond: row.avg_tokens_per_second,
188
+ firstTimestamp: row.first_timestamp || 0,
189
+ lastTimestamp: row.last_timestamp || 0,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Get overall aggregated stats.
195
+ */
196
+ export function getOverallStats(): AggregatedStats {
197
+ if (!db) return buildAggregatedStats([]);
198
+
199
+ const stmt = db.prepare(`
200
+ SELECT
201
+ COUNT(*) as total_requests,
202
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
203
+ SUM(input_tokens) as total_input_tokens,
204
+ SUM(output_tokens) as total_output_tokens,
205
+ SUM(cache_read_tokens) as total_cache_read_tokens,
206
+ SUM(cache_write_tokens) as total_cache_write_tokens,
207
+ SUM(cost_total) as total_cost,
208
+ AVG(duration) as avg_duration,
209
+ AVG(ttft) as avg_ttft,
210
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
211
+ MIN(timestamp) as first_timestamp,
212
+ MAX(timestamp) as last_timestamp
213
+ FROM messages
214
+ `);
215
+
216
+ const rows = stmt.all();
217
+ return buildAggregatedStats(rows);
218
+ }
219
+
220
+ /**
221
+ * Get stats grouped by model.
222
+ */
223
+ export function getStatsByModel(): ModelStats[] {
224
+ if (!db) return [];
225
+
226
+ const stmt = db.prepare(`
227
+ SELECT
228
+ model,
229
+ provider,
230
+ COUNT(*) as total_requests,
231
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
232
+ SUM(input_tokens) as total_input_tokens,
233
+ SUM(output_tokens) as total_output_tokens,
234
+ SUM(cache_read_tokens) as total_cache_read_tokens,
235
+ SUM(cache_write_tokens) as total_cache_write_tokens,
236
+ SUM(cost_total) as total_cost,
237
+ AVG(duration) as avg_duration,
238
+ AVG(ttft) as avg_ttft,
239
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
240
+ MIN(timestamp) as first_timestamp,
241
+ MAX(timestamp) as last_timestamp
242
+ FROM messages
243
+ GROUP BY model, provider
244
+ ORDER BY total_requests DESC
245
+ `);
246
+
247
+ const rows = stmt.all() as any[];
248
+ return rows.map((row) => ({
249
+ model: row.model,
250
+ provider: row.provider,
251
+ ...buildAggregatedStats([row]),
252
+ }));
253
+ }
254
+
255
+ /**
256
+ * Get stats grouped by folder.
257
+ */
258
+ export function getStatsByFolder(): FolderStats[] {
259
+ if (!db) return [];
260
+
261
+ const stmt = db.prepare(`
262
+ SELECT
263
+ folder,
264
+ COUNT(*) as total_requests,
265
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
266
+ SUM(input_tokens) as total_input_tokens,
267
+ SUM(output_tokens) as total_output_tokens,
268
+ SUM(cache_read_tokens) as total_cache_read_tokens,
269
+ SUM(cache_write_tokens) as total_cache_write_tokens,
270
+ SUM(cost_total) as total_cost,
271
+ AVG(duration) as avg_duration,
272
+ AVG(ttft) as avg_ttft,
273
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
274
+ MIN(timestamp) as first_timestamp,
275
+ MAX(timestamp) as last_timestamp
276
+ FROM messages
277
+ GROUP BY folder
278
+ ORDER BY total_requests DESC
279
+ `);
280
+
281
+ const rows = stmt.all() as any[];
282
+ return rows.map((row) => ({
283
+ folder: row.folder,
284
+ ...buildAggregatedStats([row]),
285
+ }));
286
+ }
287
+
288
+ /**
289
+ * Get hourly time series data.
290
+ */
291
+ export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
292
+ if (!db) return [];
293
+
294
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
295
+
296
+ const stmt = db.prepare(`
297
+ SELECT
298
+ (timestamp / 3600000) * 3600000 as bucket,
299
+ COUNT(*) as requests,
300
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
301
+ SUM(total_tokens) as tokens,
302
+ SUM(cost_total) as cost
303
+ FROM messages
304
+ WHERE timestamp >= ?
305
+ GROUP BY bucket
306
+ ORDER BY bucket ASC
307
+ `);
308
+
309
+ const rows = stmt.all(cutoff) as any[];
310
+ return rows.map((row) => ({
311
+ timestamp: row.bucket,
312
+ requests: row.requests,
313
+ errors: row.errors,
314
+ tokens: row.tokens,
315
+ cost: row.cost,
316
+ }));
317
+ }
318
+
319
+ /**
320
+ * Get total message count.
321
+ */
322
+ export function getMessageCount(): number {
323
+ if (!db) return 0;
324
+ const stmt = db.prepare("SELECT COUNT(*) as count FROM messages");
325
+ const row = stmt.get() as { count: number };
326
+ return row.count;
327
+ }
328
+
329
+ /**
330
+ * Close the database connection.
331
+ */
332
+ export function closeDb(): void {
333
+ if (db) {
334
+ db.close();
335
+ db = null;
336
+ }
337
+ }
338
+
339
+ function rowToMessageStats(row: any): MessageStats {
340
+ return {
341
+ id: row.id,
342
+ sessionFile: row.session_file,
343
+ entryId: row.entry_id,
344
+ folder: row.folder,
345
+ model: row.model,
346
+ provider: row.provider,
347
+ api: row.api,
348
+ timestamp: row.timestamp,
349
+ duration: row.duration,
350
+ ttft: row.ttft,
351
+ stopReason: row.stop_reason as any,
352
+ errorMessage: row.error_message,
353
+ usage: {
354
+ input: row.input_tokens,
355
+ output: row.output_tokens,
356
+ cacheRead: row.cache_read_tokens,
357
+ cacheWrite: row.cache_write_tokens,
358
+ totalTokens: row.total_tokens,
359
+ cost: {
360
+ input: row.cost_input,
361
+ output: row.cost_output,
362
+ cacheRead: row.cost_cache_read,
363
+ cacheWrite: row.cost_cache_write,
364
+ total: row.cost_total,
365
+ },
366
+ },
367
+ };
368
+ }
369
+
370
+ export function getRecentRequests(limit = 100): MessageStats[] {
371
+ if (!db) return [];
372
+ const stmt = db.prepare(`
373
+ SELECT * FROM messages
374
+ ORDER BY timestamp DESC
375
+ LIMIT ?
376
+ `);
377
+ return (stmt.all(limit) as any[]).map(rowToMessageStats);
378
+ }
379
+
380
+ export function getRecentErrors(limit = 100): MessageStats[] {
381
+ if (!db) return [];
382
+ const stmt = db.prepare(`
383
+ SELECT * FROM messages
384
+ WHERE stop_reason = 'error'
385
+ ORDER BY timestamp DESC
386
+ LIMIT ?
387
+ `);
388
+ return (stmt.all(limit) as any[]).map(rowToMessageStats);
389
+ }
390
+
391
+ export function getMessageById(id: number): MessageStats | null {
392
+ if (!db) return null;
393
+ const stmt = db.prepare("SELECT * FROM messages WHERE id = ?");
394
+ const row = stmt.get(id);
395
+ return row ? rowToMessageStats(row) : null;
396
+ }
package/src/index.ts ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
5
+ import { closeDb } from "./db";
6
+ import { startServer } from "./server";
7
+
8
+ export { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
9
+ export type {
10
+ AggregatedStats,
11
+ DashboardStats,
12
+ FolderStats,
13
+ MessageStats,
14
+ ModelStats,
15
+ TimeSeriesPoint,
16
+ } from "./types";
17
+
18
+ /**
19
+ * Format a number with appropriate suffix (K, M, etc.)
20
+ */
21
+ function formatNumber(n: number): string {
22
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
23
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
24
+ return n.toFixed(0);
25
+ }
26
+
27
+ /**
28
+ * Format cost in dollars.
29
+ */
30
+ function formatCost(n: number): string {
31
+ if (n < 0.01) return `$${n.toFixed(4)}`;
32
+ if (n < 1) return `$${n.toFixed(3)}`;
33
+ return `$${n.toFixed(2)}`;
34
+ }
35
+
36
+ /**
37
+ * Format duration in ms to human-readable.
38
+ */
39
+ function formatDuration(ms: number | null): string {
40
+ if (ms === null) return "-";
41
+ if (ms < 1000) return `${ms.toFixed(0)}ms`;
42
+ return `${(ms / 1000).toFixed(1)}s`;
43
+ }
44
+
45
+ /**
46
+ * Format percentage.
47
+ */
48
+ function formatPercent(n: number): string {
49
+ return `${(n * 100).toFixed(1)}%`;
50
+ }
51
+
52
+ /**
53
+ * Print stats summary to console.
54
+ */
55
+ async function printStats(): Promise<void> {
56
+ const stats = await getDashboardStats();
57
+ const { overall, byModel, byFolder } = stats;
58
+
59
+ console.log("\n=== AI Usage Statistics ===\n");
60
+
61
+ console.log("Overall:");
62
+ console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
63
+ console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
64
+ console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
65
+ console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
66
+ console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
67
+ console.log(` Avg Duration: ${formatDuration(overall.avgDuration)}`);
68
+ console.log(` Avg TTFT: ${formatDuration(overall.avgTtft)}`);
69
+ if (overall.avgTokensPerSecond !== null) {
70
+ console.log(` Avg Tokens/s: ${overall.avgTokensPerSecond.toFixed(1)}`);
71
+ }
72
+
73
+ if (byModel.length > 0) {
74
+ console.log("\nBy Model:");
75
+ for (const m of byModel.slice(0, 10)) {
76
+ console.log(
77
+ ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
78
+ );
79
+ }
80
+ }
81
+
82
+ if (byFolder.length > 0) {
83
+ console.log("\nBy Folder:");
84
+ for (const f of byFolder.slice(0, 10)) {
85
+ console.log(` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost)}`);
86
+ }
87
+ }
88
+
89
+ console.log("");
90
+ }
91
+
92
+ /**
93
+ * Main CLI entry point.
94
+ */
95
+ async function main(): Promise<void> {
96
+ const { values } = parseArgs({
97
+ options: {
98
+ port: { type: "string", short: "p", default: "3847" },
99
+ json: { type: "boolean", short: "j", default: false },
100
+ sync: { type: "boolean", short: "s", default: false },
101
+ help: { type: "boolean", short: "h", default: false },
102
+ },
103
+ allowPositionals: true,
104
+ });
105
+
106
+ if (values.help) {
107
+ console.log(`
108
+ omp-stats - AI Usage Statistics Dashboard
109
+
110
+ Usage:
111
+ omp-stats [options]
112
+
113
+ Options:
114
+ -p, --port <port> Port for the dashboard server (default: 3847)
115
+ -j, --json Output stats as JSON and exit
116
+ -s, --sync Sync session files and show summary
117
+ -h, --help Show this help message
118
+
119
+ Examples:
120
+ omp-stats # Start dashboard server
121
+ omp-stats --json # Print stats as JSON
122
+ omp-stats --port 8080 # Start on custom port
123
+ omp-stats --sync # Sync and show summary
124
+ `);
125
+ return;
126
+ }
127
+
128
+ try {
129
+ // Sync first
130
+ console.log("Syncing session files...");
131
+ const { processed, files } = await syncAllSessions();
132
+ const total = await getTotalMessageCount();
133
+ console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
134
+
135
+ if (values.json) {
136
+ const stats = await getDashboardStats();
137
+ console.log(JSON.stringify(stats, null, 2));
138
+ return;
139
+ }
140
+
141
+ if (values.sync) {
142
+ await printStats();
143
+ return;
144
+ }
145
+
146
+ // Start server
147
+ const port = parseInt(values.port || "3847", 10);
148
+ const { port: actualPort } = startServer(port);
149
+ console.log(`Dashboard available at: http://localhost:${actualPort}`);
150
+ console.log("Press Ctrl+C to stop\n");
151
+
152
+ // Keep process running
153
+ process.on("SIGINT", () => {
154
+ console.log("\nShutting down...");
155
+ closeDb();
156
+ process.exit(0);
157
+ });
158
+ } catch (error) {
159
+ console.error("Error:", error);
160
+ closeDb();
161
+ process.exit(1);
162
+ }
163
+ }
164
+
165
+ // Run if executed directly
166
+ if (import.meta.main) {
167
+ main();
168
+ }