pi-tokenburn 0.6.0 → 0.7.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 CHANGED
@@ -232,15 +232,17 @@ CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones loc
232
232
  | Workflow | When | What |
233
233
  |---|---|---|
234
234
  | **CI** | push / PR | quality gate on Linux; tests on macOS and Windows, and in extreme time zones |
235
- | **Auto-merge library updates** | CI finished on a Dependabot PR | **patch and minor** updates (GitHub Actions) are merged automatically, but only **after CI is green** on that exact commit; a **major** update waits for you. Then it starts the nightly workflow so the update ships |
236
- | **Nightly Dependency Update** | every night (GitHub 02:00 UTC, Gitea 02:30) and after each auto-merge | `bun update` within ranges, verify on all platforms, commit `chore(deps)`; then **build, tag and publish a new patch** whenever a library was upgraded or merged, or `feat`/`fix`/`perf` commits are waiting since the last tag |
235
+ | **Auto-merge library updates** | CI finished on a Dependabot PR | **patch and minor** updates (GitHub Actions) are merged automatically, but only **after CI is green** on that exact commit; a **major** update waits for you. Needs the `GH_PAT` secret: action bumps change `.github/workflows/*`, which `GITHUB_TOKEN` may not merge |
236
+ | **Gitea Actions Update** | Mondays 03:15 UTC | Gitea has no Dependabot: moves the sha pins in `.gitea/workflows` to the newest release of the **same major** and commits `ci(deps)`; a new major is only reported |
237
+ | **Nightly Dependency Update** | every night (GitHub 02:00 UTC) and after each auto-merge | `bun update` within ranges, verify on all platforms, commit `chore(deps)`; then **build, tag and publish a new patch** whenever a library was upgraded or merged, or `feat`/`fix`/`perf` commits are waiting since the last tag |
238
+ | **Mirror GitHub** (Gitea) | hourly | Gitea has no nightly of its own: fast-forwards its `main` to GitHub's and pushes the new tags (starting Gitea's release for each), so the hosts never diverge; fails loudly if they did |
237
239
  | **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent, with provenance), creates the release |
238
240
 
239
241
  So library updates need no human: they are merged once CI passes, built, versioned and published as a patch. A downgrade, a failing check, or a major update stops the chain and waits for review.
240
242
 
241
243
  Manual release: `just release patch` (or `minor` / `major` / `X.Y.Z`) bumps, tags and pushes to GitHub; `just release-all patch` pushes to every configured remote; `just push-release-all` pushes an existing bump.
242
244
 
243
- **Secrets** (repo settings): `NPM_TOKEN` is an 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).
245
+ **Secrets** (repo settings): `NPM_TOKEN` is an npm *granular access token* with publish rights and "bypass 2FA" (required for CI publishing). `GH_PAT` (classic PAT, `repo` + `workflow`: needed to auto-merge GitHub Actions bumps; also lets a tag push trigger the release itself). Optional: `GITEA_TOKEN` (Gitea).
244
246
 
245
247
  Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
246
248
 
@@ -1,6 +1,6 @@
1
1
  /** Aggregation of parsed turns into period totals and time series. */
2
2
  import { loadAll, type Turn } from "./parser.ts";
3
- import { selectTurns, type UsageOptions } from "./select.ts";
3
+ import { cacheValue, selectTurns, type UsageOptions } from "./select.ts";
4
4
  import { bucketLabel, bucketStart, type Unit } from "./time.ts";
5
5
  import type { PeriodStats, TokenBurnReport } from "./types.ts";
6
6
 
@@ -56,10 +56,9 @@ export async function collectUsage(
56
56
  p.cacheRead += t.cacheRead;
57
57
  p.cacheWrite += t.cacheWrite;
58
58
  p.cost += t.cost;
59
- if (t.input > 0 && t.costInput > 0) {
60
- p.cacheBase += (t.cacheRead * t.costInput) / t.input;
61
- p.cacheActual += t.costCacheRead;
62
- }
59
+ const cv = cacheValue(t);
60
+ p.cacheBase += cv.base;
61
+ p.cacheActual += cv.actual;
63
62
  };
