pi-tokenburn 0.3.0 → 0.4.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
- - **Views:** `/tokenburn day|week|month|year|all`. Status bar period: `/tokenburn status <window>` or `/tokenburn cycle`.
13
+ - **Status line:** `/tokenburn day|week|month|year|all` (or `cycle`) changes the period shown at the bottom. Charts live under `/tokenburn chart <view>`.
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,22 +27,19 @@ pi install git:github.com/sorinirimies/pi-tokenburn
27
27
 
28
28
  | Command | What it does |
29
29
  |---|---|
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 |
30
+ | `/tokenburn day\|week\|month\|year\|all` | **Switch the bottom status line**: today, this week, this month, this year, or the all-time total |
31
+ | `/tokenburn chart <view>` | Report + **chart**. Views: `day` (14 days), `week` (8 weeks), `month` (6 months), `year` (5 years), `all` (whole history; monthly columns up to 18 months, yearly beyond) |
32
+ | `/tokenburn` | Report + daily chart (same as `/tokenburn chart day`) |
33
+ | `/tokenburn cycle` | Rotate the status line: today → week → month → year → total |
34
+ | `/tokenburn status <today\|week\|month\|year\|total>` | Explicit form of the first row |
38
35
  | `/tokenburn window` | Toggle the panel below the editor |
39
36
  | `/tokenburn budget <day\|week\|month> <tokens>` | Token budget, e.g. `budget day 5000000` |
40
37
  | `/tokenburn budget <day\|week\|month> $<cost>` | Dollar budget, e.g. `budget week $50` |
41
38
  | `/tokenburn cache` | Toggle counting cache read/write tokens |
42
39
 
43
- Tab-completion works for all of the above. Unknown input prints usage instead of failing.
40
+ Tab-completion works for all of these. Unknown input prints usage instead of failing. `report <view>` is kept as an alias of `chart <view>`.
44
41
 
45
- Chart example (`/tokenburn`, with a 600M day budget):
42
+ Chart example (`/tokenburn chart day`, with a 600M day budget):
46
43
 
47
44
  ```
48
45
  Daily tokens · last 14 days ┄ budget 600.00M
@@ -81,14 +78,23 @@ Weeks start on Monday, in local time.
81
78
 
82
79
  Environment overrides for the session directory: `TOKENBURN_PI_SESSIONS`, `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`.
83
80
 
81
+ ## Security notes
82
+
83
+ - **Local and read-only.** It reads pi's session logs and its own `tokenburn.json`. Nothing leaves your machine: no network, no telemetry, no shell commands, **zero runtime dependencies**.
84
+ - **Never breaks pi.** Stats refreshes are fire-and-forget and swallow their own errors, so a failure here cannot interrupt a turn or a session start.
85
+ - **Hand-edited config is sanitised.** Unknown or non-numeric budget values are dropped.
86
+
84
87
  ## Development
85
88
 
86
89
  ```bash
87
90
  bun install
88
91
  just check # typecheck + tests + pack check + nushell tests (what CI runs)
89
- just test # bun test only
92
+ just test # bun test only (with coverage)
93
+ just coverage # tests + a coverage table
90
94
  ```
91
95
 
96
+ **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`.
97
+
92
98
  CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
93
99
  `just --list` shows every task.
94
100
 
@@ -78,18 +78,24 @@ const DEFAULT_CONFIG: TokenBurnConfig = {
78
78
  budgets: {},
79
79
  };
80
80
 
81
- function getConfigDir(): string {
82
- if (process.env.PI_CODING_AGENT_DIR) return process.env.PI_CODING_AGENT_DIR;
83
- if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "pi", "agent");
84
- return join(homedir(), ".pi", "agent");
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");
85
86
  }
86
87
 
87
- function getSessionsDir(): string {
88
- if (process.env.TOKENBURN_PI_SESSIONS) return process.env.TOKENBURN_PI_SESSIONS;
89
- if (process.env.PI_CODING_AGENT_SESSION_DIR) return process.env.PI_CODING_AGENT_SESSION_DIR;
90
- return join(getConfigDir(), "sessions");
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");
91
93
  }
92
94
 
