pi-tokenburn 0.8.0 → 0.8.3

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
@@ -183,16 +183,17 @@ 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.
189
190
 
190
191
  ## Demo recordings
191
192
 
192
- The GIFs above live in [`examples/vhs/generated/`](examples/vhs/generated) and are stored with **Git LFS** (`git lfs install` once). They are recorded with [VHS](https://github.com/charmbracelet/vhs) from a **real pi** that loads only this extension, on **synthetic** data (`examples/vhs/fixture.sh`: 900 days of made-up sessions across fictional projects, providers and models in `/tmp`, with deliberately small numbers, and a local mock model for the live-refresh demo), never from real logs, paths or credentials:
193
+ The GIFs above live in [`examples/vhs/generated/`](examples/vhs/generated) and are stored with **Git LFS** (`git lfs install` once). They are recorded with [VHS](https://github.com/charmbracelet/vhs) from a **real pi** that loads only this extension, on **synthetic** data (`examples/vhs/fixture.nu`: 900 days of made-up sessions across fictional projects, providers and models in `/tmp`, with deliberately small numbers, and a local mock model for the live-refresh demo), never from real logs, paths or credentials:
193
194
 
194
195
  ```sh
195
- just vhs-all # every tape (examples/vhs/*.tape): needs vhs, ttyd, ffmpeg, pi, bun, python3
196
+ just vhs-all # every tape (examples/vhs/*.tape): needs vhs, ttyd, ffmpeg, pi, bun, nu
196
197
  just vhs-tape charts # one tape
197
198
  just vhs-list # list the tapes
198
199
  just demo # try it yourself in a real pi on the same synthetic data
@@ -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
@@ -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
+ }
@@ -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 };
@@ -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.8.0",
3
+ "version": "0.8.3",
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",
@@ -18,7 +18,7 @@
18
18
  "test": "bun test",
19
19
  "typecheck": "tsc --noEmit",
20
20
  "audit": "bun audit",
21
- "fuzz": "FUZZ_ITERS=20000 bun test tests/fuzz.test.ts --config=bunfig.fuzz.toml",
21
+ "fuzz": "nu scripts/ci/fuzz.nu",
22
22
  "check": "nu scripts/ci/quality_gate.nu"
23
23
  },
24
24
  "overrides": {
@@ -26,7 +26,7 @@
26
26
  "minimatch": "11.0.3"
27
27
  },
28
28
  "devDependencies": {
29
- "@earendil-works/pi-coding-agent": "^1.4.0",
29
+ "@earendil-works/pi-coding-agent": "^1.5.0",
30
30
  "@types/bun": "^1.4.4",
31
31
  "typescript": "^7.0.2"
32
32
  }