@oh-my-pi/omp-stats 18.0.10 → 18.1.0

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 (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +101 -101
  4. package/dist/client/styles.css +265 -0
  5. package/dist/types/client/api.d.ts +7 -1
  6. package/dist/types/client/app/routes.d.ts +1 -1
  7. package/dist/types/client/data/useHashRoute.d.ts +2 -0
  8. package/dist/types/client/routes/TracesRoute.d.ts +11 -0
  9. package/dist/types/client/routes/index.d.ts +1 -0
  10. package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
  11. package/dist/types/client/traces/Minimap.d.ts +14 -0
  12. package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
  13. package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
  14. package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
  15. package/dist/types/client/traces/TraceView.d.ts +11 -0
  16. package/dist/types/client/traces/TranscriptList.d.ts +24 -0
  17. package/dist/types/client/traces/time-scale.d.ts +40 -0
  18. package/dist/types/client/traces/trace-colors.d.ts +31 -0
  19. package/dist/types/db.d.ts +24 -1
  20. package/dist/types/index.d.ts +1 -1
  21. package/dist/types/parser.d.ts +8 -0
  22. package/dist/types/server.d.ts +3 -2
  23. package/dist/types/shared-types.d.ts +130 -0
  24. package/dist/types/trace.d.ts +33 -0
  25. package/dist/types/types.d.ts +35 -1
  26. package/package.json +8 -8
  27. package/src/client/App.tsx +11 -1
  28. package/src/client/api.ts +23 -0
  29. package/src/client/app/routes.ts +7 -0
  30. package/src/client/data/useHashRoute.ts +28 -10
  31. package/src/client/routes/ProvidersRoute.tsx +1 -0
  32. package/src/client/routes/TracesRoute.tsx +182 -0
  33. package/src/client/routes/index.ts +1 -0
  34. package/src/client/styles.css +316 -0
  35. package/src/client/traces/AggregatesPanel.tsx +88 -0
  36. package/src/client/traces/Minimap.tsx +177 -0
  37. package/src/client/traces/SpanDrawer.tsx +309 -0
  38. package/src/client/traces/SummaryStrip.tsx +37 -0
  39. package/src/client/traces/TimelineCanvas.tsx +768 -0
  40. package/src/client/traces/TraceView.tsx +344 -0
  41. package/src/client/traces/TranscriptList.tsx +166 -0
  42. package/src/client/traces/time-scale.ts +250 -0
  43. package/src/client/traces/trace-colors.ts +77 -0
  44. package/src/db.ts +77 -0
  45. package/src/index.ts +1 -6
  46. package/src/parser.ts +55 -2
  47. package/src/server.ts +69 -17
  48. package/src/shared-types.ts +138 -0
  49. package/src/trace.ts +1002 -0
  50. package/src/types.ts +45 -1
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Virtual time axis for the trace flamegraph.
3
+ *
4
+ * All three axis modes are piecewise-linear monotonic maps over global wall
5
+ * time, so cross-track alignment is preserved in every mode:
6
+ * - `time`: identity, or idle-compressed (long gaps collapse to fixed bridges)
7
+ * - `turns`: each main-track turn interval gets equal virtual width
8
+ * - `calls`: each interval between consecutive span boundaries gets equal width
9
+ */
10
+
11
+ import type { TraceTrack } from "../types";
12
+
13
+ export type AxisMode = "time" | "turns" | "calls";
14
+
15
+ export interface TraceScale {
16
+ /** ms epoch → virtual axis; monotonic non-decreasing. */
17
+ toU(t: number): number;
18
+ /** Inverse (clamped to domain). */
19
+ toT(u: number): number;
20
+ /** Virtual axis domain. */
21
+ domain: [number, number];
22
+ /** Idle bridges (time mode + compression): real gap [t0,t1] at virtual uMid. */
23
+ gaps: Array<{ t0: number; t1: number; uMid: number }>;
24
+ }
25
+
26
+ /** Padding added around activity segments before gap detection (ms). */
27
+ const ACTIVITY_PAD_MS = 2000;
28
+ /** Gaps longer than this get compressed (ms). */
29
+ const GAP_THRESHOLD_MS = 30_000;
30
+ /** Fixed virtual width of a compressed gap bridge (virtual ms). */
31
+ const GAP_BRIDGE_U = 8000;
32
+
33
+ /** Piecewise-linear map defined by matched breakpoints (t ascending, u non-decreasing). */
34
+ function piecewise(tPoints: number[], uPoints: number[]): TraceScale {
35
+ const n = tPoints.length;
36
+ const domain: [number, number] = [uPoints[0], uPoints[n - 1]];
37
+
38
+ const segmentAt = (points: number[], value: number): number => {
39
+ // Binary search for the last breakpoint <= value.
40
+ let lo = 0;
41
+ let hi = n - 1;
42
+ while (lo < hi) {
43
+ const mid = (lo + hi + 1) >> 1;
44
+ if (points[mid] <= value) lo = mid;
45
+ else hi = mid - 1;
46
+ }
47
+ return Math.min(lo, n - 2);
48
+ };
49
+
50
+ return {
51
+ toU(t: number): number {
52
+ if (t <= tPoints[0]) return uPoints[0];
53
+ if (t >= tPoints[n - 1]) return uPoints[n - 1];
54
+ const i = segmentAt(tPoints, t);
55
+ const tSpan = tPoints[i + 1] - tPoints[i];
56
+ if (tSpan <= 0) return uPoints[i];
57
+ return uPoints[i] + ((t - tPoints[i]) / tSpan) * (uPoints[i + 1] - uPoints[i]);
58
+ },
59
+ toT(u: number): number {
60
+ if (u <= uPoints[0]) return tPoints[0];
61
+ if (u >= uPoints[n - 1]) return tPoints[n - 1];
62
+ const i = segmentAt(uPoints, u);
63
+ const uSpan = uPoints[i + 1] - uPoints[i];
64
+ if (uSpan <= 0) return tPoints[i];
65
+ return tPoints[i] + ((u - uPoints[i]) / uSpan) * (tPoints[i + 1] - tPoints[i]);
66
+ },
67
+ domain,
68
+ gaps: [],
69
+ };
70
+ }
71
+
72
+ /** All span intervals across tracks, sorted by start. */
73
+ function collectIntervals(tracks: TraceTrack[]): Array<[number, number]> {
74
+ const intervals: Array<[number, number]> = [];
75
+ for (const track of tracks) {
76
+ for (const span of track.spans) intervals.push([span.start, span.end]);
77
+ }
78
+ intervals.sort((a, b) => a[0] - b[0]);
79
+ return intervals;
80
+ }
81
+
82
+ /** Trace time bounds across all spans (falls back to [0, 1]). */
83
+ function traceBounds(tracks: TraceTrack[]): [number, number] {
84
+ let min = Number.POSITIVE_INFINITY;
85
+ let max = Number.NEGATIVE_INFINITY;
86
+ for (const track of tracks) {
87
+ for (const span of track.spans) {
88
+ if (span.start < min) min = span.start;
89
+ if (span.end > max) max = span.end;
90
+ }
91
+ }
92
+ if (!Number.isFinite(min)) return [0, 1];
93
+ return [min, Math.max(max, min + 1)];
94
+ }
95
+
96
+ /** Equal-virtual-width map over consecutive anchor times. */
97
+ function equalWidthScale(anchors: number[], tMin: number, tMax: number): TraceScale {
98
+ const unique = [...new Set(anchors.filter(t => t >= tMin && t <= tMax))].sort((a, b) => a - b);
99
+ if (unique[0] !== tMin) unique.unshift(tMin);
100
+ if (unique[unique.length - 1] !== tMax) unique.push(tMax);
101
+ if (unique.length < 2) return piecewise([tMin, tMax], [tMin, tMax]);
102
+ // Keep the virtual domain the same length as the real one so zoom math is
103
+ // scale-independent; each anchor interval gets an equal slice of it.
104
+ const total = tMax - tMin;
105
+ const step = total / (unique.length - 1);
106
+ const uPoints = unique.map((_, i) => tMin + i * step);
107
+ return piecewise(unique, uPoints);
108
+ }
109
+
110
+ /** Idle-compressed time scale: activity segments identity, long gaps bridged. */
111
+ function compressedTimeScale(tracks: TraceTrack[], tMin: number, tMax: number): TraceScale {
112
+ const intervals = collectIntervals(tracks);
113
+ // Merge padded activity intervals into segments.
114
+ const segments: Array<[number, number]> = [];
115
+ for (const [s, e] of intervals) {
116
+ const start = s - ACTIVITY_PAD_MS;
117
+ const end = e + ACTIVITY_PAD_MS;
118
+ const last = segments[segments.length - 1];
119
+ if (last && start <= last[1]) {
120
+ if (end > last[1]) last[1] = end;
121
+ } else {
122
+ segments.push([start, end]);
123
+ }
124
+ }
125
+ if (segments.length === 0) segments.push([tMin, tMax]);
126
+ // Clamp to the trace bounds.
127
+ segments[0][0] = Math.max(segments[0][0], tMin);
128
+ segments[segments.length - 1][1] = Math.min(
129
+ Math.max(segments[segments.length - 1][1], segments[segments.length - 1][0]),
130
+ tMax,
131
+ );
132
+
133
+ const tPoints: number[] = [];
134
+ const uPoints: number[] = [];
135
+ const gaps: TraceScale["gaps"] = [];
136
+ let u = segments[0][0];
137
+ for (let i = 0; i < segments.length; i++) {
138
+ const [s, e] = segments[i];
139
+ if (i === 0) {
140
+ tPoints.push(s);
141
+ uPoints.push(u);
142
+ } else {
143
+ const prevEnd = segments[i - 1][1];
144
+ const gap = s - prevEnd;
145
+ if (gap > GAP_THRESHOLD_MS) {
146
+ u += GAP_BRIDGE_U;
147
+ gaps.push({ t0: prevEnd, t1: s, uMid: u - GAP_BRIDGE_U / 2 });
148
+ } else {
149
+ u += gap;
150
+ }
151
+ tPoints.push(s);
152
+ uPoints.push(u);
153
+ }
154
+ u += Math.max(0, e - s);
155
+ tPoints.push(e);
156
+ uPoints.push(u);
157
+ }
158
+ const scale = piecewise(tPoints, uPoints);
159
+ return { ...scale, gaps };
160
+ }
161
+
162
+ /** Build the axis map for the given mode over all tracks. */
163
+ export function buildScale(tracks: TraceTrack[], mode: AxisMode, compressIdle: boolean): TraceScale {
164
+ const [tMin, tMax] = traceBounds(tracks);
165
+
166
+ if (mode === "turns") {
167
+ const main = tracks.find(track => track.id === "main");
168
+ const anchors = (main?.spans ?? []).filter(span => span.kind === "turn").map(span => span.start);
169
+ return equalWidthScale(anchors, tMin, tMax);
170
+ }
171
+ if (mode === "calls") {
172
+ const anchors: number[] = [];
173
+ for (const track of tracks) {
174
+ for (const span of track.spans) {
175
+ if (span.kind === "model" || span.kind === "tool" || span.kind === "background") {
176
+ anchors.push(span.start, span.end);
177
+ }
178
+ }
179
+ }
180
+ return equalWidthScale(anchors, tMin, tMax);
181
+ }
182
+ if (compressIdle) return compressedTimeScale(tracks, tMin, tMax);
183
+ return piecewise([tMin, tMax], [tMin, tMax]);
184
+ }
185
+
186
+ export interface Tick {
187
+ u: number;
188
+ label: string;
189
+ major: boolean;
190
+ }
191
+
192
+ /** Target pixel spacing between ticks. */
193
+ const TICK_TARGET_PX = 90;
194
+
195
+ /** Round a raw step up to a "nice" 1/2/5 × 10ⁿ ms step. */
196
+ function niceStep(raw: number): number {
197
+ const pow = 10 ** Math.floor(Math.log10(Math.max(raw, 1)));
198
+ for (const mult of [1, 2, 5, 10]) {
199
+ if (pow * mult >= raw) return pow * mult;
200
+ }
201
+ return pow * 10;
202
+ }
203
+
204
+ function formatWallClock(t: number): string {
205
+ const date = new Date(t);
206
+ const hh = String(date.getHours()).padStart(2, "0");
207
+ const mm = String(date.getMinutes()).padStart(2, "0");
208
+ const ss = String(date.getSeconds()).padStart(2, "0");
209
+ return `${hh}:${mm}:${ss}`;
210
+ }
211
+
212
+ /** Offset label from trace start: `+mm:ss.mmm`, hours prepended when needed. */
213
+ export function formatOffset(deltaMs: number): string {
214
+ const sign = deltaMs < 0 ? "-" : "+";
215
+ const abs = Math.abs(deltaMs);
216
+ const hours = Math.floor(abs / 3_600_000);
217
+ const minutes = Math.floor((abs % 3_600_000) / 60_000);
218
+ const seconds = Math.floor((abs % 60_000) / 1000);
219
+ const millis = Math.floor(abs % 1000);
220
+ const withMs = abs % 1000 !== 0;
221
+ const mmss = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${withMs ? "." : ""}${withMs ? String(millis).padStart(3, "0") : ""}`;
222
+ return hours > 0 ? `${sign}${hours}:${mmss}` : `${sign}${mmss}`;
223
+ }
224
+
225
+ /**
226
+ * Ruler ticks inside `[u0, u1]`. Ticks are placed at nice wall-time steps in
227
+ * the underlying t-space (per linear segment in compressed/sequence modes),
228
+ * with major ticks labelled as local wall clock and minors as offsets.
229
+ */
230
+ export function buildTicks(scale: TraceScale, u0: number, u1: number, widthPx: number): Tick[] {
231
+ const uSpan = u1 - u0;
232
+ if (uSpan <= 0 || widthPx <= 0) return [];
233
+ const ticks: Tick[] = [];
234
+ const d0 = scale.domain[0];
235
+ // Nice step in u-space (u is virtual milliseconds).
236
+ const step = niceStep((uSpan * TICK_TARGET_PX) / widthPx);
237
+ // Anchor the grid to the trace start so offset labels come out round.
238
+ const first = d0 + Math.ceil((u0 - d0) / step) * step;
239
+ let count = 0;
240
+ for (let u = first; u <= u1 && count < 200; u += step, count++) {
241
+ const t = scale.toT(u);
242
+ const major = Math.round((u - d0) / step) % 5 === 0;
243
+ ticks.push({
244
+ u,
245
+ label: major ? formatWallClock(t) : formatOffset(u - d0),
246
+ major,
247
+ });
248
+ }
249
+ return ticks;
250
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Category palette + canvas chrome tokens for the trace flamegraph, keyed by
3
+ * resolved theme. Hues align with the dashboard chip palette; chrome tokens
4
+ * mirror `CHART_THEMES` so the canvas matches the chart.js surfaces.
5
+ */
6
+
7
+ import type { TraceSpanKind } from "../types";
8
+
9
+ export interface TraceTheme {
10
+ /** Span fill per category. */
11
+ category: Record<TraceSpanKind, string>;
12
+ /** Error fill tint + left-edge accent. */
13
+ error: string;
14
+ /** Marker diamond fill. */
15
+ marker: string;
16
+ /** Alternating turn-band fill. */
17
+ turnBand: string;
18
+ /** Canvas background. */
19
+ surface: string;
20
+ /** Ruler/lane hairlines. */
21
+ grid: string;
22
+ /** Ruler tick labels. */
23
+ tick: string;
24
+ /** Span label text drawn inside blocks. */
25
+ spanText: string;
26
+ /** Selection outline (brand cyan focus ring). */
27
+ selection: string;
28
+ tooltipBackground: string;
29
+ tooltipTitle: string;
30
+ tooltipBody: string;
31
+ tooltipBorder: string;
32
+ }
33
+
34
+ export const TRACE_THEMES: Record<"dark" | "light", TraceTheme> = {
35
+ dark: {
36
+ category: {
37
+ turn: "#34d399",
38
+ model: "#a78bfa",
39
+ tool: "#fbbf24",
40
+ subagent: "#38bdf8",
41
+ background: "#94a3b8",
42
+ },
43
+ error: "#f87171",
44
+ marker: "#facc15",
45
+ turnBand: "rgba(255, 255, 255, 0.035)",
46
+ surface: "transparent",
47
+ grid: "rgba(255, 255, 255, 0.06)",
48
+ tick: "#867a93",
49
+ spanText: "#16121c",
50
+ selection: "#5ad8e6",
51
+ tooltipBackground: "#241a2e",
52
+ tooltipTitle: "#eae5ef",
53
+ tooltipBody: "#a89fb3",
54
+ tooltipBorder: "rgba(255, 255, 255, 0.12)",
55
+ },
56
+ light: {
57
+ category: {
58
+ turn: "#059669",
59
+ model: "#7c3aed",
60
+ tool: "#d97706",
61
+ subagent: "#0284c7",
62
+ background: "#64748b",
63
+ },
64
+ error: "#ef4444",
65
+ marker: "#ca8a04",
66
+ turnBand: "rgba(0, 0, 0, 0.04)",
67
+ surface: "transparent",
68
+ grid: "rgba(20, 12, 28, 0.08)",
69
+ tick: "#6a6275",
70
+ spanText: "#ffffff",
71
+ selection: "#0e7490",
72
+ tooltipBackground: "#ffffff",
73
+ tooltipTitle: "#241a2e",
74
+ tooltipBody: "#5a5462",
75
+ tooltipBorder: "rgba(20, 12, 28, 0.15)",
76
+ },
77
+ };
package/src/db.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  BehaviorOverallStats,
19
19
  BehaviorTimeSeriesPoint,
20
20
  CostTimeSeriesPoint,
21
+ DailyActivityPoint,
21
22
  FolderStats,
22
23
  MessageStats,
23
24
  ModelPerformancePoint,
@@ -1150,6 +1151,47 @@ export function getMessageById(id: number): MessageStats | null {
1150
1151
  const row = stmt.get(id);
1151
1152
  return row ? rowToMessageStats(row) : null;
1152
1153
  }
1154
+ /** Per-transcript-file rollup for the Traces session list. */
1155
+ export interface SessionRollupRow {
1156
+ sessionFile: string;
1157
+ requests: number;
1158
+ startedAt: number;
1159
+ endedAt: number;
1160
+ totalTokens: number;
1161
+ costTotal: number;
1162
+ /** Comma-joined DISTINCT models. */
1163
+ models: string;
1164
+ }
1165
+
1166
+ /** Aggregate every synced transcript file into one row (subagents unfolded). */
1167
+ export function getSessionRollups(): SessionRollupRow[] {
1168
+ if (!db) return [];
1169
+ const stmt = db.prepare(`
1170
+ SELECT session_file AS sessionFile,
1171
+ COUNT(*) AS requests,
1172
+ MIN(timestamp) AS startedAt,
1173
+ MAX(timestamp + COALESCE(duration, 0)) AS endedAt,
1174
+ SUM(total_tokens) AS totalTokens,
1175
+ SUM(cost_total) AS costTotal,
1176
+ GROUP_CONCAT(DISTINCT model) AS models
1177
+ FROM messages
1178
+ GROUP BY session_file
1179
+ `);
1180
+ return stmt.all() as SessionRollupRow[];
1181
+ }
1182
+
1183
+ /** Tool-call counts keyed by transcript file, for the Traces session list. */
1184
+ export function getToolCallCountsBySession(): Map<string, number> {
1185
+ const counts = new Map<string, number>();
1186
+ if (!db) return counts;
1187
+ const stmt = db.prepare(
1188
+ "SELECT session_file AS sessionFile, COUNT(*) AS calls FROM tool_calls GROUP BY session_file",
1189
+ );
1190
+ for (const row of stmt.all() as Array<{ sessionFile: string; calls: number }>) {
1191
+ counts.set(row.sessionFile, row.calls);
1192
+ }
1193
+ return counts;
1194
+ }
1153
1195
 
1154
1196
  /**
1155
1197
  * Get daily cost time series data for the last N days, broken down by model.
@@ -1193,6 +1235,41 @@ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSe
1193
1235
  }));
1194
1236
  }
1195
1237
 
1238
+ /**
1239
+ * Per-local-day activity aggregates for the last `days` days, oldest first.
1240
+ * Self-initializing (opens the stats DB on first use) so the coding-agent TUI
1241
+ * can query without the dashboard server's init flow. Days use the machine's
1242
+ * timezone — this is a localhost tool, same rationale as
1243
+ * {@link getProviderHourlyBurn}.
1244
+ */
1245
+ export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]> {
1246
+ const database = await initDb();
1247
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
1248
+ const stmt = database.prepare(`
1249
+ SELECT
1250
+ date(timestamp / 1000, 'unixepoch', 'localtime') as day,
1251
+ SUM(cost_total) as cost,
1252
+ COUNT(*) as requests,
1253
+ SUM(total_tokens) as total_tokens
1254
+ FROM messages
1255
+ WHERE timestamp >= ?
1256
+ GROUP BY day
1257
+ ORDER BY day ASC
1258
+ `);
1259
+ const rows = stmt.all(cutoff) as Array<{
1260
+ day: string;
1261
+ cost: number | null;
1262
+ requests: number;
1263
+ total_tokens: number | null;
1264
+ }>;
1265
+ return rows.map(row => ({
1266
+ day: row.day,
1267
+ cost: row.cost ?? 0,
1268
+ requests: row.requests,
1269
+ totalTokens: row.total_tokens ?? 0,
1270
+ }));
1271
+ }
1272
+
1196
1273
  /**
1197
1274
  * Reset `file_offsets` (and any existing `user_messages` rows) so the next
1198
1275
  * successful sync re-parses every session and re-derives behavioral metrics.
package/src/index.ts CHANGED
@@ -18,12 +18,7 @@ export {
18
18
  export { closeDb } from "./db";
19
19
  export { getGainDashboardStats } from "./gain-aggregator";
20
20
  export { formatStatsDashboardUrl, startServer } from "./server";
21
- export type {
22
- GainDashboardStats,
23
- GainSource,
24
- GainSourceTotals,
25
- GainTimeSeriesPoint,
26
- } from "./shared-types";
21
+ export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types";
27
22
  export type {
28
23
  AggregatedStats,
29
24
  DashboardStats,
package/src/parser.ts CHANGED
@@ -10,12 +10,14 @@ import {
10
10
  type ToolResultMessage,
11
11
  type Usage,
12
12
  } from "@oh-my-pi/pi-ai";
13
+ import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
13
14
  import { getSessionsDir, isEnoent, readLines } from "@oh-my-pi/pi-utils";
14
15
  import type {
15
16
  AgentType,
16
17
  MessageStats,
17
18
  SessionEntry,
18
19
  SessionMessageEntry,
20
+ SessionModelUsageEntry,
19
21
  SessionServiceTierChangeEntry,
20
22
  ToolCallStats,
21
23
  ToolResultLink,
@@ -52,7 +54,7 @@ export function classifyAgentType(sessionPath: string): AgentType {
52
54
  * Session files are named like: --work--pi--/timestamp_uuid.jsonl
53
55
  * The folder part uses -- as path separator.
54
56
  */
55
- function extractFolderFromPath(sessionPath: string): string {
57
+ export function extractFolderFromPath(sessionPath: string): string {
56
58
  const sessionsDir = getSessionsDir();
57
59
  const rel = path.relative(sessionsDir, sessionPath);
58
60
  const projectDir = rel.split(path.sep)[0];
@@ -73,6 +75,12 @@ function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
73
75
  return msgEntry.message?.role === "assistant";
74
76
  }
75
77
 
78
+ function isModelUsage(entry: SessionEntry): entry is SessionModelUsageEntry {
79
+ if (entry.type !== "model_usage") return false;
80
+ const usageEntry = entry as SessionModelUsageEntry;
81
+ return typeof usageEntry.id === "string" && usageEntry.id.length > 0;
82
+ }
83
+
76
84
  /**
77
85
  * Check if an entry is a user message (non-toolResult).
78
86
  */
@@ -173,7 +181,11 @@ function extractStats(
173
181
  // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
174
182
  // the new AI code path) and only synthesise when the field is missing/zero.
175
183
  const recorded = rawUsage.premiumRequests ?? 0;
176
- const model = { provider: msg.provider, api: msg.api, id: msg.model };
184
+ const model = {
185
+ provider: msg.provider,
186
+ api: msg.api,
187
+ identity: classifyModel(msg.provider, msg.model, { lenient: true }),
188
+ };
177
189
  const tier = resolveModelServiceTier(currentServiceTier, model);
178
190
  const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
179
191
  const wellFormed =
@@ -215,6 +227,38 @@ function extractStats(
215
227
  };
216
228
  }
217
229
 
230
+ function extractModelUsageStats(
231
+ sessionFile: string,
232
+ folder: string,
233
+ entry: SessionModelUsageEntry,
234
+ agentType: AgentType,
235
+ ): MessageStats | null {
236
+ const timestamp = Date.parse(entry.timestamp);
237
+ return extractStats(
238
+ sessionFile,
239
+ folder,
240
+ {
241
+ type: "message",
242
+ id: entry.id,
243
+ parentId: entry.parentId,
244
+ timestamp: entry.timestamp,
245
+ message: {
246
+ role: "assistant",
247
+ content: [],
248
+ api: entry.api,
249
+ provider: entry.provider,
250
+ model: entry.model,
251
+ usage: entry.usage,
252
+ stopReason: entry.stopReason ?? "stop",
253
+ errorMessage: entry.errorMessage,
254
+ timestamp: Number.isFinite(timestamp) ? timestamp : 0,
255
+ },
256
+ },
257
+ undefined,
258
+ agentType,
259
+ );
260
+ }
261
+
218
262
  /** Message timestamp, falling back to the entry's ISO timestamp, then 0. */
219
263
  function coerceEntryTimestamp(timestamp: number | undefined, entry: SessionMessageEntry): number {
220
264
  if (typeof timestamp === "number" && Number.isFinite(timestamp)) return timestamp;
@@ -336,6 +380,10 @@ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[
336
380
  const read = visitSessionEntriesLenient(bytes, entry => entries.push(entry));
337
381
  return { entries, read };
338
382
  }
383
+ /** Parse every well-formed entry in a transcript buffer (malformed lines skipped). */
384
+ export function parseAllSessionEntries(bytes: Uint8Array): SessionEntry[] {
385
+ return parseSessionEntriesLenient(bytes).entries;
386
+ }
339
387
 
340
388
  function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined {
341
389
  let currentServiceTier: ServiceTierByFamily | undefined;
@@ -411,6 +459,11 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
411
459
  if (link) toolResults.push(link);
412
460
  continue;
413
461
  }
462
+ if (isModelUsage(entry)) {
463
+ const modelUsageStats = extractModelUsageStats(sessionPath, folder, entry, agentType);
464
+ if (modelUsageStats) stats.push(modelUsageStats);
465
+ continue;
466
+ }
414
467
  if (isAssistantMessage(entry)) {
415
468
  const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, agentType);
416
469
  if (msgStats) stats.push(msgStats);
package/src/server.ts CHANGED
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
- import { $ } from "bun";
6
+ import { $, type Server } from "bun";
7
7
  import {
8
8
  getBehaviorDashboardStats,
9
9
  getCostDashboardStats,
@@ -30,6 +30,7 @@ import {
30
30
  STATS_DASHBOARD_HOSTNAME_HEADER,
31
31
  STATS_DASHBOARD_SECURITY_VERSION,
32
32
  } from "./port-conflict";
33
+ import { buildSessionTrace, getTraceEntry, listSessionSummaries, TRACE_ETAG_VERSION, TracePathError } from "./trace";
33
34
 
34
35
  const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
35
36
 
@@ -282,6 +283,41 @@ export async function handleApi(req: Request): Promise<Response> {
282
283
  const stats = await getGainDashboardStats(range, project);
283
284
  return Response.json(stats);
284
285
  }
286
+ if (path === "/api/sessions") {
287
+ const limitParam = Number(url.searchParams.get("limit") ?? "100");
288
+ const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.floor(limitParam) : 100;
289
+ const q = url.searchParams.get("q") ?? undefined;
290
+ return Response.json(await listSessionSummaries(limit, q));
291
+ }
292
+
293
+ if (path === "/api/session/trace") {
294
+ const file = url.searchParams.get("file");
295
+ if (!file) return Response.json({ error: "file required" }, { status: 400 });
296
+ try {
297
+ const trace = await buildSessionTrace(file);
298
+ const etag = `"${TRACE_ETAG_VERSION}:${trace.mtimeMs}"`;
299
+ if (req.headers.get("if-none-match") === etag) return new Response(null, { status: 304 });
300
+ return Response.json(trace, { headers: { ETag: etag } });
301
+ } catch (err) {
302
+ if (err instanceof TracePathError) return Response.json({ error: err.message }, { status: 400 });
303
+ if (isEnoent(err)) return Response.json({ error: "session not found" }, { status: 404 });
304
+ throw err;
305
+ }
306
+ }
307
+
308
+ if (path === "/api/session/entry") {
309
+ const file = url.searchParams.get("file");
310
+ const id = url.searchParams.get("id");
311
+ if (!file || !id) return Response.json({ error: "file and id required" }, { status: 400 });
312
+ try {
313
+ const entry = await getTraceEntry(file, id);
314
+ if (!entry) return Response.json({ error: "entry not found" }, { status: 404 });
315
+ return Response.json({ entry });
316
+ } catch (err) {
317
+ if (err instanceof TracePathError) return Response.json({ error: err.message }, { status: 400 });
318
+ throw err;
319
+ }
320
+ }
285
321
 
286
322
  return new Response("Not Found", { status: 404 });
287
323
  }
@@ -314,7 +350,7 @@ export function formatStatsDashboardUrl(hostname: string, port: number): string
314
350
  return `http://${urlHostname}:${port}`;
315
351
  }
316
352
 
317
- function createDashboardServer(port: number, hostname: string) {
353
+ function createDashboardServer(port: number, hostname: string): Server<undefined> {
318
354
  const server = Bun.serve({
319
355
  port,
320
356
  hostname,
@@ -367,23 +403,44 @@ function createDashboardServer(port: number, hostname: string) {
367
403
  /**
368
404
  * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
369
405
  */
370
- export async function startServer(
371
- port = 3847,
372
- hostname = STATS_DASHBOARD_HOSTNAME,
373
- ): Promise<{ hostname: string; port: number; stop: () => void }> {
406
+ export interface StatsServerHandle {
407
+ hostname: string;
408
+ port: number;
409
+ stop: () => void;
410
+ }
411
+
412
+ // Dashboards this process already bound, keyed by requested `hostname:port`.
413
+ // A second in-process start (e.g. `/trace` twice in one session) must return
414
+ // the live handle: probing our own port can time out under load and would
415
+ // then dead-end in the reclaim path's self-PID guard.
416
+ const activeServers = new Map<string, StatsServerHandle>();
417
+
418
+ export async function startServer(port = 3847, hostname = STATS_DASHBOARD_HOSTNAME): Promise<StatsServerHandle> {
419
+ const activeKey = `${hostname}:${port}`;
420
+ if (port !== 0) {
421
+ const active = activeServers.get(activeKey);
422
+ if (active) return active;
423
+ }
374
424
  await ensureClientBuild();
375
425
  const preparation = await prepareStatsPort(port, hostname);
376
426
  if (preparation === "reuse") {
377
427
  return { hostname, port, stop: () => {} };
378
428
  }
379
-
380
- try {
381
- const server = createDashboardServer(port, hostname);
382
- return {
429
+ const register = (server: Server<undefined>): StatsServerHandle => {
430
+ const handle: StatsServerHandle = {
383
431
  hostname,
384
432
  port: server.port ?? port,
385
- stop: () => server.stop(),
433
+ stop: () => {
434
+ activeServers.delete(activeKey);
435
+ server.stop();
436
+ },
386
437
  };
438
+ if (port !== 0) activeServers.set(activeKey, handle);
439
+ return handle;
440
+ };
441
+
442
+ try {
443
+ return register(createDashboardServer(port, hostname));
387
444
  } catch (error) {
388
445
  if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
389
446
 
@@ -393,12 +450,7 @@ export async function startServer(
393
450
  }
394
451
 
395
452
  try {
396
- const server = createDashboardServer(port, hostname);
397
- return {
398
- hostname,
399
- port: server.port ?? port,
400
- stop: () => server.stop(),
401
- };
453
+ return register(createDashboardServer(port, hostname));
402
454
  } catch (retryError) {
403
455
  throw new Error(`Failed to start stats dashboard on ${hostname}:${port} after reclaiming it.`, {
404
456
  cause: retryError,