95
+ const getConfigDir = () => resolveAgentDir(process.env, homedir());
96
+
97
+ const getSessionsDir = () => resolveSessionsDir(process.env, homedir());
98
+
93
99
  export async function loadConfig(): Promise<TokenBurnConfig> {
94
100
  try {
95
101
  const raw = await readFile(join(getConfigDir(), "tokenburn.json"), "utf8");
@@ -102,13 +108,26 @@ export async function loadConfig(): Promise<TokenBurnConfig> {
102
108
  statusWindow: (STATUS_WINDOWS as readonly string[]).includes(parsed.statusWindow)
103
109
  ? parsed.statusWindow
104
110
  : DEFAULT_CONFIG.statusWindow,
105
- budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? { ...parsed.budgets } : {},
111
+ budgets: sanitizeBudgets(parsed.budgets),
106
112
  };
107
113
  } catch {
108
114
  return freshConfig();
109
115
  }
110
116
  }
111
117
 
118
+ const BUDGET_KEYS = ["dayTokens", "weekTokens", "monthTokens", "dayCost", "weekCost", "monthCost"] as const;
119
+
120
+ /** Keep only known budget keys holding positive, finite numbers (the file is hand-editable). */
121
+ export function sanitizeBudgets(value: unknown): TokenBurnConfig["budgets"] {
122
+ const out: TokenBurnConfig["budgets"] = {};
123
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
124
+ for (const key of BUDGET_KEYS) {
125
+ const n = (value as Record<string, unknown>)[key];
126
+ if (typeof n === "number" && Number.isFinite(n) && n > 0) out[key] = n;
127
+ }
128
+ return out;
129
+ }
130
+
112
131
  /** A config that shares no mutable state with DEFAULT_CONFIG. */
