@oh-my-pi-zen/omp-stats 16.3.6-zen.1

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.
Files changed (120) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1656 -0
  8. package/dist/types/aggregator.d.ts +87 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +21 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +67 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  36. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  37. package/dist/types/client/routes/index.d.ts +9 -0
  38. package/dist/types/client/types.d.ts +63 -0
  39. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  40. package/dist/types/client/ui/DataTable.d.ts +17 -0
  41. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  42. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  43. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  44. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  45. package/dist/types/client/ui/Panel.d.ts +7 -0
  46. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  47. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  48. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  49. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  50. package/dist/types/client/ui/index.d.ts +11 -0
  51. package/dist/types/client/useSystemTheme.d.ts +11 -0
  52. package/dist/types/db.d.ts +144 -0
  53. package/dist/types/embedded-client.d.ts +18 -0
  54. package/dist/types/gain-aggregator.d.ts +26 -0
  55. package/dist/types/index.d.ts +7 -0
  56. package/dist/types/parser.d.ts +53 -0
  57. package/dist/types/server.d.ts +7 -0
  58. package/dist/types/shared-types.d.ts +301 -0
  59. package/dist/types/sync-worker.d.ts +31 -0
  60. package/dist/types/types.d.ts +164 -0
  61. package/dist/types/user-metrics.d.ts +72 -0
  62. package/package.json +95 -0
  63. package/src/aggregator.ts +501 -0
  64. package/src/client/App.tsx +109 -0
  65. package/src/client/api.ts +102 -0
  66. package/src/client/app/AppLayout.tsx +93 -0
  67. package/src/client/app/NavRail.tsx +44 -0
  68. package/src/client/app/RangeControl.tsx +39 -0
  69. package/src/client/app/SyncButton.tsx +75 -0
  70. package/src/client/app/ThemeToggle.tsx +37 -0
  71. package/src/client/app/TopBar.tsx +73 -0
  72. package/src/client/app/routes.ts +69 -0
  73. package/src/client/components/AgentTokenShare.tsx +68 -0
  74. package/src/client/components/chart-shared.tsx +257 -0
  75. package/src/client/components/models-table-shared.tsx +255 -0
  76. package/src/client/components/range-meta.ts +73 -0
  77. package/src/client/css.d.ts +1 -0
  78. package/src/client/data/charts.ts +14 -0
  79. package/src/client/data/formatters.ts +45 -0
  80. package/src/client/data/useHashRoute.ts +87 -0
  81. package/src/client/data/useResource.ts +154 -0
  82. package/src/client/data/view-models.ts +251 -0
  83. package/src/client/index.tsx +10 -0
  84. package/src/client/routes/BehaviorRoute.tsx +623 -0
  85. package/src/client/routes/CostsRoute.tsx +234 -0
  86. package/src/client/routes/ErrorsRoute.tsx +118 -0
  87. package/src/client/routes/GainRoute.tsx +226 -0
  88. package/src/client/routes/ModelsRoute.tsx +430 -0
  89. package/src/client/routes/OverviewRoute.tsx +342 -0
  90. package/src/client/routes/ProjectsRoute.tsx +163 -0
  91. package/src/client/routes/RequestsRoute.tsx +123 -0
  92. package/src/client/routes/ToolsRoute.tsx +463 -0
  93. package/src/client/routes/index.ts +9 -0
  94. package/src/client/styles.css +1330 -0
  95. package/src/client/types.ts +80 -0
  96. package/src/client/ui/AsyncBoundary.tsx +54 -0
  97. package/src/client/ui/DataTable.tsx +122 -0
  98. package/src/client/ui/EmptyState.tsx +16 -0
  99. package/src/client/ui/ErrorState.tsx +25 -0
  100. package/src/client/ui/JsonBlock.tsx +75 -0
  101. package/src/client/ui/MetricCluster.tsx +67 -0
  102. package/src/client/ui/Panel.tsx +24 -0
  103. package/src/client/ui/RequestDrawer.tsx +208 -0
  104. package/src/client/ui/SegmentedControl.tsx +36 -0
  105. package/src/client/ui/Skeleton.tsx +17 -0
  106. package/src/client/ui/StatusPill.tsx +15 -0
  107. package/src/client/ui/index.ts +11 -0
  108. package/src/client/useSystemTheme.ts +87 -0
  109. package/src/db.ts +1530 -0
  110. package/src/embedded-client.generated.txt +0 -0
  111. package/src/embedded-client.ts +26 -0
  112. package/src/gain-aggregator.ts +281 -0
  113. package/src/index.ts +194 -0
  114. package/src/parser.ts +450 -0
  115. package/src/server.ts +352 -0
  116. package/src/shared-types.ts +323 -0
  117. package/src/sync-worker.ts +40 -0
  118. package/src/types.ts +171 -0
  119. package/src/user-metrics.ts +685 -0
  120. package/tailwind.config.js +40 -0
