pi-tokenburn 0.2.0 → 0.3.1
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 +37 -7
- package/extensions/tokenburn.ts +263 -105
- package/package.json +17 -3
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
|
-
- **
|
|
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`
|
|
31
|
-
| `/tokenburn
|
|
32
|
-
| `/tokenburn
|
|
33
|
-
| `/tokenburn
|
|
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,36 @@ 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
|
-
##
|
|
84
|
+
## Development
|
|
79
85
|
|
|
80
86
|
```bash
|
|
81
|
-
bun
|
|
87
|
+
bun install
|
|
88
|
+
just check # typecheck + tests + pack check + nushell tests (what CI runs)
|
|
89
|
+
just test # bun test only (with coverage)
|
|
90
|
+
just coverage # tests + a coverage table
|
|
82
91
|
```
|
|
83
92
|
|
|
93
|
+
**Coverage:** every `bun test` collects coverage (`bunfig.toml`) and **fails below 95% lines / 95% functions**. CI shows the table on the run page and uploads `lcov.info`.
|
|
94
|
+
|
|
95
|
+
CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
|
|
96
|
+
`just --list` shows every task.
|
|
97
|
+
|
|
98
|
+
## Releases (automatic)
|
|
99
|
+
|
|
100
|
+
| Workflow | When | What |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| **CI** | push / PR | quality gate on Linux; tests on macOS, Windows and extreme time zones |
|
|
103
|
+
| **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 |
|
|
104
|
+
| **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent), creates the release |
|
|
105
|
+
|
|
106
|
+
Dev-tooling-only bumps are committed but never released on their own. A downgrade blocks the whole update.
|
|
107
|
+
|
|
108
|
+
Manual release: `just bump patch` (or `minor` / `major` / `X.Y.Z`), then `just release-push`.
|
|
109
|
+
|
|
110
|
+
**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).
|
|
111
|
+
|
|
112
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
|
|
113
|
+
|
|
84
114
|
## Scope
|
|
85
115
|
|
|
86
116
|
Pi sessions only. For Zed, Claude Code, Codex, Gemini and more, use the Rust [tokenburn](https://github.com/sorinirimies/tokenburn).
|
package/extensions/tokenburn.ts
CHANGED
|
@@ -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:
|
|
61
|
+
statusWindow: Window;
|
|
58
62
|
budgets: {
|
|
59
63
|
dayTokens?: number;
|
|
60
64
|
weekTokens?: number;
|
|
@@ -74,18 +78,24 @@ const DEFAULT_CONFIG: TokenBurnConfig = {
|
|
|
74
78
|
budgets: {},
|
|
75
79
|
};
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
return join(
|
|
81
|
+
/** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
|
|
82
|
+
export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
|
|
83
|
+
if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
|
|
84
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
|
|
85
|
+
return join(home, ".pi", "agent");
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
88
|
+
/** Pi session logs: TOKENBURN_PI_SESSIONS, PI_CODING_AGENT_SESSION_DIR, then <agent dir>/sessions. */
|
|
89
|
+
export function resolveSessionsDir(env: Record<string, string | undefined>, home: string): string {
|
|
90
|
+
if (env.TOKENBURN_PI_SESSIONS) return env.TOKENBURN_PI_SESSIONS;
|
|
91
|
+
if (env.PI_CODING_AGENT_SESSION_DIR) return env.PI_CODING_AGENT_SESSION_DIR;
|
|
92
|
+
return join(resolveAgentDir(env, home), "sessions");
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
const getConfigDir = () => resolveAgentDir(process.env, homedir());
|
|
96
|
+
|
|
97
|
+
const getSessionsDir = () => resolveSessionsDir(process.env, homedir());
|
|
98
|
+
|
|
89
99
|
export async function loadConfig(): Promise<TokenBurnConfig> {
|
|
90
100
|
try {
|
|
91
101
|
const raw = await readFile(join(getConfigDir(), "tokenburn.json"), "utf8");
|
|
@@ -95,16 +105,21 @@ export async function loadConfig(): Promise<TokenBurnConfig> {
|
|
|
95
105
|
chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
|
|
96
106
|
showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
|
|
97
107
|
showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
|
|
98
|
-
statusWindow:
|
|
108
|
+
statusWindow: (STATUS_WINDOWS as readonly string[]).includes(parsed.statusWindow)
|
|
99
109
|
? parsed.statusWindow
|
|
100
110
|
: DEFAULT_CONFIG.statusWindow,
|
|
101
|
-
budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? parsed.budgets : {},
|
|
111
|
+
budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? { ...parsed.budgets } : {},
|
|
102
112
|
};
|
|
103
113
|
} catch {
|
|
104
|
-
return
|
|
114
|
+
return freshConfig();
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
/** A config that shares no mutable state with DEFAULT_CONFIG. */
|
|
119
|
+
export function freshConfig(): TokenBurnConfig {
|
|
120
|
+
return { ...DEFAULT_CONFIG, budgets: {} };
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
|
|
109
124
|
await mkdir(getConfigDir(), { recursive: true });
|
|
110
125
|
await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
@@ -163,6 +178,16 @@ async function parseFile(file: string): Promise<Turn[]> {
|
|
|
163
178
|
return turns;
|
|
164
179
|
}
|
|
165
180
|
|
|
181
|
+
/** Cache key for a file: mtime + size. `undefined` when it cannot be stat'ed. */
|
|
182
|
+
export function fileSignature(file: string): string | undefined {
|
|
183
|
+
try {
|
|
184
|
+
const st = statSync(file);
|
|
185
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
166
191
|
function listSessionFiles(root: string): string[] {
|
|
167
192
|
const files: string[] = [];
|
|
168
193
|
const walk = (dir: string) => {
|
|
@@ -200,13 +225,8 @@ async function loadAll(sessionsRoot: string): Promise<Turn[][]> {
|
|
|
200
225
|
|
|
201
226
|
const out: Turn[][] = [];
|
|
202
227
|
for (const file of files) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const st = statSync(file);
|
|
206
|
-
sig = `${st.mtimeMs}:${st.size}`;
|
|
207
|
-
} catch {
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
228
|
+
const sig = fileSignature(file);
|
|
229
|
+
if (sig === undefined) continue; // vanished between listing and stat
|
|
210
230
|
let entry = FILE_CACHE.get(file);
|
|
211
231
|
if (!entry || entry.sig !== sig) {
|
|
212
232
|
entry = { sig, turns: await parseFile(file) };
|
|
@@ -228,11 +248,13 @@ export async function collectUsage(
|
|
|
228
248
|
const startOfDay = bucketStart(now.getTime(), "day");
|
|
229
249
|
const startOfWeek = bucketStart(now.getTime(), "week");
|
|
230
250
|
const startOfMonth = bucketStart(now.getTime(), "month");
|
|
251
|
+
const startOfYear = bucketStart(now.getTime(), "year");
|
|
231
252
|
|
|
232
253
|
const report: TokenBurnReport = {
|
|
233
254
|
today: emptyStats(),
|
|
234
255
|
week: emptyStats(),
|
|
235
256
|
month: emptyStats(),
|
|
257
|
+
year: emptyStats(),
|
|
236
258
|
total: emptyStats(),
|
|
237
259
|
};
|
|
238
260
|
|
|
@@ -250,11 +272,13 @@ export async function collectUsage(
|
|
|
250
272
|
for (const t of turns) {
|
|
251
273
|
if (t.ts > maxTs) maxTs = t.ts;
|
|
252
274
|
add(report.total, t);
|
|
275
|
+
if (t.ts >= startOfYear) add(report.year, t);
|
|
253
276
|
if (t.ts >= startOfMonth) add(report.month, t);
|
|
254
277
|
if (t.ts >= startOfWeek) add(report.week, t);
|
|
255
278
|
if (t.ts >= startOfDay) add(report.today, t);
|
|
256
279
|
}
|
|
257
280
|
if (turns.length > 0) report.total.sessions++;
|
|
281
|
+
if (maxTs >= startOfYear) report.year.sessions++;
|
|
258
282
|
if (maxTs >= startOfMonth) report.month.sessions++;
|
|
259
283
|
if (maxTs >= startOfWeek) report.week.sessions++;
|
|
260
284
|
if (maxTs >= startOfDay) report.today.sessions++;
|
|
@@ -267,7 +291,7 @@ export async function collectUsage(
|
|
|
267
291
|
// Time series + chart
|
|
268
292
|
// ---------------------------------------------------------------------------
|
|
269
293
|
|
|
270
|
-
export type Unit = "day" | "week" | "month";
|
|
294
|
+
export type Unit = "day" | "week" | "month" | "year";
|
|
271
295
|
|
|
272
296
|
/** Local-time start of the day / Monday-week / month containing `ts`. */
|
|
273
297
|
export function bucketStart(ts: number, unit: Unit): number {
|
|
@@ -277,6 +301,7 @@ export function bucketStart(ts: number, unit: Unit): number {
|
|
|
277
301
|
const dow = (d.getDay() + 6) % 7; // Monday = 0
|
|
278
302
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
|
|
279
303
|
}
|
|
304
|
+
if (unit === "year") return new Date(d.getFullYear(), 0, 1).getTime();
|
|
280
305
|
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
281
306
|
}
|
|
282
307
|
|
|
@@ -284,6 +309,7 @@ const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
|
284
309
|
|
|
285
310
|
export function bucketLabel(start: number, unit: Unit): string {
|
|
286
311
|
const d = new Date(start);
|
|
312
|
+
if (unit === "year") return String(d.getFullYear());
|
|
287
313
|
if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
|
|
288
314
|
const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
289
315
|
return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
|
|
@@ -296,7 +322,7 @@ export interface SeriesPoint {
|
|
|
296
322
|
cost: number;
|
|
297
323
|
}
|
|
298
324
|
|
|
299
|
-
export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6 };
|
|
325
|
+
export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6, year: 5 };
|
|
300
326
|
|
|
301
327
|
/** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
|
|
302
328
|
export async function collectSeries(
|
|
@@ -316,7 +342,9 @@ export async function collectSeries(
|
|
|
316
342
|
? new Date(y, m, day - i)
|
|
317
343
|
: unit === "week"
|
|
318
344
|
? new Date(y, m, day - i * 7)
|
|
319
|
-
:
|
|
345
|
+
: unit === "year"
|
|
346
|
+
? new Date(y - i, 0, 1)
|
|
347
|
+
: new Date(y, m - i, 1);
|
|
320
348
|
starts.push(bucketStart(anchor.getTime(), unit));
|
|
321
349
|
}
|
|
322
350
|
const points = new Map<number, SeriesPoint>(
|
|
@@ -333,6 +361,27 @@ export async function collectSeries(
|
|
|
333
361
|
return starts.map((s) => points.get(s)!);
|
|
334
362
|
}
|
|
335
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Whole history: monthly columns while it spans <= 18 months, yearly after that.
|
|
366
|
+
* Always at least 2 columns so the chart has a shape.
|
|
367
|
+
*/
|
|
368
|
+
export async function collectAllSeries(
|
|
369
|
+
sessionsRoot: string,
|
|
370
|
+
now: Date = new Date(),
|
|
371
|
+
includeCache = true,
|
|
372
|
+
): Promise<{ unit: Unit; series: SeriesPoint[] }> {
|
|
373
|
+
let first = now.getTime();
|
|
374
|
+
for (const turns of await loadAll(sessionsRoot)) for (const t of turns) if (t.ts < first) first = t.ts;
|
|
375
|
+
const f = new Date(first);
|
|
376
|
+
const months = (now.getFullYear() - f.getFullYear()) * 12 + (now.getMonth() - f.getMonth()) + 1;
|
|
377
|
+
if (months <= 18) {
|
|
378
|
+
const unit: Unit = "month";
|
|
379
|
+
return { unit, series: await collectSeries(sessionsRoot, unit, Math.max(2, months), now, includeCache) };
|
|
380
|
+
}
|
|
381
|
+
const years = now.getFullYear() - f.getFullYear() + 1;
|
|
382
|
+
return { unit: "year", series: await collectSeries(sessionsRoot, "year", years, now, includeCache) };
|
|
383
|
+
}
|
|
384
|
+
|
|
336
385
|
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
337
386
|
const SPARK = "▁▂▃▄▅▆▇█";
|
|
338
387
|
|
|
@@ -350,8 +399,8 @@ export function sparkline(values: number[]): string {
|
|
|
350
399
|
return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
|
|
351
400
|
}
|
|
352
401
|
|
|
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" };
|
|
402
|
+
const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" };
|
|
403
|
+
const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month", year: "year" };
|
|
355
404
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
356
405
|
|
|
357
406
|
export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
|
|
@@ -362,6 +411,7 @@ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
|
|
|
362
411
|
const d = new Date(p.start);
|
|
363
412
|
if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
|
|
364
413
|
if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
|
|
414
|
+
if (unit === "year") return [String(d.getFullYear()), ""];
|
|
365
415
|
return [MONTHS[d.getMonth()], String(d.getFullYear())];
|
|
366
416
|
}
|
|
367
417
|
|
|
@@ -372,7 +422,7 @@ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
|
|
|
372
422
|
export function renderChart(
|
|
373
423
|
series: SeriesPoint[],
|
|
374
424
|
unit: Unit,
|
|
375
|
-
opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint } = {},
|
|
425
|
+
opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint; title?: string } = {},
|
|
376
426
|
): string[] {
|
|
377
427
|
const paint = opts.paint ?? plain;
|
|
378
428
|
const height = opts.height ?? 8;
|
|
@@ -398,8 +448,8 @@ export function renderChart(
|
|
|
398
448
|
|
|
399
449
|
const lines: string[] = [];
|
|
400
450
|
lines.push(
|
|
401
|
-
paint("title", `${UNIT_TITLE[unit]} tokens`) +
|
|
402
|
-
paint("dim", ` · last ${series.length} ${UNIT_NOUN[unit]}s`) +
|
|
451
|
+
paint("title", opts.title ?? `${UNIT_TITLE[unit]} tokens`) +
|
|
452
|
+
paint("dim", ` · ${opts.title ? "" : "last "}${series.length} ${UNIT_NOUN[unit]}${series.length === 1 ? "" : "s"}`) +
|
|
403
453
|
(budgetTokens ? paint("budget", ` ┄ budget ${formatNum(budgetTokens)}`) : ""),
|
|
404
454
|
);
|
|
405
455
|
|
|
@@ -443,8 +493,6 @@ export function clearCache(): void {
|
|
|
443
493
|
FILE_CACHE.clear();
|
|
444
494
|
}
|
|
445
495
|
|
|
446
|
-
export type Window = "today" | "week" | "month" | "total";
|
|
447
|
-
|
|
448
496
|
export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
|
|
449
497
|
const b = cfg.budgets;
|
|
450
498
|
if (w === "today") return { tokens: b.dayTokens, cost: b.dayCost };
|
|
@@ -472,7 +520,7 @@ export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint
|
|
|
472
520
|
}
|
|
473
521
|
|
|
474
522
|
export default function tokenBurnExtension(pi: ExtensionAPI) {
|
|
475
|
-
let config: TokenBurnConfig =
|
|
523
|
+
let config: TokenBurnConfig = freshConfig();
|
|
476
524
|
|
|
477
525
|
const updateUI = async (ctx: ExtensionContext) => {
|
|
478
526
|
if (!ctx.hasUI) return;
|
|
@@ -510,6 +558,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
|
|
|
510
558
|
line("Today", report.today, budgetsFor(config, "today")),
|
|
511
559
|
line("Week", report.week, budgetsFor(config, "week")),
|
|
512
560
|
line("Month", report.month, budgetsFor(config, "month")),
|
|
561
|
+
line("Year", report.year),
|
|
513
562
|
line("Total", report.total),
|
|
514
563
|
`└${"─".repeat(46)}┘`,
|
|
515
564
|
];
|
|
@@ -531,91 +580,200 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
|
|
|
531
580
|
});
|
|
532
581
|
|
|
533
582
|
pi.registerCommand("tokenburn", {
|
|
534
|
-
description: "TokenBurn
|
|
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
|
-
},
|
|
583
|
+
description: "TokenBurn: /tokenburn [day|week|month|year|all] chart, status <window>, cycle, window, budget, cache",
|
|
584
|
+
getArgumentCompletions: (prefix: string) => completions(prefix),
|
|
539
585
|
handler: async (args, ctx) => {
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
if (cmd === "window" || cmd === "widget") {
|
|
543
|
-
config.showWidget = !config.showWidget;
|
|
586
|
+
const cmd = parseCommand(args);
|
|
587
|
+
const persist = async () => {
|
|
544
588
|
await saveConfig(config);
|
|
545
589
|
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
590
|
};
|
|
557
591
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
}
|
|
592
|
+
switch (cmd.kind) {
|
|
593
|
+
case "window":
|
|
594
|
+
config.showWidget = !config.showWidget;
|
|
595
|
+
await persist();
|
|
596
|
+
ctx.ui.notify(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`, "info");
|
|
597
|
+
return;
|
|
572
598
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
599
|
+
case "status":
|
|
600
|
+
config.statusWindow = cmd.window;
|
|
601
|
+
await persist();
|
|
602
|
+
ctx.ui.notify(`tokenburn status window: ${cmd.window}`, "info");
|
|
603
|
+
return;
|
|
580
604
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
ctx.ui.notify(
|
|
605
|
+
case "cycle": {
|
|
606
|
+
const next = STATUS_WINDOWS[(STATUS_WINDOWS.indexOf(config.statusWindow) + 1) % STATUS_WINDOWS.length];
|
|
607
|
+
config.statusWindow = next;
|
|
608
|
+
await persist();
|
|
609
|
+
ctx.ui.notify(`tokenburn status window: ${next}`, "info");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
case "cache":
|
|
614
|
+
config.includeCache = !config.includeCache;
|
|
615
|
+
await persist();
|
|
616
|
+
ctx.ui.notify(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`, "info");
|
|
617
|
+
return;
|
|
618
|
+
|
|
619
|
+
case "budget": {
|
|
620
|
+
const key = `${cmd.period}${cmd.isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
|
|
621
|
+
config.budgets[key] = cmd.amount;
|
|
622
|
+
await persist();
|
|
623
|
+
ctx.ui.notify(
|
|
624
|
+
`tokenburn ${cmd.period} budget: ${cmd.isCost ? formatCost(cmd.amount) : formatNum(cmd.amount) + " tokens"}`,
|
|
625
|
+
"info",
|
|
626
|
+
);
|
|
586
627
|
return;
|
|
587
628
|
}
|
|
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
629
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
630
|
+
case "invalid":
|
|
631
|
+
ctx.ui.notify(cmd.message, "warning");
|
|
632
|
+
return;
|
|
633
|
+
|
|
634
|
+
case "report": {
|
|
635
|
+
const root = getSessionsDir();
|
|
636
|
+
const now = new Date();
|
|
637
|
+
const report = await collectUsage(root, now, config.includeCache);
|
|
638
|
+
const paint = config.chartColor ? themePaint(ctx.ui.theme) : undefined;
|
|
639
|
+
|
|
640
|
+
let chart: string[];
|
|
641
|
+
if (cmd.view === "all") {
|
|
642
|
+
const all = await collectAllSeries(root, now, config.includeCache);
|
|
643
|
+
chart = renderChart(all.series, all.unit, { paint, title: "All time" });
|
|
644
|
+
} else {
|
|
645
|
+
const series = await collectSeries(root, cmd.view, DEFAULT_COUNT[cmd.view], now, config.includeCache);
|
|
646
|
+
const budgetWindow: Window | undefined =
|
|
647
|
+
cmd.view === "day" ? "today" : cmd.view === "year" ? undefined : cmd.view;
|
|
648
|
+
chart = renderChart(series, cmd.view, {
|
|
649
|
+
paint,
|
|
650
|
+
budget: budgetWindow ? budgetsFor(config, budgetWindow) : undefined,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const row = (name: string, p: PeriodStats) =>
|
|
655
|
+
`${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`;
|
|
656
|
+
const rule = "─".repeat(60);
|
|
657
|
+
ctx.ui.notify(
|
|
658
|
+
[
|
|
659
|
+
"🔥 TokenBurn Report",
|
|
660
|
+
rule,
|
|
661
|
+
row("Today", report.today),
|
|
662
|
+
row("This Week", report.week),
|
|
663
|
+
row("This Month", report.month),
|
|
664
|
+
row("This Year", report.year),
|
|
665
|
+
row("All Time", report.total),
|
|
666
|
+
rule,
|
|
667
|
+
...chart,
|
|
668
|
+
rule,
|
|
669
|
+
"/tokenburn day|week|month|year|all · status <window> · cycle · window · budget · cache",
|
|
670
|
+
].join("\n"),
|
|
671
|
+
"info",
|
|
672
|
+
);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
619
676
|
},
|
|
620
677
|
});
|
|
621
678
|
}
|
|
679
|
+
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
// Command parsing + completions (pure, unit-tested)
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
export const REPORT_VIEWS = ["day", "week", "month", "year", "all"] as const;
|
|
685
|
+
export type ReportView = (typeof REPORT_VIEWS)[number];
|
|
686
|
+
export const BUDGET_PERIODS = ["day", "week", "month"] as const;
|
|
687
|
+
|
|
688
|
+
export type Command =
|
|
689
|
+
| { kind: "report"; view: ReportView }
|
|
690
|
+
| { kind: "status"; window: Window }
|
|
691
|
+
| { kind: "cycle" }
|
|
692
|
+
| { kind: "window" }
|
|
693
|
+
| { kind: "cache" }
|
|
694
|
+
| { kind: "budget"; period: (typeof BUDGET_PERIODS)[number]; amount: number; isCost: boolean }
|
|
695
|
+
| { kind: "invalid"; message: string };
|
|
696
|
+
|
|
697
|
+
export const USAGE =
|
|
698
|
+
"Usage: /tokenburn [day|week|month|year|all] · status <today|week|month|year|total> · cycle · window · budget <day|week|month> <tokens|$cost> · cache";
|
|
699
|
+
|
|
700
|
+
const asView = (w: string | undefined): ReportView | undefined =>
|
|
701
|
+
w === "today" ? "day" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
|
|
702
|
+
|
|
703
|
+
export function parseCommand(args: string | undefined): Command {
|
|
704
|
+
const [cmd, p1, p2] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
705
|
+
if (!cmd) return { kind: "report", view: "day" };
|
|
706
|
+
|
|
707
|
+
const view = asView(cmd);
|
|
708
|
+
if (view) return { kind: "report", view };
|
|
709
|
+
|
|
710
|
+
switch (cmd) {
|
|
711
|
+
case "report":
|
|
712
|
+
case "chart": {
|
|
713
|
+
if (p1 === undefined) return { kind: "report", view: "day" };
|
|
714
|
+
const v = asView(p1);
|
|
715
|
+
return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown view "${p1}". ${USAGE}` };
|
|
716
|
+
}
|
|
717
|
+
case "status":
|
|
718
|
+
return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
|
|
719
|
+
? { kind: "status", window: p1 as Window }
|
|
720
|
+
: { kind: "invalid", message: `Usage: /tokenburn status <${STATUS_WINDOWS.join("|")}>` };
|
|
721
|
+
case "cycle":
|
|
722
|
+
return { kind: "cycle" };
|
|
723
|
+
case "window":
|
|
724
|
+
case "widget":
|
|
725
|
+
return { kind: "window" };
|
|
726
|
+
case "cache":
|
|
727
|
+
return { kind: "cache" };
|
|
728
|
+
case "budget": {
|
|
729
|
+
const isCost = (p2 ?? "").startsWith("$");
|
|
730
|
+
const amount = Number((p2 ?? "").replace(/^\$/, ""));
|
|
731
|
+
if (!(BUDGET_PERIODS as readonly string[]).includes(p1 ?? "") || !Number.isFinite(amount) || amount <= 0) {
|
|
732
|
+
return { kind: "invalid", message: "Usage: /tokenburn budget <day|week|month> <tokens | $cost>" };
|
|
733
|
+
}
|
|
734
|
+
return { kind: "budget", period: p1 as (typeof BUDGET_PERIODS)[number], amount, isCost };
|
|
735
|
+
}
|
|
736
|
+
default:
|
|
737
|
+
return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export interface CompletionItem {
|
|
742
|
+
value: string;
|
|
743
|
+
/** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
|
|
744
|
+
label: string;
|
|
745
|
+
description?: string;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const TOP_LEVEL: CompletionItem[] = [
|
|
749
|
+
{ value: "day", label: "day", description: "Report + daily chart (last 14 days)" },
|
|
750
|
+
{ value: "week", label: "week", description: "Report + weekly chart (last 8 weeks)" },
|
|
751
|
+
{ value: "month", label: "month", description: "Report + monthly chart (last 6 months)" },
|
|
752
|
+
{ value: "year", label: "year", description: "Report + yearly chart (last 5 years)" },
|
|
753
|
+
{ value: "all", label: "all", description: "Report + whole history" },
|
|
754
|
+
{ value: "status ", label: "status", description: "Set the status-bar period" },
|
|
755
|
+
{ value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
|
|
756
|
+
{ value: "window", label: "window", description: "Toggle the panel below the editor" },
|
|
757
|
+
{ value: "budget ", label: "budget", description: "Set a day/week/month budget" },
|
|
758
|
+
{ value: "cache", label: "cache", description: "Toggle counting cache tokens" },
|
|
759
|
+
];
|
|
760
|
+
|
|
761
|
+
/** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
|
|
762
|
+
export function completions(prefix: string): CompletionItem[] | null {
|
|
763
|
+
const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
|
|
764
|
+
// `tail` = " " when the completed text still needs another word (a budget amount).
|
|
765
|
+
const sub = (head: string, words: readonly string[], desc: (w: string) => string, tail = ""): CompletionItem[] =>
|
|
766
|
+
words.map((w) => ({ value: `${head} ${w}${tail}`, label: w, description: desc(w) }));
|
|
767
|
+
|
|
768
|
+
let pool: CompletionItem[];
|
|
769
|
+
if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
|
|
770
|
+
else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
|
|
771
|
+
pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`, " ");
|
|
772
|
+
else if (/^(report|chart) /.test(text)) {
|
|
773
|
+
const head = text.split(" ")[0];
|
|
774
|
+
pool = sub(head, REPORT_VIEWS, (w) => `${head} for ${w}`);
|
|
775
|
+
} else pool = TOP_LEVEL;
|
|
776
|
+
|
|
777
|
+
const out = pool.filter((i) => i.value.startsWith(text));
|
|
778
|
+
return out.length > 0 ? out : null;
|
|
779
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tokenburn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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": {
|
|
15
|
-
|
|
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
|
}
|