@oh-my-pi/omp-stats 16.1.22 → 16.2.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.
@@ -48,6 +48,17 @@ export declare function syncAllSessions(opts?: SyncOptions): Promise<{
48
48
  processed: number;
49
49
  files: number;
50
50
  }>;
51
+ interface TimeRangeConfig {
52
+ timeSeriesHours: number;
53
+ timeSeriesBucketMs: number;
54
+ modelSeriesDays: number;
55
+ modelSeriesBucketMs: number;
56
+ modelPerformanceDays: number;
57
+ modelPerformanceBucketMs: number;
58
+ costSeriesDays: number;
59
+ cutoff: number | null;
60
+ }
61
+ export declare function getTimeRangeConfig(range?: string | null): TimeRangeConfig;
51
62
  /**
52
63
  * Get all dashboard stats.
53
64
  */
@@ -63,3 +74,4 @@ export declare function getRequestDetails(id: number): Promise<RequestDetails |
63
74
  */
64
75
  export declare function getTotalMessageCount(): Promise<number>;
65
76
  export declare function getBehaviorDashboardStats(range?: string | null): Promise<BehaviorDashboardStats>;
77
+ export {};
@@ -1,4 +1,4 @@
1
- import type { BehaviorDashboardStats, CostDashboardStats, FolderStats, MessageStats, ModelDashboardStats, OverviewStats, RequestDetails, TimeRange } from "./types";
1
+ import type { BehaviorDashboardStats, CostDashboardStats, FolderStats, GainDashboardStats, MessageStats, ModelDashboardStats, OverviewStats, RequestDetails, TimeRange } from "./types";
2
2
  export declare class ApiError extends Error {
3
3
  status: number;
4
4
  endpoint: string;
@@ -17,3 +17,4 @@ export declare function sync(signal?: AbortSignal): Promise<{
17
17
  }>;
18
18
  export declare function getBehaviorDashboardStats(range?: TimeRange, signal?: AbortSignal): Promise<BehaviorDashboardStats>;
19
19
  export declare function getFolderStats(range?: TimeRange, signal?: AbortSignal): Promise<FolderStats[]>;
20
+ export declare function getGainDashboardStats(range?: TimeRange, project?: string | null, signal?: AbortSignal): Promise<GainDashboardStats>;
@@ -1,5 +1,5 @@
1
1
  import type React from "react";
2
- export type DashboardSection = "overview" | "requests" | "errors" | "models" | "costs" | "behavior" | "projects";
2
+ export type DashboardSection = "overview" | "requests" | "errors" | "models" | "costs" | "behavior" | "projects" | "gain";
3
3
  export interface DashboardRoute {
4
4
  id: DashboardSection;
5
5
  label: string;
@@ -5,3 +5,4 @@ export declare function formatPercent(value: number, digits?: number): string;
5
5
  export declare function formatDurationMs(value: number | null, digits?: number): string;
6
6
  export declare function formatTokensPerSecond(value: number | null): string;
7
7
  export declare function formatRelativeTime(timestamp: number): string;
8
+ export declare function formatBytes(value: number): string;
@@ -0,0 +1,7 @@
1
+ import type { TimeRange } from "../types";
2
+ export interface GainRouteProps {
3
+ active: boolean;
4
+ range: TimeRange;
5
+ refreshTrigger: number;
6
+ }
7
+ export declare function GainRoute({ active, range, refreshTrigger }: GainRouteProps): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  export * from "./BehaviorRoute";
2
2
  export * from "./CostsRoute";
3
3
  export * from "./ErrorsRoute";
4
+ export * from "./GainRoute";
4
5
  export * from "./ModelsRoute";
5
6
  export * from "./OverviewRoute";
6
7
  export * from "./ProjectsRoute";
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `embedded-client.generated.txt` holds the base64 of a gzipped tar of the
5
5
  * built dashboard (`dist/client`). It is populated by
6
- * `scripts/generate-client-bundle.ts --generate` for compiled binaries and the
6
+ * `gen:stats` for compiled binaries and the
7
7
  * prepacked npm bundle, and reset to an empty file afterwards so the dev tree
8
8
  * keeps building the dashboard from source.
9
9
  */
@@ -0,0 +1,26 @@
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
+ import type { GainDashboardStats } from "./shared-types";
10
+ /**
11
+ * Collapse conventional worktree sub-paths to their logical project root.
12
+ *
13
+ * Rules are generic: omp internal wt paths are dropped; conventional worktree
14
+ * suffixes (`.wt/`, `-wt/`, `.worktrees/`, `-worktrees/`) are stripped. No
15
+ * author-specific IDE or tool paths are baked in.
16
+ *
17
+ * Returns null to drop temp/internal paths entirely.
18
+ */
19
+ export declare function normalizeProjectPath(p: string): string | null;
20
+ /**
21
+ * Given a raw set of paths, normalize worktree paths and remove sub-paths
22
+ * that are already covered by a shorter parent at depth ≥ 4.
23
+ * Returns a sorted, deduped list of meaningful project roots.
24
+ */
25
+ export declare function dedupeProjects(rawPaths: Set<string>): string[];
26
+ export declare function getGainDashboardStats(range?: string | null, project?: string | null): Promise<GainDashboardStats>;
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  export { getDashboardStats, getTotalMessageCount, type SyncOptions, type SyncProgress, smokeTestSyncWorker, syncAllSessions, } from "./aggregator";
3
3
  export { closeDb } from "./db";
4
+ export { getGainDashboardStats } from "./gain-aggregator";
4
5
  export { startServer } from "./server";
6
+ export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint, } from "./shared-types";
5
7
  export type { AggregatedStats, DashboardStats, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, } from "./types";
@@ -218,3 +218,36 @@ export interface BehaviorDashboardStats {
218
218
  byModel: BehaviorModelStats[];
219
219
  behaviorSeries: BehaviorTimeSeriesPoint[];
220
220
  }
221
+ /** Token savings from a single source type. */
222
+ export interface GainSourceTotals {
223
+ savedTokens: number;
224
+ savedBytes: number;
225
+ hits: number;
226
+ /** originalBytes - savedBytes, when original is known */
227
+ outputBytes: number;
228
+ /** Total original bytes before compression, when known */
229
+ originalBytes: number;
230
+ /** savedBytes / originalBytes when both are known, else null */
231
+ reductionPercent: number | null;
232
+ }
233
+ /** Per-source breakdown. */
234
+ export type GainSource = "snapcompact";
235
+ /** Time-series point for gain (daily bucket). */
236
+ export interface GainTimeSeriesPoint {
237
+ date: string;
238
+ snapcompact: number;
239
+ total: number;
240
+ }
241
+ /** Complete gain dashboard payload. */
242
+ export interface GainDashboardStats {
243
+ /** Aggregate across all sources for the active range. */
244
+ overall: GainSourceTotals;
245
+ /** Per-source breakdown. */
246
+ bySource: Record<GainSource, GainSourceTotals>;
247
+ /** Daily time series. */
248
+ timeSeries: GainTimeSeriesPoint[];
249
+ /** Active project filter (cwd prefix), or null for all projects. */
250
+ project: string | null;
251
+ /** All distinct projects seen in the data, for the selector. */
252
+ projects: string[];
253
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "16.1.22",
4
+ "version": "16.2.0",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -28,6 +28,8 @@
28
28
  "omp-stats": "./src/index.ts"
29
29
  },
30
30
  "scripts": {
31
+ "gen:stats": "bun scripts/generate-client-bundle.ts --generate",
32
+ "gen:stats:reset": "bun scripts/generate-client-bundle.ts --reset",
31
33
  "build": "bun run build.ts",
32
34
  "dev": "bun run src/index.ts",
33
35
  "check": "biome check . && bun run check:types",
@@ -37,9 +39,9 @@
37
39
  "fmt": "biome format --write ."
38
40
  },
39
41
  "dependencies": {
40
- "@oh-my-pi/pi-ai": "16.1.22",
41
- "@oh-my-pi/pi-catalog": "16.1.22",
42
- "@oh-my-pi/pi-utils": "16.1.22",
42
+ "@oh-my-pi/pi-ai": "16.2.0",
43
+ "@oh-my-pi/pi-catalog": "16.2.0",
44
+ "@oh-my-pi/pi-utils": "16.2.0",
43
45
  "@tailwindcss/node": "^4.3.0",
44
46
  "chart.js": "^4.5.1",
45
47
  "date-fns": "^4.4.0",
package/src/aggregator.ts CHANGED
@@ -330,7 +330,7 @@ const TIME_RANGE_TO_CONFIG: Record<TimeRange, Omit<TimeRangeConfig, "cutoff">> =
330
330
  },
331
331
  };
