pi-tokenburn 0.8.0 → 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 +3 -2
- package/extensions/core/cache-file.ts +143 -0
- package/extensions/core/parser.ts +29 -3
- package/extensions/tokenburn.ts +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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 };
|
package/extensions/tokenburn.ts
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
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.
|
|
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",
|