File without changes
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Embedded stats dashboard archive handling.
3
+ *
4
+ * `embedded-client.generated.txt` holds the base64 of a gzipped tar of the
5
+ * built dashboard (`dist/client`). It is populated by
6
+ * `gen:stats` for compiled binaries and the
7
+ * prepacked npm bundle, and reset to an empty file afterwards so the dev tree
8
+ * keeps building the dashboard from source.
9
+ */
10
+
11
+ /**
12
+ * Decode the generated archive text.
13
+ *
14
+ * Returns `null` when the content is blank or not a raw gzip archive encoded as
15
+ * base64 — notably the legacy placeholder that contained a TypeScript
16
+ * `export const … = "";` stub, which must be treated as "no archive embedded"
17
+ * rather than decoded into garbage bytes.
18
+ */
19
+ export function decodeEmbeddedClientArchive(txt: string): Buffer | null {
20
+ const normalized = txt.replaceAll(/\s+/g, "");
21
+ if (!normalized) return null;
22
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return null;
23
+ const archiveBytes = Buffer.from(normalized, "base64");
24
+ if (archiveBytes[0] !== 0x1f || archiveBytes[1] !== 0x8b) return null;
25
+ return archiveBytes;
26
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Aggregates token-savings data for the Gain dashboard.
3
+ *
4
+ * Source:
5
+ * 1. Snapcompact: colocated with stats.db as snapcompact-savings.jsonl
6
+ *
7
+ * Missing files are treated as zero records — never an error.
8
+ */
9
+
10
+ import * as path from "node:path";
11
+ import { getStatsDbPath, isEnoent, logger } from "@oh-my-pi-zen/pi-utils";
12
+ import { getTimeRangeConfig } from "./aggregator";
13
+ import { initDb } from "./db";
14
+ import type { GainDashboardStats, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types";
15
+
16
+ const BYTES_PER_TOKEN_ESTIMATE = 4;
17
+ const SQLITE_VARIABLE_CHUNK_SIZE = 500;
18
+
19
+ // Paths that carry no dashboard signal — temp/internal locations.
20
+ const TEMP_PATH_RE = /(?:^|\/)(?:T|tmp|pi-bash-exec|omp-bash-exec|pi-bash-detach)(?:\/|$)|^\/var\/folders(?:\/|$)/;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Project-match helper
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function canonicalProjectPath(p: string): string {
27
+ const normalized = p.replaceAll("\\", "/").replace(/\/+$/u, "");
28
+ return normalized || "/";
29
+ }
30
+
31
+ /** True when `candidate` exactly equals `parent` or is a separator-bounded sub-path. */
32
+ function isSameOrSubPath(candidate: string, parent: string): boolean {
33
+ const normalizedCandidate = canonicalProjectPath(candidate);
34
+ const normalizedParent = canonicalProjectPath(parent);
35
+ return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}/`);
36
+ }
37
+
38
+ /**
39
+ * True when `cwd` (or its normalized project root) exactly equals `project`
40
+ * or is a direct sub-path of it.
41
+ *
42
+ * Normalization is applied so that a cwd of `/repo/.worktrees/lane/src`
43
+ * matches a project root of `/repo` — the selector shows normalized roots, so
44
+ * the filter must compare apples-to-apples.
45
+ */
46
+ function matchesProject(cwd: string | undefined, project: string): boolean {
47
+ if (!cwd) return false;
48
+ const normalizedCwd = normalizeProjectPath(cwd) ?? canonicalProjectPath(cwd);
49
+ const normalizedProject = normalizeProjectPath(project) ?? canonicalProjectPath(project);
50
+ return isSameOrSubPath(normalizedCwd, normalizedProject) || isSameOrSubPath(cwd, normalizedProject);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Project normalization & deduplication
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Collapse conventional worktree sub-paths to their logical project root.
59
+ *
60
+ * Rules are generic: omp internal wt paths are dropped; conventional worktree
61
+ * suffixes (`.wt/`, `-wt/`, `.worktrees/`, `-worktrees/`) are stripped. No
62
+ * author-specific IDE or tool paths are baked in.
63
+ *
64
+ * Returns null to drop temp/internal paths entirely.
65
+ */
66
+ export function normalizeProjectPath(p: string): string | null {
67
+ const clean = canonicalProjectPath(p);
68
+ if (TEMP_PATH_RE.test(clean)) return null;
69
+ if (/\/\.omp\/wt\//u.test(clean)) return null;
70
+
71
+ const worktreePatterns = [
72
+ /^(.+)\/\.wt\/[^/]+(?:\/.*)?$/u,
73
+ /^(.+)\/\.worktrees\/[^/]+(?:\/.*)?$/u,
74
+ /^(.+)-wt\/[^/]+(?:\/.*)?$/u,
75
+ /^(.+)-worktrees\/[^/]+(?:\/.*)?$/u,
76
+ /^(.+)\.wt\/[^/]+(?:\/.*)?$/u,
77
+ ];
78
+ for (const pattern of worktreePatterns) {
79
+ const match = clean.match(pattern);
80
+ if (match?.[1]) return canonicalProjectPath(match[1]);
81
+ }
82
+
83
+ return clean;
84
+ }
85
+
86
+ /**
87
+ * Given a raw set of paths, normalize worktree paths and remove sub-paths
88
+ * that are already covered by a shorter parent at depth ≥ 4.
89
+ * Returns a sorted, deduped list of meaningful project roots.
90
+ */
91
+ export function dedupeProjects(rawPaths: Set<string>): string[] {
92
+ const normalized = new Set<string>();
93
+ for (const p of rawPaths) {
94
+ const n = normalizeProjectPath(p);
95
+ if (n) normalized.add(n);
96
+ }
97
+ const sorted = Array.from(normalized).sort();
98
+ return sorted.filter(p => {
99
+ return !sorted.some(
100
+ other =>
101
+ other !== p &&
102
+ other.length < p.length &&
103
+ isSameOrSubPath(p, other) &&
104
+ other.split("/").filter(Boolean).length >= 4,
105
+ );
106
+ });
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Snapcompact record schema
111
+ // ---------------------------------------------------------------------------
112
+
113
+ interface SnapcompactRecord {
114
+ ts: number; // epoch ms
115
+ session: string;
116
+ provider: string;
117
+ model: string;
118
+ toolCallId: string;
119
+ savedTokens: number;
120
+ }
121
+
122
+ interface SnapcompactSets {
123
+ records: SnapcompactRecord[];
124
+ projects: Set<string>;
125
+ }
126
+
127
+ async function readProjectsBySession(sessions: readonly string[]): Promise<Map<string, Set<string>>> {
128
+ const uniqueSessions = Array.from(new Set(sessions.filter(Boolean)));
129
+ const projectsBySession = new Map<string, Set<string>>();
130
+ if (uniqueSessions.length === 0) return projectsBySession;
131
+
132
+ const database = await initDb();
133
+ for (let i = 0; i < uniqueSessions.length; i += SQLITE_VARIABLE_CHUNK_SIZE) {
134
+ const chunk = uniqueSessions.slice(i, i + SQLITE_VARIABLE_CHUNK_SIZE);
135
+ const placeholders = chunk.map(() => "?").join(",");
136
+ const rows = database
137
+ .prepare(`SELECT DISTINCT session_file, folder FROM messages WHERE session_file IN (${placeholders})`)
138
+ .all(...chunk) as Array<{ session_file: string; folder: string }>;
139
+ for (const row of rows) {
140
+ if (!row.folder) continue;
141
+ let projects = projectsBySession.get(row.session_file);
142
+ if (!projects) {
143
+ projects = new Set<string>();
144
+ projectsBySession.set(row.session_file, projects);
145
+ }
146
+ projects.add(row.folder);
147
+ }
148
+ }
149
+ return projectsBySession;
150
+ }
151
+
152
+ async function readSnapcompactRecords(cutoff: number | null, project: string | null): Promise<SnapcompactSets> {
153
+ const filePath = path.join(path.dirname(getStatsDbPath()), "snapcompact-savings.jsonl");
154
+ let text: string;
155
+ try {
156
+ text = await Bun.file(filePath).text();
157
+ } catch (err) {
158
+ if (isEnoent(err)) return { records: [], projects: new Set() };
159
+ logger.debug("gain-aggregator: failed to read snapcompact-savings.jsonl", { err: String(err) });
160
+ return { records: [], projects: new Set() };
161
+ }
162
+
163
+ const seen = new Set<string>();
164
+ const parsed: SnapcompactRecord[] = [];
165
+ for (const line of text.split("\n")) {
166
+ if (!line.trim()) continue;
167
+ try {
168
+ const rec = JSON.parse(line) as SnapcompactRecord;
169
+ if (cutoff !== null && rec.ts < cutoff) continue;
170
+ const key = `${rec.session}:${rec.toolCallId}`;
171
+ if (seen.has(key)) continue;
172
+ seen.add(key);
173
+ parsed.push(rec);
174
+ } catch {
175
+ /* skip malformed line */
176
+ }
177
+ }
178
+
179
+ const projectsBySession = await readProjectsBySession(parsed.map(rec => rec.session));
180
+ const projects = new Set<string>();
181
+ const records: SnapcompactRecord[] = [];
182
+ for (const rec of parsed) {
183
+ const sessionProjects = projectsBySession.get(rec.session);
184
+ if (sessionProjects) {
185
+ for (const sessionProject of sessionProjects) projects.add(sessionProject);
186
+ }
187
+ if (project !== null) {
188
+ if (
189
+ !sessionProjects ||
190
+ !Array.from(sessionProjects).some(sessionProject => matchesProject(sessionProject, project))
191
+ ) {
192
+ continue;
193
+ }
194
+ }
195
+ records.push(rec);
196
+ }
197
+
198
+ return { records, projects };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Aggregation helpers
203
+ // ---------------------------------------------------------------------------
204
+
205
+ function emptyTotals(): GainSourceTotals {
206
+ return {
207
+ savedTokens: 0,
208
+ savedBytes: 0,
209
+ hits: 0,
210
+ outputBytes: 0,
211
+ originalBytes: 0,
212
+ reductionPercent: null,
213
+ };
214
+ }
215
+
216
+ /** ISO date string from epoch ms, bucketed to the day. */
217
+ function toDateBucket(epochMs: number): string {
218
+ return new Date(epochMs).toISOString().slice(0, 10); // "YYYY-MM-DD"
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Main aggregation function
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export async function getGainDashboardStats(
226
+ range?: string | null,
227
+ project?: string | null,
228
+ ): Promise<GainDashboardStats> {
229
+ const { cutoff: effectiveCutoff } = getTimeRangeConfig(range);
230
+ const effectiveProject: string | null = project?.trim() || null;
231
+
232
+ const { records: snapcompactRecords, projects: snapcompactProjects } = await readSnapcompactRecords(
233
+ effectiveCutoff,
234
+ effectiveProject,
235
+ );
236
+
237
+ const snapcompactTotals = emptyTotals();
238
+ const timeMap = new Map<string, { snapcompact: number }>();
239
+
240
+ for (const rec of snapcompactRecords) {
241
+ snapcompactTotals.savedTokens += rec.savedTokens;
242
+ const approxBytes = rec.savedTokens * BYTES_PER_TOKEN_ESTIMATE;
243
+ snapcompactTotals.savedBytes += approxBytes;
244
+ snapcompactTotals.hits += 1;
245
+
246
+ const date = toDateBucket(rec.ts);
247
+ const bucket = timeMap.get(date) ?? { snapcompact: 0 };
248
+ bucket.snapcompact += rec.savedTokens;
249
+ timeMap.set(date, bucket);
250
+ }
251
+ // No originalBytes for snapcompact — reductionPercent stays null.
252
+
253
+ const overall: GainSourceTotals = {
254
+ savedTokens: snapcompactTotals.savedTokens,
255
+ savedBytes: snapcompactTotals.savedBytes,
256
+ hits: snapcompactTotals.hits,
257
+ outputBytes: 0,
258
+ originalBytes: 0,
259
+ reductionPercent: null,
260
+ };
261
+
262
+ const timeSeries: GainTimeSeriesPoint[] = Array.from(timeMap.entries())
263
+ .sort(([a], [b]) => a.localeCompare(b))
264
+ .map(([date, bucket]) => ({
265
+ date,
266
+ snapcompact: bucket.snapcompact,
267
+ total: bucket.snapcompact,
268
+ }));
269
+
270
+ const projects = dedupeProjects(snapcompactProjects);
271
+
272
+ return {
273
+ overall,
274
+ bySource: {
275
+ snapcompact: snapcompactTotals,
276
+ },
277
+ timeSeries,
278
+ project: effectiveProject,
279
+ projects,
280
+ };
281
+ }
package/src/index.ts ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi-zen/pi-utils";
5
+ import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
6
+ import { closeDb } from "./db";
7
+ import { startServer } from "./server";
8
+
9
+ export {
10
+ getDashboardStats,
11
+ getToolDashboardStats,
12
+ getTotalMessageCount,
13
+ type SyncOptions,
14
+ type SyncProgress,
15
+ smokeTestSyncWorker,
16
+ syncAllSessions,
17
+ } from "./aggregator";
18
+ export { closeDb } from "./db";
19
+ export { getGainDashboardStats } from "./gain-aggregator";
20
+ export { startServer } from "./server";
21
+ export type {
22
+ GainDashboardStats,
23
+ GainSource,
24
+ GainSourceTotals,
25
+ GainTimeSeriesPoint,
26
+ } from "./shared-types";
27
+ export type {
28
+ AggregatedStats,
29
+ DashboardStats,
30
+ FolderStats,
31
+ MessageStats,
32
+ ModelPerformancePoint,
33
+ ModelStats,
34
+ ModelTimeSeriesPoint,
35
+ TimeSeriesPoint,
36
+ ToolDashboardStats,
37
+ ToolModelStats,
38
+ ToolTimeSeriesPoint,
39
+ ToolUsageStats,
40
+ } from "./types";
41
+
42
+ /**
43
+ * Format cost in dollars.
44
+ */
45
+ function formatCost(n: number): string {
46
+ if (n < 0.01) return `$${n.toFixed(4)}`;
47
+ if (n < 1) return `$${n.toFixed(3)}`;
48
+ return `$${n.toFixed(2)}`;
49
+ }
50
+
51
+ function normalizePremiumRequests(n: number): number {
52
+ return Math.round((n + Number.EPSILON) * 100) / 100;
53
+ }
54
+
55
+ /**
56
+ * Print stats summary to console.
57
+ */
58
+ async function printStats(): Promise<void> {
59
+ const stats = await getDashboardStats();
60
+ const { overall, byModel, byFolder } = stats;
61
+
62
+ console.log("\n=== AI Usage Statistics ===\n");
63
+
64
+ console.log("Overall:");
65
+ console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
66
+ console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
67
+ console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
68
+ console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
69
+ console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
70
+ console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
71
+ console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
72
+ console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
73
+ console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
74
+ console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`);
75
+ if (overall.avgTokensPerSecond !== null) {
76
+ console.log(` Avg Tokens/s: ${overall.avgTokensPerSecond.toFixed(1)}`);
77
+ }
78
+
79
+ if (byModel.length > 0) {
80
+ console.log("\nBy Model:");
81
+ for (const m of byModel.slice(0, 10)) {
82
+ console.log(
83
+ ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
84
+ );
85
+ }
86
+ }
87
+
88
+ if (byFolder.length > 0) {
89
+ console.log("\nBy Folder:");
90
+ for (const f of byFolder.slice(0, 10)) {
91
+ console.log(` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost)}`);
92
+ }
93
+ }
94
+
95
+ console.log("");
96
+ }
97
+
98
+ /**
99
+ * Main CLI entry point.
100
+ */
101
+ async function main(): Promise<void> {
102
+ const { values } = parseArgs({
103
+ options: {
104
+ port: { type: "string", short: "p", default: "3847" },
105
+ json: { type: "boolean", short: "j", default: false },
106
+ sync: { type: "boolean", short: "s", default: false },
107
+ help: { type: "boolean", short: "h", default: false },
108
+ },
109
+ allowPositionals: true,
110
+ });
111
+
112
+ if (values.help) {
113
+ console.log(`
114
+ omp-stats - AI Usage Statistics Dashboard
115
+
116
+ Usage:
117
+ omp-stats [options]
118
+
119
+ Options:
120
+ -p, --port <port> Port for the dashboard server (default: 3847)
121
+ -j, --json Output stats as JSON and exit
122
+ -s, --sync Sync session files and show summary
123
+ -h, --help Show this help message
124
+
125
+ Examples:
126
+ omp-stats # Start dashboard server
127
+ omp-stats --json # Print stats as JSON
128
+ omp-stats --port 8080 # Start on custom port
129
+ omp-stats --sync # Sync and show summary
130
+ `);
131
+ return;
132
+ }
133
+
134
+ try {
135
+ // Sync first
136
+ const tty = process.stderr.isTTY === true;
137
+ process.stderr.write("Syncing session files...\n");
138
+ let lastWidth = 0;
139
+ let lastRender = 0;
140
+ const { processed, files } = await syncAllSessions({
141
+ onProgress: event => {
142
+ if (!tty) return;
143
+ const now = Date.now();
144
+ if (event.current < event.total && now - lastRender < 33) return;
145
+ lastRender = now;
146
+ const marker = "/sessions/";
147
+ const idx = event.sessionFile.indexOf(marker);
148
+ const short = idx >= 0 ? event.sessionFile.slice(idx + marker.length) : event.sessionFile;
149
+ const pct = ((event.current / event.total) * 100).toFixed(0).padStart(3, " ");
150
+ const line = `[${event.current}/${event.total}] ${pct}% ${short}`;
151
+ const columns = process.stderr.columns ?? 120;
152
+ const clipped = line.length > columns - 1 ? `${line.slice(0, columns - 2)}\u2026` : line;
153
+ process.stderr.write(`\r${clipped.padEnd(lastWidth)}`);
154
+ lastWidth = clipped.length;
155
+ },
156
+ });
157
+ if (tty && lastWidth > 0) process.stderr.write(`\r${" ".repeat(lastWidth)}\r`);
158
+ const total = await getTotalMessageCount();
159
+ console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
160
+
161
+ if (values.json) {
162
+ const stats = await getDashboardStats();
163
+ console.log(JSON.stringify(stats, null, 2));
164
+ return;
165
+ }
166
+
167
+ if (values.sync) {
168
+ await printStats();
169
+ return;
170
+ }
171
+
172
+ // Start server
173
+ const port = parseInt(values.port || "3847", 10);
174
+ const { port: actualPort } = await startServer(port);
175
+ console.log(`Dashboard available at: http://localhost:${actualPort}`);
176
+ console.log("Press Ctrl+C to stop\n");
177
+
178
+ // Keep process running
179
+ process.on("SIGINT", () => {
180
+ console.log("\nShutting down...");
181
+ closeDb();
182
+ process.exit(0);
183
+ });
184
+ } catch (error) {
185
+ console.error("Error:", error);
186
+ closeDb();
187
+ process.exit(1);
188
+ }
189
+ }
190
+
191
+ // Run if executed directly
192
+ if (import.meta.main) {
193
+ main();
194
+ }