113
132
  export function freshConfig(): TokenBurnConfig {
114
133
  return { ...DEFAULT_CONFIG, budgets: {} };
@@ -172,6 +191,16 @@ async function parseFile(file: string): Promise<Turn[]> {
172
191
  return turns;
173
192
  }
174
193
 
194
+ /** Cache key for a file: mtime + size. `undefined` when it cannot be stat'ed. */
195
+ export function fileSignature(file: string): string | undefined {
196
+ try {
197
+ const st = statSync(file);
198
+ return `${st.mtimeMs}:${st.size}`;
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ }
203
+
175
204
  function listSessionFiles(root: string): string[] {
176
205
  const files: string[] = [];
177
206
  const walk = (dir: string) => {
@@ -209,13 +238,8 @@ async function loadAll(sessionsRoot: string): Promise<Turn[][]> {
209
238
 
210
239
  const out: Turn[][] = [];
211
240
  for (const file of files) {
212
- let sig: string;
213
- try {
214
- const st = statSync(file);
215
- sig = `${st.mtimeMs}:${st.size}`;
216
- } catch {
217
- continue;
218
- }
241
+ const sig = fileSignature(file);
242
+ if (sig === undefined) continue; // vanished between listing and stat
219
243
  let entry = FILE_CACHE.get(file);
220
244
  if (!entry || entry.sig !== sig) {
221
245
  entry = { sig, turns: await parseFile(file) };
@@ -560,7 +584,8 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
560
584
 
561
585
  pi.on("session_start", async (_event, ctx) => {
562
586
  config = await loadConfig();
563
- await updateUI(ctx);
587
+ // A failing refresh must never break session start.
588
+ await updateUI(ctx).catch(() => {});
564
589
  });
565
590
 
566
591
  // Fire-and-forget: never block the agent loop on a stats refresh.
@@ -569,7 +594,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
569
594
  });
570
595
 
571
596
  pi.registerCommand("tokenburn", {
572
- description: "TokenBurn: /tokenburn [day|week|month|year|all] chart, status <window>, cycle, window, budget, cache",
597
+ description: "TokenBurn: /tokenburn [day|week|month|year|all] sets the status bar; /tokenburn chart <day|week|month|year|all> shows the chart",
573
598
  getArgumentCompletions: (prefix: string) => completions(prefix),
574
599
  handler: async (args, ctx) => {
575
600
  const cmd = parseCommand(args);
@@ -655,7 +680,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
655
680
  rule,
656
681
  ...chart,
657
682
  rule,
658
- "/tokenburn day|week|month|year|all · status <window> · cycle · window · budget · cache",
683
+ "/tokenburn chart <day|week|month|year|all> · /tokenburn <day|week|month|year|all> sets the status bar · cycle · window · budget · cache",
659
684
  ].join("\n"),
660
685
  "info",
661
686
  );
@@ -684,24 +709,37 @@ export type Command =
684
709
  | { kind: "invalid"; message: string };
685
710
 
686
711
  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";
712
+ "Usage: /tokenburn [day|week|month|year|all] (status-bar period) · chart [day|week|month|year|all] · status <today|week|month|year|total> · cycle · window · budget <day|week|month> <tokens|$cost> · cache";
713
+
714
+ /** Words that pick the status-bar period: `day` shows today, `all` shows the all-time total. */
715
+ const WORD_TO_WINDOW: Record<string, Window> = {
716
+ day: "today",
717
+ today: "today",
718
+ week: "week",
719
+ month: "month",
720
+ year: "year",
721
+ all: "total",
722
+ total: "total",
723
+ };
688
724
 
725
+ /** Views the chart can show. */
689
726
  const asView = (w: string | undefined): ReportView | undefined =>
690
- w === "today" ? "day" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
727
+ w === "today" ? "day" : w === "total" ? "all" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
691
728
 
692
729
  export function parseCommand(args: string | undefined): Command {
693
730
  const [cmd, p1, p2] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
694
731
  if (!cmd) return { kind: "report", view: "day" };
695
732
 
696
- const view = asView(cmd);
697
- if (view) return { kind: "report", view };
733
+ // /tokenburn day|week|month|year|all → what the bottom status line shows
734
+ const window = WORD_TO_WINDOW[cmd];
735
+ if (window) return { kind: "status", window };
698
736
 
699
737
  switch (cmd) {
700
- case "report":
701
- case "chart": {
738
+ case "chart":
739
+ case "report": {
702
740
  if (p1 === undefined) return { kind: "report", view: "day" };
703
741
  const v = asView(p1);
704
- return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown view "${p1}". ${USAGE}` };
742
+ return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown chart "${p1}". ${USAGE}` };
705
743
  }
706
744
  case "status":
707
745
  return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
@@ -734,13 +772,22 @@ export interface CompletionItem {
734
772
  description?: string;
735
773
  }
736
774
 
775
+ const CHART_SPAN: Record<ReportView, string> = {
776
+ day: "last 14 days",
777
+ week: "last 8 weeks",
778
+ month: "last 6 months",
779
+ year: "last 5 years",
780
+ all: "whole history",
781
+ };
782
+
737
783
  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" },
784
+ { value: "day", label: "day", description: "Status bar: today" },
785
+ { value: "week", label: "week", description: "Status bar: this week" },
786
+ { value: "month", label: "month", description: "Status bar: this month" },
787
+ { value: "year", label: "year", description: "Status bar: this year" },
788
+ { value: "all", label: "all", description: "Status bar: all-time total" },
789
+ { value: "chart ", label: "chart", description: "Report + chart: day, week, month, year or all" },
790
+ { value: "status ", label: "status", description: "Set the status-bar period explicitly" },
744
791
  { value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
745
792
  { value: "window", label: "window", description: "Toggle the panel below the editor" },
746
793
  { value: "budget ", label: "budget", description: "Set a day/week/month budget" },
@@ -750,16 +797,17 @@ const TOP_LEVEL: CompletionItem[] = [
750
797
  /** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
751
798
  export function completions(prefix: string): CompletionItem[] | null {
752
799
  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) }));
800
+ // `tail` = " " when the completed text still needs another word (a budget amount).
801
+ const sub = (head: string, words: readonly string[], desc: (w: string) => string, tail = ""): CompletionItem[] =>
802
+ words.map((w) => ({ value: `${head} ${w}${tail}`, label: w, description: desc(w) }));
755
803
 
756
804
  let pool: CompletionItem[];
757
805
  if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
758
806
  else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
759
- pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`);
807
+ pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`, " ");
760
808
  else if (/^(report|chart) /.test(text)) {
761
809
  const head = text.split(" ")[0];
762
- pool = sub(head, REPORT_VIEWS, (w) => `${head} for ${w}`);
810
+ pool = sub(head, REPORT_VIEWS, (w) => `Chart: ${CHART_SPAN[w as ReportView]}`);
763
811
  } else pool = TOP_LEVEL;
764
812
 
765
813
  const out = pool.filter((i) => i.value.startsWith(text));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",