pi-tokenburn 0.0.0-stage โ†’ 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sorinirimies
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 CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # pi-tokenburn
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Token usage and cost monitor with budgets, directly in your [Pi](https://github.com/earendil-works/pi-coding-agent) session.
4
+
5
+ TypeScript port of the pi collector from [tokenburn](https://github.com/sorinirimies/tokenburn).
6
+ Reads Pi's local session logs (`~/.pi/agent/sessions/**/*.jsonl`). **No network, zero prompt tokens.**
7
+ Totals match the `tokenburn` CLI exactly.
8
+
9
+ ## Features
10
+
11
+ - **Status bar:** `๐Ÿ”ฅ 1.20M ($52.79) [today]`, turns red with `OVER BUDGET` past your limit.
12
+ - **Report chart:** `/tokenburn` shows the summary plus a vertical column chart (y-axis, budget line, current period marked, over-budget columns in red, peak/avg footer). Daily (14 days), weekly (8 weeks) or monthly (6 months).
13
+ - **Switch periods fast:** `/tokenburn week`, `/tokenburn month`, `/tokenburn cycle`.
14
+ - **Window panel (optional):** Today / Week / Month / Total below the editor, with in/out tokens and cost.
15
+ - **Budgets** per day, week and month, in tokens and/or dollars.
16
+ - **Fast:** per-file cache keyed on mtime+size. First scan of ~400 MB of sessions takes under a second; refreshes after that take milliseconds. Refresh never blocks the agent loop.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pi install npm:pi-tokenburn
22
+ # or
23
+ pi install git:github.com/sorinirimies/pi-tokenburn
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ | Command | What it does |
29
+ |---|---|
30
+ | `/tokenburn` | Report: Today / Week / Month / All-time + **daily** chart |
31
+ | `/tokenburn report week` ยท `report month` | Same report with a **weekly** / **monthly** chart (also `chart month`) |
32
+ | `/tokenburn status <today\|week\|month\|total>` | Set the status-bar period (shortcut: `/tokenburn total`) |
33
+ | `/tokenburn cycle` | Rotate the status-bar period: today โ†’ week โ†’ month โ†’ total |
34
+ | `/tokenburn window` | Toggle the panel below the editor |
35
+ | `/tokenburn budget <day\|week\|month> <tokens>` | Token budget, e.g. `budget day 5000000` |
36
+ | `/tokenburn budget <day\|week\|month> $<cost>` | Dollar budget, e.g. `budget week $50` |
37
+ | `/tokenburn cache` | Toggle counting cache read/write tokens |
38
+
39
+ Chart example (`/tokenburn`, with a 600M day budget):
40
+
41
+ ```
42
+ Daily tokens ยท last 14 days โ”„ budget 600.00M
43
+ 1.13B โ”‚ โ–ˆโ–ˆ
44
+ โ”‚ โ–ˆโ–ˆ
45
+ โ”‚โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ–ˆโ–ˆโ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ–ˆโ–ˆโ”„โ–†โ–†โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„
46
+ 565.16M โ”‚ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–‡โ–‡
47
+ โ”‚ โ–…โ–… โ–ˆโ–ˆ โ–‚โ–‚ โ–ˆโ–ˆ โ–โ– โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–„โ–„ โ–‡โ–‡
48
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
49
+ 26 27 28 29 30 01 02 03 04 05 06 07 08 09
50
+ Sa Su Mo Tu We Th Fr Sa Su Mo Tu We Th Fr
51
+ โ–ฒ
52
+ ฮฃ 3.83B ($1264.30) ยท avg 273.80M/day ยท peak 1.13B (09-29 Tue)
53
+ ```
54
+
55
+ Weeks start on Monday, in local time.
56
+
57
+ ## Config
58
+
59
+ `~/.pi/agent/tokenburn.json` (written by the commands, editable by hand):
60
+
61
+ ```json
62
+ {
63
+ "includeCache": true,
64
+ "chartColor": true,
65
+ "showStatus": true,
66
+ "showWidget": false,
67
+ "statusWindow": "today",
68
+ "budgets": { "dayTokens": 5000000, "weekCost": 50 }
69
+ }
70
+ ```
71
+
72
+ - `includeCache` (default `true`): count cache read/write tokens in totals, like the `tokenburn` CLI. Cache reads dominate (often 95%+ of tokens) but are billed far cheaper; set `false` to budget on input + output only.
73
+
74
+ - `chartColor` (default `true`): color the chart using your pi theme (current period green, over-budget red, budget line yellow). Set `false` for plain text.
75
+
76
+ Environment overrides for the session directory: `TOKENBURN_PI_SESSIONS`, `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`.
77
+
78
+ ## Develop
79
+
80
+ ```bash
81
+ bun test
82
+ ```
83
+
84
+ ## Scope
85
+
86
+ Pi sessions only. For Zed, Claude Code, Codex, Gemini and more, use the Rust [tokenburn](https://github.com/sorinirimies/tokenburn).
87
+
88
+ MIT
@@ -0,0 +1,621 @@
1
+ /**
2
+ * pi-tokenburn โ€” token usage monitor with budget alerts in your Pi session.
3
+ *
4
+ * Inspired by the tokenburn Rust project (https://github.com/sorinirimies/tokenburn).
5
+ * Reads local Pi session JSONL logs directly (0 network, 0 prompt tokens).
6
+ *
7
+ * Features:
8
+ * - Status bar indicator: `๐Ÿ”ฅ 1.2M (today) ยท $0.45`
9
+ * - Optional widget window with Day / Week / Month / Total breakdown
10
+ * - Configurable limits with visual warning when budget threshold is exceeded
11
+ * - Commands:
12
+ * /tokenburn show token burn report
13
+ * /tokenburn window toggle permanent tokenburn widget in editor
14
+ * /tokenburn budget <d|w|m> <tokens|cost> set budget
15
+ */
16
+
17
+ import { readdirSync, statSync } from "node:fs";
18
+ import { open } from "node:fs/promises";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
23
+
24
+ export interface TokenUsage {
25
+ input: number;
26
+ output: number;
27
+ cacheRead: number;
28
+ cacheWrite: number;
29
+ cost: number;
30
+ }
31
+
32
+ export interface PeriodStats {
33
+ /** input + output (+ cache read/write when includeCache). */
34
+ tokens: number;
35
+ input: number;
36
+ output: number;
37
+ cacheRead: number;
38
+ cacheWrite: number;
39
+ cost: number;
40
+ sessions: number;
41
+ }
42
+
43
+ export interface TokenBurnReport {
44
+ today: PeriodStats;
45
+ week: PeriodStats;
46
+ month: PeriodStats;
47
+ total: PeriodStats;
48
+ }
49
+
50
+ export interface TokenBurnConfig {
51
+ /** Count cache read/write tokens in totals (matches the tokenburn CLI). */
52
+ includeCache: boolean;
53
+ /** Color the report chart (set false if your terminal garbles ANSI in notifications). */
54
+ chartColor: boolean;
55
+ showStatus: boolean;
56
+ showWidget: boolean;
57
+ statusWindow: "today" | "week" | "month" | "total";
58
+ budgets: {
59
+ dayTokens?: number;
60
+ weekTokens?: number;
61
+ monthTokens?: number;
62
+ dayCost?: number;
63
+ weekCost?: number;
64
+ monthCost?: number;
65
+ };
66
+ }
67
+
68
+ const DEFAULT_CONFIG: TokenBurnConfig = {
69
+ includeCache: true,
70
+ chartColor: true,
71
+ showStatus: true,
72
+ showWidget: false,
73
+ statusWindow: "today",
74
+ budgets: {},
75
+ };
76
+
77
+ function getConfigDir(): string {
78
+ if (process.env.PI_CODING_AGENT_DIR) return process.env.PI_CODING_AGENT_DIR;
79
+ if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "pi", "agent");
80
+ return join(homedir(), ".pi", "agent");
81
+ }
82
+
83
+ function getSessionsDir(): string {
84
+ if (process.env.TOKENBURN_PI_SESSIONS) return process.env.TOKENBURN_PI_SESSIONS;
85
+ if (process.env.PI_CODING_AGENT_SESSION_DIR) return process.env.PI_CODING_AGENT_SESSION_DIR;
86
+ return join(getConfigDir(), "sessions");
87
+ }
88
+
89
+ export async function loadConfig(): Promise<TokenBurnConfig> {
90
+ try {
91
+ const raw = await readFile(join(getConfigDir(), "tokenburn.json"), "utf8");
92
+ const parsed = JSON.parse(raw);
93
+ return {
94
+ includeCache: typeof parsed.includeCache === "boolean" ? parsed.includeCache : DEFAULT_CONFIG.includeCache,
95
+ chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
96
+ showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
97
+ showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
98
+ statusWindow: ["today", "week", "month", "total"].includes(parsed.statusWindow)
99
+ ? parsed.statusWindow
100
+ : DEFAULT_CONFIG.statusWindow,
101
+ budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? parsed.budgets : {},
102
+ };
103
+ } catch {
104
+ return { ...DEFAULT_CONFIG };
105
+ }
106
+ }
107
+
108
+ export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
109
+ await mkdir(getConfigDir(), { recursive: true });
110
+ await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
111
+ }
112
+
113
+ export function formatNum(n: number): string {
114
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B";
115
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
116
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
117
+ return n.toString();
118
+ }
119
+
120
+ export function formatCost(c: number): string {
121
+ return `$${c.toFixed(2)}`;
122
+ }
123
+
124
+ interface Turn {
125
+ ts: number;
126
+ input: number;
127
+ output: number;
128
+ cacheRead: number;
129
+ cacheWrite: number;
130
+ cost: number;
131
+ }
132
+
133
+ /** Parsed turns per session file, reused while the file is unchanged (mtime + size). */
134
+ const FILE_CACHE = new Map<string, { sig: string; turns: Turn[] }>();
135
+
136
+ async function parseFile(file: string): Promise<Turn[]> {
137
+ const turns: Turn[] = [];
138
+ try {
139
+ const fd = await open(file, "r");
140
+ try {
141
+ for await (const line of fd.readLines({ encoding: "utf8" })) {
142
+ if (!line.includes('"usage"')) continue;
143
+ try {
144
+ const obj = JSON.parse(line);
145
+ const usage = obj.message?.usage;
146
+ if (!obj.timestamp || !usage) continue;
147
+ const ts = new Date(obj.timestamp).getTime();
148
+ if (!Number.isFinite(ts)) continue;
149
+ turns.push({
150
+ ts,
151
+ input: Number(usage.input) || 0,
152
+ output: Number(usage.output) || 0,
153
+ cacheRead: Number(usage.cacheRead) || 0,
154
+ cacheWrite: Number(usage.cacheWrite) || 0,
155
+ cost: Number(usage.cost?.total) || 0,
156
+ });
157
+ } catch {}
158
+ }
159
+ } finally {
160
+ await fd.close();
161
+ }
162
+ } catch {}
163
+ return turns;
164
+ }
165
+
166
+ function listSessionFiles(root: string): string[] {
167
+ const files: string[] = [];
168
+ const walk = (dir: string) => {
169
+ let entries;
170
+ try {
171
+ entries = readdirSync(dir, { withFileTypes: true });
172
+ } catch {
173
+ return;
174
+ }
175
+ for (const e of entries) {
176
+ const full = join(dir, e.name);
177
+ if (e.isDirectory()) walk(full);
178
+ else if (e.isFile() && e.name.endsWith(".jsonl")) files.push(full);
179
+ }
180
+ };
181
+ walk(root);
182
+ return files;
183
+ }
184
+
185
+ const emptyStats = (): PeriodStats => ({
186
+ tokens: 0,
187
+ input: 0,
188
+ output: 0,
189
+ cacheRead: 0,
190
+ cacheWrite: 0,
191
+ cost: 0,
192
+ sessions: 0,
193
+ });
194
+
195
+ /** Refresh the per-file cache and return every session file's parsed turns. */
196
+ async function loadAll(sessionsRoot: string): Promise<Turn[][]> {
197
+ const files = listSessionFiles(sessionsRoot);
198
+ const live = new Set(files);
199
+ for (const key of FILE_CACHE.keys()) if (!live.has(key)) FILE_CACHE.delete(key);
200
+
201
+ const out: Turn[][] = [];
202
+ for (const file of files) {
203
+ let sig: string;
204
+ try {
205
+ const st = statSync(file);
206
+ sig = `${st.mtimeMs}:${st.size}`;
207
+ } catch {
208
+ continue;
209
+ }
210
+ let entry = FILE_CACHE.get(file);
211
+ if (!entry || entry.sig !== sig) {
212
+ entry = { sig, turns: await parseFile(file) };
213
+ FILE_CACHE.set(file, entry);
214
+ }
215
+ out.push(entry.turns);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ const turnTokens = (t: Turn, includeCache: boolean) =>
221
+ t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
222
+
223
+ export async function collectUsage(
224
+ sessionsRoot: string,
225
+ now: Date = new Date(),
226
+ includeCache = true,
227
+ ): Promise<TokenBurnReport> {
228
+ const startOfDay = bucketStart(now.getTime(), "day");
229
+ const startOfWeek = bucketStart(now.getTime(), "week");
230
+ const startOfMonth = bucketStart(now.getTime(), "month");
231
+
232
+ const report: TokenBurnReport = {
233
+ today: emptyStats(),
234
+ week: emptyStats(),
235
+ month: emptyStats(),
236
+ total: emptyStats(),
237
+ };
238
+
239
+ const add = (p: PeriodStats, t: Turn) => {
240
+ p.tokens += turnTokens(t, includeCache);
241
+ p.input += t.input;
242
+ p.output += t.output;
243
+ p.cacheRead += t.cacheRead;
244
+ p.cacheWrite += t.cacheWrite;
245
+ p.cost += t.cost;
246
+ };
247
+
248
+ for (const turns of await loadAll(sessionsRoot)) {
249
+ let maxTs = 0;
250
+ for (const t of turns) {
251
+ if (t.ts > maxTs) maxTs = t.ts;
252
+ add(report.total, t);
253
+ if (t.ts >= startOfMonth) add(report.month, t);
254
+ if (t.ts >= startOfWeek) add(report.week, t);
255
+ if (t.ts >= startOfDay) add(report.today, t);
256
+ }
257
+ if (turns.length > 0) report.total.sessions++;
258
+ if (maxTs >= startOfMonth) report.month.sessions++;
259
+ if (maxTs >= startOfWeek) report.week.sessions++;
260
+ if (maxTs >= startOfDay) report.today.sessions++;
261
+ }
262
+
263
+ return report;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Time series + chart
268
+ // ---------------------------------------------------------------------------
269
+
270
+ export type Unit = "day" | "week" | "month";
271
+
272
+ /** Local-time start of the day / Monday-week / month containing `ts`. */
273
+ export function bucketStart(ts: number, unit: Unit): number {
274
+ const d = new Date(ts);
275
+ if (unit === "day") return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
276
+ if (unit === "week") {
277
+ const dow = (d.getDay() + 6) % 7; // Monday = 0
278
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
279
+ }
280
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
281
+ }
282
+
283
+ const pad2 = (n: number) => String(n).padStart(2, "0");
284
+
285
+ export function bucketLabel(start: number, unit: Unit): string {
286
+ const d = new Date(start);
287
+ if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
288
+ const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
289
+ return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
290
+ }
291
+
292
+ export interface SeriesPoint {
293
+ start: number;
294
+ label: string;
295
+ tokens: number;
296
+ cost: number;
297
+ }
298
+
299
+ export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6 };
300
+
301
+ /** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
302
+ export async function collectSeries(
303
+ sessionsRoot: string,
304
+ unit: Unit,
305
+ count = DEFAULT_COUNT[unit],
306
+ now: Date = new Date(),
307
+ includeCache = true,
308
+ ): Promise<SeriesPoint[]> {
309
+ const starts: number[] = [];
310
+ for (let i = count - 1; i >= 0; i--) {
311
+ const y = now.getFullYear();
312
+ const m = now.getMonth();
313
+ const day = now.getDate();
314
+ const anchor =
315
+ unit === "day"
316
+ ? new Date(y, m, day - i)
317
+ : unit === "week"
318
+ ? new Date(y, m, day - i * 7)
319
+ : new Date(y, m - i, 1);
320
+ starts.push(bucketStart(anchor.getTime(), unit));
321
+ }
322
+ const points = new Map<number, SeriesPoint>(
323
+ starts.map((start) => [start, { start, label: bucketLabel(start, unit), tokens: 0, cost: 0 }]),
324
+ );
325
+ for (const turns of await loadAll(sessionsRoot)) {
326
+ for (const t of turns) {
327
+ const pt = points.get(bucketStart(t.ts, unit));
328
+ if (!pt) continue;
329
+ pt.tokens += turnTokens(t, includeCache);
330
+ pt.cost += t.cost;
331
+ }
332
+ }
333
+ return starts.map((s) => points.get(s)!);
334
+ }
335
+
336
+ const EIGHTHS = ["", "โ–", "โ–Ž", "โ–", "โ–Œ", "โ–‹", "โ–Š", "โ–‰"];
337
+ const SPARK = "โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ";
338
+
339
+ /** Horizontal bar of `value/max` over `width` cells, with 1/8-cell precision. */
340
+ export function bar(value: number, max: number, width: number): string {
341
+ if (max <= 0 || value <= 0) return "";
342
+ const eighths = Math.max(1, Math.round((value / max) * width * 8));
343
+ const full = Math.floor(eighths / 8);
344
+ return "โ–ˆ".repeat(full) + EIGHTHS[eighths % 8];
345
+ }
346
+
347
+ export function sparkline(values: number[]): string {
348
+ const max = Math.max(...values, 0);
349
+ if (max <= 0) return SPARK[0].repeat(values.length);
350
+ return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
351
+ }
352
+
353
+ const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly" };
354
+ const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month" };
355
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
356
+
357
+ export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
358
+ const plain: Paint = (_k, t) => t;
359
+
360
+ /** Two header rows for a column: [top, bottom]. */
361
+ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
362
+ const d = new Date(p.start);
363
+ if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
364
+ if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
365
+ return [MONTHS[d.getMonth()], String(d.getFullYear())];
366
+ }
367
+
368
+ /**
369
+ * Vertical column chart: y-axis, 1/8-row precision, optional budget line,
370
+ * highlighted current bucket and over-budget columns, summary footer.
371
+ */
372
+ export function renderChart(
373
+ series: SeriesPoint[],
374
+ unit: Unit,
375
+ opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint } = {},
376
+ ): string[] {
377
+ const paint = opts.paint ?? plain;
378
+ const height = opts.height ?? 8;
379
+ const colW = unit === "day" ? 3 : 6; // bar cells + 1 gap
380
+ const barW = colW - 1;
381
+
382
+ const peakPoint = series.reduce((m, p) => (p.tokens > m.tokens ? p : m), series[0]);
383
+ const dataMax = peakPoint?.tokens ?? 0;
384
+ const budgetTokens = opts.budget?.tokens;
385
+ const scaleMax = Math.max(dataMax, budgetTokens ?? 0);
386
+ const total = series.reduce((a, p) => a + p.tokens, 0);
387
+ const totalCost = series.reduce((a, p) => a + p.cost, 0);
388
+
389
+ const yLabels = [formatNum(scaleMax), formatNum(scaleMax / 2), "0"];
390
+ const yW = Math.max(...yLabels.map((l) => l.length)) + 1;
391
+ const labelFor = (row: number) => (row === height ? yLabels[0] : row === Math.ceil(height / 2) ? yLabels[1] : "");
392
+ const budgetRow = budgetTokens && scaleMax > 0 ? Math.max(1, Math.ceil((budgetTokens / scaleMax) * height)) : 0;
393
+
394
+ const isOver = (p: SeriesPoint) =>
395
+ opts.budget ? isOverBudget({ tokens: p.tokens, cost: p.cost } as PeriodStats, opts.budget) : false;
396
+ const kindOf = (i: number): "bar" | "now" | "over" =>
397
+ isOver(series[i]) ? "over" : i === series.length - 1 ? "now" : "bar";
398
+
399
+ const lines: string[] = [];
400
+ lines.push(
401
+ paint("title", `${UNIT_TITLE[unit]} tokens`) +
402
+ paint("dim", ` ยท last ${series.length} ${UNIT_NOUN[unit]}s`) +
403
+ (budgetTokens ? paint("budget", ` โ”„ budget ${formatNum(budgetTokens)}`) : ""),
404
+ );
405
+
406
+ for (let row = height; row >= 1; row--) {
407
+ let line = paint("axis", `${labelFor(row).padStart(yW)} โ”‚`);
408
+ for (let i = 0; i < series.length; i++) {
409
+ const h = scaleMax > 0 ? (series[i].tokens / scaleMax) * height : 0;
410
+ let cell: string;
411
+ if (h >= row) cell = "โ–ˆ";
412
+ else if (h > row - 1 && series[i].tokens > 0) cell = SPARK[Math.min(7, Math.max(0, Math.floor((h - (row - 1)) * 8) - 1))];
413
+ else if (row === 1 && series[i].tokens > 0) cell = "โ–";
414
+ else cell = row === budgetRow ? "โ”„" : " ";
415
+ const filled = cell !== " " && cell !== "โ”„";
416
+ const body = cell.repeat(barW);
417
+ line += filled ? paint(kindOf(i), body) : cell === "โ”„" ? paint("budget", body) : body;
418
+ line += row === budgetRow ? paint("budget", "โ”„") : " ";
419
+ }
420
+ lines.push(line);
421
+ }
422
+
423
+ lines.push(paint("axis", `${"".padStart(yW)} โ””${"โ”€".repeat(series.length * colW)}`));
424
+ const pad = " ".repeat(yW + 2);
425
+ const cols = series.map((p, i) => ({ l: columnLabels(p, unit), now: i === series.length - 1 }));
426
+ lines.push(pad + cols.map((c) => c.l[0].padEnd(colW)).join(""));
427
+ lines.push(paint("dim", pad + cols.map((c) => c.l[1].padEnd(colW)).join("")));
428
+ lines.push(pad + cols.map((c) => (c.now ? paint("now", "โ–ฒ".padEnd(colW)) : " ".repeat(colW))).join(""));
429
+
430
+ const avg = series.length ? total / series.length : 0;
431
+ lines.push(
432
+ paint(
433
+ "dim",
434
+ `ฮฃ ${formatNum(total)} (${formatCost(totalCost)}) ยท avg ${formatNum(avg)}/${UNIT_NOUN[unit]}` +
435
+ (dataMax > 0 ? ` ยท peak ${formatNum(dataMax)} (${bucketLabel(peakPoint.start, unit)})` : ""),
436
+ ),
437
+ );
438
+ return lines;
439
+ }
440
+
441
+ /** Clear the per-file cache (tests). */
442
+ export function clearCache(): void {
443
+ FILE_CACHE.clear();
444
+ }
445
+
446
+ export type Window = "today" | "week" | "month" | "total";
447
+
448
+ export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
449
+ const b = cfg.budgets;
450
+ if (w === "today") return { tokens: b.dayTokens, cost: b.dayCost };
451
+ if (w === "week") return { tokens: b.weekTokens, cost: b.weekCost };
452
+ if (w === "month") return { tokens: b.monthTokens, cost: b.monthCost };
453
+ return {};
454
+ }
455
+
456
+ export function isOverBudget(p: PeriodStats, b: { tokens?: number; cost?: number }): boolean {
457
+ return (b.tokens !== undefined && p.tokens > b.tokens) || (b.cost !== undefined && p.cost > b.cost);
458
+ }
459
+
460
+ /** Map chart roles to pi theme colors. */
461
+ export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint {
462
+ const color: Record<string, string> = {
463
+ bar: "accent",
464
+ now: "success",
465
+ over: "error",
466
+ axis: "dim",
467
+ budget: "warning",
468
+ dim: "dim",
469
+ title: "accent",
470
+ };
471
+ return (kind, text) => theme.fg(color[kind], text);
472
+ }
473
+
474
+ export default function tokenBurnExtension(pi: ExtensionAPI) {
475
+ let config: TokenBurnConfig = { ...DEFAULT_CONFIG };
476
+
477
+ const updateUI = async (ctx: ExtensionContext) => {
478
+ if (!ctx.hasUI) return;
479
+ const report = await collectUsage(getSessionsDir(), new Date(), config.includeCache);
480
+ const theme = ctx.ui.theme;
481
+
482
+ // Status bar update
483
+ if (config.showStatus) {
484
+ const stat = report[config.statusWindow];
485
+ const isOver = isOverBudget(stat, budgetsFor(config, config.statusWindow));
486
+ const fire = isOver ? theme.fg("error", "๐Ÿ”ฅ OVER BUDGET") : theme.fg("warning", "๐Ÿ”ฅ");
487
+ const tokensText = theme.fg(isOver ? "error" : "accent", formatNum(stat.tokens));
488
+ const costText = theme.fg("dim", `(${formatCost(stat.cost)})`);
489
+ const label = theme.fg("dim", `[${config.statusWindow}]`);
490
+
491
+ ctx.ui.setStatus("tokenburn", `${fire} ${tokensText} ${costText} ${label}`);
492
+ } else {
493
+ ctx.ui.setStatus("tokenburn", "");
494
+ }
495
+
496
+ // Widget panel update
497
+ if (config.showWidget) {
498
+ const border = theme.fg("dim", "โ”€".repeat(46));
499
+ const header = theme.fg("accent", " ๐Ÿ”ฅ TokenBurn Dashboard ");
500
+ const line = (label: string, p: PeriodStats, b: { tokens?: number; cost?: number } = {}) => {
501
+ const over = isOverBudget(p, b);
502
+ const tokStr = (over ? theme.fg("error", "!") : " ") + formatNum(p.tokens).padStart(8);
503
+ const inStr = `(in:${formatNum(p.input)} out:${formatNum(p.output)})`.padEnd(20);
504
+ const costStr = formatCost(p.cost).padStart(8);
505
+ return ` ${theme.fg("muted", label.padEnd(8))} ${tokStr} ${theme.fg("dim", inStr)} ${costStr}`;
506
+ };
507
+
508
+ const lines = [
509
+ `โ”Œ${header}${"โ”€".repeat(21)}โ”`,
510
+ line("Today", report.today, budgetsFor(config, "today")),
511
+ line("Week", report.week, budgetsFor(config, "week")),
512
+ line("Month", report.month, budgetsFor(config, "month")),
513
+ line("Total", report.total),
514
+ `โ””${"โ”€".repeat(46)}โ”˜`,
515
+ ];
516
+
517
+ ctx.ui.setWidget("tokenburn-panel", lines, { placement: "belowEditor" });
518
+ } else {
519
+ ctx.ui.setWidget("tokenburn-panel", []);
520
+ }
521
+ };
522
+
523
+ pi.on("session_start", async (_event, ctx) => {
524
+ config = await loadConfig();
525
+ await updateUI(ctx);
526
+ });
527
+
528
+ // Fire-and-forget: never block the agent loop on a stats refresh.
529
+ pi.on("turn_end", (_event, ctx) => {
530
+ void updateUI(ctx).catch(() => {});
531
+ });
532
+
533
+ pi.registerCommand("tokenburn", {
534
+ description: "TokenBurn stats: show report, toggle widget, or set budgets",
535
+ getArgumentCompletions: (prefix: string) => {
536
+ const opts = ["report", "day", "week", "month", "today", "total", "cycle", "window", "cache", "status <today|week|month|total>", "budget <day|week|month> <tokens|$cost>"];
537
+ return opts.filter((o) => o.startsWith(prefix)).map((value) => ({ value }));
538
+ },
539
+ handler: async (args, ctx) => {
540
+ const [cmd, p1, p2] = (args ?? "").trim().split(/\s+/).filter(Boolean);
541
+
542
+ if (cmd === "window" || cmd === "widget") {
543
+ config.showWidget = !config.showWidget;
544
+ await saveConfig(config);
545
+ await updateUI(ctx);
546
+ ctx.ui.notify(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`, "info");
547
+ return;
548
+ }
549
+
550
+ const WINDOWS = ["today", "week", "month", "total"] as const;
551
+ const setWindow = async (w: (typeof WINDOWS)[number]) => {
552
+ config.statusWindow = w;
553
+ await saveConfig(config);
554
+ await updateUI(ctx);
555
+ ctx.ui.notify(`tokenburn status window: ${w}`, "info");
556
+ };
557
+
558
+ // /tokenburn today|week|month|total โ€” shortcut for the status bar period
559
+ if ((WINDOWS as readonly string[]).includes(cmd ?? "")) {
560
+ await setWindow(cmd as (typeof WINDOWS)[number]);
561
+ return;
562
+ }
563
+ if (cmd === "status" && (WINDOWS as readonly string[]).includes(p1 ?? "")) {
564
+ await setWindow(p1 as (typeof WINDOWS)[number]);
565
+ return;
566
+ }
567
+ // /tokenburn cycle โ€” today โ†’ week โ†’ month โ†’ total โ†’ today
568
+ if (cmd === "cycle") {
569
+ await setWindow(WINDOWS[(WINDOWS.indexOf(config.statusWindow) + 1) % WINDOWS.length]);
570
+ return;
571
+ }
572
+
573
+ if (cmd === "cache") {
574
+ config.includeCache = !config.includeCache;
575
+ await saveConfig(config);
576
+ await updateUI(ctx);
577
+ ctx.ui.notify(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`, "info");
578
+ return;
579
+ }
580
+
581
+ if (cmd === "budget") {
582
+ const isCost = (p2 ?? "").startsWith("$");
583
+ const val = Number((p2 ?? "").replace(/^\$/, ""));
584
+ if (!["day", "week", "month"].includes(p1 ?? "") || !Number.isFinite(val) || val <= 0) {
585
+ ctx.ui.notify("Usage: /tokenburn budget <day|week|month> <tokens | $cost>", "warning");
586
+ return;
587
+ }
588
+ const key = `${p1}${isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
589
+ config.budgets[key] = val;
590
+ await saveConfig(config);
591
+ await updateUI(ctx);
592
+ ctx.ui.notify(`tokenburn ${p1} budget: ${isCost ? formatCost(val) : formatNum(val) + " tokens"}`, "info");
593
+ return;
594
+ }
595
+
596
+ // /tokenburn [report] [day|week|month] โ€” summary + chart
597
+ const unitArg = (cmd === "report" || cmd === "chart" ? p1 : cmd) as string | undefined;
598
+ const unit: Unit = unitArg === "week" || unitArg === "month" ? unitArg : "day";
599
+ const report = await collectUsage(getSessionsDir(), new Date(), config.includeCache);
600
+ const series = await collectSeries(getSessionsDir(), unit, DEFAULT_COUNT[unit], new Date(), config.includeCache);
601
+ const budgetKey = unit === "day" ? "today" : unit === "week" ? "week" : "month";
602
+ const formatRow = (name: string, p: PeriodStats) =>
603
+ `${name.padEnd(10)} ${formatNum(p.tokens).padStart(8)} tokens ${formatCost(p.cost).padStart(9)} ยท in:${formatNum(p.input)} out:${formatNum(p.output)} cache:${formatNum(p.cacheRead + p.cacheWrite)} ยท ${p.sessions} sess`;
604
+
605
+ const output = [
606
+ "๐Ÿ”ฅ TokenBurn Report",
607
+ "โ”€".repeat(60),
608
+ formatRow("Today", report.today),
609
+ formatRow("This Week", report.week),
610
+ formatRow("This Month", report.month),
611
+ formatRow("All Time", report.total),
612
+ "โ”€".repeat(60),
613
+ ...renderChart(series, unit, { budget: budgetsFor(config, budgetKey as Window), paint: config.chartColor ? themePaint(ctx.ui.theme) : undefined }),
614
+ "โ”€".repeat(60),
615
+ "/tokenburn [day|week|month] chart ยท today|week|month|total|cycle status bar ยท window ยท budget ยท cache",
616
+ ].join("\n");
617
+
618
+ ctx.ui.notify(output, "info");
619
+ },
620
+ });
621
+ }
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Pi extension for live token usage monitoring and budget tracking (Day / Week / Month / Total). 0 prompt tokens.",
5
+ "keywords": ["pi-package", "pi-extension", "tokenburn", "tokens", "analytics", "budget", "stats"],
6
+ "license": "MIT",
7
+ "author": "sorinirimies",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sorinirimies/pi-tokenburn.git"
11
+ },
12
+ "homepage": "https://github.com/sorinirimies/pi-tokenburn#readme",
13
+ "files": ["extensions", "README.md", "LICENSE"],
14
+ "pi": { "extensions": ["./extensions/tokenburn.ts"] },
15
+ "peerDependencies": { "@earendil-works/pi-coding-agent": "*" }
16
+ }