token-larper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +334 -0
  2. package/Start-TokenLarper.vbs +5 -0
  3. package/bin/cli.js +34 -0
  4. package/llms.txt +59 -0
  5. package/package.json +61 -0
  6. package/scripts/docs-demo-server.ts +141 -0
  7. package/scripts/launch-on-default-desktop.ps1 +42 -0
  8. package/scripts/launch-silent.vbs +6 -0
  9. package/scripts/run-server.ps1 +59 -0
  10. package/scripts/tray-host.ps1 +181 -0
  11. package/src/ccusage.ts +1075 -0
  12. package/src/client/App.tsx +560 -0
  13. package/src/client/charts.tsx +341 -0
  14. package/src/client/components/Calendar.tsx +207 -0
  15. package/src/client/components/DateRangePicker.tsx +131 -0
  16. package/src/client/components/KpiCards.tsx +122 -0
  17. package/src/client/components/RankChip.tsx +22 -0
  18. package/src/client/components/SelectMenu.tsx +116 -0
  19. package/src/client/components/SettingsDialog.tsx +207 -0
  20. package/src/client/components/ThemeSettings.tsx +131 -0
  21. package/src/client/context.tsx +60 -0
  22. package/src/client/index.html +54 -0
  23. package/src/client/lib/aggregate.ts +266 -0
  24. package/src/client/lib/range.ts +87 -0
  25. package/src/client/lib/rank.ts +323 -0
  26. package/src/client/lib/shareCard.ts +139 -0
  27. package/src/client/logo.svg +8 -0
  28. package/src/client/main.tsx +12 -0
  29. package/src/client/styles.css +1113 -0
  30. package/src/client/themes.ts +268 -0
  31. package/src/client/utils.ts +61 -0
  32. package/src/client/views/Models.tsx +71 -0
  33. package/src/client/views/Overview.tsx +280 -0
  34. package/src/client/views/Projects.tsx +88 -0
  35. package/src/client/views/Rank.tsx +255 -0
  36. package/src/client/views/Sessions.tsx +108 -0
  37. package/src/client/views/Tools.tsx +114 -0
  38. package/src/projects.ts +214 -0
  39. package/src/server.ts +173 -0
  40. package/src/startup.ts +110 -0
  41. package/src/tray.ts +293 -0
  42. package/src/types.ts +155 -0
