pi-tokenburn 0.2.0 → 0.3.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/README.md CHANGED
@@ -10,7 +10,7 @@ Totals match the `tokenburn` CLI exactly.
10
10
 
11
11
  - **Status bar:** `🔥 1.20M ($52.79) [today]`, turns red with `OVER BUDGET` past your limit.
12
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`.
13
+ - **Views:** `/tokenburn day|week|month|year|all`. Status bar period: `/tokenburn status <window>` or `/tokenburn cycle`.
14
14
  - **Window panel (optional):** Today / Week / Month / Total below the editor, with in/out tokens and cost.
15
15
  - **Budgets** per day, week and month, in tokens and/or dollars.
16
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.
@@ -27,15 +27,21 @@ pi install git:github.com/sorinirimies/pi-tokenburn
27
27
 
28
28
  | Command | What it does |
29
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 |
30
+ | `/tokenburn` · `day` · `today` | Report + **daily** chart (last 14 days) |
31
+ | `/tokenburn week` | Report + **weekly** chart (last 8 weeks) |
32
+ | `/tokenburn month` | Report + **monthly** chart (last 6 months) |
33
+ | `/tokenburn year` | Report + **yearly** chart (last 5 years) |
34
+ | `/tokenburn all` | Report + whole history (monthly columns up to 18 months, yearly beyond) |
35
+ | `/tokenburn report <view>` · `chart <view>` | Same as above, explicit form |
36
+ | `/tokenburn status <today\|week\|month\|year\|total>` | Set the status-bar period |
37
+ | `/tokenburn cycle` | Rotate the status-bar period: today → week → month → year → total |
34
38
  | `/tokenburn window` | Toggle the panel below the editor |
35
39
  | `/tokenburn budget <day\|week\|month> <tokens>` | Token budget, e.g. `budget day 5000000` |
36
40
  | `/tokenburn budget <day\|week\|month> $<cost>` | Dollar budget, e.g. `budget week $50` |
37
41
  | `/tokenburn cache` | Toggle counting cache read/write tokens |
38
42
 
43
+ Tab-completion works for all of the above. Unknown input prints usage instead of failing.
44
+
39
45
  Chart example (`/tokenburn`, with a 600M day budget):
40
46
 
41
47
  ```
@@ -75,12 +81,33 @@ Weeks start on Monday, in local time.
75
81
 
76
82
  Environment overrides for the session directory: `TOKENBURN_PI_SESSIONS`, `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`.
77
83
 
78
- ## Develop
84
+ ## Development
79
85
 
80
86
  ```bash
81
- bun test
87
+ bun install
88
+ just check # typecheck + tests + pack check + nushell tests (what CI runs)
89
+ just test # bun test only
82
90
  ```
83
91
 
92
+ CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
93
+ `just --list` shows every task.
94
+
95
+ ## Releases (automatic)
96
+
97
+ | Workflow | When | What |
98
+ |---|---|---|
99
+ | **CI** | push / PR | quality gate on Linux; tests on macOS, Windows and extreme time zones |
100
+ | **Nightly Dependency Update** | every night (GitHub 02:00 UTC, Gitea 02:30) | `bun update` within ranges, verify on all platforms, commit `chore(deps)`; then **publish a patch** when a *runtime* dependency was upgraded or `feat`/`fix`/`perf` commits are waiting since the last tag |
101
+ | **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent), creates the release |
102
+
103
+ Dev-tooling-only bumps are committed but never released on their own. A downgrade blocks the whole update.
104
+
105
+ Manual release: `just bump patch` (or `minor` / `major` / `X.Y.Z`), then `just release-push`.
106
+
107
+ **Secrets** (repo settings): `NPM_TOKEN` — npm *granular access token* with publish rights and "bypass 2FA" (required for CI publishing). Optional: `GH_PAT` (lets a tag push trigger the release itself), `GITEA_TOKEN` (Gitea).
108
+
109
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
110
+
84
111
  ## Scope
