@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.
- package/CHANGELOG.md +197 -0
- package/README.md +82 -0
- package/build.ts +95 -0
- package/dist/client/index.css +1 -0
- package/dist/client/index.html +24 -0
- package/dist/client/index.js +257 -0
- package/dist/client/styles.css +1656 -0
- package/dist/types/aggregator.d.ts +87 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/api.d.ts +21 -0
- package/dist/types/client/app/AppLayout.d.ts +16 -0
- package/dist/types/client/app/NavRail.d.ts +7 -0
- package/dist/types/client/app/RangeControl.d.ts +7 -0
- package/dist/types/client/app/SyncButton.d.ts +14 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/TopBar.d.ts +15 -0
- package/dist/types/client/app/routes.d.ts +12 -0
- package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
- package/dist/types/client/components/chart-shared.d.ts +173 -0
- package/dist/types/client/components/models-table-shared.d.ts +175 -0
- package/dist/types/client/components/range-meta.d.ts +21 -0
- package/dist/types/client/data/charts.d.ts +1 -0
- package/dist/types/client/data/formatters.d.ts +8 -0
- package/dist/types/client/data/useHashRoute.d.ts +8 -0
- package/dist/types/client/data/useResource.d.ts +13 -0
- package/dist/types/client/data/view-models.d.ts +67 -0
- package/dist/types/client/index.d.ts +2 -0
- package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
- package/dist/types/client/routes/CostsRoute.d.ts +7 -0
- package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
- package/dist/types/client/routes/GainRoute.d.ts +7 -0
- package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
- package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
- package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
- package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
- package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +9 -0
- package/dist/types/client/types.d.ts +63 -0
- package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
- package/dist/types/client/ui/DataTable.d.ts +17 -0
- package/dist/types/client/ui/EmptyState.d.ts +7 -0
- package/dist/types/client/ui/ErrorState.d.ts +6 -0
- package/dist/types/client/ui/JsonBlock.d.ts +7 -0
- package/dist/types/client/ui/MetricCluster.d.ts +5 -0
- package/dist/types/client/ui/Panel.d.ts +7 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
- package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
- package/dist/types/client/ui/Skeleton.d.ts +8 -0
- package/dist/types/client/ui/StatusPill.d.ts +7 -0
- package/dist/types/client/ui/index.d.ts +11 -0
- package/dist/types/client/useSystemTheme.d.ts +11 -0
- package/dist/types/db.d.ts +144 -0
- package/dist/types/embedded-client.d.ts +18 -0
- package/dist/types/gain-aggregator.d.ts +26 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/parser.d.ts +53 -0
- package/dist/types/server.d.ts +7 -0
- package/dist/types/shared-types.d.ts +301 -0
- package/dist/types/sync-worker.d.ts +31 -0
- package/dist/types/types.d.ts +164 -0
- package/dist/types/user-metrics.d.ts +72 -0
- package/package.json +95 -0
- package/src/aggregator.ts +501 -0
- package/src/client/App.tsx +109 -0
- package/src/client/api.ts +102 -0
- package/src/client/app/AppLayout.tsx +93 -0
- package/src/client/app/NavRail.tsx +44 -0
- package/src/client/app/RangeControl.tsx +39 -0
- package/src/client/app/SyncButton.tsx +75 -0
- package/src/client/app/ThemeToggle.tsx +37 -0
- package/src/client/app/TopBar.tsx +73 -0
- package/src/client/app/routes.ts +69 -0
- package/src/client/components/AgentTokenShare.tsx +68 -0
- package/src/client/components/chart-shared.tsx +257 -0
- package/src/client/components/models-table-shared.tsx +255 -0
- package/src/client/components/range-meta.ts +73 -0
- package/src/client/css.d.ts +1 -0
- package/src/client/data/charts.ts +14 -0
- package/src/client/data/formatters.ts +45 -0
- package/src/client/data/useHashRoute.ts +87 -0
- package/src/client/data/useResource.ts +154 -0
- package/src/client/data/view-models.ts +251 -0
- package/src/client/index.tsx +10 -0
- package/src/client/routes/BehaviorRoute.tsx +623 -0
- package/src/client/routes/CostsRoute.tsx +234 -0
- package/src/client/routes/ErrorsRoute.tsx +118 -0
- package/src/client/routes/GainRoute.tsx +226 -0
- package/src/client/routes/ModelsRoute.tsx +430 -0
- package/src/client/routes/OverviewRoute.tsx +342 -0
- package/src/client/routes/ProjectsRoute.tsx +163 -0
- package/src/client/routes/RequestsRoute.tsx +123 -0
- package/src/client/routes/ToolsRoute.tsx +463 -0
- package/src/client/routes/index.ts +9 -0
- package/src/client/styles.css +1330 -0
- package/src/client/types.ts +80 -0
- package/src/client/ui/AsyncBoundary.tsx +54 -0
- package/src/client/ui/DataTable.tsx +122 -0
- package/src/client/ui/EmptyState.tsx +16 -0
- package/src/client/ui/ErrorState.tsx +25 -0
- package/src/client/ui/JsonBlock.tsx +75 -0
- package/src/client/ui/MetricCluster.tsx +67 -0
- package/src/client/ui/Panel.tsx +24 -0
- package/src/client/ui/RequestDrawer.tsx +208 -0
- package/src/client/ui/SegmentedControl.tsx +36 -0
- package/src/client/ui/Skeleton.tsx +17 -0
- package/src/client/ui/StatusPill.tsx +15 -0
- package/src/client/ui/index.ts +11 -0
- package/src/client/useSystemTheme.ts +87 -0
- package/src/db.ts +1530 -0
- package/src/embedded-client.generated.txt +0 -0
- package/src/embedded-client.ts +26 -0
- package/src/gain-aggregator.ts +281 -0
- package/src/index.ts +194 -0
- package/src/parser.ts +450 -0
- package/src/server.ts +352 -0
- package/src/shared-types.ts +323 -0
- package/src/sync-worker.ts +40 -0
- package/src/types.ts +171 -0
- package/src/user-metrics.ts +685 -0
- 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
|
+
}
|