@codersbrew/pi-tools 0.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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/extensions/security.ts +113 -0
- package/extensions/session-breakdown.ts +1629 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /session-breakdown
|
|
3
|
+
*
|
|
4
|
+
* Interactive TUI that analyzes ~/.pi/agent/sessions (recursively, *.jsonl) and shows
|
|
5
|
+
* last 7/30/90 days of:
|
|
6
|
+
* - sessions/day
|
|
7
|
+
* - messages/day
|
|
8
|
+
* - tokens/day (if available)
|
|
9
|
+
* - cost/day (if available)
|
|
10
|
+
* - model breakdown (sessions/messages/tokens + cost)
|
|
11
|
+
*
|
|
12
|
+
* Graph:
|
|
13
|
+
* - GitHub-contributions-style calendar (weeks x weekdays)
|
|
14
|
+
* - Hue: weighted mix of popular model colors (weighted by the selected metric)
|
|
15
|
+
* - Brightness: selected metric per day (log-scaled)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import {
|
|
21
|
+
Key,
|
|
22
|
+
matchesKey,
|
|
23
|
+
type Component,
|
|
24
|
+
type TUI,
|
|
25
|
+
truncateToWidth,
|
|
26
|
+
visibleWidth,
|
|
27
|
+
} from "@mariozechner/pi-tui";
|
|
28
|
+
import { sliceByColumn } from "@mariozechner/pi-tui/dist/utils.js";
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import fs from "node:fs/promises";
|
|
32
|
+
import { createReadStream, type Dirent } from "node:fs";
|
|
33
|
+
import readline from "node:readline";
|
|
34
|
+
|
|
35
|
+
type ModelKey = string; // `${provider}/${model}`
|
|
36
|
+
type CwdKey = string; // normalized cwd path
|
|
37
|
+
type DowKey = string; // "Mon", "Tue", etc.
|
|
38
|
+
type TodKey = string; // "after-midnight", "morning", "afternoon", "evening", "night"
|
|
39
|
+
type BreakdownView = "model" | "cwd" | "dow" | "tod";
|
|
40
|
+
|
|
41
|
+
const DOW_NAMES: DowKey[] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
42
|
+
|
|
43
|
+
const TOD_BUCKETS: { key: TodKey; label: string; from: number; to: number }[] = [
|
|
44
|
+
{ key: "after-midnight", label: "After midnight (0–5)", from: 0, to: 5 },
|
|
45
|
+
{ key: "morning", label: "Morning (6–11)", from: 6, to: 11 },
|
|
46
|
+
{ key: "afternoon", label: "Afternoon (12–16)", from: 12, to: 16 },
|
|
47
|
+
{ key: "evening", label: "Evening (17–21)", from: 17, to: 21 },
|
|
48
|
+
{ key: "night", label: "Night (22–23)", from: 22, to: 23 },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function todBucketForHour(hour: number): TodKey {
|
|
52
|
+
for (const b of TOD_BUCKETS) {
|
|
53
|
+
if (hour >= b.from && hour <= b.to) return b.key;
|
|
54
|
+
}
|
|
55
|
+
return "after-midnight";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function todBucketLabel(key: TodKey): string {
|
|
59
|
+
return TOD_BUCKETS.find((b) => b.key === key)?.label ?? key;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ParsedSession {
|
|
63
|
+
filePath: string;
|
|
64
|
+
startedAt: Date;
|
|
65
|
+
dayKeyLocal: string; // YYYY-MM-DD (local)
|
|
66
|
+
cwd: CwdKey | null;
|
|
67
|
+
dow: DowKey;
|
|
68
|
+
tod: TodKey;
|
|
69
|
+
modelsUsed: Set<ModelKey>;
|
|
70
|
+
messages: number;
|
|
71
|
+
tokens: number;
|
|
72
|
+
totalCost: number;
|
|
73
|
+
costByModel: Map<ModelKey, number>;
|
|
74
|
+
messagesByModel: Map<ModelKey, number>;
|
|
75
|
+
tokensByModel: Map<ModelKey, number>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface DayAgg {
|
|
79
|
+
date: Date; // local midnight
|
|
80
|
+
dayKeyLocal: string;
|
|
81
|
+
sessions: number;
|
|
82
|
+
messages: number;
|
|
83
|
+
tokens: number;
|
|
84
|
+
totalCost: number;
|
|
85
|
+
costByModel: Map<ModelKey, number>;
|
|
86
|
+
sessionsByModel: Map<ModelKey, number>;
|
|
87
|
+
messagesByModel: Map<ModelKey, number>;
|
|
88
|
+
tokensByModel: Map<ModelKey, number>;
|
|
89
|
+
sessionsByCwd: Map<CwdKey, number>;
|
|
90
|
+
messagesByCwd: Map<CwdKey, number>;
|
|
91
|
+
tokensByCwd: Map<CwdKey, number>;
|
|
92
|
+
costByCwd: Map<CwdKey, number>;
|
|
93
|
+
sessionsByTod: Map<TodKey, number>;
|
|
94
|
+
messagesByTod: Map<TodKey, number>;
|
|
95
|
+
tokensByTod: Map<TodKey, number>;
|
|
96
|
+
costByTod: Map<TodKey, number>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface RangeAgg {
|
|
100
|
+
days: DayAgg[];
|
|
101
|
+
dayByKey: Map<string, DayAgg>;
|
|
102
|
+
sessions: number;
|
|
103
|
+
totalMessages: number;
|
|
104
|
+
totalTokens: number;
|
|
105
|
+
totalCost: number;
|
|
106
|
+
modelCost: Map<ModelKey, number>;
|
|
107
|
+
modelSessions: Map<ModelKey, number>; // number of sessions where model was used
|
|
108
|
+
modelMessages: Map<ModelKey, number>;
|
|
109
|
+
modelTokens: Map<ModelKey, number>;
|
|
110
|
+
cwdCost: Map<CwdKey, number>;
|
|
111
|
+
cwdSessions: Map<CwdKey, number>;
|
|
112
|
+
cwdMessages: Map<CwdKey, number>;
|
|
113
|
+
cwdTokens: Map<CwdKey, number>;
|
|
114
|
+
dowCost: Map<DowKey, number>;
|
|
115
|
+
dowSessions: Map<DowKey, number>;
|
|
116
|
+
dowMessages: Map<DowKey, number>;
|
|
117
|
+
dowTokens: Map<DowKey, number>;
|
|
118
|
+
todCost: Map<TodKey, number>;
|
|
119
|
+
todSessions: Map<TodKey, number>;
|
|
120
|
+
todMessages: Map<TodKey, number>;
|
|
121
|
+
todTokens: Map<TodKey, number>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface RGB {
|
|
125
|
+
r: number;
|
|
126
|
+
g: number;
|
|
127
|
+
b: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface BreakdownData {
|
|
131
|
+
generatedAt: Date;
|
|
132
|
+
ranges: Map<number, RangeAgg>;
|
|
133
|
+
palette: {
|
|
134
|
+
modelColors: Map<ModelKey, RGB>;
|
|
135
|
+
otherColor: RGB;
|
|
136
|
+
orderedModels: ModelKey[];
|
|
137
|
+
};
|
|
138
|
+
cwdPalette: {
|
|
139
|
+
cwdColors: Map<CwdKey, RGB>;
|
|
140
|
+
otherColor: RGB;
|
|
141
|
+
orderedCwds: CwdKey[];
|
|
142
|
+
};
|
|
143
|
+
dowPalette: {
|
|
144
|
+
dowColors: Map<DowKey, RGB>;
|
|
145
|
+
orderedDows: DowKey[];
|
|
146
|
+
};
|
|
147
|
+
todPalette: {
|
|
148
|
+
todColors: Map<TodKey, RGB>;
|
|
149
|
+
orderedTods: TodKey[];
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
|
|
154
|
+
const RANGE_DAYS = [7, 30, 90] as const;
|
|
155
|
+
|
|
156
|
+
type MeasurementMode = "sessions" | "messages" | "tokens";
|
|
157
|
+
|
|
158
|
+
type BreakdownProgressPhase = "scan" | "parse" | "finalize";
|
|
159
|
+
|
|
160
|
+
interface BreakdownProgressState {
|
|
161
|
+
phase: BreakdownProgressPhase;
|
|
162
|
+
foundFiles: number;
|
|
163
|
+
parsedFiles: number;
|
|
164
|
+
totalFiles: number;
|
|
165
|
+
currentFile?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function setBorderedLoaderMessage(loader: BorderedLoader, message: string) {
|
|
169
|
+
// BorderedLoader wraps a (Cancellable)Loader which supports setMessage(),
|
|
170
|
+
// but it doesn't expose it publicly. Access the inner loader for progress updates.
|
|
171
|
+
const inner = (loader as any)["loader"]; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
172
|
+
if (inner && typeof inner.setMessage === "function") {
|
|
173
|
+
inner.setMessage(message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Dark-ish background and empty cell color (close to GitHub dark)
|
|
178
|
+
const DEFAULT_BG: RGB = { r: 13, g: 17, b: 23 };
|
|
179
|
+
const EMPTY_CELL_BG: RGB = { r: 22, g: 27, b: 34 };
|
|
180
|
+
|
|
181
|
+
// Default palette (assigned to top models)
|
|
182
|
+
const PALETTE: RGB[] = [
|
|
183
|
+
{ r: 64, g: 196, b: 99 }, // green
|
|
184
|
+
{ r: 47, g: 129, b: 247 }, // blue
|
|
185
|
+
{ r: 163, g: 113, b: 247 }, // purple
|
|
186
|
+
{ r: 255, g: 159, b: 10 }, // orange
|
|
187
|
+
{ r: 244, g: 67, b: 54 }, // red
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
function clamp01(x: number): number {
|
|
191
|
+
return Math.max(0, Math.min(1, x));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function lerp(a: number, b: number, t: number): number {
|
|
195
|
+
return a + (b - a) * t;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mixRgb(a: RGB, b: RGB, t: number): RGB {
|
|
199
|
+
return {
|
|
200
|
+
r: Math.round(lerp(a.r, b.r, t)),
|
|
201
|
+
g: Math.round(lerp(a.g, b.g, t)),
|
|
202
|
+
b: Math.round(lerp(a.b, b.b, t)),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function weightedMix(colors: Array<{ color: RGB; weight: number }>): RGB {
|
|
207
|
+
let total = 0;
|
|
208
|
+
let r = 0;
|
|
209
|
+
let g = 0;
|
|
210
|
+
let b = 0;
|
|
211
|
+
for (const c of colors) {
|
|
212
|
+
if (!Number.isFinite(c.weight) || c.weight <= 0) continue;
|
|
213
|
+
total += c.weight;
|
|
214
|
+
r += c.color.r * c.weight;
|
|
215
|
+
g += c.color.g * c.weight;
|
|
216
|
+
b += c.color.b * c.weight;
|
|
217
|
+
}
|
|
218
|
+
if (total <= 0) return EMPTY_CELL_BG;
|
|
219
|
+
return { r: Math.round(r / total), g: Math.round(g / total), b: Math.round(b / total) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ansiBg(rgb: RGB, text: string): string {
|
|
223
|
+
return `\x1b[48;2;${rgb.r};${rgb.g};${rgb.b}m${text}\x1b[0m`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function ansiFg(rgb: RGB, text: string): string {
|
|
227
|
+
return `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m${text}\x1b[0m`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function dim(text: string): string {
|
|
231
|
+
return `\x1b[2m${text}\x1b[0m`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function bold(text: string): string {
|
|
235
|
+
return `\x1b[1m${text}\x1b[0m`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatCount(n: number): string {
|
|
239
|
+
if (!Number.isFinite(n) || n === 0) return "0";
|
|
240
|
+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
241
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
242
|
+
if (n >= 10_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
243
|
+
return n.toLocaleString("en-US");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatUsd(cost: number): string {
|
|
247
|
+
if (!Number.isFinite(cost)) return "$0.00";
|
|
248
|
+
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
|
249
|
+
if (cost >= 0.1) return `$${cost.toFixed(3)}`;
|
|
250
|
+
return `$${cost.toFixed(4)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Abbreviate a path for display. Strategy:
|
|
255
|
+
* - Replace home dir with ~
|
|
256
|
+
* - If still too long, keep first segment + last N segments with … in between
|
|
257
|
+
* Examples:
|
|
258
|
+
* /Users/mitsuhiko/Development/agent-stuff → ~/Development/agent-stuff
|
|
259
|
+
* /Users/mitsuhiko/Development/minijinja/minijinja-go → ~/…/minijinja/minijinja-go
|
|
260
|
+
*/
|
|
261
|
+
function abbreviatePath(p: string, maxWidth = 40): string {
|
|
262
|
+
const home = os.homedir();
|
|
263
|
+
let display = p;
|
|
264
|
+
if (display.startsWith(home)) {
|
|
265
|
+
display = "~" + display.slice(home.length);
|
|
266
|
+
}
|
|
267
|
+
if (display.length <= maxWidth) return display;
|
|
268
|
+
|
|
269
|
+
const parts = display.split("/").filter(Boolean);
|
|
270
|
+
// Always keep the first part (~ or root indicator) and try to keep as many trailing parts as possible
|
|
271
|
+
if (parts.length <= 2) return display;
|
|
272
|
+
|
|
273
|
+
const prefix = parts[0]; // typically "~"
|
|
274
|
+
// Try keeping last N parts, increasing until it fits
|
|
275
|
+
for (let keep = parts.length - 1; keep >= 1; keep--) {
|
|
276
|
+
const tail = parts.slice(parts.length - keep);
|
|
277
|
+
const candidate = prefix + "/…/" + tail.join("/");
|
|
278
|
+
if (candidate.length <= maxWidth || keep === 1) return candidate;
|
|
279
|
+
}
|
|
280
|
+
return display;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function padRight(s: string, n: number): string {
|
|
284
|
+
const delta = n - s.length;
|
|
285
|
+
return delta > 0 ? s + " ".repeat(delta) : s;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function padLeft(s: string, n: number): string {
|
|
289
|
+
const delta = n - s.length;
|
|
290
|
+
return delta > 0 ? " ".repeat(delta) + s : s;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function toLocalDayKey(d: Date): string {
|
|
294
|
+
const yyyy = d.getFullYear();
|
|
295
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
296
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
297
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function localMidnight(d: Date): Date {
|
|
301
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function addDaysLocal(d: Date, days: number): Date {
|
|
305
|
+
const x = new Date(d);
|
|
306
|
+
x.setDate(x.getDate() + days);
|
|
307
|
+
return x;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function countDaysInclusiveLocal(start: Date, end: Date): number {
|
|
311
|
+
// Avoid ms-based day math because DST transitions can make a “day” 23/25h in local time.
|
|
312
|
+
let n = 0;
|
|
313
|
+
for (let d = new Date(start); d <= end; d = addDaysLocal(d, 1)) n++;
|
|
314
|
+
return n;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function mondayIndex(date: Date): number {
|
|
318
|
+
// Mon=0 .. Sun=6
|
|
319
|
+
return (date.getDay() + 6) % 7;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function modelKeyFromParts(provider?: unknown, model?: unknown): ModelKey | null {
|
|
323
|
+
const p = typeof provider === "string" ? provider.trim() : "";
|
|
324
|
+
const m = typeof model === "string" ? model.trim() : "";
|
|
325
|
+
if (!p && !m) return null;
|
|
326
|
+
if (!p) return m;
|
|
327
|
+
if (!m) return p;
|
|
328
|
+
return `${p}/${m}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseSessionStartFromFilename(name: string): Date | null {
|
|
332
|
+
// Example: 2026-02-02T21-52-28-774Z_<uuid>.jsonl
|
|
333
|
+
const m = name.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z_/);
|
|
334
|
+
if (!m) return null;
|
|
335
|
+
const iso = `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`;
|
|
336
|
+
const d = new Date(iso);
|
|
337
|
+
return Number.isFinite(d.getTime()) ? d : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function extractProviderModelAndUsage(obj: any): { provider?: any; model?: any; modelId?: any; usage?: any } {
|
|
341
|
+
// Session format varies across versions.
|
|
342
|
+
// - Newer: { provider, model, usage } on the message wrapper
|
|
343
|
+
// - Older: { message: { provider, model, usage } }
|
|
344
|
+
const msg = obj?.message;
|
|
345
|
+
return {
|
|
346
|
+
provider: obj?.provider ?? msg?.provider,
|
|
347
|
+
model: obj?.model ?? msg?.model,
|
|
348
|
+
modelId: obj?.modelId ?? msg?.modelId,
|
|
349
|
+
usage: obj?.usage ?? msg?.usage,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function extractCostTotal(usage: any): number {
|
|
354
|
+
if (!usage) return 0;
|
|
355
|
+
const c = usage?.cost;
|
|
356
|
+
if (typeof c === "number") return Number.isFinite(c) ? c : 0;
|
|
357
|
+
if (typeof c === "string") {
|
|
358
|
+
const n = Number(c);
|
|
359
|
+
return Number.isFinite(n) ? n : 0;
|
|
360
|
+
}
|
|
361
|
+
const t = c?.total;
|
|
362
|
+
if (typeof t === "number") return Number.isFinite(t) ? t : 0;
|
|
363
|
+
if (typeof t === "string") {
|
|
364
|
+
const n = Number(t);
|
|
365
|
+
return Number.isFinite(n) ? n : 0;
|
|
366
|
+
}
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function extractTokensTotal(usage: any): number {
|
|
371
|
+
// Usage format varies across providers and pi versions.
|
|
372
|
+
// We try a few common shapes:
|
|
373
|
+
// - { totalTokens }
|
|
374
|
+
// - { total_tokens }
|
|
375
|
+
// - { promptTokens, completionTokens }
|
|
376
|
+
// - { prompt_tokens, completion_tokens }
|
|
377
|
+
// - { input_tokens, output_tokens }
|
|
378
|
+
// - { inputTokens, outputTokens }
|
|
379
|
+
// - { tokens: number | { total } }
|
|
380
|
+
if (!usage) return 0;
|
|
381
|
+
|
|
382
|
+
const readNum = (v: any): number => {
|
|
383
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : 0;
|
|
384
|
+
if (typeof v === "string") {
|
|
385
|
+
const n = Number(v);
|
|
386
|
+
return Number.isFinite(n) ? n : 0;
|
|
387
|
+
}
|
|
388
|
+
return 0;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
let total = 0;
|
|
392
|
+
// direct totals
|
|
393
|
+
total =
|
|
394
|
+
readNum(usage?.totalTokens) ||
|
|
395
|
+
readNum(usage?.total_tokens) ||
|
|
396
|
+
readNum(usage?.tokens) ||
|
|
397
|
+
readNum(usage?.tokenCount) ||
|
|
398
|
+
readNum(usage?.token_count);
|
|
399
|
+
if (total > 0) return total;
|
|
400
|
+
|
|
401
|
+
// nested tokens object
|
|
402
|
+
total = readNum(usage?.tokens?.total) || readNum(usage?.tokens?.totalTokens) || readNum(usage?.tokens?.total_tokens);
|
|
403
|
+
if (total > 0) return total;
|
|
404
|
+
|
|
405
|
+
// sum of parts
|
|
406
|
+
const a =
|
|
407
|
+
readNum(usage?.promptTokens) ||
|
|
408
|
+
readNum(usage?.prompt_tokens) ||
|
|
409
|
+
readNum(usage?.inputTokens) ||
|
|
410
|
+
readNum(usage?.input_tokens);
|
|
411
|
+
const b =
|
|
412
|
+
readNum(usage?.completionTokens) ||
|
|
413
|
+
readNum(usage?.completion_tokens) ||
|
|
414
|
+
readNum(usage?.outputTokens) ||
|
|
415
|
+
readNum(usage?.output_tokens);
|
|
416
|
+
const sum = a + b;
|
|
417
|
+
return sum > 0 ? sum : 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function walkSessionFiles(
|
|
421
|
+
root: string,
|
|
422
|
+
startCutoffLocal: Date,
|
|
423
|
+
signal?: AbortSignal,
|
|
424
|
+
onFound?: (found: number) => void,
|
|
425
|
+
): Promise<string[]> {
|
|
426
|
+
const out: string[] = [];
|
|
427
|
+
const stack: string[] = [root];
|
|
428
|
+
while (stack.length) {
|
|
429
|
+
if (signal?.aborted) break;
|
|
430
|
+
const dir = stack.pop()!;
|
|
431
|
+
let entries: Dirent[] = [];
|
|
432
|
+
try {
|
|
433
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
434
|
+
} catch {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const ent of entries) {
|
|
439
|
+
if (signal?.aborted) break;
|
|
440
|
+
const p = path.join(dir, ent.name);
|
|
441
|
+
if (ent.isDirectory()) {
|
|
442
|
+
stack.push(p);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (!ent.isFile() || !ent.name.endsWith(".jsonl")) continue;
|
|
446
|
+
|
|
447
|
+
// Prefer filename timestamp, else fall back to mtime.
|
|
448
|
+
const startedAt = parseSessionStartFromFilename(ent.name);
|
|
449
|
+
if (startedAt) {
|
|
450
|
+
if (localMidnight(startedAt) >= startCutoffLocal) {
|
|
451
|
+
out.push(p);
|
|
452
|
+
if (onFound && out.length % 10 === 0) onFound(out.length);
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const st = await fs.stat(p);
|
|
459
|
+
const approx = new Date(st.mtimeMs);
|
|
460
|
+
if (localMidnight(approx) >= startCutoffLocal) {
|
|
461
|
+
out.push(p);
|
|
462
|
+
if (onFound && out.length % 10 === 0) onFound(out.length);
|
|
463
|
+
}
|
|
464
|
+
} catch {
|
|
465
|
+
// ignore
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
onFound?.(out.length);
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function parseSessionFile(filePath: string, signal?: AbortSignal): Promise<ParsedSession | null> {
|
|
474
|
+
const fileName = path.basename(filePath);
|
|
475
|
+
let startedAt = parseSessionStartFromFilename(fileName);
|
|
476
|
+
let currentModel: ModelKey | null = null;
|
|
477
|
+
let cwd: CwdKey | null = null;
|
|
478
|
+
|
|
479
|
+
const modelsUsed = new Set<ModelKey>();
|
|
480
|
+
let messages = 0;
|
|
481
|
+
let tokens = 0;
|
|
482
|
+
let totalCost = 0;
|
|
483
|
+
const costByModel = new Map<ModelKey, number>();
|
|
484
|
+
const messagesByModel = new Map<ModelKey, number>();
|
|
485
|
+
const tokensByModel = new Map<ModelKey, number>();
|
|
486
|
+
|
|
487
|
+
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
488
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
for await (const line of rl) {
|
|
492
|
+
if (signal?.aborted) {
|
|
493
|
+
rl.close();
|
|
494
|
+
stream.destroy();
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
if (!line) continue;
|
|
498
|
+
let obj: any;
|
|
499
|
+
try {
|
|
500
|
+
obj = JSON.parse(line);
|
|
501
|
+
} catch {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (obj?.type === "session") {
|
|
506
|
+
if (!startedAt && typeof obj?.timestamp === "string") {
|
|
507
|
+
const d = new Date(obj.timestamp);
|
|
508
|
+
if (Number.isFinite(d.getTime())) startedAt = d;
|
|
509
|
+
}
|
|
510
|
+
if (typeof obj?.cwd === "string" && obj.cwd.trim()) {
|
|
511
|
+
cwd = obj.cwd.trim();
|
|
512
|
+
}
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (obj?.type === "model_change") {
|
|
517
|
+
const mk = modelKeyFromParts(obj.provider, obj.modelId);
|
|
518
|
+
if (mk) {
|
|
519
|
+
currentModel = mk;
|
|
520
|
+
modelsUsed.add(mk);
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (obj?.type !== "message") continue;
|
|
526
|
+
|
|
527
|
+
const { provider, model, modelId, usage } = extractProviderModelAndUsage(obj);
|
|
528
|
+
const mk =
|
|
529
|
+
modelKeyFromParts(provider, model) ??
|
|
530
|
+
modelKeyFromParts(provider, modelId) ??
|
|
531
|
+
currentModel ??
|
|
532
|
+
"unknown";
|
|
533
|
+
modelsUsed.add(mk);
|
|
534
|
+
|
|
535
|
+
messages += 1;
|
|
536
|
+
messagesByModel.set(mk, (messagesByModel.get(mk) ?? 0) + 1);
|
|
537
|
+
|
|
538
|
+
const tok = extractTokensTotal(usage);
|
|
539
|
+
if (tok > 0) {
|
|
540
|
+
tokens += tok;
|
|
541
|
+
tokensByModel.set(mk, (tokensByModel.get(mk) ?? 0) + tok);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const cost = extractCostTotal(usage);
|
|
545
|
+
if (cost > 0) {
|
|
546
|
+
totalCost += cost;
|
|
547
|
+
costByModel.set(mk, (costByModel.get(mk) ?? 0) + cost);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} finally {
|
|
551
|
+
rl.close();
|
|
552
|
+
stream.destroy();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!startedAt) return null;
|
|
556
|
+
const dayKeyLocal = toLocalDayKey(startedAt);
|
|
557
|
+
const dow = DOW_NAMES[mondayIndex(startedAt)];
|
|
558
|
+
const tod = todBucketForHour(startedAt.getHours());
|
|
559
|
+
return {
|
|
560
|
+
filePath,
|
|
561
|
+
startedAt,
|
|
562
|
+
dayKeyLocal,
|
|
563
|
+
cwd,
|
|
564
|
+
dow,
|
|
565
|
+
tod,
|
|
566
|
+
modelsUsed,
|
|
567
|
+
messages,
|
|
568
|
+
tokens,
|
|
569
|
+
totalCost,
|
|
570
|
+
costByModel,
|
|
571
|
+
messagesByModel,
|
|
572
|
+
tokensByModel,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function buildRangeAgg(days: number, now: Date): RangeAgg {
|
|
577
|
+
const end = localMidnight(now);
|
|
578
|
+
const start = addDaysLocal(end, -(days - 1));
|
|
579
|
+
const outDays: DayAgg[] = [];
|
|
580
|
+
const dayByKey = new Map<string, DayAgg>();
|
|
581
|
+
|
|
582
|
+
for (let i = 0; i < days; i++) {
|
|
583
|
+
const d = addDaysLocal(start, i);
|
|
584
|
+
const dayKeyLocal = toLocalDayKey(d);
|
|
585
|
+
const day: DayAgg = {
|
|
586
|
+
date: d,
|
|
587
|
+
dayKeyLocal,
|
|
588
|
+
sessions: 0,
|
|
589
|
+
messages: 0,
|
|
590
|
+
tokens: 0,
|
|
591
|
+
totalCost: 0,
|
|
592
|
+
costByModel: new Map(),
|
|
593
|
+
sessionsByModel: new Map(),
|
|
594
|
+
messagesByModel: new Map(),
|
|
595
|
+
tokensByModel: new Map(),
|
|
596
|
+
sessionsByCwd: new Map(),
|
|
597
|
+
messagesByCwd: new Map(),
|
|
598
|
+
tokensByCwd: new Map(),
|
|
599
|
+
costByCwd: new Map(),
|
|
600
|
+
sessionsByTod: new Map(),
|
|
601
|
+
messagesByTod: new Map(),
|
|
602
|
+
tokensByTod: new Map(),
|
|
603
|
+
costByTod: new Map(),
|
|
604
|
+
};
|
|
605
|
+
outDays.push(day);
|
|
606
|
+
dayByKey.set(dayKeyLocal, day);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
days: outDays,
|
|
611
|
+
dayByKey,
|
|
612
|
+
sessions: 0,
|
|
613
|
+
totalMessages: 0,
|
|
614
|
+
totalTokens: 0,
|
|
615
|
+
totalCost: 0,
|
|
616
|
+
modelCost: new Map(),
|
|
617
|
+
modelSessions: new Map(),
|
|
618
|
+
modelMessages: new Map(),
|
|
619
|
+
modelTokens: new Map(),
|
|
620
|
+
cwdCost: new Map(),
|
|
621
|
+
cwdSessions: new Map(),
|
|
622
|
+
cwdMessages: new Map(),
|
|
623
|
+
cwdTokens: new Map(),
|
|
624
|
+
dowCost: new Map(),
|
|
625
|
+
dowSessions: new Map(),
|
|
626
|
+
dowMessages: new Map(),
|
|
627
|
+
dowTokens: new Map(),
|
|
628
|
+
todCost: new Map(),
|
|
629
|
+
todSessions: new Map(),
|
|
630
|
+
todMessages: new Map(),
|
|
631
|
+
todTokens: new Map(),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function addSessionToRange(range: RangeAgg, session: ParsedSession): void {
|
|
636
|
+
const day = range.dayByKey.get(session.dayKeyLocal);
|
|
637
|
+
if (!day) return;
|
|
638
|
+
|
|
639
|
+
range.sessions += 1;
|
|
640
|
+
range.totalMessages += session.messages;
|
|
641
|
+
range.totalTokens += session.tokens;
|
|
642
|
+
range.totalCost += session.totalCost;
|
|
643
|
+
day.sessions += 1;
|
|
644
|
+
day.messages += session.messages;
|
|
645
|
+
day.tokens += session.tokens;
|
|
646
|
+
day.totalCost += session.totalCost;
|
|
647
|
+
|
|
648
|
+
// Sessions-per-model (presence)
|
|
649
|
+
for (const mk of session.modelsUsed) {
|
|
650
|
+
day.sessionsByModel.set(mk, (day.sessionsByModel.get(mk) ?? 0) + 1);
|
|
651
|
+
range.modelSessions.set(mk, (range.modelSessions.get(mk) ?? 0) + 1);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Messages-per-model
|
|
655
|
+
for (const [mk, n] of session.messagesByModel.entries()) {
|
|
656
|
+
day.messagesByModel.set(mk, (day.messagesByModel.get(mk) ?? 0) + n);
|
|
657
|
+
range.modelMessages.set(mk, (range.modelMessages.get(mk) ?? 0) + n);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Tokens-per-model
|
|
661
|
+
for (const [mk, n] of session.tokensByModel.entries()) {
|
|
662
|
+
day.tokensByModel.set(mk, (day.tokensByModel.get(mk) ?? 0) + n);
|
|
663
|
+
range.modelTokens.set(mk, (range.modelTokens.get(mk) ?? 0) + n);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Cost-per-model
|
|
667
|
+
for (const [mk, cost] of session.costByModel.entries()) {
|
|
668
|
+
day.costByModel.set(mk, (day.costByModel.get(mk) ?? 0) + cost);
|
|
669
|
+
range.modelCost.set(mk, (range.modelCost.get(mk) ?? 0) + cost);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// CWD aggregation
|
|
673
|
+
const cwd = session.cwd;
|
|
674
|
+
if (cwd) {
|
|
675
|
+
day.sessionsByCwd.set(cwd, (day.sessionsByCwd.get(cwd) ?? 0) + 1);
|
|
676
|
+
range.cwdSessions.set(cwd, (range.cwdSessions.get(cwd) ?? 0) + 1);
|
|
677
|
+
day.messagesByCwd.set(cwd, (day.messagesByCwd.get(cwd) ?? 0) + session.messages);
|
|
678
|
+
range.cwdMessages.set(cwd, (range.cwdMessages.get(cwd) ?? 0) + session.messages);
|
|
679
|
+
day.tokensByCwd.set(cwd, (day.tokensByCwd.get(cwd) ?? 0) + session.tokens);
|
|
680
|
+
range.cwdTokens.set(cwd, (range.cwdTokens.get(cwd) ?? 0) + session.tokens);
|
|
681
|
+
day.costByCwd.set(cwd, (day.costByCwd.get(cwd) ?? 0) + session.totalCost);
|
|
682
|
+
range.cwdCost.set(cwd, (range.cwdCost.get(cwd) ?? 0) + session.totalCost);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Day-of-week aggregation
|
|
686
|
+
const dow = session.dow;
|
|
687
|
+
range.dowSessions.set(dow, (range.dowSessions.get(dow) ?? 0) + 1);
|
|
688
|
+
range.dowMessages.set(dow, (range.dowMessages.get(dow) ?? 0) + session.messages);
|
|
689
|
+
range.dowTokens.set(dow, (range.dowTokens.get(dow) ?? 0) + session.tokens);
|
|
690
|
+
range.dowCost.set(dow, (range.dowCost.get(dow) ?? 0) + session.totalCost);
|
|
691
|
+
|
|
692
|
+
// Time-of-day aggregation
|
|
693
|
+
const tod = session.tod;
|
|
694
|
+
day.sessionsByTod.set(tod, (day.sessionsByTod.get(tod) ?? 0) + 1);
|
|
695
|
+
day.messagesByTod.set(tod, (day.messagesByTod.get(tod) ?? 0) + session.messages);
|
|
696
|
+
day.tokensByTod.set(tod, (day.tokensByTod.get(tod) ?? 0) + session.tokens);
|
|
697
|
+
day.costByTod.set(tod, (day.costByTod.get(tod) ?? 0) + session.totalCost);
|
|
698
|
+
range.todSessions.set(tod, (range.todSessions.get(tod) ?? 0) + 1);
|
|
699
|
+
range.todMessages.set(tod, (range.todMessages.get(tod) ?? 0) + session.messages);
|
|
700
|
+
range.todTokens.set(tod, (range.todTokens.get(tod) ?? 0) + session.tokens);
|
|
701
|
+
range.todCost.set(tod, (range.todCost.get(tod) ?? 0) + session.totalCost);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function sortMapByValueDesc<K extends string>(m: Map<K, number>): Array<{ key: K; value: number }> {
|
|
705
|
+
return [...m.entries()]
|
|
706
|
+
.map(([key, value]) => ({ key, value }))
|
|
707
|
+
.sort((a, b) => b.value - a.value);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function choosePaletteFromLast30Days(range30: RangeAgg, topN = 4): {
|
|
711
|
+
modelColors: Map<ModelKey, RGB>;
|
|
712
|
+
otherColor: RGB;
|
|
713
|
+
orderedModels: ModelKey[];
|
|
714
|
+
} {
|
|
715
|
+
// Prefer cost if any cost exists, else tokens, else messages, else sessions.
|
|
716
|
+
const costSum = [...range30.modelCost.values()].reduce((a, b) => a + b, 0);
|
|
717
|
+
const popularity =
|
|
718
|
+
costSum > 0
|
|
719
|
+
? range30.modelCost
|
|
720
|
+
: range30.totalTokens > 0
|
|
721
|
+
? range30.modelTokens
|
|
722
|
+
: range30.totalMessages > 0
|
|
723
|
+
? range30.modelMessages
|
|
724
|
+
: range30.modelSessions;
|
|
725
|
+
|
|
726
|
+
const sorted = sortMapByValueDesc(popularity);
|
|
727
|
+
const orderedModels = sorted.slice(0, topN).map((x) => x.key);
|
|
728
|
+
const modelColors = new Map<ModelKey, RGB>();
|
|
729
|
+
for (let i = 0; i < orderedModels.length; i++) {
|
|
730
|
+
modelColors.set(orderedModels[i], PALETTE[i % PALETTE.length]);
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
modelColors,
|
|
734
|
+
otherColor: { r: 160, g: 160, b: 160 },
|
|
735
|
+
orderedModels,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function chooseCwdPaletteFromLast30Days(range30: RangeAgg, topN = 4): {
|
|
740
|
+
cwdColors: Map<CwdKey, RGB>;
|
|
741
|
+
otherColor: RGB;
|
|
742
|
+
orderedCwds: CwdKey[];
|
|
743
|
+
} {
|
|
744
|
+
const costSum = [...range30.cwdCost.values()].reduce((a, b) => a + b, 0);
|
|
745
|
+
const popularity =
|
|
746
|
+
costSum > 0
|
|
747
|
+
? range30.cwdCost
|
|
748
|
+
: range30.totalTokens > 0
|
|
749
|
+
? range30.cwdTokens
|
|
750
|
+
: range30.totalMessages > 0
|
|
751
|
+
? range30.cwdMessages
|
|
752
|
+
: range30.cwdSessions;
|
|
753
|
+
|
|
754
|
+
const sorted = sortMapByValueDesc(popularity);
|
|
755
|
+
const orderedCwds = sorted.slice(0, topN).map((x) => x.key);
|
|
756
|
+
const cwdColors = new Map<CwdKey, RGB>();
|
|
757
|
+
for (let i = 0; i < orderedCwds.length; i++) {
|
|
758
|
+
cwdColors.set(orderedCwds[i], PALETTE[i % PALETTE.length]);
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
cwdColors,
|
|
762
|
+
otherColor: { r: 160, g: 160, b: 160 },
|
|
763
|
+
orderedCwds,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Fixed palette for day-of-week: weekdays get cool tones, weekend gets warm
|
|
768
|
+
const DOW_PALETTE: RGB[] = [
|
|
769
|
+
{ r: 47, g: 129, b: 247 }, // Mon – blue
|
|
770
|
+
{ r: 64, g: 196, b: 99 }, // Tue – green
|
|
771
|
+
{ r: 163, g: 113, b: 247 }, // Wed – purple
|
|
772
|
+
{ r: 47, g: 175, b: 200 }, // Thu – teal
|
|
773
|
+
{ r: 100, g: 200, b: 150 }, // Fri – mint
|
|
774
|
+
{ r: 255, g: 159, b: 10 }, // Sat – orange
|
|
775
|
+
{ r: 244, g: 67, b: 54 }, // Sun – red
|
|
776
|
+
];
|
|
777
|
+
|
|
778
|
+
function buildDowPalette(): { dowColors: Map<DowKey, RGB>; orderedDows: DowKey[] } {
|
|
779
|
+
const dowColors = new Map<DowKey, RGB>();
|
|
780
|
+
for (let i = 0; i < DOW_NAMES.length; i++) {
|
|
781
|
+
dowColors.set(DOW_NAMES[i], DOW_PALETTE[i]);
|
|
782
|
+
}
|
|
783
|
+
return { dowColors, orderedDows: [...DOW_NAMES] };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Fixed palette for time-of-day buckets
|
|
787
|
+
const TOD_PALETTE: Map<TodKey, RGB> = new Map([
|
|
788
|
+
["after-midnight", { r: 100, g: 60, b: 180 }], // deep purple
|
|
789
|
+
["morning", { r: 255, g: 200, b: 50 }], // golden yellow
|
|
790
|
+
["afternoon", { r: 64, g: 196, b: 99 }], // green
|
|
791
|
+
["evening", { r: 47, g: 129, b: 247 }], // blue
|
|
792
|
+
["night", { r: 60, g: 40, b: 140 }], // dark indigo
|
|
793
|
+
]);
|
|
794
|
+
|
|
795
|
+
function buildTodPalette(): { todColors: Map<TodKey, RGB>; orderedTods: TodKey[] } {
|
|
796
|
+
const todColors = new Map<TodKey, RGB>();
|
|
797
|
+
const orderedTods: TodKey[] = [];
|
|
798
|
+
for (const b of TOD_BUCKETS) {
|
|
799
|
+
const c = TOD_PALETTE.get(b.key);
|
|
800
|
+
if (c) todColors.set(b.key, c);
|
|
801
|
+
orderedTods.push(b.key);
|
|
802
|
+
}
|
|
803
|
+
return { todColors, orderedTods };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function dayMixedColor(
|
|
807
|
+
day: DayAgg,
|
|
808
|
+
colorMap: Map<string, RGB>,
|
|
809
|
+
otherColor: RGB,
|
|
810
|
+
mode: MeasurementMode,
|
|
811
|
+
view: BreakdownView = "model",
|
|
812
|
+
): RGB {
|
|
813
|
+
const parts: Array<{ color: RGB; weight: number }> = [];
|
|
814
|
+
let otherWeight = 0;
|
|
815
|
+
|
|
816
|
+
let map: Map<string, number>;
|
|
817
|
+
if (view === "dow") {
|
|
818
|
+
// For dow, each day IS a single dow – use the dow color directly
|
|
819
|
+
const dowKey = DOW_NAMES[mondayIndex(day.date)];
|
|
820
|
+
const c = colorMap.get(dowKey);
|
|
821
|
+
return c ?? otherColor;
|
|
822
|
+
} else if (view === "tod") {
|
|
823
|
+
if (mode === "tokens") {
|
|
824
|
+
map = day.tokens > 0 ? day.tokensByTod : day.messages > 0 ? day.messagesByTod : day.sessionsByTod;
|
|
825
|
+
} else if (mode === "messages") {
|
|
826
|
+
map = day.messages > 0 ? day.messagesByTod : day.sessionsByTod;
|
|
827
|
+
} else {
|
|
828
|
+
map = day.sessionsByTod;
|
|
829
|
+
}
|
|
830
|
+
} else if (view === "cwd") {
|
|
831
|
+
if (mode === "tokens") {
|
|
832
|
+
map = day.tokens > 0 ? day.tokensByCwd : day.messages > 0 ? day.messagesByCwd : day.sessionsByCwd;
|
|
833
|
+
} else if (mode === "messages") {
|
|
834
|
+
map = day.messages > 0 ? day.messagesByCwd : day.sessionsByCwd;
|
|
835
|
+
} else {
|
|
836
|
+
map = day.sessionsByCwd;
|
|
837
|
+
}
|
|
838
|
+
} else {
|
|
839
|
+
if (mode === "tokens") {
|
|
840
|
+
map = day.tokens > 0 ? day.tokensByModel : day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
|
|
841
|
+
} else if (mode === "messages") {
|
|
842
|
+
map = day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
|
|
843
|
+
} else {
|
|
844
|
+
map = day.sessionsByModel;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
for (const [mk, w] of map.entries()) {
|
|
849
|
+
const c = colorMap.get(mk);
|
|
850
|
+
if (c) parts.push({ color: c, weight: w });
|
|
851
|
+
else otherWeight += w;
|
|
852
|
+
}
|
|
853
|
+
if (otherWeight > 0) parts.push({ color: otherColor, weight: otherWeight });
|
|
854
|
+
return weightedMix(parts);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function graphMetricForRange(
|
|
858
|
+
range: RangeAgg,
|
|
859
|
+
mode: MeasurementMode,
|
|
860
|
+
): { kind: "sessions" | "messages" | "tokens"; max: number; denom: number } {
|
|
861
|
+
if (mode === "tokens") {
|
|
862
|
+
const maxTokens = Math.max(0, ...range.days.map((d) => d.tokens));
|
|
863
|
+
if (maxTokens > 0) return { kind: "tokens", max: maxTokens, denom: Math.log1p(maxTokens) };
|
|
864
|
+
// fall back if tokens aren't available
|
|
865
|
+
mode = "messages";
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (mode === "messages") {
|
|
869
|
+
const maxMessages = Math.max(0, ...range.days.map((d) => d.messages));
|
|
870
|
+
if (maxMessages > 0) return { kind: "messages", max: maxMessages, denom: Math.log1p(maxMessages) };
|
|
871
|
+
// fall back if messages aren't available
|
|
872
|
+
mode = "sessions";
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const maxSessions = Math.max(0, ...range.days.map((d) => d.sessions));
|
|
876
|
+
return { kind: "sessions", max: maxSessions, denom: Math.log1p(maxSessions) };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function weeksForRange(range: RangeAgg): number {
|
|
880
|
+
const days = range.days;
|
|
881
|
+
const start = days[0].date;
|
|
882
|
+
const end = days[days.length - 1].date;
|
|
883
|
+
const gridStart = addDaysLocal(start, -mondayIndex(start));
|
|
884
|
+
const gridEnd = addDaysLocal(end, 6 - mondayIndex(end));
|
|
885
|
+
const totalGridDays = countDaysInclusiveLocal(gridStart, gridEnd);
|
|
886
|
+
return Math.ceil(totalGridDays / 7);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function renderGraphLines(
|
|
890
|
+
range: RangeAgg,
|
|
891
|
+
colorMap: Map<string, RGB>,
|
|
892
|
+
otherColor: RGB,
|
|
893
|
+
mode: MeasurementMode,
|
|
894
|
+
options?: { cellWidth?: number; gap?: number },
|
|
895
|
+
view: BreakdownView = "model",
|
|
896
|
+
): string[] {
|
|
897
|
+
const days = range.days;
|
|
898
|
+
const start = days[0].date;
|
|
899
|
+
const end = days[days.length - 1].date;
|
|
900
|
+
|
|
901
|
+
const gridStart = addDaysLocal(start, -mondayIndex(start));
|
|
902
|
+
const gridEnd = addDaysLocal(end, 6 - mondayIndex(end));
|
|
903
|
+
const totalGridDays = countDaysInclusiveLocal(gridStart, gridEnd);
|
|
904
|
+
const weeks = Math.ceil(totalGridDays / 7);
|
|
905
|
+
|
|
906
|
+
const cellWidth = Math.max(1, Math.floor(options?.cellWidth ?? 1));
|
|
907
|
+
const gap = Math.max(0, Math.floor(options?.gap ?? 1));
|
|
908
|
+
const block = "█".repeat(cellWidth);
|
|
909
|
+
const gapStr = " ".repeat(gap);
|
|
910
|
+
|
|
911
|
+
const metric = graphMetricForRange(range, mode);
|
|
912
|
+
const denom = metric.denom;
|
|
913
|
+
|
|
914
|
+
// Label only Mon/Wed/Fri like GitHub (saves space)
|
|
915
|
+
const labelByRow = new Map<number, string>([
|
|
916
|
+
[0, "Mon"],
|
|
917
|
+
[2, "Wed"],
|
|
918
|
+
[4, "Fri"],
|
|
919
|
+
]);
|
|
920
|
+
|
|
921
|
+
const lines: string[] = [];
|
|
922
|
+
for (let row = 0; row < 7; row++) {
|
|
923
|
+
const label = labelByRow.get(row);
|
|
924
|
+
let line = label ? padRight(label, 3) + " " : " ";
|
|
925
|
+
|
|
926
|
+
for (let w = 0; w < weeks; w++) {
|
|
927
|
+
const cellDate = addDaysLocal(gridStart, w * 7 + row);
|
|
928
|
+
const inRange = cellDate >= start && cellDate <= end;
|
|
929
|
+
const colGap = w < weeks - 1 ? gapStr : "";
|
|
930
|
+
if (!inRange) {
|
|
931
|
+
line += " ".repeat(cellWidth) + colGap;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const key = toLocalDayKey(cellDate);
|
|
936
|
+
const day = range.dayByKey.get(key);
|
|
937
|
+
const value =
|
|
938
|
+
metric.kind === "tokens"
|
|
939
|
+
? (day?.tokens ?? 0)
|
|
940
|
+
: metric.kind === "messages"
|
|
941
|
+
? (day?.messages ?? 0)
|
|
942
|
+
: (day?.sessions ?? 0);
|
|
943
|
+
|
|
944
|
+
if (!day || value <= 0) {
|
|
945
|
+
line += ansiFg(EMPTY_CELL_BG, block) + colGap;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const hue = dayMixedColor(day, colorMap, otherColor, mode, view);
|
|
950
|
+
let t = denom > 0 ? Math.log1p(value) / denom : 0;
|
|
951
|
+
t = clamp01(t);
|
|
952
|
+
const minVisible = 0.2;
|
|
953
|
+
const intensity = minVisible + (1 - minVisible) * t;
|
|
954
|
+
const rgb = mixRgb(DEFAULT_BG, hue, intensity);
|
|
955
|
+
line += ansiFg(rgb, block) + colGap;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
lines.push(line);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
return lines;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function displayModelName(modelKey: string): string {
|
|
965
|
+
const idx = modelKey.indexOf("/");
|
|
966
|
+
return idx === -1 ? modelKey : modelKey.slice(idx + 1);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function renderLegendItems(modelColors: Map<ModelKey, RGB>, orderedModels: ModelKey[], otherColor: RGB): string[] {
|
|
970
|
+
const items: string[] = [];
|
|
971
|
+
for (const mk of orderedModels) {
|
|
972
|
+
const c = modelColors.get(mk);
|
|
973
|
+
if (!c) continue;
|
|
974
|
+
items.push(`${ansiFg(c, "█")} ${displayModelName(mk)}`);
|
|
975
|
+
}
|
|
976
|
+
items.push(`${ansiFg(otherColor, "█")} other`);
|
|
977
|
+
return items;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function fitRight(text: string, width: number): string {
|
|
981
|
+
if (width <= 0) return "";
|
|
982
|
+
let w = visibleWidth(text);
|
|
983
|
+
let t = text;
|
|
984
|
+
if (w > width) {
|
|
985
|
+
t = sliceByColumn(t, w - width, width, true);
|
|
986
|
+
w = visibleWidth(t);
|
|
987
|
+
}
|
|
988
|
+
return " ".repeat(Math.max(0, width - w)) + t;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function renderLegendBlock(leftLabel: string, items: string[], width: number): string[] {
|
|
992
|
+
if (width <= 0) return [];
|
|
993
|
+
if (items.length === 0) return [truncateToWidth(leftLabel, width)];
|
|
994
|
+
|
|
995
|
+
const lines: string[] = [];
|
|
996
|
+
// First line: label on left, first item right-aligned into remaining space.
|
|
997
|
+
const leftW = visibleWidth(leftLabel);
|
|
998
|
+
if (leftW >= width) {
|
|
999
|
+
lines.push(truncateToWidth(leftLabel, width));
|
|
1000
|
+
// Put all items on their own lines right-aligned.
|
|
1001
|
+
for (const it of items) lines.push(fitRight(it, width));
|
|
1002
|
+
return lines;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const remaining = Math.max(0, width - leftW);
|
|
1006
|
+
lines.push(leftLabel + fitRight(items[0], remaining));
|
|
1007
|
+
|
|
1008
|
+
for (let i = 1; i < items.length; i++) {
|
|
1009
|
+
lines.push(fitRight(items[i], width));
|
|
1010
|
+
}
|
|
1011
|
+
return lines;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function renderModelTable(range: RangeAgg, mode: MeasurementMode, maxRows = 8): string[] {
|
|
1015
|
+
// Keep this relatively narrow: model + selected metric + cost + share.
|
|
1016
|
+
const metric = graphMetricForRange(range, mode);
|
|
1017
|
+
const kind = metric.kind;
|
|
1018
|
+
|
|
1019
|
+
let perModel: Map<ModelKey, number>;
|
|
1020
|
+
let total = 0;
|
|
1021
|
+
let label = kind;
|
|
1022
|
+
|
|
1023
|
+
if (kind === "tokens") {
|
|
1024
|
+
perModel = range.modelTokens;
|
|
1025
|
+
total = range.totalTokens;
|
|
1026
|
+
} else if (kind === "messages") {
|
|
1027
|
+
perModel = range.modelMessages;
|
|
1028
|
+
total = range.totalMessages;
|
|
1029
|
+
} else {
|
|
1030
|
+
perModel = range.modelSessions;
|
|
1031
|
+
total = range.sessions;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const sorted = sortMapByValueDesc(perModel);
|
|
1035
|
+
const rows = sorted.slice(0, maxRows);
|
|
1036
|
+
|
|
1037
|
+
const valueWidth = kind === "tokens" ? 10 : 8;
|
|
1038
|
+
const modelWidth = Math.min(52, Math.max("model".length, ...rows.map((r) => r.key.length)));
|
|
1039
|
+
|
|
1040
|
+
const lines: string[] = [];
|
|
1041
|
+
lines.push(`${padRight("model", modelWidth)} ${padLeft(label, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
|
|
1042
|
+
lines.push(`${"-".repeat(modelWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
|
|
1043
|
+
|
|
1044
|
+
for (const r of rows) {
|
|
1045
|
+
const value = perModel.get(r.key) ?? 0;
|
|
1046
|
+
const cost = range.modelCost.get(r.key) ?? 0;
|
|
1047
|
+
const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
|
|
1048
|
+
lines.push(
|
|
1049
|
+
`${padRight(r.key.slice(0, modelWidth), modelWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (sorted.length === 0) {
|
|
1054
|
+
lines.push(dim("(no model data found)"));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
return lines;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function renderCwdTable(range: RangeAgg, mode: MeasurementMode, maxRows = 8): string[] {
|
|
1061
|
+
const metric = graphMetricForRange(range, mode);
|
|
1062
|
+
const kind = metric.kind;
|
|
1063
|
+
|
|
1064
|
+
let perCwd: Map<CwdKey, number>;
|
|
1065
|
+
let total = 0;
|
|
1066
|
+
let label = kind;
|
|
1067
|
+
|
|
1068
|
+
if (kind === "tokens") {
|
|
1069
|
+
perCwd = range.cwdTokens;
|
|
1070
|
+
total = range.totalTokens;
|
|
1071
|
+
} else if (kind === "messages") {
|
|
1072
|
+
perCwd = range.cwdMessages;
|
|
1073
|
+
total = range.totalMessages;
|
|
1074
|
+
} else {
|
|
1075
|
+
perCwd = range.cwdSessions;
|
|
1076
|
+
total = range.sessions;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const sorted = sortMapByValueDesc(perCwd);
|
|
1080
|
+
const rows = sorted.slice(0, maxRows);
|
|
1081
|
+
|
|
1082
|
+
const valueWidth = kind === "tokens" ? 10 : 8;
|
|
1083
|
+
const displayPaths = rows.map((r) => abbreviatePath(r.key, 40));
|
|
1084
|
+
const cwdWidth = Math.min(42, Math.max("directory".length, ...displayPaths.map((p) => p.length)));
|
|
1085
|
+
|
|
1086
|
+
const lines: string[] = [];
|
|
1087
|
+
lines.push(`${padRight("directory", cwdWidth)} ${padLeft(label, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
|
|
1088
|
+
lines.push(`${"-".repeat(cwdWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
|
|
1089
|
+
|
|
1090
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1091
|
+
const r = rows[i];
|
|
1092
|
+
const value = perCwd.get(r.key) ?? 0;
|
|
1093
|
+
const cost = range.cwdCost.get(r.key) ?? 0;
|
|
1094
|
+
const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
|
|
1095
|
+
lines.push(
|
|
1096
|
+
`${padRight(displayPaths[i].slice(0, cwdWidth), cwdWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (sorted.length === 0) {
|
|
1101
|
+
lines.push(dim("(no directory data found)"));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return lines;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function dowMetricForRange(
|
|
1108
|
+
range: RangeAgg,
|
|
1109
|
+
mode: MeasurementMode,
|
|
1110
|
+
): { kind: "sessions" | "messages" | "tokens"; perDow: Map<DowKey, number>; total: number } {
|
|
1111
|
+
const metric = graphMetricForRange(range, mode);
|
|
1112
|
+
const kind = metric.kind;
|
|
1113
|
+
|
|
1114
|
+
if (kind === "tokens") {
|
|
1115
|
+
return { kind, perDow: range.dowTokens, total: range.totalTokens };
|
|
1116
|
+
}
|
|
1117
|
+
if (kind === "messages") {
|
|
1118
|
+
return { kind, perDow: range.dowMessages, total: range.totalMessages };
|
|
1119
|
+
}
|
|
1120
|
+
return { kind, perDow: range.dowSessions, total: range.sessions };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function renderDowDistributionLines(
|
|
1124
|
+
range: RangeAgg,
|
|
1125
|
+
mode: MeasurementMode,
|
|
1126
|
+
dowColors: Map<DowKey, RGB>,
|
|
1127
|
+
width: number,
|
|
1128
|
+
): string[] {
|
|
1129
|
+
const { kind, perDow, total } = dowMetricForRange(range, mode);
|
|
1130
|
+
const dayWidth = 3;
|
|
1131
|
+
const pctWidth = 4; // "100%"
|
|
1132
|
+
const valueWidth = kind === "tokens" ? 10 : 8;
|
|
1133
|
+
const showValue = width >= dayWidth + 1 + 10 + 1 + pctWidth + 1 + valueWidth;
|
|
1134
|
+
const fixedWidth = dayWidth + 1 + 1 + pctWidth + (showValue ? 1 + valueWidth : 0);
|
|
1135
|
+
const barWidth = Math.max(1, width - fixedWidth);
|
|
1136
|
+
const fallbackColor: RGB = { r: 160, g: 160, b: 160 };
|
|
1137
|
+
|
|
1138
|
+
const lines: string[] = [];
|
|
1139
|
+
for (const dow of DOW_NAMES) {
|
|
1140
|
+
const value = perDow.get(dow) ?? 0;
|
|
1141
|
+
const share = total > 0 ? value / total : 0;
|
|
1142
|
+
let filled = share > 0 ? Math.round(share * barWidth) : 0;
|
|
1143
|
+
if (share > 0) filled = Math.max(1, filled);
|
|
1144
|
+
filled = Math.min(barWidth, filled);
|
|
1145
|
+
const empty = Math.max(0, barWidth - filled);
|
|
1146
|
+
|
|
1147
|
+
const color = dowColors.get(dow) ?? fallbackColor;
|
|
1148
|
+
const filledBar = filled > 0 ? ansiFg(color, "█".repeat(filled)) : "";
|
|
1149
|
+
const emptyBar = empty > 0 ? ansiFg(EMPTY_CELL_BG, "█".repeat(empty)) : "";
|
|
1150
|
+
const pct = padLeft(`${Math.round(share * 100)}%`, pctWidth);
|
|
1151
|
+
|
|
1152
|
+
let line = `${padRight(dow, dayWidth)} ${filledBar}${emptyBar} ${pct}`;
|
|
1153
|
+
if (showValue) line += ` ${padLeft(formatCount(value), valueWidth)}`;
|
|
1154
|
+
lines.push(line);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
return lines;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function renderDowTable(range: RangeAgg, mode: MeasurementMode): string[] {
|
|
1161
|
+
const { kind, perDow, total } = dowMetricForRange(range, mode);
|
|
1162
|
+
const valueWidth = kind === "tokens" ? 10 : 8;
|
|
1163
|
+
const dowWidth = 5; // "day "
|
|
1164
|
+
|
|
1165
|
+
const lines: string[] = [];
|
|
1166
|
+
lines.push(`${padRight("day", dowWidth)} ${padLeft(kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
|
|
1167
|
+
lines.push(`${"-".repeat(dowWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
|
|
1168
|
+
|
|
1169
|
+
// Always show in Mon–Sun order
|
|
1170
|
+
for (const dow of DOW_NAMES) {
|
|
1171
|
+
const value = perDow.get(dow) ?? 0;
|
|
1172
|
+
const cost = range.dowCost.get(dow) ?? 0;
|
|
1173
|
+
const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
|
|
1174
|
+
lines.push(
|
|
1175
|
+
`${padRight(dow, dowWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
return lines;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function renderTodTable(range: RangeAgg, mode: MeasurementMode): string[] {
|
|
1183
|
+
const metric = graphMetricForRange(range, mode);
|
|
1184
|
+
const kind = metric.kind;
|
|
1185
|
+
|
|
1186
|
+
let perTod: Map<TodKey, number>;
|
|
1187
|
+
let total = 0;
|
|
1188
|
+
|
|
1189
|
+
if (kind === "tokens") {
|
|
1190
|
+
perTod = range.todTokens;
|
|
1191
|
+
total = range.totalTokens;
|
|
1192
|
+
} else if (kind === "messages") {
|
|
1193
|
+
perTod = range.todMessages;
|
|
1194
|
+
total = range.totalMessages;
|
|
1195
|
+
} else {
|
|
1196
|
+
perTod = range.todSessions;
|
|
1197
|
+
total = range.sessions;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const valueWidth = kind === "tokens" ? 10 : 8;
|
|
1201
|
+
const todWidth = 22; // widest label
|
|
1202
|
+
|
|
1203
|
+
const lines: string[] = [];
|
|
1204
|
+
lines.push(`${padRight("time of day", todWidth)} ${padLeft(kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
|
|
1205
|
+
lines.push(`${"-".repeat(todWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
|
|
1206
|
+
|
|
1207
|
+
// Always show in chronological order
|
|
1208
|
+
for (const b of TOD_BUCKETS) {
|
|
1209
|
+
const value = perTod.get(b.key) ?? 0;
|
|
1210
|
+
const cost = range.todCost.get(b.key) ?? 0;
|
|
1211
|
+
const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
|
|
1212
|
+
lines.push(
|
|
1213
|
+
`${padRight(b.label, todWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return lines;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function renderLeftRight(left: string, right: string, width: number): string {
|
|
1221
|
+
const leftW = visibleWidth(left);
|
|
1222
|
+
if (width <= 0) return "";
|
|
1223
|
+
if (leftW >= width) return truncateToWidth(left, width);
|
|
1224
|
+
|
|
1225
|
+
const remaining = width - leftW;
|
|
1226
|
+
let rightText = right;
|
|
1227
|
+
const rightW = visibleWidth(rightText);
|
|
1228
|
+
if (rightW > remaining) {
|
|
1229
|
+
// Keep the *rightmost* part visible.
|
|
1230
|
+
rightText = sliceByColumn(rightText, rightW - remaining, remaining, true);
|
|
1231
|
+
}
|
|
1232
|
+
const pad = Math.max(0, remaining - visibleWidth(rightText));
|
|
1233
|
+
return left + " ".repeat(pad) + rightText;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function rangeSummary(range: RangeAgg, days: number, mode: MeasurementMode): string {
|
|
1237
|
+
const avg = range.sessions > 0 ? range.totalCost / range.sessions : 0;
|
|
1238
|
+
const costPart = range.totalCost > 0 ? `${formatUsd(range.totalCost)} · avg ${formatUsd(avg)}/session` : `$0.0000`;
|
|
1239
|
+
|
|
1240
|
+
if (mode === "tokens") {
|
|
1241
|
+
return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalTokens)} tokens · ${costPart}`;
|
|
1242
|
+
}
|
|
1243
|
+
if (mode === "messages") {
|
|
1244
|
+
return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalMessages)} messages · ${costPart}`;
|
|
1245
|
+
}
|
|
1246
|
+
return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${costPart}`;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function computeBreakdown(
|
|
1250
|
+
signal?: AbortSignal,
|
|
1251
|
+
onProgress?: (update: Partial<BreakdownProgressState>) => void,
|
|
1252
|
+
): Promise<BreakdownData> {
|
|
1253
|
+
const now = new Date();
|
|
1254
|
+
const ranges = new Map<number, RangeAgg>();
|
|
1255
|
+
for (const d of RANGE_DAYS) ranges.set(d, buildRangeAgg(d, now));
|
|
1256
|
+
const range90 = ranges.get(90)!;
|
|
1257
|
+
const start90 = range90.days[0].date;
|
|
1258
|
+
|
|
1259
|
+
onProgress?.({ phase: "scan", foundFiles: 0, parsedFiles: 0, totalFiles: 0, currentFile: undefined });
|
|
1260
|
+
|
|
1261
|
+
const candidates = await walkSessionFiles(SESSION_ROOT, start90, signal, (found) => {
|
|
1262
|
+
onProgress?.({ phase: "scan", foundFiles: found });
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
const totalFiles = candidates.length;
|
|
1266
|
+
onProgress?.({
|
|
1267
|
+
phase: "parse",
|
|
1268
|
+
foundFiles: totalFiles,
|
|
1269
|
+
totalFiles,
|
|
1270
|
+
parsedFiles: 0,
|
|
1271
|
+
currentFile: totalFiles > 0 ? path.basename(candidates[0]!) : undefined,
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
let parsedFiles = 0;
|
|
1275
|
+
for (const filePath of candidates) {
|
|
1276
|
+
if (signal?.aborted) break;
|
|
1277
|
+
parsedFiles += 1;
|
|
1278
|
+
onProgress?.({ phase: "parse", parsedFiles, totalFiles, currentFile: path.basename(filePath) });
|
|
1279
|
+
|
|
1280
|
+
const session = await parseSessionFile(filePath, signal);
|
|
1281
|
+
if (!session) continue;
|
|
1282
|
+
|
|
1283
|
+
const sessionDay = localMidnight(session.startedAt);
|
|
1284
|
+
for (const d of RANGE_DAYS) {
|
|
1285
|
+
const range = ranges.get(d)!;
|
|
1286
|
+
const start = range.days[0].date;
|
|
1287
|
+
const end = range.days[range.days.length - 1].date;
|
|
1288
|
+
if (sessionDay < start || sessionDay > end) continue;
|
|
1289
|
+
addSessionToRange(range, session);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
onProgress?.({ phase: "finalize", currentFile: undefined });
|
|
1294
|
+
|
|
1295
|
+
const palette = choosePaletteFromLast30Days(ranges.get(30)!, 4);
|
|
1296
|
+
const cwdPalette = chooseCwdPaletteFromLast30Days(ranges.get(30)!, 4);
|
|
1297
|
+
const dowPalette = buildDowPalette();
|
|
1298
|
+
const todPalette = buildTodPalette();
|
|
1299
|
+
return { generatedAt: now, ranges, palette, cwdPalette, dowPalette, todPalette };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
class BreakdownComponent implements Component {
|
|
1303
|
+
private data: BreakdownData;
|
|
1304
|
+
private tui: TUI;
|
|
1305
|
+
private onDone: () => void;
|
|
1306
|
+
private rangeIndex = 1; // default 30d
|
|
1307
|
+
private measurement: MeasurementMode = "sessions";
|
|
1308
|
+
private view: BreakdownView = "model";
|
|
1309
|
+
private cachedWidth?: number;
|
|
1310
|
+
private cachedLines?: string[];
|
|
1311
|
+
|
|
1312
|
+
constructor(data: BreakdownData, tui: TUI, onDone: () => void) {
|
|
1313
|
+
this.data = data;
|
|
1314
|
+
this.tui = tui;
|
|
1315
|
+
this.onDone = onDone;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
invalidate(): void {
|
|
1319
|
+
this.cachedWidth = undefined;
|
|
1320
|
+
this.cachedLines = undefined;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
handleInput(data: string): void {
|
|
1324
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "q") {
|
|
1325
|
+
this.onDone();
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")) || data.toLowerCase() === "t") {
|
|
1330
|
+
const order: MeasurementMode[] = ["sessions", "messages", "tokens"];
|
|
1331
|
+
const idx = Math.max(0, order.indexOf(this.measurement));
|
|
1332
|
+
const dir = matchesKey(data, Key.shift("tab")) ? -1 : 1;
|
|
1333
|
+
this.measurement = order[(idx + order.length + dir) % order.length] ?? "sessions";
|
|
1334
|
+
this.invalidate();
|
|
1335
|
+
this.tui.requestRender();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const prev = () => {
|
|
1340
|
+
this.rangeIndex = (this.rangeIndex + RANGE_DAYS.length - 1) % RANGE_DAYS.length;
|
|
1341
|
+
this.invalidate();
|
|
1342
|
+
this.tui.requestRender();
|
|
1343
|
+
};
|
|
1344
|
+
const next = () => {
|
|
1345
|
+
this.rangeIndex = (this.rangeIndex + 1) % RANGE_DAYS.length;
|
|
1346
|
+
this.invalidate();
|
|
1347
|
+
this.tui.requestRender();
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
if (matchesKey(data, Key.left) || data.toLowerCase() === "h") prev();
|
|
1351
|
+
if (matchesKey(data, Key.right) || data.toLowerCase() === "l") next();
|
|
1352
|
+
|
|
1353
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down) || data.toLowerCase() === "j" || data.toLowerCase() === "k") {
|
|
1354
|
+
const views: BreakdownView[] = ["model", "cwd", "dow", "tod"];
|
|
1355
|
+
const idx = views.indexOf(this.view);
|
|
1356
|
+
const dir = matchesKey(data, Key.up) || data.toLowerCase() === "k" ? -1 : 1;
|
|
1357
|
+
this.view = views[(idx + views.length + dir) % views.length] ?? "model";
|
|
1358
|
+
this.invalidate();
|
|
1359
|
+
this.tui.requestRender();
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if (data === "1") {
|
|
1364
|
+
this.rangeIndex = 0;
|
|
1365
|
+
this.invalidate();
|
|
1366
|
+
this.tui.requestRender();
|
|
1367
|
+
}
|
|
1368
|
+
if (data === "2") {
|
|
1369
|
+
this.rangeIndex = 1;
|
|
1370
|
+
this.invalidate();
|
|
1371
|
+
this.tui.requestRender();
|
|
1372
|
+
}
|
|
1373
|
+
if (data === "3") {
|
|
1374
|
+
this.rangeIndex = 2;
|
|
1375
|
+
this.invalidate();
|
|
1376
|
+
this.tui.requestRender();
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
render(width: number): string[] {
|
|
1381
|
+
if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
|
|
1382
|
+
|
|
1383
|
+
const selectedDays = RANGE_DAYS[this.rangeIndex];
|
|
1384
|
+
const range = this.data.ranges.get(selectedDays)!;
|
|
1385
|
+
const metric = graphMetricForRange(range, this.measurement);
|
|
1386
|
+
|
|
1387
|
+
const tab = (days: number, idx: number): string => {
|
|
1388
|
+
const selected = idx === this.rangeIndex;
|
|
1389
|
+
const label = `${days}d`;
|
|
1390
|
+
return selected ? bold(`[${label}]`) : dim(` ${label} `);
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
const metricTab = (mode: MeasurementMode, label: string): string => {
|
|
1394
|
+
const selected = mode === this.measurement;
|
|
1395
|
+
return selected ? bold(`[${label}]`) : dim(` ${label} `);
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
const viewTab = (v: BreakdownView, label: string): string => {
|
|
1399
|
+
const selected = v === this.view;
|
|
1400
|
+
return selected ? bold(`[${label}]`) : dim(` ${label} `);
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
const header =
|
|
1404
|
+
`${bold("Session breakdown")} ${tab(7, 0)}${tab(30, 1)}${tab(90, 2)} ` +
|
|
1405
|
+
`${metricTab("sessions", "sess")}${metricTab("messages", "msg")}${metricTab("tokens", "tok")} ` +
|
|
1406
|
+
`${viewTab("model", "model")}${viewTab("cwd", "cwd")}${viewTab("dow", "dow")}${viewTab("tod", "tod")}`;
|
|
1407
|
+
|
|
1408
|
+
// Choose colors and legend based on current view
|
|
1409
|
+
let activeColorMap: Map<string, RGB>;
|
|
1410
|
+
let activeOtherColor: RGB = { r: 160, g: 160, b: 160 };
|
|
1411
|
+
const legendItems: string[] = [];
|
|
1412
|
+
|
|
1413
|
+
if (this.view === "model") {
|
|
1414
|
+
activeColorMap = this.data.palette.modelColors;
|
|
1415
|
+
activeOtherColor = this.data.palette.otherColor;
|
|
1416
|
+
for (const mk of this.data.palette.orderedModels) {
|
|
1417
|
+
const c = activeColorMap.get(mk);
|
|
1418
|
+
if (c) legendItems.push(`${ansiFg(c, "█")} ${displayModelName(mk)}`);
|
|
1419
|
+
}
|
|
1420
|
+
legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
|
|
1421
|
+
} else if (this.view === "cwd") {
|
|
1422
|
+
activeColorMap = this.data.cwdPalette.cwdColors;
|
|
1423
|
+
activeOtherColor = this.data.cwdPalette.otherColor;
|
|
1424
|
+
for (const cwd of this.data.cwdPalette.orderedCwds) {
|
|
1425
|
+
const c = activeColorMap.get(cwd);
|
|
1426
|
+
if (c) legendItems.push(`${ansiFg(c, "█")} ${abbreviatePath(cwd, 30)}`);
|
|
1427
|
+
}
|
|
1428
|
+
legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
|
|
1429
|
+
} else if (this.view === "dow") {
|
|
1430
|
+
activeColorMap = this.data.dowPalette.dowColors;
|
|
1431
|
+
for (const dow of this.data.dowPalette.orderedDows) {
|
|
1432
|
+
const c = activeColorMap.get(dow);
|
|
1433
|
+
if (c) legendItems.push(`${ansiFg(c, "█")} ${dow}`);
|
|
1434
|
+
}
|
|
1435
|
+
} else {
|
|
1436
|
+
activeColorMap = this.data.todPalette.todColors;
|
|
1437
|
+
for (const tod of this.data.todPalette.orderedTods) {
|
|
1438
|
+
const c = activeColorMap.get(tod);
|
|
1439
|
+
if (c) legendItems.push(`${ansiFg(c, "█")} ${todBucketLabel(tod)}`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const graphDescriptor = this.view === "dow" ? `share of ${metric.kind} by weekday` : `${metric.kind}/day`;
|
|
1444
|
+
const summary = rangeSummary(range, selectedDays, metric.kind) + dim(` (graph: ${graphDescriptor})`);
|
|
1445
|
+
|
|
1446
|
+
let graphLines: string[];
|
|
1447
|
+
if (this.view === "dow") {
|
|
1448
|
+
graphLines = renderDowDistributionLines(range, this.measurement, this.data.dowPalette.dowColors, width);
|
|
1449
|
+
} else {
|
|
1450
|
+
const maxScale = selectedDays === 7 ? 4 : selectedDays === 30 ? 3 : 2;
|
|
1451
|
+
const weeks = weeksForRange(range);
|
|
1452
|
+
const leftMargin = 4; // "Mon " (or 4 spaces)
|
|
1453
|
+
const gap = 1;
|
|
1454
|
+
const graphArea = Math.max(1, width - leftMargin);
|
|
1455
|
+
// Each week column uses: cellWidth + gap. Last column also gets gap (fine; we truncate anyway).
|
|
1456
|
+
const idealCellWidth = Math.floor((graphArea + gap) / Math.max(1, weeks)) - gap;
|
|
1457
|
+
const cellWidth = Math.min(maxScale, Math.max(1, idealCellWidth));
|
|
1458
|
+
|
|
1459
|
+
graphLines = renderGraphLines(
|
|
1460
|
+
range,
|
|
1461
|
+
activeColorMap,
|
|
1462
|
+
activeOtherColor,
|
|
1463
|
+
this.measurement,
|
|
1464
|
+
{ cellWidth, gap },
|
|
1465
|
+
this.view,
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
const tableLines =
|
|
1469
|
+
this.view === "model" ? renderModelTable(range, metric.kind, 8)
|
|
1470
|
+
: this.view === "cwd" ? renderCwdTable(range, metric.kind, 8)
|
|
1471
|
+
: this.view === "dow" ? renderDowTable(range, metric.kind)
|
|
1472
|
+
: renderTodTable(range, metric.kind);
|
|
1473
|
+
|
|
1474
|
+
const lines: string[] = [];
|
|
1475
|
+
lines.push(truncateToWidth(header, width));
|
|
1476
|
+
lines.push(truncateToWidth(dim("←/→ range · ↑/↓ view · tab metric · q to close"), width));
|
|
1477
|
+
lines.push("");
|
|
1478
|
+
lines.push(truncateToWidth(summary, width));
|
|
1479
|
+
lines.push("");
|
|
1480
|
+
|
|
1481
|
+
if (this.view === "dow") {
|
|
1482
|
+
for (const gl of graphLines) lines.push(truncateToWidth(gl, width));
|
|
1483
|
+
} else {
|
|
1484
|
+
// Render legend on the RIGHT of the graph if there is space.
|
|
1485
|
+
const graphWidth = Math.max(0, ...graphLines.map((l) => visibleWidth(l)));
|
|
1486
|
+
const sep = 2;
|
|
1487
|
+
const legendWidth = width - graphWidth - sep;
|
|
1488
|
+
const showSideLegend = legendWidth >= 22;
|
|
1489
|
+
|
|
1490
|
+
if (showSideLegend) {
|
|
1491
|
+
const legendBlock: string[] = [];
|
|
1492
|
+
const legendTitle =
|
|
1493
|
+
this.view === "model" ? "Top models (30d palette):"
|
|
1494
|
+
: this.view === "cwd" ? "Top directories (30d palette):"
|
|
1495
|
+
: "Time of day:";
|
|
1496
|
+
legendBlock.push(dim(legendTitle));
|
|
1497
|
+
legendBlock.push(...legendItems);
|
|
1498
|
+
// Fit into 7 rows (same as graph). If too many, show a final "+N more" line.
|
|
1499
|
+
const maxLegendRows = graphLines.length;
|
|
1500
|
+
let legendLines = legendBlock.slice(0, maxLegendRows);
|
|
1501
|
+
if (legendBlock.length > maxLegendRows) {
|
|
1502
|
+
const remaining = legendBlock.length - (maxLegendRows - 1);
|
|
1503
|
+
legendLines = [...legendBlock.slice(0, maxLegendRows - 1), dim(`+${remaining} more`)];
|
|
1504
|
+
}
|
|
1505
|
+
while (legendLines.length < graphLines.length) legendLines.push("");
|
|
1506
|
+
|
|
1507
|
+
const padRightAnsi = (s: string, target: number): string => {
|
|
1508
|
+
const w = visibleWidth(s);
|
|
1509
|
+
return w >= target ? s : s + " ".repeat(target - w);
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
for (let i = 0; i < graphLines.length; i++) {
|
|
1513
|
+
const left = padRightAnsi(graphLines[i] ?? "", graphWidth);
|
|
1514
|
+
const right = truncateToWidth(legendLines[i] ?? "", Math.max(0, legendWidth));
|
|
1515
|
+
lines.push(truncateToWidth(left + " ".repeat(sep) + right, width));
|
|
1516
|
+
}
|
|
1517
|
+
} else {
|
|
1518
|
+
// Fallback: graph only (legend will be shown below).
|
|
1519
|
+
for (const gl of graphLines) lines.push(truncateToWidth(gl, width));
|
|
1520
|
+
lines.push("");
|
|
1521
|
+
// Compact legend below, left-aligned.
|
|
1522
|
+
const legendTitleBelow =
|
|
1523
|
+
this.view === "model" ? "Top models (30d palette):"
|
|
1524
|
+
: this.view === "cwd" ? "Top directories (30d palette):"
|
|
1525
|
+
: "Time of day:";
|
|
1526
|
+
lines.push(truncateToWidth(dim(legendTitleBelow), width));
|
|
1527
|
+
for (const it of legendItems) lines.push(truncateToWidth(it, width));
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
lines.push("");
|
|
1532
|
+
for (const tl of tableLines) lines.push(truncateToWidth(tl, width));
|
|
1533
|
+
|
|
1534
|
+
// Ensure no overly long lines (truncateToWidth already), but keep at least 1 line.
|
|
1535
|
+
this.cachedWidth = width;
|
|
1536
|
+
this.cachedLines = lines.map((l) => (visibleWidth(l) > width ? truncateToWidth(l, width) : l));
|
|
1537
|
+
return this.cachedLines;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
export default function sessionBreakdownExtension(pi: ExtensionAPI) {
|
|
1542
|
+
pi.registerCommand("session-breakdown", {
|
|
1543
|
+
description: "Interactive breakdown of last 7/30/90 days of ~/.pi session usage (sessions/messages/tokens + cost by model)",
|
|
1544
|
+
handler: async (_args, ctx: ExtensionContext) => {
|
|
1545
|
+
if (!ctx.hasUI) {
|
|
1546
|
+
// Non-interactive fallback: just notify.
|
|
1547
|
+
const data = await computeBreakdown(undefined);
|
|
1548
|
+
const range = data.ranges.get(30)!;
|
|
1549
|
+
pi.sendMessage(
|
|
1550
|
+
{
|
|
1551
|
+
customType: "session-breakdown",
|
|
1552
|
+
content: `Session breakdown (non-interactive)\n${rangeSummary(range, 30, "sessions")}`,
|
|
1553
|
+
display: true,
|
|
1554
|
+
},
|
|
1555
|
+
{ triggerTurn: false },
|
|
1556
|
+
);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
let aborted = false;
|
|
1561
|
+
const data = await ctx.ui.custom<BreakdownData | null>((tui, theme, _kb, done) => {
|
|
1562
|
+
const baseMessage = "Analyzing sessions (last 90 days)…";
|
|
1563
|
+
const loader = new BorderedLoader(tui, theme, baseMessage);
|
|
1564
|
+
|
|
1565
|
+
const startedAt = Date.now();
|
|
1566
|
+
const progress: BreakdownProgressState = {
|
|
1567
|
+
phase: "scan",
|
|
1568
|
+
foundFiles: 0,
|
|
1569
|
+
parsedFiles: 0,
|
|
1570
|
+
totalFiles: 0,
|
|
1571
|
+
currentFile: undefined,
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
const renderMessage = (): string => {
|
|
1575
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
1576
|
+
if (progress.phase === "scan") {
|
|
1577
|
+
return `${baseMessage} scanning (${formatCount(progress.foundFiles)} files) · ${elapsed}s`;
|
|
1578
|
+
}
|
|
1579
|
+
if (progress.phase === "parse") {
|
|
1580
|
+
return `${baseMessage} parsing (${formatCount(progress.parsedFiles)}/${formatCount(progress.totalFiles)}) · ${elapsed}s`;
|
|
1581
|
+
}
|
|
1582
|
+
return `${baseMessage} finalizing · ${elapsed}s`;
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
let intervalId: NodeJS.Timeout | null = null;
|
|
1586
|
+
const stopTicker = () => {
|
|
1587
|
+
if (intervalId) {
|
|
1588
|
+
clearInterval(intervalId);
|
|
1589
|
+
intervalId = null;
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
// Update every 0.5s so long-running scans show some visible progress.
|
|
1594
|
+
setBorderedLoaderMessage(loader, renderMessage());
|
|
1595
|
+
intervalId = setInterval(() => {
|
|
1596
|
+
setBorderedLoaderMessage(loader, renderMessage());
|
|
1597
|
+
}, 500);
|
|
1598
|
+
|
|
1599
|
+
loader.onAbort = () => {
|
|
1600
|
+
aborted = true;
|
|
1601
|
+
stopTicker();
|
|
1602
|
+
done(null);
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
computeBreakdown(loader.signal, (update) => Object.assign(progress, update))
|
|
1606
|
+
.then((d) => {
|
|
1607
|
+
stopTicker();
|
|
1608
|
+
if (!aborted) done(d);
|
|
1609
|
+
})
|
|
1610
|
+
.catch((err) => {
|
|
1611
|
+
stopTicker();
|
|
1612
|
+
console.error("session-breakdown: failed to analyze sessions", err);
|
|
1613
|
+
if (!aborted) done(null);
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
return loader;
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
if (!data) {
|
|
1620
|
+
ctx.ui.notify(aborted ? "Cancelled" : "Failed to analyze sessions", aborted ? "info" : "error");
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
await ctx.ui.custom<void>((tui, _theme, _kb, done) => {
|
|
1625
|
+
return new BreakdownComponent(data, tui, done);
|
|
1626
|
+
});
|
|
1627
|
+
},
|
|
1628
|
+
});
|
|
1629
|
+
}
|