332
332
 
333
- function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
333
+ export function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
334
334
  const normalized = range?.trim().toLowerCase() ?? DEFAULT_TIME_RANGE;
335
335
  const config = TIME_RANGE_TO_CONFIG[normalized as TimeRange];
336
336
  if (config) {
@@ -6,6 +6,7 @@ import {
6
6
  BehaviorRoute,
7
7
  CostsRoute,
8
8
  ErrorsRoute,
9
+ GainRoute,
9
10
  ModelsRoute,
10
11
  OverviewRoute,
11
12
  ProjectsRoute,
@@ -77,6 +78,8 @@ export default function App() {
77
78
  return <BehaviorRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
78
79
  case "projects":
79
80
  return <ProjectsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
81
+ case "gain":
82
+ return <GainRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
80
83
  }
81
84
  };
82
85
 
package/src/client/api.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  BehaviorDashboardStats,
3
3
  CostDashboardStats,
4
4
  FolderStats,
5
+ GainDashboardStats,
5
6
  MessageStats,
6
7
  ModelDashboardStats,
7
8
  OverviewStats,
@@ -81,3 +82,13 @@ export async function getBehaviorDashboardStats(
81
82
  export async function getFolderStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<FolderStats[]> {
82
83
  return fetchJson<FolderStats[]>(`${API_BASE}/stats/folders?range=${encodeURIComponent(range)}`, { signal });
83
84
  }
85
+
86
+ export async function getGainDashboardStats(
87
+ range: TimeRange = "24h",
88
+ project?: string | null,
89
+ signal?: AbortSignal,
90
+ ): Promise<GainDashboardStats> {
91
+ const params = new URLSearchParams({ range });
92
+ if (project) params.set("project", project);
93
+ return fetchJson<GainDashboardStats>(`${API_BASE}/stats/gain?${params}`, { signal });
94
+ }
@@ -1,7 +1,15 @@
1
- import { Activity, AlertCircle, Coins, Cpu, Folder, LayoutDashboard, Smile } from "lucide-react";
1
+ import { Activity, AlertCircle, Coins, Cpu, Folder, LayoutDashboard, Smile, TrendingUp } from "lucide-react";
2
2
  import type React from "react";
3
3
 
4
- export type DashboardSection = "overview" | "requests" | "errors" | "models" | "costs" | "behavior" | "projects";
4
+ export type DashboardSection =
5
+ | "overview"
6
+ | "requests"
7
+ | "errors"
8
+ | "models"
9
+ | "costs"
10
+ | "behavior"
11
+ | "projects"
12
+ | "gain";
5
13
 
6
14
  export interface DashboardRoute {
7
15
  id: DashboardSection;
@@ -47,4 +55,9 @@ export const routes: DashboardRoute[] = [
47
55
  label: "Projects",
48
56
  icon: Folder,
49
57
  },
58
+ {
59
+ id: "gain",
60
+ label: "Gain",
61
+ icon: TrendingUp,
62
+ },
50
63
  ];
@@ -36,3 +36,10 @@ export function formatTokensPerSecond(value: number | null): string {
36
36
  export function formatRelativeTime(timestamp: number): string {
37
37
  return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
38
38
  }
39
+
40
+ export function formatBytes(value: number): string {
41
+ if (value >= 1e9) return `${(value / 1e9).toFixed(1)} GB`;
42
+ if (value >= 1e6) return `${(value / 1e6).toFixed(1)} MB`;
43
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)} KB`;
44
+ return `${value} B`;
45
+ }
@@ -10,6 +10,7 @@ const VALID_SECTIONS: DashboardSection[] = [
10
10
  "costs",
11
11
  "behavior",
12
12
  "projects",
13
+ "gain",
13
14
  ];
14
15
 
15
16
  const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
@@ -0,0 +1,226 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Line } from "react-chartjs-2";
3
+ import { getGainDashboardStats } from "../api";
4
+ import { buildSharedPlugins, buildSharedScales, CHART_THEMES, lineDatasetStyle } from "../components/chart-shared";
5
+ import { formatBytes, formatCompact, formatInteger, formatPercent } from "../data/formatters";
6
+ import { useResource } from "../data/useResource";
7
+ import type { GainDashboardStats, GainSourceTotals, GainTimeSeriesPoint, TimeRange } from "../types";
8
+ import { AsyncBoundary, Panel } from "../ui";
9
+ import { useSystemTheme } from "../useSystemTheme";
10
+
11
+ export interface GainRouteProps {
12
+ active: boolean;
13
+ range: TimeRange;
14
+ refreshTrigger: number;
15
+ }
16
+
17
+ export function GainRoute({ active, range, refreshTrigger }: GainRouteProps) {
18
+ const [project, setProject] = useState<string | null>(null);
19
+
20
+ const {
21
+ data: stats,
22
+ error,
23
+ loading,
24
+ } = useResource(["gain", range, refreshTrigger, project], signal => getGainDashboardStats(range, project, signal), {
25
+ pollMs: 30_000,
26
+ enabled: active,
27
+ });
28
+
29
+ return (
30
+ <div className="stats-route-container space-y-6">
31
+ <AsyncBoundary loading={loading} error={error} data={stats}>
32
+ {stats && (
33
+ <>
34
+ <GainProjectSelector projects={stats.projects} selected={project} onChange={setProject} />
35
+ <GainOverallPanel overall={stats.overall} />
36
+ <GainBySourcePanel bySource={stats.bySource} />
37
+ <GainTimeSeriesPanel timeSeries={stats.timeSeries} />
38
+ </>
39
+ )}
40
+ </AsyncBoundary>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Project selector
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function GainProjectSelector({
50
+ projects,
51
+ selected,
52
+ onChange,
53
+ }: {
54
+ projects: string[];
55
+ selected: string | null;
56
+ onChange: (p: string | null) => void;
57
+ }) {
58
+ if (projects.length === 0) return null;
59
+ return (
60
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
61
+ <span className="stats-text-secondary" style={{ fontSize: "0.875rem", whiteSpace: "nowrap" }}>
62
+ Project
63
+ </span>
64
+ <select
65
+ className="stats-select"
66
+ value={selected ?? ""}
67
+ onChange={e => onChange(e.target.value || null)}
68
+ style={{ maxWidth: "480px", flex: 1 }}
69
+ >
70
+ <option value="">All projects</option>
71
+ {projects.map(p => (
72
+ <option key={p} value={p}>
73
+ {p}
74
+ </option>
75
+ ))}
76
+ </select>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Overall metrics panel
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function GainOverallPanel({ overall }: { overall: GainSourceTotals }) {
86
+ return (
87
+ <Panel title="Overall Gain" subtitle="Aggregate snapcompact savings">
88
+ <div className="stats-metric-primary-grid">
89
+ <div className="stats-metric-card primary">
90
+ <div className="stats-metric-label">Saved Tokens</div>
91
+ <div className="stats-metric-value">{formatCompact(overall.savedTokens)}</div>
92
+ </div>
93
+ <div className="stats-metric-card primary">
94
+ <div className="stats-metric-label">Saved Bytes</div>
95
+ <div className="stats-metric-value">{formatBytes(overall.savedBytes)}</div>
96
+ </div>
97
+ <div className="stats-metric-card primary">
98
+ <div className="stats-metric-label">Reduction</div>
99
+ <div className="stats-metric-value">
100
+ {overall.reductionPercent !== null ? formatPercent(overall.reductionPercent) : "—"}
101
+ </div>
102
+ </div>
103
+ <div className="stats-metric-card primary">
104
+ <div className="stats-metric-label">Total Hits</div>
105
+ <div className="stats-metric-value">{formatInteger(overall.hits)}</div>
106
+ </div>
107
+ </div>
108
+ </Panel>
109
+ );
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // By-source breakdown panel
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function SourceCard({ title, totals }: { title: string; totals: GainSourceTotals }) {
117
+ return (
118
+ <div className="stats-metric-card secondary" style={{ flex: 1 }}>
119
+ <div className="stats-metric-label" style={{ fontWeight: 600, marginBottom: 8 }}>
120
+ {title}
121
+ </div>
122
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
123
+ <div>
124
+ <div className="stats-metric-label">Saved Tokens</div>
125
+ <div className="stats-metric-value" style={{ fontSize: "1rem" }}>
126
+ {formatCompact(totals.savedTokens)}
127
+ </div>
128
+ </div>
129
+ <div>
130
+ <div className="stats-metric-label">Saved Bytes</div>
131
+ <div className="stats-metric-value" style={{ fontSize: "1rem" }}>
132
+ {formatBytes(totals.savedBytes)}
133
+ </div>
134
+ </div>
135
+ <div>
136
+ <div className="stats-metric-label">Hits</div>
137
+ <div className="stats-metric-value" style={{ fontSize: "1rem" }}>
138
+ {formatInteger(totals.hits)}
139
+ </div>
140
+ </div>
141
+ <div>
142
+ <div className="stats-metric-label">Reduction</div>
143
+ <div className="stats-metric-value" style={{ fontSize: "1rem" }}>
144
+ {totals.reductionPercent !== null ? formatPercent(totals.reductionPercent) : "—"}
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ function GainBySourcePanel({ bySource }: { bySource: GainDashboardStats["bySource"] }) {
153
+ return (
154
+ <Panel title="By Source" subtitle="Savings breakdown per subsystem">
155
+ <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
156
+ <SourceCard title="Snapcompact" totals={bySource.snapcompact} />
157
+ </div>
158
+ </Panel>
159
+ );
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Time series chart (stacked area, daily)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ const GAIN_COLORS = {
167
+ snapcompact: "rgb(34, 197, 94)",
168
+ } as const;
169
+
170
+ function GainTimeSeriesPanel({ timeSeries }: { timeSeries: GainTimeSeriesPoint[] }) {
171
+ const theme = useSystemTheme();
172
+ const chartTheme = CHART_THEMES[theme];
173
+
174
+ const { data, options } = useMemo(() => {
175
+ const labelFormatter = new Intl.DateTimeFormat(undefined, {
176
+ month: "short",
177
+ day: "numeric",
178
+ timeZone: "UTC",
179
+ });
180
+ const labels = timeSeries.map(p => labelFormatter.format(new Date(`${p.date}T00:00:00.000Z`)));
181
+ const chartData = {
182
+ labels,
183
+ datasets: [
184
+ {
185
+ label: "Snapcompact",
186
+ data: timeSeries.map(p => p.snapcompact),
187
+ ...lineDatasetStyle(GAIN_COLORS.snapcompact),
188
+ },
189
+ ],
190
+ };
191
+
192
+ const { sharedScaleBase, yScale } = buildSharedScales({
193
+ chartTheme,
194
+ formatY: n => formatCompact(n),
195
+ });
196
+
197
+ const chartOptions = {
198
+ responsive: true,
199
+ maintainAspectRatio: false,
200
+ plugins: buildSharedPlugins({
201
+ chartTheme,
202
+ showLegend: true,
203
+ defaultLabel: "Tokens Saved",
204
+ formatValue: formatCompact,
205
+ }),
206
+ scales: {
207
+ x: { ...sharedScaleBase, stacked: true },
208
+ y: { ...yScale, stacked: true },
209
+ },
210
+ };
211
+
212
+ return { data: chartData, options: chartOptions };
213
+ }, [timeSeries, chartTheme]);
214
+
215
+ return (
216
+ <Panel title="Savings Over Time" subtitle="Daily token savings">
217
+ <div style={{ height: 240 }}>
218
+ {timeSeries.length === 0 ? (
219
+ <div className="stats-table-empty">No time series data yet</div>
220
+ ) : (
221
+ <Line data={data} options={options as Parameters<typeof Line>[0]["options"]} />
222
+ )}
223
+ </div>
224
+ </Panel>
225
+ );
226
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./BehaviorRoute";
2
2
  export * from "./CostsRoute";
3
3
  export * from "./ErrorsRoute";
4
+ export * from "./GainRoute";
4
5
  export * from "./ModelsRoute";
5
6
  export * from "./OverviewRoute";
6
7
  export * from "./ProjectsRoute";
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `embedded-client.generated.txt` holds the base64 of a gzipped tar of the
5
5
  * built dashboard (`dist/client`). It is populated by
6
- * `scripts/generate-client-bundle.ts --generate` for compiled binaries and the
6
+ * `gen:stats` for compiled binaries and the
7
7
  * prepacked npm bundle, and reset to an empty file afterwards so the dev tree
8
8
  * keeps building the dashboard from source.
9
9
  */