@oh-my-pi/omp-stats 16.1.23 → 16.2.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.
@@ -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/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 CHANGED
@@ -15,7 +15,14 @@ export {
15
15
  syncAllSessions,
16
16
  } from "./aggregator";
17
17
  export { closeDb } from "./db";
18
+ export { getGainDashboardStats } from "./gain-aggregator";
18
19
  export { startServer } from "./server";
20
+ export type {
21
+ GainDashboardStats,
22
+ GainSource,
23
+ GainSourceTotals,
24
+ GainTimeSeriesPoint,
25
+ } from "./shared-types";
19
26
  export type {
20
27
  AggregatedStats,
21
28
  DashboardStats,
package/src/server.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from "./aggregator";
19
19
  import { decodeEmbeddedClientArchive } from "./embedded-client";
20
20
  import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
21
+ import { getGainDashboardStats } from "./gain-aggregator";
21
22
 
22
23
  const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
23
24
 
@@ -255,6 +256,12 @@ async function handleApi(req: Request): Promise<Response> {
255
256
  return Response.json({ ...result, totalMessages: count });
256
257
  }
257
258
 
259
+ if (path === "/api/stats/gain") {
260
+ const project = url.searchParams.get("project");
261
+ const stats = await getGainDashboardStats(range, project);
262
+ return Response.json(stats);
263
+ }
264
+
258
265
  return new Response("Not Found", { status: 404 });
259
266
  }
260
267
 
@@ -232,3 +232,40 @@ export interface BehaviorDashboardStats {
232
232
  byModel: BehaviorModelStats[];
233
233
  behaviorSeries: BehaviorTimeSeriesPoint[];
234
234
  }
235
+
236
+ /** Token savings from a single source type. */
237
+ export interface GainSourceTotals {
238
+ savedTokens: number;
239
+ savedBytes: number;
240
+ hits: number;
241
+ /** originalBytes - savedBytes, when original is known */
242
+ outputBytes: number;
243
+ /** Total original bytes before compression, when known */
244
+ originalBytes: number;
245
+ /** savedBytes / originalBytes when both are known, else null */
246
+ reductionPercent: number | null;
247
+ }
248
+
249
+ /** Per-source breakdown. */
250
+ export type GainSource = "snapcompact";
251
+
252
+ /** Time-series point for gain (daily bucket). */
253
+ export interface GainTimeSeriesPoint {
254
+ date: string;
255
+ snapcompact: number;
256
+ total: number;
257
+ }
258
+
259
+ /** Complete gain dashboard payload. */
260
+ export interface GainDashboardStats {
261
+ /** Aggregate across all sources for the active range. */
262
+ overall: GainSourceTotals;
263
+ /** Per-source breakdown. */
264
+ bySource: Record<GainSource, GainSourceTotals>;
265
+ /** Daily time series. */
266
+ timeSeries: GainTimeSeriesPoint[];
267
+ /** Active project filter (cwd prefix), or null for all projects. */
268
+ project: string | null;
269
+ /** All distinct projects seen in the data, for the selector. */
270
+ projects: string[];
271
+ }