85
112
 
86
113
  Pi sessions only. For Zed, Claude Code, Codex, Gemini and more, use the Rust [tokenburn](https://github.com/sorinirimies/tokenburn).
@@ -44,9 +44,13 @@ export interface TokenBurnReport {
44
44
  today: PeriodStats;
45
45
  week: PeriodStats;
46
46
  month: PeriodStats;
47
+ year: PeriodStats;
47
48
  total: PeriodStats;
48
49
  }
49
50
 
51
+ export const STATUS_WINDOWS = ["today", "week", "month", "year", "total"] as const;
52
+ export type Window = (typeof STATUS_WINDOWS)[number];
53
+
50
54
  export interface TokenBurnConfig {
51
55
  /** Count cache read/write tokens in totals (matches the tokenburn CLI). */
52
56
  includeCache: boolean;
@@ -54,7 +58,7 @@ export interface TokenBurnConfig {
54
58
  chartColor: boolean;
55
59
  showStatus: boolean;
56
60
  showWidget: boolean;
57
- statusWindow: "today" | "week" | "month" | "total";
61
+ statusWindow: Window;
58
62
  budgets: {
59
63
  dayTokens?: number;
60
64
  weekTokens?: number;
@@ -95,16 +99,21 @@ export async function loadConfig(): Promise<TokenBurnConfig> {
95
99
  chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
96
100
  showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
97
101
  showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
98
- statusWindow: ["today", "week", "month", "total"].includes(parsed.statusWindow)
102
+ statusWindow: (STATUS_WINDOWS as readonly string[]).includes(parsed.statusWindow)
99
103
  ? parsed.statusWindow
100
104
  : DEFAULT_CONFIG.statusWindow,
101
- budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? parsed.budgets : {},
105
+ budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? { ...parsed.budgets } : {},
102
106
  };
103
107
  } catch {
104
- return { ...DEFAULT_CONFIG };
108
+ return freshConfig();
105
109
  }
106
110
  }
107
111
 
112
+ /** A config that shares no mutable state with DEFAULT_CONFIG. */
113
+ export function freshConfig(): TokenBurnConfig {
114
+ return { ...DEFAULT_CONFIG, budgets: {} };
115
+ }
116
+
108
117
  export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
109
118
  await mkdir(getConfigDir(), { recursive: true });
110
119
  await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
@@ -228,11 +237,13 @@ export async function collectUsage(
228
237
  const startOfDay = bucketStart(now.getTime(), "day");
229
238
  const startOfWeek = bucketStart(now.getTime(), "week");
230
239
  const startOfMonth = bucketStart(now.getTime(), "month");
240
+ const startOfYear = bucketStart(now.getTime(), "year");
231
241
 
232
242
  const report: TokenBurnReport = {
233
243
  today: emptyStats(),
234
244
  week: emptyStats(),
235
245
  month: emptyStats(),
246
+ year: emptyStats(),
236
247
  total: emptyStats(),
237
248
  };
238
249
 
@@ -250,11 +261,13 @@ export async function collectUsage(
250
261
  for (const t of turns) {
251
262
  if (t.ts > maxTs) maxTs = t.ts;
252
263
  add(report.total, t);
264
+ if (t.ts >= startOfYear) add(report.year, t);
253
265
  if (t.ts >= startOfMonth) add(report.month, t);
254
266
  if (t.ts >= startOfWeek) add(report.week, t);
255
267
  if (t.ts >= startOfDay) add(report.today, t);
256
268
  }
257
269
  if (turns.length > 0) report.total.sessions++;
270
+ if (maxTs >= startOfYear) report.year.sessions++;
258
271
  if (maxTs >= startOfMonth) report.month.sessions++;
259
272
  if (maxTs >= startOfWeek) report.week.sessions++;
260
273
  if (maxTs >= startOfDay) report.today.sessions++;
@@ -267,7 +280,7 @@ export async function collectUsage(
267
280
  // Time series + chart
268
281
  // ---------------------------------------------------------------------------
269
282
 
270
- export type Unit = "day" | "week" | "month";
283
+ export type Unit = "day" | "week" | "month" | "year";
271
284
 
272
285
  /** Local-time start of the day / Monday-week / month containing `ts`. */
273
286
  export function bucketStart(ts: number, unit: Unit): number {
@@ -277,6 +290,7 @@ export function bucketStart(ts: number, unit: Unit): number {
277
290
  const dow = (d.getDay() + 6) % 7; // Monday = 0
278
291
  return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
279
292
  }
293
+ if (unit === "year") return new Date(d.getFullYear(), 0, 1).getTime();
280
294
  return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
281
295
  }
282
296
 
@@ -284,6 +298,7 @@ const pad2 = (n: number) => String(n).padStart(2, "0");
284
298
 
285
299
  export function bucketLabel(start: number, unit: Unit): string {
286
300
  const d = new Date(start);
301
+ if (unit === "year") return String(d.getFullYear());
287
302
  if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
288
303
  const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
289
304
  return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
@@ -296,7 +311,7 @@ export interface SeriesPoint {
296
311
  cost: number;
297
312
  }
298
313
 
299
- export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6 };
314
+ export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6, year: 5 };
300
315
 
301
316
  /** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
302
317
  export async function collectSeries(
@@ -316,7 +331,9 @@ export async function collectSeries(
316
331
  ? new Date(y, m, day - i)
317
332
  : unit === "week"
318
333
  ? new Date(y, m, day - i * 7)
319
- : new Date(y, m - i, 1);
334
+ : unit === "year"
335
+ ? new Date(y - i, 0, 1)
336
+ : new Date(y, m - i, 1);
320
337
  starts.push(bucketStart(anchor.getTime(), unit));
321
338
  }
322
339
  const points = new Map<number, SeriesPoint>(
@@ -333,6 +350,27 @@ export async function collectSeries(
333
350
  return starts.map((s) => points.get(s)!);
334
351
  }
335
352
 
353
+ /**
354
+ * Whole history: monthly columns while it spans <= 18 months, yearly after that.
355
+ * Always at least 2 columns so the chart has a shape.
356
+ */
357
+ export async function collectAllSeries(
358
+ sessionsRoot: string,
359
+ now: Date = new Date(),
360
+ includeCache = true,
361
+ ): Promise<{ unit: Unit; series: SeriesPoint[] }> {
362
+ let first = now.getTime();
363
+ for (const turns of await loadAll(sessionsRoot)) for (const t of turns) if (t.ts < first) first = t.ts;
364
+ const f = new Date(first);
365
+ const months = (now.getFullYear() - f.getFullYear()) * 12 + (now.getMonth() - f.getMonth()) + 1;
366
+ if (months <= 18) {
367
+ const unit: Unit = "month";
368
+ return { unit, series: await collectSeries(sessionsRoot, unit, Math.max(2, months), now, includeCache) };
369
+ }
370
+ const years = now.getFullYear() - f.getFullYear() + 1;
371
+ return { unit: "year", series: await collectSeries(sessionsRoot, "year", years, now, includeCache) };
372
+ }
373
+
336
374
  const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
337
375
  const SPARK = "▁▂▃▄▅▆▇█";
338
376
 
@@ -350,8 +388,8 @@ export function sparkline(values: number[]): string {
350
388
  return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
351
389
  }
352
390
 
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" };
391
+ const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" };
392
+ const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month", year: "year" };
355
393
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
356
394
 
357
395
  export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
@@ -362,6 +400,7 @@ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
362
400
  const d = new Date(p.start);
363
401
  if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
364
402
  if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
403
+ if (unit === "year") return [String(d.getFullYear()), ""];
365
404
  return [MONTHS[d.getMonth()], String(d.getFullYear())];
366
405
  }
367
406
 
@@ -372,7 +411,7 @@ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
372
411
  export function renderChart(
373
412
  series: SeriesPoint[],
374
413
  unit: Unit,
375
- opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint } = {},
414
+ opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint; title?: string } = {},
376
415
  ): string[] {
377
416
  const paint = opts.paint ?? plain;
378
417
  const height = opts.height ?? 8;
@@ -398,8 +437,8 @@ export function renderChart(
398
437
 
399
438
  const lines: string[] = [];
400
439
  lines.push(
401
- paint("title", `${UNIT_TITLE[unit]} tokens`) +
402
- paint("dim", ` · last ${series.length} ${UNIT_NOUN[unit]}s`) +
440
+ paint("title", opts.title ?? `${UNIT_TITLE[unit]} tokens`) +
441
+ paint("dim", ` · ${opts.title ? "" : "last "}${series.length} ${UNIT_NOUN[unit]}${series.length === 1 ? "" : "s"}`) +
403
442
  (budgetTokens ? paint("budget", ` ┄ budget ${formatNum(budgetTokens)}`) : ""),
404
443
  );
405
444
 
@@ -443,8 +482,6 @@ export function clearCache(): void {
443
482
  FILE_CACHE.clear();
444
483
  }
445
484
 
446
- export type Window = "today" | "week" | "month" | "total";
447
-
448
485
  export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
449
486
  const b = cfg.budgets;
450
487
  if (w === "today") return { tokens: b.dayTokens, cost: b.dayCost };
@@ -472,7 +509,7 @@ export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint
472
509
  }
473
510
 
474
511
  export default function tokenBurnExtension(pi: ExtensionAPI) {
475
- let config: TokenBurnConfig = { ...DEFAULT_CONFIG };
512
+ let config: TokenBurnConfig = freshConfig();
476
513
 
477
514
  const updateUI = async (ctx: ExtensionContext) => {
478
515
  if (!ctx.hasUI) return;
@@ -510,6 +547,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
510
547
  line("Today", report.today, budgetsFor(config, "today")),
511
548
  line("Week", report.week, budgetsFor(config, "week")),
512
549
  line("Month", report.month, budgetsFor(config, "month")),
550
+ line("Year", report.year),
513
551
  line("Total", report.total),
514
552
  `└${"─".repeat(46)}┘`,
515
553
  ];
@@ -531,91 +569,199 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
531
569
  });
532
570
 
533
571
  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
- },
572
+ description: "TokenBurn: /tokenburn [day|week|month|year|all] chart, status <window>, cycle, window, budget, cache",
573
+ getArgumentCompletions: (prefix: string) => completions(prefix),
539
574
  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;