@@ -0,0 +1,54 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <script>
7
+ // An explicit choice wins; otherwise follow the OS setting.
8
+ var stored = null;
9
+ try { stored = localStorage.getItem("token-larper-theme"); } catch {}
10
+ document.documentElement.dataset.theme =
11
+ stored === "dark" || stored === "light"
12
+ ? stored
13
+ : matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
14
+ var savedPalette = null;
15
+ try { savedPalette = localStorage.getItem("token-larper-palette"); } catch {}
16
+ var palettes = ["pine", "slate", "ocean", "iris", "clay", "rose", "custom"];
17
+ var palette = palettes.includes(savedPalette) ? savedPalette : "pine";
18
+ document.documentElement.dataset.palette = palette;
19
+ if (palette === "custom") {
20
+ try {
21
+ var imported = JSON.parse(localStorage.getItem("token-larper-imported-theme") || "null");
22
+ var tokens = imported && imported[document.documentElement.dataset.theme];
23
+ var properties = [
24
+ "--bg", "--surface", "--surface-2", "--surface-3", "--border", "--border-strong",
25
+ "--text", "--text-2", "--text-3", "--accent", "--accent-ink", "--accent-soft", "--gold", "--focus",
26
+ "--warning-bg", "--warning-border", "--warning-text", "--error-bg", "--error-border", "--error-text",
27
+ "--series-1", "--series-2", "--series-3", "--series-4", "--series-5", "--series-6", "--series-7",
28
+ "--series-other", "--heat-0", "--heat-1", "--heat-2", "--heat-3", "--heat-4",
29
+ ];
30
+ properties.forEach(function (property) {
31
+ var value = tokens && tokens[property];
32
+ if (typeof value === "string" && value.length <= 180 && !/[;{}]|\bvar\s*\(|url\s*\(/i.test(value) && CSS.supports("color", value)) {
33
+ document.documentElement.style.setProperty(property, value);
34
+ }
35
+ });
36
+ ["--radius", "--radius-sm"].forEach(function (property) {
37
+ var radius = tokens && tokens[property];
38
+ if (typeof radius === "string" && /^\d+(?:\.\d+)?(?:px|rem|em)$/.test(radius)) {
39
+ document.documentElement.style.setProperty(property, radius);
40
+ }
41
+ });
42
+ } catch {}
43
+ }
44
+ </script>
45
+ <meta name="theme-color" content="#f8f9f7" />
46
+ <title>Token Larper — Usage overview</title>
47
+ <link rel="icon" type="image/svg+xml" href="/logo.svg" />
48
+ <link rel="stylesheet" href="/styles.css" />
49
+ </head>
50
+ <body>
51
+ <div id="root"></div>
52
+ <script type="module" src="/app.js"></script>
53
+ </body>
54
+ </html>
@@ -0,0 +1,266 @@
1
+ import type {
2
+ HarnessId,
3
+ ModelMetric,
4
+ PeriodHarnessBreakdown,
5
+ SessionEntry,
6
+ TimePeriodRow,
7
+ } from "../../types.ts";
8
+ import { isoWeek } from "../utils.ts";
9
+ import { eachDay, parseDay } from "./range.ts";
10
+
11
+ // Everything the dashboard shows is derived here from the server's daily rows and session
12
+ // list, so a date range or tool filter narrows every number on the page the same way.
13
+
14
+ export type HarnessFilter = HarnessId | "all";
15
+ export type Bucket = "daily" | "weekly" | "monthly";
16
+
17
+ export interface TokenCounts {
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ cacheCreationTokens: number;
21
+ cacheReadTokens: number;
22
+ reasoningOutputTokens: number;
23
+ totalTokens: number;
24
+ verifiedCost: number;
25
+ estimatedCost: number;
26
+ }
27
+
28
+ const COUNT_KEYS: (keyof TokenCounts)[] = [
29
+ "inputTokens",
30
+ "outputTokens",
31
+ "cacheCreationTokens",
32
+ "cacheReadTokens",
33
+ "reasoningOutputTokens",
34
+ "totalTokens",
35
+ "verifiedCost",
36
+ "estimatedCost",
37
+ ];
38
+
39
+ export function zeroCounts(): TokenCounts {
40
+ return {
41
+ inputTokens: 0,
42
+ outputTokens: 0,
43
+ cacheCreationTokens: 0,
44
+ cacheReadTokens: 0,
45
+ reasoningOutputTokens: 0,
46
+ totalTokens: 0,
47
+ verifiedCost: 0,
48
+ estimatedCost: 0,
49
+ };
50
+ }
51
+
52
+ export function addCounts<T extends TokenCounts>(target: T, source: TokenCounts): T {
53
+ for (const key of COUNT_KEYS) target[key] += source[key];
54
+ return target;
55
+ }
56
+
57
+ /** Share of prompt-side tokens served from cache. */
58
+ export function cacheReadRate(c: TokenCounts): number {
59
+ const prompt = c.inputTokens + c.cacheReadTokens + c.cacheCreationTokens;
60
+ return prompt > 0 ? (c.cacheReadTokens / prompt) * 100 : 0;
61
+ }
62
+
63
+ export function emptyPeriod(period: string, label = period): TimePeriodRow {
64
+ return { period, label, ...zeroCounts(), byHarness: {}, modelsUsed: [] };
65
+ }
66
+
67
+ function mergeModels(into: ModelMetric[], from: ModelMetric[]): void {
68
+ for (const m of from) {
69
+ const existing = into.find((x) => x.modelName === m.modelName);
70
+ if (existing) {
71
+ addCounts(existing, m);
72
+ existing.missingPricing ||= m.missingPricing;
73
+ } else into.push({ ...m });
74
+ }
75
+ }
76
+
77
+ function mergeRow(target: TimePeriodRow, source: TimePeriodRow): void {
78
+ addCounts(target, source);
79
+ for (const [id, hb] of Object.entries(source.byHarness)) {
80
+ const existing = target.byHarness[id];
81
+ if (existing) {
82
+ addCounts(existing, hb);
83
+ mergeModels(existing.models, hb.models);
84
+ } else {
85
+ target.byHarness[id] = { ...hb, models: hb.models.map((m) => ({ ...m })) };
86
+ }
87
+ }
88
+ for (const m of source.modelsUsed) if (!target.modelsUsed.includes(m)) target.modelsUsed.push(m);
89
+ }
90
+
91
+ /** Narrow one day to one harness (or keep it whole for "all"). */
92
+ function projectDay(row: TimePeriodRow, harness: HarnessFilter): TimePeriodRow | null {
93
+ if (harness === "all") return row;
94
+ const hb = row.byHarness[harness];
95
+ if (!hb) return null;
96
+ const { harness: _id, models, ...counts } = hb;
97
+ return {
98
+ period: row.period,
99
+ label: row.label,
100
+ ...counts,
101
+ byHarness: { [harness]: hb },
102
+ modelsUsed: models.map((m) => m.modelName),
103
+ };
104
+ }
105
+
106
+ /** Days with usage inside [start, end] for the selected harness, oldest first. */
107
+ export function daysInRange(daily: TimePeriodRow[], harness: HarnessFilter, start: string, end: string): TimePeriodRow[] {
108
+ const out: TimePeriodRow[] = [];
109
+ for (const row of daily) {
110
+ if (row.period < start || row.period > end) continue;
111
+ const projected = projectDay(row, harness);
112
+ if (projected && projected.totalTokens > 0) out.push(projected);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ export interface RangeTotals extends TokenCounts {
118
+ activeDays: number;
119
+ cacheHitRate: number;
120
+ busiestDay: TimePeriodRow | null;
121
+ }
122
+
123
+ export function summarize(days: TimePeriodRow[]): RangeTotals {
124
+ const totals = zeroCounts();
125
+ let busiest: TimePeriodRow | null = null;
126
+ for (const d of days) {
127
+ addCounts(totals, d);
128
+ if (!busiest || d.totalTokens > busiest.totalTokens) busiest = d;
129
+ }
130
+ return { ...totals, activeDays: days.length, cacheHitRate: cacheReadRate(totals), busiestDay: busiest };
131
+ }
132
+
133
+ function bucketKey(day: string, bucket: Bucket): { key: string; label: string } {
134
+ if (bucket === "daily") return { key: day, label: day };
135
+ if (bucket === "monthly") {
136
+ const d = parseDay(day);
137
+ return { key: day.slice(0, 7), label: d.toLocaleDateString("en-US", { month: "short", year: "numeric" }) };
138
+ }
139
+ const d = parseDay(day);
140
+ return isoWeek(d.getFullYear(), d.getMonth() + 1, d.getDate());
141
+ }
142
+
143
+ /**
144
+ * Every bucket in the range, idle ones included, so a gap in usage shows as a gap.
145
+ * Weeks and months at the edges are clipped to the range rather than padded out.
146
+ */
147
+ export function bucketize(days: TimePeriodRow[], bucket: Bucket, start: string, end: string): TimePeriodRow[] {
148
+ const buckets = new Map<string, TimePeriodRow>();
149
+ for (const day of eachDay(start, end)) {
150
+ const { key, label } = bucketKey(day, bucket);
151
+ if (!buckets.has(key)) buckets.set(key, emptyPeriod(key, label));
152
+ }
153
+ for (const d of days) {
154
+ const target = buckets.get(bucketKey(d.period, bucket).key);
155
+ if (target) mergeRow(target, d);
156
+ }
157
+ return [...buckets.values()];
158
+ }
159
+
160
+ export interface HarnessRangeSummary extends TokenCounts {
161
+ harness: HarnessId;
162
+ activeDays: number;
163
+ cacheHitRate: number;
164
+ lastActive: string | null;
165
+ models: ModelMetric[];
166
+ }
167
+
168
+ export function harnessTotals(days: TimePeriodRow[]): HarnessRangeSummary[] {
169
+ const map = new Map<HarnessId, HarnessRangeSummary>();
170
+ for (const d of days) {
171
+ for (const hb of Object.values(d.byHarness) as PeriodHarnessBreakdown[]) {
172
+ let entry = map.get(hb.harness);
173
+ if (!entry) {
174
+ entry = { harness: hb.harness, ...zeroCounts(), activeDays: 0, cacheHitRate: 0, lastActive: null, models: [] };
175
+ map.set(hb.harness, entry);
176
+ }
177
+ addCounts(entry, hb);
178
+ mergeModels(entry.models, hb.models);
179
+ entry.activeDays += 1;
180
+ if (!entry.lastActive || d.period > entry.lastActive) entry.lastActive = d.period;
181
+ }
182
+ }
183
+ const list = [...map.values()];
184
+ for (const h of list) {
185
+ h.cacheHitRate = cacheReadRate(h);
186
+ h.models.sort((a, b) => b.totalTokens - a.totalTokens);
187
+ }
188
+ return list.sort((a, b) => b.totalTokens - a.totalTokens);
189
+ }
190
+
191
+ export function modelTotals(days: TimePeriodRow[]): ModelMetric[] {
192
+ const map = new Map<string, ModelMetric>();
193
+ for (const d of days) {
194
+ for (const hb of Object.values(d.byHarness)) {
195
+ for (const m of hb.models) {
196
+ const key = `${m.harness}::${m.modelName}`;
197
+ const existing = map.get(key);
198
+ if (existing) {
199
+ addCounts(existing, m);
200
+ existing.missingPricing ||= m.missingPricing;
201
+ } else map.set(key, { ...m });
202
+ }
203
+ }
204
+ }
205
+ return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
206
+ }
207
+
208
+ /** Sessions count toward the day they last had activity. */
209
+ export function sessionsInRange(sessions: SessionEntry[], harness: HarnessFilter, start: string, end: string): SessionEntry[] {
210
+ return sessions.filter(
211
+ (s) => (harness === "all" || s.harness === harness) && s.date >= start && s.date <= end
212
+ );
213
+ }
214
+
215
+ export interface ProjectSummary extends TokenCounts {
216
+ key: string;
217
+ name: string;
218
+ root: string | null;
219
+ harnesses: HarnessId[];
220
+ sessions: SessionEntry[];
221
+ lastActivity: string;
222
+ }
223
+
224
+ export function groupProjects(sessions: SessionEntry[]): ProjectSummary[] {
225
+ const map = new Map<string, ProjectSummary>();
226
+ for (const s of sessions) {
227
+ const key = s.projectRoot ?? `unattributed:${s.harness}`;
228
+ let p = map.get(key);
229
+ if (!p) {
230
+ p = {
231
+ key,
232
+ name: s.projectName ?? "No project recorded",
233
+ root: s.projectRoot ?? null,
234
+ harnesses: [],
235
+ sessions: [],
236
+ lastActivity: "",
237
+ ...zeroCounts(),
238
+ };
239
+ map.set(key, p);
240
+ }
241
+ addCounts(p, s);
242
+ p.sessions.push(s);
243
+ if (!p.harnesses.includes(s.harness)) p.harnesses.push(s.harness);
244
+ const when = s.lastActivity ?? s.date;
245
+ if (when > p.lastActivity) p.lastActivity = when;
246
+ }
247
+ return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
248
+ }
249
+
250
+ /**
251
+ * A fixed number of evenly sized buckets across the range for a stat-tile sparkline;
252
+ * long ranges are summed into wider buckets instead of drawing hundreds of points.
253
+ */
254
+ export function sparkline(days: TimePeriodRow[], start: string, end: string, pick: (d: TokenCounts) => number, points = 24): number[] {
255
+ const all = eachDay(start, end);
256
+ const n = Math.min(points, all.length);
257
+ if (n <= 1) return [];
258
+ const byDay = new Map(days.map((d) => [d.period, d]));
259
+ const values = new Array<number>(n).fill(0);
260
+ all.forEach((day, i) => {
261
+ const d = byDay.get(day);
262
+ if (d) values[Math.min(n - 1, Math.floor((i * n) / all.length))]! += pick(d);
263
+ });
264
+ return values;
265
+ }
266
+
@@ -0,0 +1,87 @@
1
+ import { localDateKey } from "../utils.ts";
2
+
3
+ export type RangePreset = "7d" | "30d" | "90d" | "month" | "last-month" | "year" | "all" | "custom";
4
+
5
+ /** An inclusive span of local calendar days, as YYYY-MM-DD keys. */
6
+ export interface DateRange {
7
+ preset: RangePreset;
8
+ start: string;
9
+ end: string;
10
+ }
11
+
12
+ export const RANGE_PRESETS: { id: Exclude<RangePreset, "custom">; label: string }[] = [
13
+ { id: "7d", label: "Last 7 days" },
14
+ { id: "30d", label: "Last 30 days" },
15
+ { id: "90d", label: "Last 90 days" },
16
+ { id: "month", label: "This month" },
17
+ { id: "last-month", label: "Last month" },
18
+ { id: "year", label: "This year" },
19
+ { id: "all", label: "All time" },
20
+ ];
21
+
22
+ export function parseDay(key: string): Date {
23
+ return new Date(`${key}T00:00:00`);
24
+ }
25
+
26
+ export function addDays(key: string, n: number): string {
27
+ const d = parseDay(key);
28
+ d.setDate(d.getDate() + n);
29
+ return localDateKey(d);
30
+ }
31
+
32
+ export function daysInRange(start: string, end: string): number {
33
+ return Math.round((parseDay(end).getTime() - parseDay(start).getTime()) / 86_400_000) + 1;
34
+ }
35
+
36
+ export function eachDay(start: string, end: string): string[] {
37
+ const days: string[] = [];
38
+ for (let key = start; key <= end; key = addDays(key, 1)) days.push(key);
39
+ return days;
40
+ }
41
+
42
+ export function todayKey(): string {
43
+ return localDateKey(new Date());
44
+ }
45
+
46
+ /** Resolve a preset against today. "All time" starts at the first day with any usage. */
47
+ export function presetRange(preset: Exclude<RangePreset, "custom">, firstDay: string | undefined): DateRange {
48
+ const end = todayKey();
49
+ const today = parseDay(end);
50
+ let start = end;
51
+ let last = end;
52
+ if (preset === "7d") start = addDays(end, -6);
53
+ else if (preset === "30d") start = addDays(end, -29);
54
+ else if (preset === "90d") start = addDays(end, -89);
55
+ else if (preset === "month") start = localDateKey(new Date(today.getFullYear(), today.getMonth(), 1));
56
+ else if (preset === "last-month") {
57
+ start = localDateKey(new Date(today.getFullYear(), today.getMonth() - 1, 1));
58
+ last = localDateKey(new Date(today.getFullYear(), today.getMonth(), 0));
59
+ } else if (preset === "year") start = `${today.getFullYear()}-01-01`;
60
+ else start = firstDay && firstDay < end ? firstDay : end;
61
+ return { preset, start, end: last };
62
+ }
63
+
64
+ /** The span of equal length immediately before this one, for period-over-period deltas. */
65
+ export function previousRange(range: DateRange): { start: string; end: string; days: number } | null {
66
+ if (range.preset === "all") return null;
67
+ const days = daysInRange(range.start, range.end);
68
+ return { start: addDays(range.start, -days), end: addDays(range.start, -1), days };
69
+ }
70
+
71
+ export function rangeLabel(range: DateRange): string {
72
+ return RANGE_PRESETS.find((p) => p.id === range.preset)?.label ?? "Custom range";
73
+ }
74
+
75
+ export function formatDay(key: string, withYear = false): string {
76
+ return parseDay(key).toLocaleDateString("en-US", {
77
+ month: "short",
78
+ day: "numeric",
79
+ ...(withYear ? { year: "numeric" } : {}),
80
+ });
81
+ }
82
+
83
+ export function formatSpan(start: string, end: string): string {
84
+ if (start === end) return formatDay(start, true);
85
+ const sameYear = start.slice(0, 4) === end.slice(0, 4);
86
+ return `${formatDay(start, !sameYear)} – ${formatDay(end, true)}`;
87
+ }