pi-tokenburn 0.7.4 → 0.8.2

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
@@ -17,7 +17,7 @@ Totals match the `tokenburn` CLI exactly.
17
17
 
18
18
  ## Features
19
19
 
20
- - **Status bar:** `🔥 1.2M ($5.10) [today] · 52k/h · 95% hit · ~$12.40 saved`: tokens, cost, burn rate, cache hit ratio and estimated cache savings for the chosen period (each extra toggleable). Turns red with `OVER BUDGET` past your limit.
20
+ - **Status bar:** `🔥 1.2M ($5.10) [today] · 52k/h · 95% hit · ~$12.40 saved`: tokens, cost, burn rate, cache hit ratio and estimated savings (prompt cache, plus [pi-context-diet](https://github.com/sorinirimies/pi-context-diet) and [pi-squeeze](https://github.com/sorinirimies/pi-squeeze) when installed) for the chosen period (each extra toggleable). Turns red with `OVER BUDGET` past your limit.
21
21
  - **Tables:** usage per model, provider, project and session, for any period, with cache hit %, savings, cost and share.
22
22
  - **Filter and export:** limit everything to a provider, model or project; write csv or json.
23
23
  - **Pricing:** a price table fills in (or overrides) costs the logs don't have.
@@ -61,7 +61,7 @@ pi install git:github.com/sorinirimies/pi-tokenburn
61
61
  | `/tokenburn warn <pct>` | Notify once when a budget reaches `pct` percent (default 80) and once when it is exceeded |
62
62
  | `/tokenburn rate` | Toggle the burn rate in the status line (`42k/h`: tokens per hour averaged over the status period) |
63
63
  | `/tokenburn hit` | Toggle the cache hit ratio (`95% hit`) in the status line |
64
- | `/tokenburn saved` | Toggle the estimated cache savings (`~$12.40 saved`) in the status line |
64
+ | `/tokenburn saved` | Toggle the estimated savings (`~$12.40 saved`: prompt cache, pi-context-diet, pi-squeeze) in the status line |
65
65
  | `/tokenburn pricing` | Where costs come from (session logs vs your price table) and which models are unpriced |
66
66
  | `/tokenburn price <model> <in> <out>` | Set a price in $ per million tokens (key `model` or `provider/model`); `price remove <model>` deletes it. `pricing mode <fill\|override>`: use the table only where the log has no cost (default), or always |
67
67
  | `/tokenburn window` | Toggle the panel below the editor |
@@ -183,6 +183,7 @@ Environment overrides for the session directory: `TOKENBURN_PI_SESSIONS`, `PI_CO
183
183
  Refreshing is designed to cost nothing you can notice:
184
184
 
185
185
  - **Incremental:** pi's session logs are append-only, so after the first parse only the bytes added since then are read (with a cheap integrity check that falls back to a full parse if a file was rewritten or truncated). On a 35 MB active session a refresh after one new message takes **~1.2 ms**, down from ~18 ms, and the cost no longer grows with the session.
186
+ - **Fast start:** pi waits for nothing: the first refresh runs in the background. The parse state is saved in `tokenburn-cache.json` (pi's agent dir, at most once a minute and at shutdown), so a new pi run reads only what was appended since; on a 2.4 GB session history the status line appears in **~40 ms** instead of ~4 s, and pi starts ~5 s sooner. Delete the file any time: the next start parses everything once.
186
187
  - **Coalesced:** a burst of messages triggers one refresh (trailing delay, `refreshMs`), never two at once.
187
188
  - **Never blocks:** refreshes are fire-and-forget and errors are swallowed; the timer is unref'd and cancelled at shutdown.
188
189
  - **Off means off:** `/tokenburn disable` (or `"enabled": false`) reads no files and starts no timers.
@@ -200,7 +201,7 @@ just demo # try it yourself in a real pi on the same synthetic data
200
201
 
201
202
  ## Security notes
202
203
 
203
- - **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**.
204
+ - **Local.** It reads pi's session logs, its own `tokenburn.json` and the savings logs of pi-context-diet / pi-squeeze, and writes only `tokenburn.json` and its parse cache `tokenburn-cache.json`. Nothing leaves your machine: no network, no telemetry, no shell commands, **zero runtime dependencies**.
204
205
  - **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.
205
206
  - **Hand-edited config is sanitised.** Unknown or non-numeric budget values are dropped.
206
207
 
@@ -224,7 +225,7 @@ One folder per feature under `extensions/features/`, each with its logic and its
224
225
  ```
225
226
  extensions/
226
227
  tokenburn.ts wiring only: pi events + the /tokenburn command
227
- core/ types, config, session-log parser (incremental cache), aggregation, filters + prices, paint
228
+ core/ types, config, session-log parser (incremental cache, saved between runs), aggregation, filters + prices, paint
228
229
  command/ parse + completions (pure), handler table, the Host handlers receive
229
230
  features/
230
231
  status-line/ footer text, optional panel, burn rate / cache hit / savings
@@ -41,7 +41,7 @@ const TOP_LEVEL: CompletionItem[] = [
41
41
  { value: "price ", label: "price", description: "Set a $/M-token price for a model" },
42
42
  { value: "rate", label: "rate", description: "Toggle the tokens/hour burn rate in the status line" },
43
43
  { value: "hit", label: "hit", description: "Toggle the cache hit ratio in the status line" },
44
- { value: "saved", label: "saved", description: "Toggle the estimated cache savings in the status line" },
44
+ { value: "saved", label: "saved", description: "Toggle the estimated savings (cache, pi context-diet, pi squeeze) in the status line" },
45
45
  { value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
46
46
  { value: "window", label: "window", description: "Toggle the panel below the editor" },
47
47
  { value: "budget ", label: "budget", description: "Set a day/week/month budget" },
@@ -1,6 +1,7 @@
1
1
  /** Aggregation of parsed turns into period totals and time series. */
2
+ import { DIET_LOG, loadPluginLog, pluginLogPath, SQUEEZE_LOG } from "./plugin-logs.ts";
2
3
  import { loadAll, type Turn } from "./parser.ts";
3
- import { cacheValue, selectTurns, type UsageOptions } from "./select.ts";
4
+ import { cacheValue, hasFilter, selectTurns, type UsageOptions } from "./select.ts";
4
5
  import { bucketLabel, bucketStart, type Unit } from "./time.ts";
5
6
  import type { PeriodStats, TokenBurnReport } from "./types.ts";
6
7
 
@@ -14,6 +15,13 @@ const emptyStats = (): PeriodStats => ({
14
15
  sessions: 0,
15
16
  cacheBase: 0,
16
17
  cacheActual: 0,
18
+ cacheReadPriced: 0,
19
+ dietTokens: 0,
20
+ dietRequests: 0,
21
+ dietUsd: 0,
22
+ squeezeTokens: 0,
23
+ squeezeOutputs: 0,
24
+ squeezeUsd: 0,
17
25
  });
18
26
 
19
27
  export const HOUR_MS = 3_600_000;
@@ -33,6 +41,8 @@ export async function collectUsage(
33
41
  now: Date = new Date(),
34
42
  includeCache = true,
35
43
  opts?: UsageOptions,
44
+ /** Where the pi plugins' savings logs are (default: pi's agent dir). */
45
+ logDir?: string,
36
46
  ): Promise<TokenBurnReport> {
37
47
  const startOfHour = now.getTime() - HOUR_MS;
38
48
  const startOfDay = bucketStart(now.getTime(), "day");
@@ -59,6 +69,7 @@ export async function collectUsage(
59
69
  const cv = cacheValue(t);
60
70
  p.cacheBase += cv.base;
61
71
  p.cacheActual += cv.actual;
72
+ if (cv.base > 0) p.cacheReadPriced += t.cacheRead;
62
73
  };
63
74
 
64
75
  for (const turns of await loadSelected(sessionsRoot, opts)) {
@@ -81,6 +92,35 @@ export async function collectUsage(
81
92
  if (maxTs >= startOfHour) report.hour.sessions++;
82
93
  }
83
94
 
95
+ // The pi plugins' logs are not attributed to providers, models or projects: only unfiltered views count them.
96
+ if (!hasFilter(opts?.filter)) {
97
+ const windows: [PeriodStats, number][] = [
98
+ [report.total, -Infinity],
99
+ [report.year, startOfYear],
100
+ [report.month, startOfMonth],
101
+ [report.week, startOfWeek],
102
+ [report.today, startOfDay],
103
+ [report.hour, startOfHour],
104
+ ];
105
+ const [diet, squeeze] = await Promise.all([loadPluginLog(pluginLogPath(DIET_LOG, logDir)), loadPluginLog(pluginLogPath(SQUEEZE_LOG, logDir))]);
106
+ for (const [p, start] of windows) {
107
+ for (const r of diet) {
108
+ if (r.ts < start) continue;
109
+ p.dietTokens += r.tokens;
110
+ p.dietRequests++;
111
+ }
112
+ for (const r of squeeze) {
113
+ if (r.ts < start) continue;
114
+ p.squeezeTokens += r.tokens;
115
+ p.squeezeOutputs++;
116
+ }
117
+ // Priced at the window's own prices: elided context would mostly have been cache reads,
118
+ // squeezed bash output would have been fresh input.
119
+ p.dietUsd = p.cacheReadPriced > 0 ? (p.dietTokens * p.cacheActual) / p.cacheReadPriced : 0;
120
+ p.squeezeUsd = p.cacheReadPriced > 0 ? (p.squeezeTokens * p.cacheBase) / p.cacheReadPriced : 0;
121
+ }
122
+ }
123
+
84
124
  return report;
85
125
  }
86
126
 
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The session-log parse cache, saved between pi runs in `tokenburn-cache.json` (pi's agent dir).
3
+ *
4
+ * Without it every pi start re-reads the whole session history (gigabytes for a long-used pi)
5
+ * before the status line can show anything. With it, a start reads only what was appended since.
6
+ * The parser still checks size, mtime and the head / last-parsed bytes of every file, so a stale
7
+ * or foreign cache file only costs a re-parse, never wrong numbers. Any read or write failure
8
+ * is ignored: the cache is an optimization.
9
+ */
10
+ import { readFile, rename, writeFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { getConfigDir } from "./config.ts";
13
+ import { cacheGeneration, exportCache, importCache, type FileEntry, type Turn } from "./parser.ts";
14
+
15
+ export const CACHE_FILE = "tokenburn-cache.json";
16
+ const VERSION = 1;
17
+ /** Rewrite the file at most this often while pi runs; the last state is written at shutdown. */
18
+ export const SAVE_EVERY_MS = 60_000;
19
+
20
+ export const cacheFilePath = (dir: string = getConfigDir()) => join(dir, CACHE_FILE);
21
+
22
+ /** A turn without its session (the file it belongs to); strings are indexes into `strings`. */
23
+ type Row = [ts: number, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number, costInput: number, costCacheRead: number, provider: number, model: number, project: number];
24
+
25
+ interface SavedEntry {
26
+ size: number;
27
+ mtimeMs: number;
28
+ offset: number;
29
+ cwd?: string;
30
+ head: string;
31
+ guard: string;
32
+ turns: Row[];
33
+ tail: Row[];
34
+ }
35
+
36
+ interface Saved {
37
+ version: number;
38
+ strings: string[];
39
+ files: Record<string, SavedEntry>;
40
+ }
41
+
42
+ export function encodeCache(entries: [string, FileEntry][]): Saved {
43
+ const strings: string[] = [];
44
+ const index = new Map<string, number>();
45
+ const id = (s: string) => {
46
+ let i = index.get(s);
47
+ if (i === undefined) {
48
+ i = strings.push(s) - 1;
49
+ index.set(s, i);
50
+ }
51
+ return i;
52
+ };
53
+ const row = (t: Turn): Row => [t.ts, t.input, t.output, t.cacheRead, t.cacheWrite, t.cost, t.costInput, t.costCacheRead, id(t.provider), id(t.model), id(t.project)];
54
+ const files: Record<string, SavedEntry> = {};
55
+ for (const [file, e] of entries) {
56
+ files[file] = {
57
+ size: e.size,
58
+ mtimeMs: e.mtimeMs,
59
+ offset: e.offset,
60
+ ...(e.cwd === undefined ? {} : { cwd: e.cwd }),
61
+ head: e.head.toString("base64"),
62
+ guard: e.guard.toString("base64"),
63
+ turns: e.turns.map(row),
64
+ tail: e.tail.map(row),
65
+ };
66
+ }
67
+ return { version: VERSION, strings, files };
68
+ }
69
+
70
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
71
+
72
+ /** The entries of a saved cache; anything malformed is dropped (that file is simply re-parsed). */
73
+ export function decodeCache(data: unknown): [string, FileEntry][] {
74
+ const saved = data as Partial<Saved> | null;
75
+ if (!saved || saved.version !== VERSION || !Array.isArray(saved.strings) || typeof saved.files !== "object" || saved.files === null) return [];
76
+ const strings = saved.strings;
77
+ const str = (i: unknown) => (Number.isInteger(i) && typeof strings[i as number] === "string" ? strings[i as number] : undefined);
78
+ const turn = (r: unknown, session: string): Turn | undefined => {
79
+ if (!Array.isArray(r) || r.length !== 11 || !r.slice(0, 8).every(isNum)) return undefined;
80
+ const [provider, model, project] = [str(r[8]), str(r[9]), str(r[10])];
81
+ if (provider === undefined || model === undefined || project === undefined) return undefined;
82
+ const [ts, input, output, cacheRead, cacheWrite, cost, costInput, costCacheRead] = r as number[];
83
+ return { ts: ts!, input: input!, output: output!, cacheRead: cacheRead!, cacheWrite: cacheWrite!, cost: cost!, costInput: costInput!, costCacheRead: costCacheRead!, provider, model, project, session };
84
+ };
85
+ const turns = (rows: unknown, session: string): Turn[] | undefined => {
86
+ if (!Array.isArray(rows)) return undefined;
87
+ const out: Turn[] = [];
88
+ for (const r of rows) {
89
+ const t = turn(r, session);
90
+ if (!t) return undefined;
91
+ out.push(t);
92
+ }
93
+ return out;
94
+ };
95
+ const out: [string, FileEntry][] = [];
96
+ for (const [file, e] of Object.entries(saved.files as Record<string, Partial<SavedEntry>>)) {
97
+ if (!e || !isNum(e.size) || !isNum(e.mtimeMs) || !isNum(e.offset) || typeof e.head !== "string" || typeof e.guard !== "string") continue;
98
+ if (e.cwd !== undefined && typeof e.cwd !== "string") continue;
99
+ const [t, tail] = [turns(e.turns, file), turns(e.tail, file)];
100
+ if (!t || !tail) continue;
101
+ out.push([file, { size: e.size, mtimeMs: e.mtimeMs, offset: e.offset, cwd: e.cwd, head: Buffer.from(e.head, "base64"), guard: Buffer.from(e.guard, "base64"), turns: t, tail }]);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ let savedGeneration = -1;
107
+ let lastSave = -Infinity;
108
+
109
+ /** Seed the parser from the cache file (a no-op when it is missing or broken, or the parser is warm). */
110
+ export async function restoreCache(path: string = cacheFilePath()): Promise<void> {
111
+ try {
112
+ importCache(decodeCache(JSON.parse(await readFile(path, "utf8"))));
113
+ savedGeneration = cacheGeneration();
114
+ } catch {
115
+ /* no cache yet, or a broken one: the first refresh parses everything */
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Write the parse state when it changed, at most every SAVE_EVERY_MS unless `force` (shutdown).
121
+ * Written to a temp file and renamed, so a crash never leaves a torn cache. Returns whether it wrote.
122
+ */
123
+ export async function persistCache(path: string = cacheFilePath(), opts: { force?: boolean; now?: number } = {}): Promise<boolean> {
124
+ const now = opts.now ?? Date.now();
125
+ const gen = cacheGeneration();
126
+ if (gen === savedGeneration || (!opts.force && now - lastSave < SAVE_EVERY_MS)) return false;
127
+ try {
128
+ const tmp = `${path}.${process.pid}.tmp`;
129
+ await writeFile(tmp, JSON.stringify(encodeCache(exportCache())));
130
+ await rename(tmp, path);
131
+ savedGeneration = gen;
132
+ lastSave = now;
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /** Test hook: forget what was saved and when. */
140
+ export function resetCacheFileState(): void {
141
+ savedGeneration = -1;
142
+ lastSave = -Infinity;
143
+ }
@@ -23,7 +23,7 @@ export interface TokenBurnConfig {
23
23
  showRate: boolean;
24
24
  /** Show the cache hit ratio of the status window in the status line. */
25
25
  showHit: boolean;
26
- /** Show the estimated cache savings of the status window in the status line. */
26
+ /** Show the estimated savings (prompt cache, pi-context-diet, pi-squeeze) of the status window in the status line. */
27
27
  showSaved: boolean;
28
28
  /** Notify once when a budget reaches this percentage (1 to 100); 0 turns warnings off. */
29
29
  warnPct: number;
@@ -230,11 +230,25 @@ function listSessionFiles(root: string): string[] {
230
230
  return files;
231
231
  }
232
232
 
233
- /** Bring the per-file cache up to date (reading only what changed) and return every file's turns. */
234
- export async function loadAll(sessionsRoot: string, chunkSize = CHUNK_BYTES): Promise<Turn[][]> {
233
+ /** Bring the per-file cache up to date (reading only what changed) and return every file's turns.
234
+ * Calls run one at a time: an incremental parse appends to the cached turns, so two at once
235
+ * (the background startup refresh and a /tokenburn command) would count the new turns twice. */
236
+ export function loadAll(sessionsRoot: string, chunkSize = CHUNK_BYTES): Promise<Turn[][]> {
237
+ const run = queue.then(() => loadAllNow(sessionsRoot, chunkSize));
238
+ queue = Promise.allSettled([run]);
239
+ return run;
240
+ }
241
+
242
+ let queue: Promise<unknown> = Promise.resolve();
243
+
244
+ /** Bumped whenever a cache entry changes, so the cache file is only rewritten when needed. */
245
+ let generation = 0;
246
+ export const cacheGeneration = () => generation;
247
+
248
+ async function loadAllNow(sessionsRoot: string, chunkSize: number): Promise<Turn[][]> {
235
249
  const files = listSessionFiles(sessionsRoot);
236
250
  const live = new Set(files);
237
- for (const key of FILE_CACHE.keys()) if (!live.has(key)) FILE_CACHE.delete(key);
251
+ for (const key of FILE_CACHE.keys()) if (!live.has(key) && FILE_CACHE.delete(key)) generation++;
238
252
 
239
253
  const out: Turn[][] = [];
240
254
  for (const file of files) {
@@ -245,6 +259,7 @@ export async function loadAll(sessionsRoot: string, chunkSize = CHUNK_BYTES): Pr
245
259
  const fresh = await refreshEntry(file, st, entry, chunkSize);
246
260
  if (fresh) FILE_CACHE.set(file, fresh);
247
261
  else FILE_CACHE.delete(file);
262
+ generation++;
248
263
  entry = fresh;
249
264
  }
250
265
  if (entry) out.push(entry.tail.length > 0 ? entry.turns.concat(entry.tail) : entry.turns);
@@ -259,3 +274,14 @@ export const __refreshEntryForTests = refreshEntry;
259
274
  export function clearCache(): void {
260
275
  FILE_CACHE.clear();
261
276
  }
277
+
278
+ /** The parse state of every session file, to save it. */
279
+ export const exportCache = (): [string, FileEntry][] => [...FILE_CACHE];
280
+
281
+ /** Seed a fresh runtime with saved parse state. A warm cache is newer than any file: kept. */
282
+ export function importCache(entries: Iterable<[string, FileEntry]>): void {
283
+ if (FILE_CACHE.size > 0) return;
284
+ for (const [file, entry] of entries) FILE_CACHE.set(file, entry);
285
+ }
286
+
287
+ export type { FileEntry };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The savings logs of pi plugins, in pi's agent dir: one JSON line per saving
3
+ * (`{"ts":"2026-01-02T09:15:00.000Z","tokens":18400,...}`). Only `ts` and `tokens` are read.
4
+ *
5
+ * - pi-context-diet: `context-diet-savings.jsonl`, one line per request it trimmed;
6
+ * - pi-squeeze: `squeeze-savings.jsonl`, one line per bash output it shortened.
7
+ *
8
+ * A missing or unreadable file means "no savings"; lines that do not parse are skipped.
9
+ */
10
+ import { readFile, stat } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { getConfigDir } from "./config.ts";
13
+
14
+ export const DIET_LOG = "context-diet-savings.jsonl";
15
+ export const SQUEEZE_LOG = "squeeze-savings.jsonl";
16
+
17
+ export interface LogRecord {
18
+ ts: number;
19
+ tokens: number;
20
+ }
21
+
22
+ /** Where a plugin's log is: `file` in pi's agent dir. */
23
+ export const pluginLogPath = (file: string, dir: string = getConfigDir()) => join(dir, file);
24
+
25
+ export function parsePluginLog(text: string): LogRecord[] {
26
+ const out: LogRecord[] = [];
27
+ for (const line of text.split("\n")) {
28
+ if (!line.trim()) continue;
29
+ try {
30
+ const r = JSON.parse(line) as { ts?: unknown; tokens?: unknown };
31
+ const ts = typeof r.ts === "string" ? Date.parse(r.ts) : NaN;
32
+ const tokens = r.tokens;
33
+ if (Number.isFinite(ts) && typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) out.push({ ts, tokens });
34
+ } catch {
35
+ /* a torn or foreign line */
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ // The status line refreshes after every message: re-read a log only when it changed.
42
+ const cache = new Map<string, { mtimeMs: number; size: number; records: LogRecord[] }>();
43
+
44
+ export async function loadPluginLog(path: string): Promise<LogRecord[]> {
45
+ try {
46
+ const s = await stat(path);
47
+ const hit = cache.get(path);
48
+ if (hit && hit.mtimeMs === s.mtimeMs && hit.size === s.size) return hit.records;
49
+ const records = parsePluginLog(await readFile(path, "utf8"));
50
+ cache.set(path, { mtimeMs: s.mtimeMs, size: s.size, records });
51
+ return records;
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ export const clearPluginLogCache = () => cache.clear();
@@ -20,6 +20,16 @@ export interface PeriodStats {
20
20
  /** What cache reads would have cost as plain input vs what they cost (turns with a known input price). */
21
21
  cacheBase: number;
22
22
  cacheActual: number;
23
+ /** Cache-read tokens of the turns with a known input price (`cacheActual` / this = the cache-read price). */
24
+ cacheReadPriced: number;
25
+ /** pi-context-diet: tokens kept out of requests, the requests, and what they would have cost as cache reads. */
26
+ dietTokens: number;
27
+ dietRequests: number;
28
+ dietUsd: number;
29
+ /** pi-squeeze: tokens cut from bash output, the outputs, and what they would have cost as input. */
30
+ squeezeTokens: number;
31
+ squeezeOutputs: number;
32
+ squeezeUsd: number;
23
33
  }
24
34
 
25
35
  export interface TokenBurnReport {
@@ -27,6 +27,12 @@ export function renderReport(input: {
27
27
  ...(models.cacheRead + models.cacheWrite > 0
28
28
  ? [`cache: ${Math.round(cacheHitRatio(models) * 100)}% of prompt tokens read from cache${cacheSavings(models) > 0 ? ` · saved ~${formatCost(cacheSavings(models))}` : ""}`]
29
29
  : []),
30
+ ...(report.total.dietTokens > 0
31
+ ? [`pi context-diet: ~${formatNum(report.total.dietTokens)} tokens kept out of ${report.total.dietRequests} requests${report.total.dietUsd > 0 ? ` · saved ~${formatCost(report.total.dietUsd)}` : ""}`]
32
+ : []),
33
+ ...(report.total.squeezeTokens > 0
34
+ ? [`pi squeeze: ~${formatNum(report.total.squeezeTokens)} tokens cut from ${report.total.squeezeOutputs} bash outputs${report.total.squeezeUsd > 0 ? ` · saved ~${formatCost(report.total.squeezeUsd)}` : ""}`]
35
+ : []),
30
36
  ...forecastLines(report, config, now),
31
37
  ];
32
38
  return [
@@ -73,6 +73,6 @@ export const statusLineCommands: HandlersFor<"window" | "status" | "cycle" | "ca
73
73
  async saved(_cmd, { config, persist, note }) {
74
74
  config.showSaved = !config.showSaved;
75
75
  await persist();
76
- note(`tokenburn estimated cache savings in the status line: ${onOff(config.showSaved)}`);
76
+ note(`tokenburn estimated savings (cache, pi context-diet, pi squeeze) in the status line: ${onOff(config.showSaved)}`);
77
77
  },
78
78
  };
@@ -20,8 +20,8 @@ export function windowRate(stat: PeriodStats, w: Window, firstTs: number | undef
20
20
  return { tokensPerHour: stat.tokens / hours, costPerHour: stat.cost / hours };
21
21
  }
22
22
 
23
- /** Estimated dollars saved by cache reads in a window. */
24
- export const statSavings = (s: PeriodStats): number => Math.max(0, s.cacheBase - s.cacheActual);
23
+ /** Estimated dollars saved in a window: cache reads, plus what pi-context-diet and pi-squeeze kept out. */
24
+ export const statSavings = (s: PeriodStats): number => Math.max(0, s.cacheBase - s.cacheActual) + s.dietUsd + s.squeezeUsd;
25
25
 
26
26
  /** Status-line extras for the window, in order: burn rate, cache hit ratio, cache savings (each only when enabled and meaningful). */
27
27
  export function statusExtras(report: TokenBurnReport, cfg: TokenBurnConfig, now: number): string[] {
@@ -22,6 +22,7 @@ import type { Host } from "./command/host.ts";
22
22
  import { completions } from "./command/completions.ts";
23
23
  import { parseCommand } from "./command/parse.ts";
24
24
  import { collectUsage } from "./core/aggregate.ts";
25
+ import { persistCache, restoreCache } from "./core/cache-file.ts";
25
26
  import { freshConfig, getSessionsDir, loadConfig, saveConfig, type TokenBurnConfig } from "./core/config.ts";
26
27
  import type { UsageFilter, UsageOptions } from "./core/select.ts";
27
28
  import { budgetWarnings } from "./features/budgets/budgets.ts";
@@ -49,6 +50,10 @@ export * from "./features/breakdown/table.ts";
49
50
  export * from "./features/pricing/report.ts";
50
51
  export * from "./features/export/export.ts";
51
52
 
53
+ /** The background refresh started by the latest session_start (tests await it). */
54
+ let startup: Promise<void> = Promise.resolve();
55
+ export const startupRefresh = () => startup;
56
+
52
57
  export default function tokenBurnExtension(pi: ExtensionAPI) {
53
58
  let config: TokenBurnConfig = freshConfig();
54
59
  /** Budget warnings already shown (id = budget + period bucket + level): each shows once. */
@@ -84,6 +89,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
84
89
 
85
90
  if (config.showWidget) ctx.ui.setWidget("tokenburn-panel", renderWidget(report, config, theme), { placement: "belowEditor" });
86
91
  else ctx.ui.setWidget("tokenburn-panel", []);
92
+ void persistCache();
87
93
  };
88
94
 
89
95
  // The latest context, used by the (delayed) live refresh. Dropped at shutdown so a reloaded
@@ -99,8 +105,10 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
99
105
  pi.on("session_start", async (_event, ctx) => {
100
106
  latestCtx = ctx;
101
107
  config = await loadConfig();
102
- // A failing refresh must never break session start.
103
- await updateUI(ctx).catch(() => {});
108
+ if (config.enabled) await restoreCache();
109
+ // pi waits for this handler, and the first scan of a long session history takes seconds:
110
+ // draw in the background. A failing refresh must never break session start.
111
+ startup = updateUI(ctx).catch(() => {});
104
112
  });
105
113
 
106
114
  // Live: after each assistant message, coalesced. Never blocks, never throws into pi, and
@@ -120,9 +128,10 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
120
128
  void refresher.flush();
121
129
  });
122
130
 
123
- pi.on("session_shutdown", () => {
131
+ pi.on("session_shutdown", async () => {
124
132
  refresher.cancel();
125
133
  latestCtx = undefined;
134
+ await persistCache(undefined, { force: true });
126
135
  });
127
136
 
128
137
  pi.registerCommand("tokenburn", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.7.4",
3
+ "version": "0.8.2",
4
4
  "description": "Pi extension for live token usage monitoring and budget tracking (hour / day / week / month / year / all). 0 prompt tokens.",
5
5
  "keywords": ["pi-package", "pi-extension", "tokenburn", "tokens", "analytics", "budget", "stats"],
6
6
  "license": "MIT",