@aaroncarry/pi-usage 0.1.0 → 0.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.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Interactive trends dashboard: an overlay with three views — Charts
3
+ * (braille time series + model distribution), Heatmap (calendar), Table
4
+ * (provider→model usage) — with period and metric switching.
5
+ */
6
+
7
+ import type { Component } from "@earendil-works/pi-tui";
8
+ import {
9
+ TREND_PERIODS,
10
+ dailyTotals,
11
+ distributionRows,
12
+ chartSeries,
13
+ periodStart,
14
+ projectDistributionRows,
15
+ annotateModelLabels,
16
+ type DistributionRow,
17
+ type ProjectDistributionRow,
18
+ type TrendMetric,
19
+ type TrendPeriod,
20
+ type TrendsData,
21
+ } from "./aggregate.ts";
22
+ import {
23
+ formatCost,
24
+ formatShortDate,
25
+ renderBrailleChart,
26
+ renderChartLegend,
27
+ renderHeatmap,
28
+ renderModelBars,
29
+ renderTable,
30
+ tableFootnote,
31
+ type TableRowGroup,
32
+ } from "./render.ts";
33
+ import { buildInsights, type Insight } from "./insights.ts";
34
+ import { formatTokens } from "../session-usage.ts";
35
+ import type { ThemeLike } from "../ui/statusline.ts";
36
+
37
+ const VIEWS = ["table", "charts", "heatmap", "insights"] as const;
38
+ type View = (typeof VIEWS)[number];
39
+
40
+ const VIEW_LABELS: Record<View, string> = { table: "Table", charts: "Charts", heatmap: "Heatmap", insights: "Insights" };
41
+
42
+ export class TrendsDashboard implements Component {
43
+ private readonly theme: ThemeLike;
44
+ private readonly done: (result: undefined) => void;
45
+ private data: TrendsData | undefined;
46
+ private error: string | undefined;
47
+ private readonly loadPromise: Promise<void>;
48
+ private view: View = "charts";
49
+ private periodIndex = 1; // 30d
50
+ private metricIndex = 0; // tokens
51
+ private expanded = new Set<string>();
52
+ private selected = 0;
53
+ private groupMode: "provider" | "project" = "provider";
54
+
55
+ constructor(deps: { theme: ThemeLike; done: (result: undefined) => void; data: Promise<TrendsData> }) {
56
+ this.theme = deps.theme;
57
+ this.done = deps.done;
58
+ this.loadPromise = deps.data.then(
59
+ (data) => {
60
+ this.data = data;
61
+ },
62
+ (error: unknown) => {
63
+ this.error = error instanceof Error ? error.message : String(error);
64
+ },
65
+ );
66
+ }
67
+
68
+ handleInput(data: string): void {
69
+ if (data === "\x1b" || data === "q" || data === "Q") {
70
+ this.done(undefined);
71
+ return;
72
+ }
73
+ if (!this.data) return;
74
+ if (data === "v" || data === "V") {
75
+ this.view = VIEWS[(VIEWS.indexOf(this.view) + 1) % VIEWS.length]!;
76
+ return;
77
+ }
78
+ if (data === "\t" || data === "\x1b[C") {
79
+ this.periodIndex = (this.periodIndex + 1) % TREND_PERIODS.length;
80
+ return;
81
+ }
82
+ if (data === "\x1b[D") {
83
+ this.periodIndex = (this.periodIndex + TREND_PERIODS.length - 1) % TREND_PERIODS.length;
84
+ return;
85
+ }
86
+ if (data === "m" || data === "M") {
87
+ this.metricIndex = this.metricIndex === 0 ? 1 : 0;
88
+ return;
89
+ }
90
+ if (data === "g" || data === "G") {
91
+ this.groupMode = this.groupMode === "provider" ? "project" : "provider";
92
+ return;
93
+ }
94
+ if (this.view === "table") {
95
+ const groups = this.tableGroups();
96
+ if (data === "\x1b[A") {
97
+ this.selected = Math.max(0, this.selected - 1);
98
+ return;
99
+ }
100
+ if (data === "\x1b[B") {
101
+ this.selected = Math.min(Math.max(0, groups.length - 1), this.selected + 1);
102
+ return;
103
+ }
104
+ if (data === "\r" || data === "\n" || data === " ") {
105
+ const group = groups[this.selected];
106
+ if (group && group.children.length > 0) {
107
+ if (this.expanded.has(group.provider)) this.expanded.delete(group.provider);
108
+ else this.expanded.add(group.provider);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ render(width: number): string[] {
115
+ const theme = this.theme;
116
+ if (!this.data) {
117
+ const loading = this.error ? theme.fg("error", this.error) : theme.fg("dim", "Scanning sessions…");
118
+ return [this.header(width), "", ` ${loading}`];
119
+ }
120
+ const period = TREND_PERIODS[this.periodIndex] as TrendPeriod;
121
+ const metric = (["tokens", "cost"] as const)[this.metricIndex]!;
122
+ const lines: string[] = [this.header(width), this.periodHeader()];
123
+ if (this.view === "charts") lines.push(...this.renderCharts(period, metric, width));
124
+ else if (this.view === "heatmap") lines.push(...this.renderHeatmapView(metric));
125
+ else if (this.view === "insights") lines.push(...this.renderInsightsView(period));
126
+ else lines.push(...this.renderTableView(period, width));
127
+ lines.push("", ` ${theme.fg("dim", "m metric · ←→ period · v view · ↑↓/enter table · esc close")}`);
128
+ return lines;
129
+ }
130
+
131
+ invalidate(): void {}
132
+
133
+ private header(width: number): string {
134
+ const theme = this.theme;
135
+ const tabs = VIEWS.map((view) =>
136
+ view === this.view ? theme.fg("accent", theme.bold(`[${VIEW_LABELS[view]}]`)) : theme.fg("dim", VIEW_LABELS[view]),
137
+ ).join(" ");
138
+ return ` ${theme.fg("accent", theme.bold("Usage trends"))} ${tabs}`.slice(0, width);
139
+ }
140
+
141
+ private periodHeader(): string {
142
+ const theme = this.theme;
143
+ return (
144
+ " " +
145
+ TREND_PERIODS.map((period, index) =>
146
+ index === this.periodIndex ? theme.fg("accent", `[${period}]`) : theme.fg("dim", period),
147
+ ).join(" ")
148
+ );
149
+ }
150
+
151
+ private range(): { fromMs?: number; toMs: number } {
152
+ const now = Date.now();
153
+ const period = TREND_PERIODS[this.periodIndex] as TrendPeriod;
154
+ const dayMs = 86_400_000;
155
+ const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "90d" ? 90 : undefined;
156
+ return { fromMs: days === undefined ? undefined : now - days * dayMs, toMs: now };
157
+ }
158
+
159
+ private renderCharts(period: TrendPeriod, metric: TrendMetric, width: number): string[] {
160
+ const theme = this.theme;
161
+ const data = this.data!;
162
+ const { fromMs, toMs } = this.range();
163
+ const days = dailyTotals(data, metric, fromMs);
164
+ const total = days.reduce((sum, entry) => sum + entry.value, 0);
165
+ const peak = days.reduce<{ dayStart: number; value: number } | undefined>(
166
+ (best, entry) => (!best || entry.value > best.value ? entry : best),
167
+ undefined,
168
+ );
169
+ const costRows = distributionRows(data, fromMs);
170
+ const totalCost = costRows.reduce((sum, row) => sum + row.cost, 0);
171
+ // Axis labels get fractional mid values; the token formatter must round.
172
+ const formatValue = metric === "cost"
173
+ ? (value: number) => `$${value < 10 ? value.toFixed(2) : Math.round(value)}`
174
+ : (value: number) => formatTokens(Math.round(value));
175
+ const summaryParts = [
176
+ `${theme.fg("dim", "Total")} ${metric === "cost" ? formatCost(total) : formatTokens(total)}`,
177
+ `${theme.fg("dim", "Cost")} ${formatCost(totalCost)}`,
178
+ ];
179
+ if (peak && peak.value > 0) {
180
+ summaryParts.push(`${theme.fg("dim", "Peak")} ${formatValue(peak.value)} (${formatShortDate(peak.dayStart)})`);
181
+ }
182
+ const streak = currentStreak(data);
183
+ if (streak > 0) summaryParts.push(`${theme.fg("dim", "Streak")} ${streak}d`);
184
+ const chart = chartSeries(data, { fromMs, toMs, metric, groupBy: "model" });
185
+ // Draw least-important series first so Total and bigger models win the
186
+ // contested braille cells; the legend keeps the original order.
187
+ const drawSeries = [...chart.series.slice(1).reverse(), ...chart.series.slice(0, 1)];
188
+ const chartLines = renderBrailleChart(
189
+ drawSeries.map((entry) => ({ label: entry.label, values: entry.points.map((point) => point.value) })),
190
+ theme,
191
+ width - 2,
192
+ 8,
193
+ chart.startMs,
194
+ toMs,
195
+ formatValue,
196
+ );
197
+ const models = annotateModelLabels(distributionRows(data, fromMs))
198
+ .filter((row) => row.model !== "summaries")
199
+ .slice(0, 5)
200
+ .map((row) => ({ label: row.label, value: metric === "cost" ? row.cost : row.tokens }));
201
+ return [
202
+ ` ${summaryParts.join(theme.fg("dim", " · "))}`,
203
+ "",
204
+ ...chartLines.map((line) => ` ${line}`),
205
+ ` ${renderChartLegend(chart.series, theme)}`,
206
+ "",
207
+ ` ${theme.fg("dim", `Models · ${period}`)}`,
208
+ ...renderModelBars(models, theme, width - 2, formatValue),
209
+ ];
210
+ }
211
+
212
+ private renderHeatmapView(metric: TrendMetric): string[] {
213
+ const theme = this.theme;
214
+ const data = this.data!;
215
+ const days = dailyTotals(data, metric, undefined);
216
+ const total = days.reduce((sum, entry) => sum + entry.value, 0);
217
+ const peak = days.reduce<{ dayStart: number; value: number } | undefined>(
218
+ (best, entry) => (!best || entry.value > best.value ? entry : best),
219
+ undefined,
220
+ );
221
+ const lines = [
222
+ ` ${theme.fg("dim", "Activity · 12 weeks")} ${theme.fg("dim", `Streak`)} ${currentStreak(data)}d`,
223
+ ...renderHeatmap(days, theme, 12),
224
+ ];
225
+ if (peak && peak.value > 0) {
226
+ const formatted = metric === "cost" ? formatCost(peak.value) : formatTokens(peak.value);
227
+ const unit = metric === "cost" ? "" : " tokens";
228
+ lines.push(
229
+ ` ${theme.fg("dim", `Peak ${formatted}${unit} on`)} ${formatShortDate(peak.dayStart)} ${theme.fg("dim", "· Total")} ${
230
+ metric === "cost" ? formatCost(total) : formatTokens(total)
231
+ }`,
232
+ );
233
+ }
234
+ lines.push(
235
+ ` ${theme.fg("muted", "░ none")} ${theme.fg("dim", "▒ light")} ${theme.fg("text", "▓ mid")} ${theme.fg("accent", "█ heavy")}`,
236
+ );
237
+ return lines;
238
+ }
239
+
240
+ private renderInsightsView(period: TrendPeriod): string[] {
241
+ const theme = this.theme;
242
+ const insights = buildInsights(this.data!, periodStart(period));
243
+ const lines = [` ${theme.fg("accent", theme.bold("What's contributing to your cost?"))} ${theme.fg("dim", period)}`];
244
+ const structure = insights.filter((insight) => insight.kind === "structure");
245
+ const alarms = insights.filter((insight) => insight.kind === "alarm");
246
+ const renderInsight = (insight: Insight): string[] => {
247
+ const out = [` ${theme.fg("dim", insight.stat.padStart(6))} ${insight.headline}`];
248
+ if (insight.advice) out.push(` ${theme.fg("dim", insight.advice)}`);
249
+ return out;
250
+ };
251
+ if (structure.length > 0) {
252
+ lines.push(` ${theme.fg("dim", "Where it went")}`);
253
+ for (const insight of structure) lines.push(...renderInsight(insight));
254
+ }
255
+ if (alarms.length > 0) {
256
+ lines.push(` ${theme.fg("dim", "Worth attention")}`);
257
+ for (const insight of alarms) lines.push(...renderInsight(insight));
258
+ }
259
+ if (structure.length === 0 && alarms.length === 0) {
260
+ lines.push(` ${theme.fg("muted", "✓ no waste patterns flagged for this period")}`);
261
+ }
262
+ return lines;
263
+ }
264
+
265
+ private tableGroups(): TableRowGroup[] {
266
+ const data = this.data!;
267
+ const { fromMs } = this.range();
268
+ const rows = distributionRows(data, fromMs);
269
+ const byProvider = new Map<string, DistributionRow[]>();
270
+ for (const row of rows) {
271
+ const list = byProvider.get(row.provider) ?? [];
272
+ list.push(row);
273
+ byProvider.set(row.provider, list);
274
+ }
275
+ const groups: TableRowGroup[] = [];
276
+ for (const [provider, children] of byProvider) {
277
+ const aggregate = children.reduce(
278
+ (accumulator, child) => {
279
+ accumulator.messages += child.messages;
280
+ accumulator.cost += child.cost;
281
+ accumulator.tokens += child.tokens;
282
+ accumulator.input += child.input;
283
+ accumulator.output += child.output;
284
+ accumulator.cacheRead += child.cacheRead;
285
+ accumulator.cacheWrite += child.cacheWrite;
286
+ accumulator.reasoning += child.reasoning;
287
+ return accumulator;
288
+ },
289
+ { provider, model: provider, sessions: 0, messages: 0, cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
290
+ );
291
+ aggregate.sessions = new Set(children.flatMap((child) => [...(data.sessions.get(`${child.provider}\u0000${child.model}`) ?? [])])).size;
292
+ children.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
293
+ groups.push({ provider, row: aggregate, children });
294
+ }
295
+ groups.sort((a, b) => b.row.cost - a.row.cost || b.row.tokens - a.row.tokens);
296
+ if (this.selected >= groups.length) this.selected = Math.max(0, groups.length - 1);
297
+ return groups;
298
+ }
299
+
300
+ private projectTableGroups(): TableRowGroup[] {
301
+ const data = this.data!;
302
+ const { fromMs } = this.range();
303
+ const rows = projectDistributionRows(data, fromMs);
304
+ const byProject = new Map<string, ProjectDistributionRow[]>();
305
+ for (const row of rows) {
306
+ const list = byProject.get(row.project) ?? [];
307
+ list.push(row);
308
+ byProject.set(row.project, list);
309
+ }
310
+ const groups: TableRowGroup[] = [];
311
+ for (const [project, children] of byProject) {
312
+ const aggregate = children.reduce(
313
+ (accumulator, child) => {
314
+ accumulator.messages += child.messages;
315
+ accumulator.cost += child.cost;
316
+ accumulator.tokens += child.tokens;
317
+ accumulator.input += child.input;
318
+ accumulator.output += child.output;
319
+ accumulator.cacheRead += child.cacheRead;
320
+ accumulator.cacheWrite += child.cacheWrite;
321
+ accumulator.reasoning += child.reasoning;
322
+ return accumulator;
323
+ },
324
+ { provider: project, model: project, sessions: 0, messages: 0, cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, project },
325
+ );
326
+ aggregate.sessions = new Set(children.flatMap((child) => [...(data.projectSessions.get(`${child.project}${child.provider}${child.model}`) ?? [])])).size;
327
+ children.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
328
+ groups.push({ provider: project, row: aggregate, children });
329
+ }
330
+ groups.sort((a, b) => b.row.cost - a.row.cost || b.row.tokens - a.row.tokens);
331
+ if (this.selected >= groups.length) this.selected = Math.max(0, groups.length - 1);
332
+ return groups;
333
+ }
334
+
335
+ private renderTableView(period: TrendPeriod, width: number): string[] {
336
+ const theme = this.theme;
337
+ const groups = this.groupMode === "project" ? this.projectTableGroups() : this.tableGroups();
338
+ if (groups.length === 0) return [` ${theme.fg("muted", "No usage recorded in this period")}`];
339
+ // Total-row session count: union across models, not the (double-counting) sum.
340
+ const data = this.data!;
341
+ const { fromMs } = this.range();
342
+ const sessionUnion = new Set(
343
+ distributionRows(data, fromMs).flatMap((row) => [...(data.sessions.get(`${row.provider}${row.model}`) ?? [])]),
344
+ );
345
+ return [
346
+ ...renderTable(groups, theme, width - 2, this.expanded, this.selected, sessionUnion.size),
347
+ ` ${tableFootnote(theme)} ${theme.fg("dim", `· ${period} · ${this.groupMode === "project" ? "by project" : "by provider"} · g switch`)}`,
348
+ ];
349
+ }
350
+ }
351
+
352
+ function currentStreak(data: TrendsData): number {
353
+ const active = new Set(dailyTotals(data, "tokens", undefined).filter((entry) => entry.value > 0).map((entry) => entry.dayStart));
354
+ const dayMs = 86_400_000;
355
+ let streak = 0;
356
+ let cursor = new Date().setHours(0, 0, 0, 0);
357
+ if (!active.has(cursor)) cursor -= dayMs; // streak survives until the day is over
358
+ while (active.has(cursor)) {
359
+ streak += 1;
360
+ cursor -= dayMs;
361
+ }
362
+ return streak;
363
+ }
364
+
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Cost insights (tmustier-style): structural facts about where spend went
3
+ * plus alarms for waste patterns. All numbers derive from the aggregated
4
+ * trends data; formulas are documented per insight.
5
+ */
6
+
7
+ import {
8
+ annotateModelLabels,
9
+ dailyTotals,
10
+ distributionRows,
11
+ periodTotals,
12
+ projectDistributionRows,
13
+ type TrendsData,
14
+ } from "./aggregate.ts";
15
+ import { formatCost } from "./render.ts";
16
+
17
+ export interface Insight {
18
+ kind: "structure" | "alarm";
19
+ /** Short right-aligned stat, e.g. "$0.03" or "74%". */
20
+ stat: string;
21
+ headline: string;
22
+ advice?: string;
23
+ }
24
+
25
+ function sum(values: number[]): number {
26
+ return values.reduce((total, value) => total + value, 0);
27
+ }
28
+
29
+ export function buildInsights(data: TrendsData, fromMs: number | undefined, now = Date.now()): Insight[] {
30
+ const rows = distributionRows(data, fromMs);
31
+ const totals = periodTotals(data, fromMs);
32
+ const insights: Insight[] = [];
33
+
34
+ const allTokens = totals.input + totals.output + totals.cacheRead + totals.cacheWrite;
35
+
36
+ // Structure: the most expensive project (only when several exist).
37
+ const projectRows = projectDistributionRows(data, fromMs);
38
+ const projectCost = new Map<string, number>();
39
+ for (const row of projectRows) {
40
+ projectCost.set(row.project, (projectCost.get(row.project) ?? 0) + row.cost);
41
+ }
42
+ if (projectCost.size >= 2 && totals.cost > 0) {
43
+ const ranked = [...projectCost.entries()].sort((a, b) => b[1] - a[1]);
44
+ const topName = ranked[0]![0];
45
+ const topShare = ranked[0]![1] / totals.cost;
46
+ if (topShare >= 0.4) {
47
+ insights.push({
48
+ kind: "structure",
49
+ stat: `${Math.round(topShare * 100)}%`,
50
+ headline: `of spend comes from "${topName}"`,
51
+ advice: `top projects: ${ranked.slice(0, 3).map(([name, value]) => `${name} ${Math.round((value / totals.cost) * 100)}%`).join(" · ")}`,
52
+ });
53
+ }
54
+ }
55
+
56
+ // Structure: the model that dominates spend.
57
+ if (totals.cost > 0 && rows.length > 1) {
58
+ const top = annotateModelLabels([...rows].sort((a, b) => b.cost - a.cost))[0]!;
59
+ const share = top.cost / totals.cost;
60
+ if (share >= 0.5) {
61
+ insights.push({
62
+ kind: "structure",
63
+ stat: formatCost(top.cost),
64
+ headline: `${top.label} drives ${Math.round(share * 100)}% of your spend`,
65
+ advice: "routing some traffic to a cheaper model is the biggest cost lever",
66
+ });
67
+ }
68
+ }
69
+
70
+ // Structure: cache leverage — share of processed tokens served from cache.
71
+ if (allTokens > 0) {
72
+ const leverage = totals.cacheRead / allTokens;
73
+ insights.push({
74
+ kind: "structure",
75
+ stat: `${Math.round(leverage * 100)}%`,
76
+ headline: "of processed tokens came from cache reads",
77
+ advice:
78
+ leverage < 0.3
79
+ ? "low cache coverage inflates cost — keep sessions warm and avoid re-sending large context"
80
+ : undefined,
81
+ });
82
+ }
83
+
84
+ // Structure: reasoning share of output (only when meaningful).
85
+ if (totals.output > 0) {
86
+ const share = totals.reasoning / totals.output;
87
+ if (share >= 0.05) {
88
+ insights.push({
89
+ kind: "structure",
90
+ stat: `${Math.round(share * 100)}%`,
91
+ headline: "of output is reasoning (thinking) tokens",
92
+ advice: "lowering the thinking level on routine tasks cuts this hidden spend",
93
+ });
94
+ }
95
+ }
96
+
97
+ // Alarm: likely cache misses (full-price prompt re-reads).
98
+ if (totals.missCount > 0) {
99
+ insights.push({
100
+ kind: "alarm",
101
+ stat: formatCost(totals.missCost),
102
+ headline: `${totals.missCount} likely cache miss${totals.missCount > 1 ? "es" : ""} re-read the prompt at full price`,
103
+ advice: "pauses over 5 minutes and mid-session model switches invalidate the prompt cache",
104
+ });
105
+ }
106
+
107
+ // Burn trend: last 7 days' daily average vs the prior 28 days.
108
+ const costDays = dailyTotals(data, "cost", undefined);
109
+ const dayMs = 86_400_000;
110
+ const todayStart = new Date(now).setHours(0, 0, 0, 0);
111
+ const last7 = costDays.filter((entry) => entry.dayStart >= todayStart - 6 * dayMs);
112
+ const prior28 = costDays.filter(
113
+ (entry) => entry.dayStart >= todayStart - 34 * dayMs && entry.dayStart < todayStart - 6 * dayMs,
114
+ );
115
+ if (last7.length >= 3 && prior28.length >= 7) {
116
+ const recentAvg = sum(last7.map((entry) => entry.value)) / 7;
117
+ const priorAvg = sum(prior28.map((entry) => entry.value)) / 28;
118
+ if (priorAvg > 0) {
119
+ const ratio = recentAvg / priorAvg;
120
+ if (ratio >= 1.5) {
121
+ insights.push({
122
+ kind: "alarm",
123
+ stat: `${ratio.toFixed(1)}x`,
124
+ headline: "daily burn vs the prior 4 weeks",
125
+ advice: "recent sessions cost materially more per day — check cache misses or a pricier model",
126
+ });
127
+ } else if (ratio <= 0.5) {
128
+ insights.push({
129
+ kind: "structure",
130
+ stat: `${ratio.toFixed(1)}x`,
131
+ headline: "daily burn vs the prior 4 weeks",
132
+ });
133
+ }
134
+ }
135
+ }
136
+
137
+ // Alarm: spend concentration in one session.
138
+ if (data.sessionCost.size >= 2) {
139
+ const costs = [...data.sessionCost.values()].sort((a, b) => b - a);
140
+ const top = costs[0]!;
141
+ const total = sum(costs);
142
+ if (total > 0 && top / total >= 0.6) {
143
+ insights.push({
144
+ kind: "alarm",
145
+ stat: `${Math.round((top / total) * 100)}%`,
146
+ headline: "of spend comes from a single session",
147
+ });
148
+ }
149
+ }
150
+
151
+ return insights;
152
+ }