@firstpick/pi-extension-stats 0.1.0 → 0.1.2

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 (3) hide show
  1. package/README.md +3 -2
  2. package/index.ts +138 -41
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,8 +6,9 @@ Token and cost analytics for Pi session history.
6
6
 
7
7
  - Parses local Pi session `.jsonl` files for the current workspace.
8
8
  - Aggregates usage by UTC day.
9
- - Displays compact daily token bars with totals.
10
- - Shows input/output/cache breakdown and top model usage.
9
+ - Displays compact daily token bars and cost bars with totals.
10
+ - Shows input/output/cache breakdown, cache hit rate, estimated cache savings, cost burn rate, and top model usage.
11
+ - Highlights highest-cost day, projected 30-day cost, most expensive sessions, and model cost efficiency.
11
12
 
12
13
  ## Install
13
14
 
package/index.ts CHANGED
@@ -9,6 +9,7 @@ type DayUsage = {
9
9
  cacheWrite: number;
10
10
  total: number;
11
11
  cost: number;
12
+ messages: number;
12
13
  };
13
14
 
14
15
  type UsageRecord = {
@@ -20,10 +21,15 @@ type UsageRecord = {
20
21
  total: number;
21
22
  cost: number;
22
23
  model: string;
24
+ sessionFile: string;
25
+ sessionId: string;
23
26
  };
24
27
 
28
+ type Totals = DayUsage;
29
+
25
30
  const DEFAULT_DAYS = 14;
26
31
  const MAX_BAR_WIDTH = 24;
32
+ const COST_BAR_WIDTH = 10;
27
33
 
28
34
  function formatTokens(count: number): string {
29
35
  if (count < 1000) return count.toString();
@@ -33,6 +39,17 @@ function formatTokens(count: number): string {
33
39
  return `${Math.round(count / 1000000)}M`;
34
40
  }
35
41
 
42
+ function formatCost(cost: number): string {
43
+ if (cost <= 0) return "$0.000";
44
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
45
+ if (cost < 10) return `$${cost.toFixed(3)}`;
46
+ return `$${cost.toFixed(2)}`;
47
+ }
48
+
49
+ function emptyUsage(): DayUsage {
50
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0, messages: 0 };
51
+ }
52
+
36
53
  function getDayKey(timestamp: string): string | null {
37
54
  const parsed = Date.parse(timestamp);
38
55
  if (!Number.isFinite(parsed)) return null;
@@ -71,6 +88,8 @@ function collectUsageRecords(sessionFiles: string[]): UsageRecord[] {
71
88
  continue;
72
89
  }
73
90
 
91
+ const sessionId = path.basename(file, ".jsonl");
92
+
74
93
  for (const line of content.split(/\r?\n/)) {
75
94
  if (!line.trim()) continue;
76
95
 
@@ -108,6 +127,8 @@ function collectUsageRecords(sessionFiles: string[]): UsageRecord[] {
108
127
  total,
109
128
  cost,
110
129
  model: `${provider}/${model}`,
130
+ sessionFile: file,
131
+ sessionId,
111
132
  });
112
133
  }
113
134
  }