575
+ const cmd = parseCommand(args);
576
+ const persist = async () => {
544
577
  await saveConfig(config);
545
578
  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
579
  };
557
580
 
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
- }
581
+ switch (cmd.kind) {
582
+ case "window":
583
+ config.showWidget = !config.showWidget;
584
+ await persist();
585
+ ctx.ui.notify(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`, "info");
586
+ return;
572
587
 
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
- }
588
+ case "status":
589
+ config.statusWindow = cmd.window;
590
+ await persist();
591
+ ctx.ui.notify(`tokenburn status window: ${cmd.window}`, "info");
592
+ return;
580
593
 
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");
594
+ case "cycle": {
595
+ const next = STATUS_WINDOWS[(STATUS_WINDOWS.indexOf(config.statusWindow) + 1) % STATUS_WINDOWS.length];
596
+ config.statusWindow = next;
597
+ await persist();
598
+ ctx.ui.notify(`tokenburn status window: ${next}`, "info");
599
+ return;
600
+ }
601
+
602
+ case "cache":
603
+ config.includeCache = !config.includeCache;
604
+ await persist();
605
+ ctx.ui.notify(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`, "info");
606
+ return;
607
+
608
+ case "budget": {
609
+ const key = `${cmd.period}${cmd.isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
610
+ config.budgets[key] = cmd.amount;
611
+ await persist();
612
+ ctx.ui.notify(
613
+ `tokenburn ${cmd.period} budget: ${cmd.isCost ? formatCost(cmd.amount) : formatNum(cmd.amount) + " tokens"}`,
614
+ "info",
615
+ );
586
616
  return;
587
617
  }
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
618
 
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
+ case "invalid":
620
+ ctx.ui.notify(cmd.message, "warning");
621
+ return;
622
+
623
+ case "report": {
624
+ const root = getSessionsDir();
625
+ const now = new Date();
626
+ const report = await collectUsage(root, now, config.includeCache);
627
+ const paint = config.chartColor ? themePaint(ctx.ui.theme) : undefined;
628
+
629
+ let chart: string[];
630
+ if (cmd.view === "all") {
631
+ const all = await collectAllSeries(root, now, config.includeCache);
632
+ chart = renderChart(all.series, all.unit, { paint, title: "All time" });
633
+ } else {
634
+ const series = await collectSeries(root, cmd.view, DEFAULT_COUNT[cmd.view], now, config.includeCache);
635
+ const budgetWindow: Window | undefined =
636
+ cmd.view === "day" ? "today" : cmd.view === "year" ? undefined : cmd.view;
637
+ chart = renderChart(series, cmd.view, {
638
+ paint,
639
+ budget: budgetWindow ? budgetsFor(config, budgetWindow) : undefined,
640
+ });
641
+ }
642
+
643
+ const row = (name: string, p: PeriodStats) =>
644
+ `${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`;
645
+ const rule = "─".repeat(60);
646
+ ctx.ui.notify(
647
+ [
648
+ "🔥 TokenBurn Report",
649
+ rule,
650
+ row("Today", report.today),
651
+ row("This Week", report.week),
652
+ row("This Month", report.month),
653
+ row("This Year", report.year),
654
+ row("All Time", report.total),
655
+ rule,
656
+ ...chart,
657
+ rule,
658
+ "/tokenburn day|week|month|year|all · status <window> · cycle · window · budget · cache",
659
+ ].join("\n"),
660
+ "info",
661
+ );
662
+ return;
663
+ }
664
+ }
619
665
  },
620
666
  });
621
667
  }
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // Command parsing + completions (pure, unit-tested)
671
+ // ---------------------------------------------------------------------------
672
+
673
+ export const REPORT_VIEWS = ["day", "week", "month", "year", "all"] as const;
674
+ export type ReportView = (typeof REPORT_VIEWS)[number];
675
+ export const BUDGET_PERIODS = ["day", "week", "month"] as const;
676
+
677
+ export type Command =
678
+ | { kind: "report"; view: ReportView }
679
+ | { kind: "status"; window: Window }
680
+ | { kind: "cycle" }
681
+ | { kind: "window" }
682
+ | { kind: "cache" }
683
+ | { kind: "budget"; period: (typeof BUDGET_PERIODS)[number]; amount: number; isCost: boolean }
684
+ | { kind: "invalid"; message: string };
685
+
686
+ export const USAGE =
687
+ "Usage: /tokenburn [day|week|month|year|all] · status <today|week|month|year|total> · cycle · window · budget <day|week|month> <tokens|$cost> · cache";
688
+
689
+ const asView = (w: string | undefined): ReportView | undefined =>
690
+ w === "today" ? "day" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
691
+
692
+ export function parseCommand(args: string | undefined): Command {
693
+ const [cmd, p1, p2] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
694
+ if (!cmd) return { kind: "report", view: "day" };
695
+
696
+ const view = asView(cmd);
697
+ if (view) return { kind: "report", view };
698
+
699
+ switch (cmd) {
700
+ case "report":
701
+ case "chart": {
702
+ if (p1 === undefined) return { kind: "report", view: "day" };
703
+ const v = asView(p1);
704
+ return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown view "${p1}". ${USAGE}` };
705
+ }
706
+ case "status":
707
+ return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
708
+ ? { kind: "status", window: p1 as Window }
709
+ : { kind: "invalid", message: `Usage: /tokenburn status <${STATUS_WINDOWS.join("|")}>` };
710
+ case "cycle":
711
+ return { kind: "cycle" };
712
+ case "window":
713
+ case "widget":
714
+ return { kind: "window" };
715
+ case "cache":
716
+ return { kind: "cache" };
717
+ case "budget": {
718
+ const isCost = (p2 ?? "").startsWith("$");
719
+ const amount = Number((p2 ?? "").replace(/^\$/, ""));
720
+ if (!(BUDGET_PERIODS as readonly string[]).includes(p1 ?? "") || !Number.isFinite(amount) || amount <= 0) {
721
+ return { kind: "invalid", message: "Usage: /tokenburn budget <day|week|month> <tokens | $cost>" };
722
+ }
723
+ return { kind: "budget", period: p1 as (typeof BUDGET_PERIODS)[number], amount, isCost };
724
+ }
725
+ default:
726
+ return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
727
+ }
728
+ }
729
+
730
+ export interface CompletionItem {
731
+ value: string;
732
+ /** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
733
+ label: string;
734
+ description?: string;
735
+ }
736
+
737
+ const TOP_LEVEL: CompletionItem[] = [
738
+ { value: "day", label: "day", description: "Report + daily chart (last 14 days)" },
739
+ { value: "week", label: "week", description: "Report + weekly chart (last 8 weeks)" },
740
+ { value: "month", label: "month", description: "Report + monthly chart (last 6 months)" },
741
+ { value: "year", label: "year", description: "Report + yearly chart (last 5 years)" },
742
+ { value: "all", label: "all", description: "Report + whole history" },
743
+ { value: "status ", label: "status", description: "Set the status-bar period" },
744
+ { value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
745
+ { value: "window", label: "window", description: "Toggle the panel below the editor" },
746
+ { value: "budget ", label: "budget", description: "Set a day/week/month budget" },
747
+ { value: "cache", label: "cache", description: "Toggle counting cache tokens" },
748
+ ];
749
+
750
+ /** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
751
+ export function completions(prefix: string): CompletionItem[] | null {
752
+ const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
753
+ const sub = (head: string, words: readonly string[], desc: (w: string) => string): CompletionItem[] =>
754
+ words.map((w) => ({ value: `${head} ${w}`, label: w, description: desc(w) }));
755
+
756
+ let pool: CompletionItem[];
757
+ if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
758
+ else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
759
+ pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`);
760
+ else if (/^(report|chart) /.test(text)) {
761
+ const head = text.split(" ")[0];
762
+ pool = sub(head, REPORT_VIEWS, (w) => `${head} for ${w}`);
763
+ } else pool = TOP_LEVEL;
764
+
765
+ const out = pool.filter((i) => i.value.startsWith(text));
766
+ return out.length > 0 ? out : null;
767
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension for live token usage monitoring and budget tracking (Day / Week / Month / Total). 0 prompt tokens.",
5
5
  "keywords": ["pi-package", "pi-extension", "tokenburn", "tokens", "analytics", "budget", "stats"],
6
6
  "license": "MIT",
@@ -11,6 +11,20 @@
11
11
  },
12
12
  "homepage": "https://github.com/sorinirimies/pi-tokenburn#readme",
13
13
  "files": ["extensions", "README.md", "LICENSE"],
14
- "pi": { "extensions": ["./extensions/tokenburn.ts"] },
15
- "peerDependencies": { "@earendil-works/pi-coding-agent": "*" }
14
+ "pi": {
15
+ "extensions": ["./extensions/tokenburn.ts"]
16
+ },
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "typecheck": "tsc --noEmit",
20
+ "check": "nu scripts/ci/quality_gate.nu"
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*"
24
+ },
25
+ "devDependencies": {
26
+ "@earendil-works/pi-coding-agent": "^1.1.0",
27
+ "@types/bun": "^1.4.2",
28
+ "typescript": "^7.0.2"
29
+ }
16
30
  }