@firstpick/pi-extension-stats 0.1.1 → 0.1.3
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/README.md +5 -2
- package/index.ts +139 -42
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Token and cost analytics for Pi session history.
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
## What it does
|
|
6
8
|
|
|
7
9
|
- Parses local Pi session `.jsonl` files for the current workspace.
|
|
8
10
|
- Aggregates usage by UTC day.
|
|
9
|
-
- Displays compact daily token bars with totals.
|
|
10
|
-
- Shows input/output/cache breakdown and top model usage.
|
|
11
|
+
- Displays compact daily token bars and cost bars with totals.
|
|
12
|
+
- Shows input/output/cache breakdown, cache hit rate, estimated cache savings, cost burn rate, and top model usage.
|
|
13
|
+
- Highlights highest-cost day, projected 30-day cost, most expensive sessions, and model cost efficiency.
|
|
11
14
|
|
|
12
15
|
## Install
|
|
13
16
|
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { ExtensionAPI } from "@
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
5
|
type DayUsage = {
|
|
6
6
|
input: number;
|
|
@@ -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) ??
|
|
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
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
191
|
+
return records.filter((r) => daySet.has(r.day));
|
|
192
|
+
}
|
|
159
193
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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]) => ({
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
const
|
|
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} ${
|
|
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 =
|
|
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)}) ·
|
|
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
|
-
? ["
|
|
334
|
+
? ["Model comparison: no model usage in selected range"]
|
|
249
335
|
: [
|
|
250
|
-
"
|
|
336
|
+
"Model comparison:",
|
|
251
337
|
...topModels.map((m, i) => {
|
|
252
|
-
const costPart =
|
|
253
|
-
return `${i + 1}. ${m.model} — ${m.percent.toFixed(1)}% (${formatTokens(m.tokens)}
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Token and cost usage analytics command for Pi session history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
]
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"@
|
|
18
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"index.ts",
|