@@ -118,13 +139,14 @@ function collectUsageRecords(sessionFiles: string[]): UsageRecord[] {
118
139
  function aggregateUsageByDay(records: UsageRecord[]): Map<string, DayUsage> {
119
140
  const byDay = new Map<string, DayUsage>();
120
141
  for (const r of records) {
121
- const prev = byDay.get(r.day) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 };
142
+ const prev = byDay.get(r.day) ?? emptyUsage();
122
143
  prev.input += r.input;
123
144
  prev.output += r.output;
124
145
  prev.cacheRead += r.cacheRead;
125
146
  prev.cacheWrite += r.cacheWrite;
126
147
  prev.total += r.total;
127
148
  prev.cost += r.cost;
149
+ prev.messages += 1;
128
150
  byDay.set(r.day, prev);
129
151
  }
130
152
  return byDay;
@@ -150,28 +172,80 @@ function getScopeDayKeys(byDay: Map<string, DayUsage>, args: { mode: "range"; da
150
172
  : buildDayRange(args.days);
151
173
  }
152
174
 
153
- function aggregateModelUsage(records: UsageRecord[], dayKeys: string[]): Array<{ model: string; tokens: number; percent: number; cost: number }> {
154
- if (dayKeys.length === 0) return [];
175
+ function sumUsage(byDay: Map<string, DayUsage>, dayKeys: string[]): Totals {
176
+ return dayKeys.reduce((acc, day) => {
177
+ const usage = byDay.get(day) ?? emptyUsage();
178
+ acc.input += usage.input;
179
+ acc.output += usage.output;
180
+ acc.cacheRead += usage.cacheRead;
181
+ acc.cacheWrite += usage.cacheWrite;
182
+ acc.total += usage.total;
183
+ acc.cost += usage.cost;
184
+ acc.messages += usage.messages;
185
+ return acc;
186
+ }, emptyUsage());
187
+ }
155
188
 
189
+ function scopedRecords(records: UsageRecord[], dayKeys: string[]): UsageRecord[] {
156
190
  const daySet = new Set(dayKeys);
157
- const modelTotals = new Map<string, { tokens: number; cost: number }>();
158
- let totalTokens = 0;
191
+ return records.filter((r) => daySet.has(r.day));
192
+ }
159
193
 
160
- for (const r of records) {
161
- if (!daySet.has(r.day)) continue;
162
- totalTokens += r.total;
163
- const prev = modelTotals.get(r.model) ?? { tokens: 0, cost: 0 };
194
+ function aggregateModelUsage(records: UsageRecord[], dayKeys: string[]): Array<{ model: string; tokens: number; percent: number; cost: number; costPercent: number; avgCostPerMillion: number; avgOutputTokens: number; messages: number }> {
195
+ const scoped = scopedRecords(records, dayKeys);
196
+ const modelTotals = new Map<string, { tokens: number; output: number; cost: number; messages: number }>();
197
+ const totalTokens = scoped.reduce((acc, r) => acc + r.total, 0);
198
+ const totalCost = scoped.reduce((acc, r) => acc + r.cost, 0);
199
+
200
+ for (const r of scoped) {
201
+ const prev = modelTotals.get(r.model) ?? { tokens: 0, output: 0, cost: 0, messages: 0 };
164
202
  prev.tokens += r.total;
203
+ prev.output += r.output;
165
204
  prev.cost += r.cost;
205
+ prev.messages += 1;
166
206
  modelTotals.set(r.model, prev);
167
207
  }
168
208
 
169
209
  if (totalTokens <= 0) return [];
170
210
 
171
211
  return Array.from(modelTotals.entries())
172
- .map(([model, v]) => ({ model, tokens: v.tokens, percent: (v.tokens / totalTokens) * 100, cost: v.cost }))
173
- .sort((a, b) => b.tokens - a.tokens)
174
- .slice(0, 3);
212
+ .map(([model, v]) => ({
213
+ model,
214
+ tokens: v.tokens,
215
+ percent: (v.tokens / totalTokens) * 100,
216
+ cost: v.cost,
217
+ costPercent: totalCost > 0 ? (v.cost / totalCost) * 100 : 0,
218
+ avgCostPerMillion: v.tokens > 0 ? (v.cost / v.tokens) * 1_000_000 : 0,
219
+ avgOutputTokens: v.messages > 0 ? v.output / v.messages : 0,
220
+ messages: v.messages,
221
+ }))
222
+ .sort((a, b) => b.cost - a.cost || b.tokens - a.tokens)
223
+ .slice(0, 5);
224
+ }
225
+
226
+ function aggregateExpensiveSessions(records: UsageRecord[], dayKeys: string[]): Array<{ day: string; model: string; tokens: number; cost: number; sessionId: string; file: string }> {
227
+ const sessions = new Map<string, { day: string; modelTokens: Map<string, number>; tokens: number; cost: number; sessionId: string; file: string }>();
228
+
229
+ for (const r of scopedRecords(records, dayKeys)) {
230
+ const prev = sessions.get(r.sessionFile) ?? { day: r.day, modelTokens: new Map(), tokens: 0, cost: 0, sessionId: r.sessionId, file: r.sessionFile };
231
+ if (r.day < prev.day) prev.day = r.day;
232
+ prev.tokens += r.total;
233
+ prev.cost += r.cost;
234
+ prev.modelTokens.set(r.model, (prev.modelTokens.get(r.model) ?? 0) + r.total);
235
+ sessions.set(r.sessionFile, prev);
236
+ }
237
+
238
+ return Array.from(sessions.values())
239
+ .map((s) => ({
240
+ day: s.day,
241
+ model: Array.from(s.modelTokens.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown",
242
+ tokens: s.tokens,
243
+ cost: s.cost,
244
+ sessionId: s.sessionId,
245
+ file: path.basename(s.file),
246
+ }))
247
+ .sort((a, b) => b.cost - a.cost || b.tokens - a.tokens)
248
+ .slice(0, 5);
175
249
  }
176
250
 
177
251
  function buildGraphLines(byDay: Map<string, DayUsage>, dayKeys: string[]): string[] {
@@ -179,45 +253,56 @@ function buildGraphLines(byDay: Map<string, DayUsage>, dayKeys: string[]): strin
179
253
  return ["No usage data found yet."];
180
254
  }
181
255
 
182
- const data = dayKeys.map((day) => ({
183
- day,
184
- usage: byDay.get(day) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 },
185
- }));
186
-
256
+ const data = dayKeys.map((day) => ({ day, usage: byDay.get(day) ?? emptyUsage() }));
187
257
  const maxTotal = Math.max(...data.map((d) => d.usage.total), 0);
258
+ const maxCost = Math.max(...data.map((d) => d.usage.cost), 0);
188
259
  const lines: string[] = [];
189
260
 
190
261
  for (const { day, usage } of data) {
191
- const barLen =
192
- usage.total <= 0 || maxTotal <= 0 ? 0 : Math.max(1, Math.round((usage.total / maxTotal) * MAX_BAR_WIDTH));
193
- const bar = "█".repeat(barLen).padEnd(MAX_BAR_WIDTH, "·");
262
+ const tokenBarLen = usage.total <= 0 || maxTotal <= 0 ? 0 : Math.max(1, Math.round((usage.total / maxTotal) * MAX_BAR_WIDTH));
263
+ const costBarLen = usage.cost <= 0 || maxCost <= 0 ? 0 : Math.max(1, Math.round((usage.cost / maxCost) * COST_BAR_WIDTH));
264
+ const tokenBar = "█".repeat(tokenBarLen).padEnd(MAX_BAR_WIDTH, "·");
265
+ const costBar = "$".repeat(costBarLen).padEnd(COST_BAR_WIDTH, "·");
194
266
 
195
267
  lines.push(
196
- `${day} ${bar} ${formatTokens(usage.total)} tok (↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} R${formatTokens(usage.cacheRead)} W${formatTokens(usage.cacheWrite)})`,
268
+ `${day} ${tokenBar} ${formatTokens(usage.total)} tok ${costBar} ${formatCost(usage.cost)} (↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} R${formatTokens(usage.cacheRead)} W${formatTokens(usage.cacheWrite)})`,
197
269
  );
198
270
  }
199
271
 
200
- const totals = data.reduce(
201
- (acc, d) => {
202
- acc.input += d.usage.input;
203
- acc.output += d.usage.output;
204
- acc.cacheRead += d.usage.cacheRead;
205
- acc.cacheWrite += d.usage.cacheWrite;
206
- acc.total += d.usage.total;
207
- acc.cost += d.usage.cost;
208
- return acc;
209
- },
210
- { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 },
211
- );
212
-
272
+ const totals = sumUsage(byDay, dayKeys);
213
273
  lines.push(
214
274
  "",
215
- `Σ ${formatTokens(totals.total)} tok (↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)} R${formatTokens(totals.cacheRead)} W${formatTokens(totals.cacheWrite)}) · $${totals.cost.toFixed(3)}`,
275
+ `Σ ${formatTokens(totals.total)} tok (↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)} R${formatTokens(totals.cacheRead)} W${formatTokens(totals.cacheWrite)}) · ${formatCost(totals.cost)}`,
216
276
  );
217
277
 
218
278
  return lines;
219
279
  }
220
280
 
281
+ function buildCostTrendLines(byDay: Map<string, DayUsage>, dayKeys: string[]): string[] {
282
+ const totals = sumUsage(byDay, dayKeys);
283
+ const activeDays = dayKeys.filter((d) => (byDay.get(d)?.total ?? 0) > 0).length;
284
+ const divisor = Math.max(activeDays, dayKeys.length, 1);
285
+ const avgPerDay = totals.cost / divisor;
286
+ const projectedMonthly = avgPerDay * 30;
287
+ const highest = dayKeys
288
+ .map((day) => ({ day, cost: byDay.get(day)?.cost ?? 0 }))
289
+ .sort((a, b) => b.cost - a.cost)[0];
290
+
291
+ return [
292
+ `Cost trend: avg/day ${formatCost(avgPerDay)} · projected 30d ${formatCost(projectedMonthly)} · highest ${highest && highest.cost > 0 ? `${highest.day} ${formatCost(highest.cost)}` : "n/a"}`,
293
+ ];
294
+ }
295
+
296
+ function buildCacheEfficiencyLines(totals: Totals): string[] {
297
+ const hitRate = totals.total > 0 ? (totals.cacheRead / totals.total) * 100 : 0;
298
+ const nonCacheTokens = totals.input + totals.output + totals.cacheWrite;
299
+ const avgCostPerNonCacheToken = nonCacheTokens > 0 && totals.cost > 0 ? totals.cost / nonCacheTokens : 0;
300
+ const estimatedSavings = totals.cacheRead * avgCostPerNonCacheToken;
301
+ const savingsPart = estimatedSavings > 0 ? ` · est. cache savings ${formatCost(estimatedSavings)}` : "";
302
+
303
+ return [`Cache hit: ${hitRate.toFixed(1)}% · reads ${formatTokens(totals.cacheRead)} · writes ${formatTokens(totals.cacheWrite)}${savingsPart}`];
304
+ }
305
+
221
306
  export default function statsExtension(pi: ExtensionAPI) {
222
307
  pi.registerCommand("stats", {
223
308
  description: "Show token usage graph per day. Usage: /stats, /stats 30, /stats all",
@@ -239,22 +324,34 @@ export default function statsExtension(pi: ExtensionAPI) {
239
324
  const byDay = aggregateUsageByDay(records);
240
325
  const dayKeys = getScopeDayKeys(byDay, parsedArgs);
241
326
  const lines = buildGraphLines(byDay, dayKeys);
327
+ const totals = sumUsage(byDay, dayKeys);
242
328
  const topModels = aggregateModelUsage(records, dayKeys);
329
+ const topSessions = aggregateExpensiveSessions(records, dayKeys);
243
330
  const scopeLabel = parsedArgs.mode === "all" ? "all days" : `last ${parsedArgs.days} days`;
244
331
 
245
- const hasAnyModelCost = topModels.some((m) => m.cost > 0);
246
332
  const modelLines =
247
333
  topModels.length === 0
248
- ? ["Top models: no model usage in selected range"]
334
+ ? ["Model comparison: no model usage in selected range"]
249
335
  : [
250
- "Top models:",
336
+ "Model comparison:",
251
337
  ...topModels.map((m, i) => {
252
- const costPart = hasAnyModelCost ? ` · $${m.cost.toFixed(3)}` : "";
253
- return `${i + 1}. ${m.model} — ${m.percent.toFixed(1)}% (${formatTokens(m.tokens)} tok)${costPart}`;
338
+ const costPart = totals.cost > 0 ? ` · ${m.costPercent.toFixed(1)}% spend` : "";
339
+ return `${i + 1}. ${m.model} — ${m.percent.toFixed(1)}% tokens (${formatTokens(m.tokens)}) · ${formatCost(m.cost)}${costPart} · ${formatCost(m.avgCostPerMillion)}/1M tok · avg ↓${formatTokens(Math.round(m.avgOutputTokens))}/msg`;
254
340
  }),
255
341
  ];
256
342
 
257
- ctx.ui.notify(`📊 Token stats (${scopeLabel}, ${files.length} sessions)\n\n${lines.join("\n")}\n\n${modelLines.join("\n")}`, "info");
343
+ const sessionLines =
344
+ topSessions.length === 0
345
+ ? ["Most expensive sessions: none in selected range"]
346
+ : [
347
+ "Most expensive sessions:",
348
+ ...topSessions.map((s, i) => `${i + 1}. ${s.day} ${s.sessionId} — ${formatCost(s.cost)} · ${formatTokens(s.tokens)} tok · ${s.model} · ${s.file}`),
349
+ ];
350
+
351
+ ctx.ui.notify(
352
+ `📊 Token stats (${scopeLabel}, ${files.length} sessions)\n\n${lines.join("\n")}\n\n${buildCostTrendLines(byDay, dayKeys).join("\n")}\n${buildCacheEfficiencyLines(totals).join("\n")}\n\n${modelLines.join("\n")}\n\n${sessionLines.join("\n")}`,
353
+ "info",
354
+ );
258
355
  },
259
356
  });
260
357
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-extension-stats",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Token and cost usage analytics command for Pi session history.",
5
5
  "license": "MIT",
6
6
  "keywords": [