@psg2/pi-costs 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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/dist/cli.js +393 -0
  4. package/package.json +48 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # pi-costs
2
+
3
+ Analyze cost and token usage from [pi coding agent](https://github.com/badlogic/pi-mono) sessions.
4
+
5
+ Reads the JSONL session logs stored in `~/.pi/agent/sessions/` and produces a summary of costs, token usage, and breakdowns by model, project, day, or individual session.
6
+
7
+ ## Usage
8
+
9
+ Run directly with `bunx` (no install needed):
10
+
11
+ ```bash
12
+ bunx @psg2/pi-costs
13
+ ```
14
+
15
+ Or install globally:
16
+
17
+ ```bash
18
+ bun install -g @psg2/pi-costs
19
+ pi-costs
20
+ ```
21
+
22
+ ## Options
23
+
24
+ ```
25
+ pi-costs All projects, last 7 days
26
+ pi-costs --days 30 Last 30 days
27
+ pi-costs --days 0 All time
28
+ pi-costs --project my-app Filter by project (substring match)
29
+ pi-costs --sessions Show per-session breakdown
30
+ pi-costs --daily Show per-day breakdown
31
+ pi-costs --dir <path> Custom sessions directory
32
+ ```
33
+
34
+ ## Example output
35
+
36
+ ```
37
+ ======================================================================
38
+ Pi Session Costs — last 7 days
39
+ ======================================================================
40
+
41
+ Total cost: $42.5100
42
+ Sessions: 18
43
+ LLM requests: 320
44
+ Input tokens: 50.2K ($0.2510)
45
+ Output tokens: 1.2M ($30.0000)
46
+ Cache read: 15.0M ($9.0000)
47
+ Cache write: 800.0K ($3.2590)
48
+
49
+ ──────────────────────────────────────────────────────────────────
50
+ By Model:
51
+ Model Cost Requests
52
+ ──────────────────────────────────────────────────────────────────
53
+ claude-opus-4-5 $30.1200 210
54
+ claude-sonnet-4-5 $12.3900 110
55
+
56
+ ──────────────────────────────────────────────────────────────────
57
+ By Project:
58
+ Project Cost Requests
59
+ ──────────────────────────────────────────────────────────────────
60
+ my-web-app $28.4500 200
61
+ my-api-server $14.0600 120
62
+
63
+ ======================================================================
64
+ ```
65
+
66
+ ## How it works
67
+
68
+ Pi stores session logs as JSONL files in `~/.pi/agent/sessions/<project>/`. Each assistant message includes a `usage` field with token counts and pre-calculated costs in USD:
69
+
70
+ ```json
71
+ {
72
+ "type": "message",
73
+ "message": {
74
+ "role": "assistant",
75
+ "model": "claude-opus-4-5",
76
+ "usage": {
77
+ "input": 3183,
78
+ "output": 104,
79
+ "cacheRead": 50000,
80
+ "cacheWrite": 4000,
81
+ "cost": {
82
+ "input": 0.0159,
83
+ "output": 0.0026,
84
+ "cacheRead": 0.025,
85
+ "cacheWrite": 0.025,
86
+ "total": 0.0685
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ See the [pi session format docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session.md) for full details.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ bun install
99
+ bun test # Run tests
100
+ bun run dev # Run from source
101
+ bun run build # Build for distribution
102
+ bun run lint # Lint
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/cli.ts
5
+ import { readFileSync } from "fs";
6
+ import { homedir } from "os";
7
+ import { join as join2, resolve } from "path";
8
+
9
+ // src/format.ts
10
+ function fmtCost(v) {
11
+ return `$${v.toFixed(4)}`;
12
+ }
13
+ function fmtTokens(v) {
14
+ if (v >= 1e6)
15
+ return `${(v / 1e6).toFixed(1)}M`;
16
+ if (v >= 1000)
17
+ return `${(v / 1000).toFixed(1)}K`;
18
+ return String(v);
19
+ }
20
+ function pad(s, width, align = "left") {
21
+ if (align === "right")
22
+ return s.padStart(width);
23
+ return s.padEnd(width);
24
+ }
25
+ var W = 70;
26
+ var SEP = "\u2500".repeat(W - 4);
27
+ function renderReport(opts) {
28
+ const lines = [];
29
+ const ln = (s = "") => {
30
+ lines.push(s);
31
+ };
32
+ const { totals, perModel, perProject, perDay, sessionRows } = opts;
33
+ ln();
34
+ ln("=".repeat(W));
35
+ ln(` Pi Session Costs \u2014 ${opts.period}`);
36
+ if (opts.projectFilter)
37
+ ln(` Filter: '${opts.projectFilter}'`);
38
+ ln("=".repeat(W));
39
+ ln();
40
+ ln(` Total cost: ${fmtCost(totals.totalCost)}`);
41
+ ln(` Sessions: ${opts.sessionCount}`);
42
+ ln(` LLM requests: ${totals.requests}`);
43
+ ln(` Input tokens: ${fmtTokens(totals.inputTokens)} ($${totals.costInput.toFixed(4)})`);
44
+ ln(` Output tokens: ${fmtTokens(totals.outputTokens)} ($${totals.costOutput.toFixed(4)})`);
45
+ ln(` Cache read: ${fmtTokens(totals.cacheReadTokens)} ($${totals.costCacheRead.toFixed(4)})`);
46
+ ln(` Cache write: ${fmtTokens(totals.cacheWriteTokens)} ($${totals.costCacheWrite.toFixed(4)})`);
47
+ if (perModel.size > 0) {
48
+ ln();
49
+ ln(` ${SEP}`);
50
+ ln(" By Model:");
51
+ ln(` ${pad("Model", 45)}${pad("Cost", 10, "right")}${pad("Requests", 10, "right")}`);
52
+ ln(` ${SEP}`);
53
+ const sorted = [...perModel.entries()].sort((a, b) => b[1].cost - a[1].cost);
54
+ for (const [model, { requests, cost }] of sorted) {
55
+ ln(` ${pad(model, 45)}${pad(fmtCost(cost), 10, "right")}${pad(String(requests), 10, "right")}`);
56
+ }
57
+ }
58
+ if (perProject.size > 1) {
59
+ ln();
60
+ ln(` ${SEP}`);
61
+ ln(" By Project:");
62
+ ln(` ${pad("Project", 45)}${pad("Cost", 10, "right")}${pad("Requests", 10, "right")}`);
63
+ ln(` ${SEP}`);
64
+ const sorted = [...perProject.entries()].sort((a, b) => b[1].totalCost - a[1].totalCost);
65
+ for (const [proj, stats] of sorted) {
66
+ let short = proj.split("/").filter(Boolean).pop() ?? proj;
67
+ if (short.length > 43)
68
+ short = `\u2026${short.slice(-42)}`;
69
+ ln(` ${pad(short, 45)}${pad(fmtCost(stats.totalCost), 10, "right")}${pad(String(stats.requests), 10, "right")}`);
70
+ }
71
+ }
72
+ if (opts.showDaily && perDay.size > 0) {
73
+ ln();
74
+ ln(` ${SEP}`);
75
+ ln(" By Day:");
76
+ ln(` ${pad("Date", 15)}${pad("Cost", 10, "right")}${pad("Requests", 10, "right")}${pad("Input", 10, "right")}${pad("Output", 10, "right")}`);
77
+ ln(` ${SEP}`);
78
+ const sorted = [...perDay.entries()].sort((a, b) => a[0].localeCompare(b[0]));
79
+ for (const [day, stats] of sorted) {
80
+ const totalIn = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
81
+ ln(` ${pad(day, 15)}${pad(fmtCost(stats.totalCost), 10, "right")}${pad(String(stats.requests), 10, "right")}${pad(fmtTokens(totalIn), 10, "right")}${pad(fmtTokens(stats.outputTokens), 10, "right")}`);
82
+ }
83
+ }
84
+ if (opts.showSessions && sessionRows.length > 0) {
85
+ const SW = 90;
86
+ const sSep = "\u2500".repeat(SW);
87
+ ln();
88
+ ln(` ${sSep}`);
89
+ ln(" Sessions:");
90
+ ln(` ${pad("Time", 18)}${pad("Project", 20)}${pad("Reqs", 6, "right")}${pad("Cost", 10, "right")}${pad("In Tok", 10, "right")}${pad("Out Tok", 10, "right")} Model`);
91
+ ln(` ${sSep}`);
92
+ for (const row of sessionRows) {
93
+ let proj = row.project;
94
+ if (proj.length > 18)
95
+ proj = `\u2026${proj.slice(-17)}`;
96
+ ln(` ${pad(row.time, 18)}${pad(proj, 20)}${pad(String(row.requests), 6, "right")}${pad(fmtCost(row.cost), 10, "right")}${pad(fmtTokens(row.inputTokens), 10, "right")}${pad(fmtTokens(row.outputTokens), 10, "right")} ${row.models}`);
97
+ }
98
+ }
99
+ ln();
100
+ ln("=".repeat(W));
101
+ ln();
102
+ return lines.join(`
103
+ `);
104
+ }
105
+
106
+ // src/parser.ts
107
+ import { readFile, readdir } from "fs/promises";
108
+ import { join } from "path";
109
+
110
+ // src/stats.ts
111
+ function createStats() {
112
+ return {
113
+ totalCost: 0,
114
+ costInput: 0,
115
+ costOutput: 0,
116
+ costCacheRead: 0,
117
+ costCacheWrite: 0,
118
+ inputTokens: 0,
119
+ outputTokens: 0,
120
+ cacheReadTokens: 0,
121
+ cacheWriteTokens: 0,
122
+ requests: 0,
123
+ models: new Map
124
+ };
125
+ }
126
+ function addUsage(stats, usage, model) {
127
+ const cost = usage.cost ?? {};
128
+ const total = cost.total ?? 0;
129
+ stats.totalCost += total;
130
+ stats.costInput += cost.input ?? 0;
131
+ stats.costOutput += cost.output ?? 0;
132
+ stats.costCacheRead += cost.cacheRead ?? 0;
133
+ stats.costCacheWrite += cost.cacheWrite ?? 0;
134
+ stats.inputTokens += usage.input ?? 0;
135
+ stats.outputTokens += usage.output ?? 0;
136
+ stats.cacheReadTokens += usage.cacheRead ?? 0;
137
+ stats.cacheWriteTokens += usage.cacheWrite ?? 0;
138
+ stats.requests += 1;
139
+ const entry = stats.models.get(model) ?? { requests: 0, cost: 0 };
140
+ entry.requests += 1;
141
+ entry.cost += total;
142
+ stats.models.set(model, entry);
143
+ }
144
+ function mergeStats(target, source) {
145
+ target.totalCost += source.totalCost;
146
+ target.costInput += source.costInput;
147
+ target.costOutput += source.costOutput;
148
+ target.costCacheRead += source.costCacheRead;
149
+ target.costCacheWrite += source.costCacheWrite;
150
+ target.inputTokens += source.inputTokens;
151
+ target.outputTokens += source.outputTokens;
152
+ target.cacheReadTokens += source.cacheReadTokens;
153
+ target.cacheWriteTokens += source.cacheWriteTokens;
154
+ target.requests += source.requests;
155
+ for (const [model, { requests, cost }] of source.models) {
156
+ const entry = target.models.get(model) ?? { requests: 0, cost: 0 };
157
+ entry.requests += requests;
158
+ entry.cost += cost;
159
+ target.models.set(model, entry);
160
+ }
161
+ }
162
+
163
+ // src/parser.ts
164
+ function parseSessionTimestamp(filename) {
165
+ const tsRaw = filename.split("_")[0];
166
+ if (!tsRaw)
167
+ return null;
168
+ const parts = tsRaw.replace("Z", "").split("T");
169
+ if (parts.length !== 2)
170
+ return null;
171
+ const [datePart, timeRaw] = parts;
172
+ const timePieces = timeRaw?.split("-");
173
+ if (timePieces.length < 3)
174
+ return null;
175
+ const timeStr = `${timePieces[0]}:${timePieces[1]}:${timePieces[2]}`;
176
+ const date = new Date(`${datePart}T${timeStr}Z`);
177
+ return Number.isNaN(date.getTime()) ? null : date;
178
+ }
179
+ function decodeProjectName(dirname) {
180
+ return dirname.replace(/--/g, "/").replace(/^\/|\/$/g, "");
181
+ }
182
+ function shortProjectName(project, maxLen = 43) {
183
+ const segments = project.split("/").filter(Boolean);
184
+ let short = segments[segments.length - 1] ?? project;
185
+ if (short.length > maxLen) {
186
+ short = `\u2026${short.slice(-(maxLen - 1))}`;
187
+ }
188
+ return short;
189
+ }
190
+ async function discoverSessions(opts) {
191
+ const sessions = [];
192
+ const cutoff = opts.days > 0 ? new Date(Date.now() - opts.days * 86400000) : new Date(0);
193
+ let projectDirs;
194
+ try {
195
+ projectDirs = await readdir(opts.sessionsDir);
196
+ } catch {
197
+ return [];
198
+ }
199
+ for (const dir of projectDirs.sort()) {
200
+ const projectPath = join(opts.sessionsDir, dir);
201
+ const project = decodeProjectName(dir);
202
+ if (opts.projectFilter && !project.toLowerCase().includes(opts.projectFilter.toLowerCase())) {
203
+ continue;
204
+ }
205
+ let files;
206
+ try {
207
+ files = await readdir(projectPath);
208
+ } catch {
209
+ continue;
210
+ }
211
+ for (const file of files.sort()) {
212
+ if (!file.endsWith(".jsonl"))
213
+ continue;
214
+ const ts = parseSessionTimestamp(file);
215
+ if (!ts || ts < cutoff)
216
+ continue;
217
+ sessions.push({
218
+ filepath: join(projectPath, file),
219
+ project,
220
+ timestamp: ts
221
+ });
222
+ }
223
+ }
224
+ return sessions;
225
+ }
226
+ async function parseSession(filepath) {
227
+ const stats = createStats();
228
+ const text = await readFile(filepath, "utf-8");
229
+ for (const line of text.split(`
230
+ `)) {
231
+ if (!line.trim())
232
+ continue;
233
+ let obj;
234
+ try {
235
+ obj = JSON.parse(line);
236
+ } catch {
237
+ continue;
238
+ }
239
+ if (obj.type !== "message")
240
+ continue;
241
+ const msg = obj.message;
242
+ if (!msg || msg.role !== "assistant")
243
+ continue;
244
+ const usage = msg.usage;
245
+ if (!usage)
246
+ continue;
247
+ const model = msg.model ?? "unknown";
248
+ addUsage(stats, usage, model);
249
+ }
250
+ return stats;
251
+ }
252
+ async function analyzeSessions(sessions, showSessions) {
253
+ const totals = createStats();
254
+ const perProject = new Map;
255
+ const perDay = new Map;
256
+ const sessionRows = [];
257
+ let sessionCount = 0;
258
+ for (const { filepath, project, timestamp } of sessions) {
259
+ const sessionStats = await parseSession(filepath);
260
+ if (sessionStats.requests === 0)
261
+ continue;
262
+ sessionCount++;
263
+ mergeStats(totals, sessionStats);
264
+ const projectStats = perProject.get(project) ?? createStats();
265
+ mergeStats(projectStats, sessionStats);
266
+ perProject.set(project, projectStats);
267
+ const dayKey = timestamp.toISOString().slice(0, 10);
268
+ const dayStats = perDay.get(dayKey) ?? createStats();
269
+ mergeStats(dayStats, sessionStats);
270
+ perDay.set(dayKey, dayStats);
271
+ if (showSessions) {
272
+ const short = shortProjectName(project, 18);
273
+ const modelsStr = [...sessionStats.models.entries()].sort((a, b) => b[1].requests - a[1].requests).map(([m]) => m).join(", ");
274
+ sessionRows.push({
275
+ time: formatTimestamp(timestamp),
276
+ project: short,
277
+ requests: sessionStats.requests,
278
+ cost: sessionStats.totalCost,
279
+ inputTokens: sessionStats.inputTokens + sessionStats.cacheReadTokens + sessionStats.cacheWriteTokens,
280
+ outputTokens: sessionStats.outputTokens,
281
+ models: modelsStr
282
+ });
283
+ }
284
+ }
285
+ return { totals, perProject, perDay, perModel: totals.models, sessionRows, sessionCount };
286
+ }
287
+ function formatTimestamp(date) {
288
+ const y = date.getUTCFullYear();
289
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
290
+ const d = String(date.getUTCDate()).padStart(2, "0");
291
+ const h = String(date.getUTCHours()).padStart(2, "0");
292
+ const min = String(date.getUTCMinutes()).padStart(2, "0");
293
+ return `${y}-${m}-${d} ${h}:${min}`;
294
+ }
295
+
296
+ // src/cli.ts
297
+ var HELP = `pi-costs \u2014 Analyze cost and token usage from pi coding agent sessions
298
+
299
+ Usage:
300
+ pi-costs All projects, last 7 days
301
+ pi-costs --days 30 Last 30 days
302
+ pi-costs --days 0 All time
303
+ pi-costs --project finances Filter by project (substring match)
304
+ pi-costs --sessions Show per-session breakdown
305
+ pi-costs --daily Show per-day breakdown
306
+
307
+ Options:
308
+ --days <n> Time window in days (default: 7, use 0 for all time)
309
+ --project <name> Filter projects by substring (case-insensitive)
310
+ --sessions Show individual session breakdown
311
+ --daily Show per-day breakdown
312
+ --dir <path> Custom sessions directory (default: ~/.pi/agent/sessions)
313
+ -h, --help Show this help
314
+ -v, --version Show version
315
+ `;
316
+ function parseArgs(args) {
317
+ const opts = {
318
+ days: 7,
319
+ projectFilter: "",
320
+ showSessions: false,
321
+ showDaily: false,
322
+ sessionsDir: join2(homedir(), ".pi", "agent", "sessions")
323
+ };
324
+ for (let i = 0;i < args.length; i++) {
325
+ const arg = args[i];
326
+ switch (arg) {
327
+ case "--days":
328
+ opts.days = Number.parseInt(args[++i] ?? "7", 10);
329
+ break;
330
+ case "--project":
331
+ opts.projectFilter = args[++i] ?? "";
332
+ break;
333
+ case "--sessions":
334
+ opts.showSessions = true;
335
+ break;
336
+ case "--daily":
337
+ opts.showDaily = true;
338
+ break;
339
+ case "--dir":
340
+ opts.sessionsDir = args[++i] ?? opts.sessionsDir;
341
+ break;
342
+ case "-h":
343
+ case "--help":
344
+ console.log(HELP);
345
+ process.exit(0);
346
+ break;
347
+ case "-v":
348
+ case "--version": {
349
+ const pkgPath = resolve(import.meta.dirname ?? ".", "..", "package.json");
350
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
351
+ console.log(`pi-costs v${pkg.version}`);
352
+ process.exit(0);
353
+ break;
354
+ }
355
+ default:
356
+ console.error(`Unknown option: ${arg}
357
+ Run 'pi-costs --help' for usage.`);
358
+ process.exit(1);
359
+ }
360
+ }
361
+ return opts;
362
+ }
363
+ async function main() {
364
+ const opts = parseArgs(process.argv.slice(2));
365
+ const sessions = await discoverSessions(opts);
366
+ if (sessions.length === 0) {
367
+ const period2 = opts.days > 0 ? `last ${opts.days} days` : "all time";
368
+ const filter = opts.projectFilter ? ` matching '${opts.projectFilter}'` : "";
369
+ console.log(`No sessions found for ${period2}${filter}`);
370
+ console.log(`
371
+ Searched: ${opts.sessionsDir}`);
372
+ process.exit(0);
373
+ }
374
+ const result = await analyzeSessions(sessions, opts.showSessions);
375
+ const period = opts.days > 0 ? `last ${opts.days} days` : "all time";
376
+ const report = renderReport({
377
+ period,
378
+ projectFilter: opts.projectFilter,
379
+ totals: result.totals,
380
+ sessionCount: result.sessionCount,
381
+ perModel: result.perModel,
382
+ perProject: result.perProject,
383
+ perDay: result.perDay,
384
+ showDaily: opts.showDaily,
385
+ sessionRows: result.sessionRows,
386
+ showSessions: opts.showSessions
387
+ });
388
+ console.log(report);
389
+ }
390
+ main().catch((err) => {
391
+ console.error("Error:", err.message);
392
+ process.exit(1);
393
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@psg2/pi-costs",
3
+ "version": "1.0.0",
4
+ "description": "Analyze cost and token usage from pi coding agent sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "pi-costs": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "bun build src/cli.ts --outdir dist --target bun",
11
+ "dev": "bun run src/cli.ts",
12
+ "lint": "bunx --bun biome check src/",
13
+ "lint:fix": "bunx --bun biome check --write src/",
14
+ "format": "bunx --bun biome format --write src/",
15
+ "test": "bun test",
16
+ "prepublishOnly": "bun run build"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/psg2/pi-costs.git"
24
+ },
25
+ "homepage": "https://github.com/psg2/pi-costs#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/psg2/pi-costs/issues"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "keywords": [
35
+ "pi",
36
+ "pi-coding-agent",
37
+ "claude",
38
+ "llm",
39
+ "cost",
40
+ "usage",
41
+ "tokens"
42
+ ],
43
+ "license": "MIT",
44
+ "devDependencies": {
45
+ "@biomejs/biome": "^1.9.4",
46
+ "@types/bun": "^1.2.4"
47
+ }
48
+ }