64
63
 
65
64
  for (const turns of await loadSelected(sessionsRoot, opts)) {
@@ -1,7 +1,7 @@
1
1
  /** Config file (tokenburn.json), path resolution, budgets. */
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
5
  import { sanitizeFilter, sanitizePrices, type PriceMode, type PriceTable, type UsageFilter } from "./select.ts";
6
6
  import { STATUS_WINDOWS, type PeriodStats, type Window } from "./types.ts";
7
7
 
@@ -140,8 +140,12 @@ export function freshConfig(): TokenBurnConfig {
140
140
  }
141
141
 
142
142
  export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
143
- await mkdir(getConfigDir(), { recursive: true });
144
- await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
143
+ const dir = getConfigDir();
144
+ await mkdir(dir, { recursive: true });
145
+ // Write beside, then rename: a crash or a second pi session never leaves half a file behind.
146
+ const tmp = join(dir, `tokenburn.json.${process.pid}.tmp`);
147
+ await writeFile(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf8");
148
+ await rename(tmp, join(dir, "tokenburn.json"));
145
149
  }
146
150
 
147
151
  export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
@@ -64,13 +64,13 @@ const GUARD_BYTES = 64;
64
64
  const HEAD_BYTES = 256;
65
65
  export const CHUNK_BYTES = 1 << 20;
66
66
 
67
- /** A Turn from one JSONL line, or undefined when it carries no usage. */
68
67
  /** A finite number, else 0 (logs can hold 1e999, strings, junk). */
69
68
  const finite = (v: unknown): number => {
70
69
  const n = Number(v);
71
70
  return Number.isFinite(n) ? n : 0;
72
71
  };
73
72
 
73
+ /** A Turn from one JSONL line, or undefined when it carries no usage. */
74
74
  function turnFromLine(line: Buffer, meta: Meta): Turn | undefined {
75
75
  if (meta.cwd === undefined && line.includes('"type":"session"')) {
76
76
  try {
@@ -103,3 +103,12 @@ export function selectTurns(turns: Turn[], opts: UsageOptions | undefined): Turn
103
103
  }
104
104
  return out;
105
105
  }
106
+
107
+ /**
108
+ * What the turn's cached tokens would have cost as plain input (`base`) and what they did cost
109
+ * (`actual`). Both 0 when the log has no input price for the turn (then no savings are claimed).
110
+ */
111
+ export function cacheValue(t: Turn): { base: number; actual: number } {
112
+ if (!(t.input > 0 && t.costInput > 0)) return { base: 0, actual: 0 };
113
+ return { base: (t.cacheRead * t.costInput) / t.input, actual: t.costCacheRead };
114
+ }
@@ -1,6 +1,6 @@
1
1
  /** Grouped usage queries: per model, provider, project and session, over a period. */
2
2
  import { HOUR_MS, loadSelected, turnTokens } from "../../core/aggregate.ts";
3
- import type { UsageOptions } from "../../core/select.ts";
3
+ import { cacheValue, type UsageOptions } from "../../core/select.ts";
4
4
  import { bucketStart } from "../../core/time.ts";
5
5
 
6
6
  export const PERIODS = ["hour", "day", "week", "month", "year", "all"] as const;
@@ -106,10 +106,9 @@ export async function collectGroups(
106
106
  r.turns++;
107
107
  if (t.ts < r.first) r.first = t.ts;
108
108
  if (t.ts > r.last) r.last = t.ts;
109
- if (t.input > 0 && t.costInput > 0) {
110
- r.cacheBase += (t.cacheRead * t.costInput) / t.input;
111
- r.cacheActual += t.costCacheRead;
112
- }
109
+ const cv = cacheValue(t);
110
+ r.cacheBase += cv.base;
111
+ r.cacheActual += cv.actual;
113
112
  if (!seen.has(key)) {
114
113
  seen.add(key);
115
114
  r.sessions++;
@@ -68,21 +68,28 @@ export function renderChart(
68
68
  if (h >= row) cell = "█";
69
69
  else if (h > row - 1 && series[i].tokens > 0) cell = SPARK[Math.min(7, Math.max(0, Math.floor((h - (row - 1)) * 8) - 1))];
70
70
  else if (row === 1 && series[i].tokens > 0) cell = "▁";
71
+ else if (row === 1 && budgetRow !== 1) cell = "·"; // an empty bucket, not a missing one
71
72
  else cell = row === budgetRow ? "┄" : " ";
72
- const filled = cell !== " " && cell !== "┄";
73
+ const filled = cell !== " " && cell !== "┄" && cell !== "·";
73
74
  const body = cell.repeat(barW);
74
- line += filled ? paint(kindOf(i), body) : cell === "┄" ? paint("budget", body) : body;
75
+ line += filled ? paint(kindOf(i), body) : cell === "┄" ? paint("budget", body) : cell === "·" ? paint("axis", body) : body;
75
76
  line += row === budgetRow ? paint("budget", "┄") : " ";
76
77
  }
77
78
  lines.push(line);
78
79
  }
79
80
 
80
- lines.push(paint("axis", `${"".padStart(yW)} └${"─".repeat(series.length * colW)}`));
81
+ lines.push(paint("axis", `${yLabels[2].padStart(yW)} └${"─".repeat(series.length * colW)}`));
81
82
  const pad = " ".repeat(yW + 2);
82
83
  const cols = series.map((p, i) => ({ l: columnLabels(p, unit), now: i === series.length - 1 }));
83
84
  lines.push(pad + cols.map((c) => c.l[0].padEnd(colW)).join(""));
84
85
  lines.push(paint("dim", pad + cols.map((c) => c.l[1].padEnd(colW)).join("")));
85
- lines.push(pad + cols.map((c) => (c.now ? paint("now", "▲".padEnd(colW)) : " ".repeat(colW))).join(""));
86
+ // The marker under the current bucket, with its value so you need not read it off the axis.
87
+ const latest = series[series.length - 1];
88
+ lines.push(
89
+ pad +
90
+ cols.map((c) => (c.now ? paint("now", "▲".padEnd(colW)) : " ".repeat(colW))).join("") +
91
+ (latest ? paint("now", `now ${formatNum(latest.tokens)} (${formatCost(latest.cost)})`) : ""),
92
+ );
86
93
 
87
94
  const avg = series.length ? total / series.length : 0;
88
95
  lines.push(
@@ -1,12 +1,11 @@
1
1
  /** Status line: window start, burn rate / cache hit / savings extras for the status window (pure). */
2
2
  import type { TokenBurnConfig } from "../../core/config.ts";
3
3
  import { formatCost, formatNum } from "../../core/format.ts";
4
+ import { HOUR_MS as HOUR } from "../../core/aggregate.ts";
4
5
  import { bucketStart } from "../../core/time.ts";
5
6
  import type { PeriodStats, Rate, TokenBurnReport, Window } from "../../core/types.ts";
6
7
  import { cacheHitRatio } from "../breakdown/query.ts";
7
8
 
8
- const HOUR = 3_600_000;
9
-
10
9
  /** Start of a status window (`hour` = rolling last 60 minutes, `total` = 0). */
11
10
  export function windowStart(w: Window, now: number): number {
12
11
  if (w === "hour") return now - HOUR;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -24,6 +24,9 @@
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": "*"
26
26
  },
27
+ "overrides": {
28
+ "undici": "8.12.3"
29
+ },
27
30
  "devDependencies": {
28
31
  "@earendil-works/pi-coding-agent": "^1.4.0",
29
32
  "@types/bun": "^1.4.4",