pi-tokenburn 0.5.7 → 0.6.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 +59 -4
- package/extensions/command/completions.ts +80 -0
- package/extensions/command/handlers.ts +22 -0
- package/extensions/command/host.ts +25 -0
- package/extensions/command/parse.ts +173 -0
- package/extensions/core/aggregate.ts +155 -0
- package/extensions/core/config.ts +157 -0
- package/extensions/core/format.ts +12 -0
- package/extensions/core/paint.ts +35 -0
- package/extensions/core/parser.ts +261 -0
- package/extensions/core/select.ts +105 -0
- package/extensions/core/time.ts +25 -0
- package/extensions/core/types.ts +43 -0
- package/extensions/features/breakdown/commands.ts +32 -0
- package/extensions/features/breakdown/query.ts +138 -0
- package/extensions/features/breakdown/table.ts +100 -0
- package/extensions/features/budgets/budgets.ts +105 -0
- package/extensions/features/budgets/commands.ts +20 -0
- package/extensions/features/export/commands.ts +19 -0
- package/extensions/features/export/export.ts +61 -0
- package/extensions/features/filter/commands.ts +18 -0
- package/extensions/features/live/coalescer.ts +71 -0
- package/extensions/features/pricing/commands.ts +30 -0
- package/extensions/features/pricing/report.ts +59 -0
- package/extensions/features/report/chart.ts +96 -0
- package/extensions/features/report/commands.ts +33 -0
- package/extensions/features/report/report.ts +47 -0
- package/extensions/features/status-line/commands.ts +78 -0
- package/extensions/features/status-line/render.ts +43 -0
- package/extensions/features/status-line/status.ts +35 -0
- package/extensions/tokenburn.ts +78 -1030
- package/package.json +5 -3
package/extensions/tokenburn.ts
CHANGED
|
@@ -1,751 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-tokenburn
|
|
2
|
+
* pi-tokenburn: token usage monitor with budget alerts in your Pi session.
|
|
3
3
|
*
|
|
4
|
-
* Inspired by the tokenburn Rust project (https://github.com/sorinirimies/tokenburn).
|
|
5
4
|
* Reads local Pi session JSONL logs directly (0 network, 0 prompt tokens).
|
|
5
|
+
* This file is wiring only: it connects pi's events and the /tokenburn command to the pieces below.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* /
|
|
7
|
+
* core/ types, config, session-log parser (incremental cache), aggregation, filters + prices
|
|
8
|
+
* command/ parse + completions (pure), the handler table, the Host handlers receive
|
|
9
|
+
* features/ one folder per feature, each with its logic and its commands:
|
|
10
|
+
* status-line footer text, optional panel, burn rate / cache hit / savings, status period
|
|
11
|
+
* report the /tokenburn summary and the column charts
|
|
12
|
+
* budgets budgets, projection ("runs out Thu 14:05"), one-time warnings
|
|
13
|
+
* breakdown tables per model / provider / project / session, current session
|
|
14
|
+
* filter limit everything to a provider / model / project
|
|
15
|
+
* pricing cost sources, price table
|
|
16
|
+
* export csv / json
|
|
17
|
+
* live refresh scheduling
|
|
15
18
|
*/
|
|
16
|
-
|
|
17
|
-
import { readdirSync, statSync } from "node:fs";
|
|
18
|
-
import { open, type FileHandle } from "node:fs/promises";
|
|
19
|
-
import { homedir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
21
19
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
export interface TokenBurnConfig {
|
|
55
|
-
/** Master switch. When false nothing runs in the background and the status line / panel are cleared. */
|
|
56
|
-
enabled: boolean;
|
|
57
|
-
/** Refresh after each assistant message (coalesced), not only when a turn ends. */
|
|
58
|
-
liveRefresh: boolean;
|
|
59
|
-
/** Trailing delay for live refreshes, in ms (250 to 60000). */
|
|
60
|
-
refreshMs: number;
|
|
61
|
-
/** Count cache read/write tokens in totals (matches the tokenburn CLI). */
|
|
62
|
-
includeCache: boolean;
|
|
63
|
-
/** Color the report chart (set false if your terminal garbles ANSI in notifications). */
|
|
64
|
-
chartColor: boolean;
|
|
65
|
-
showStatus: boolean;
|
|
66
|
-
showWidget: boolean;
|
|
67
|
-
statusWindow: Window;
|
|
68
|
-
budgets: {
|
|
69
|
-
dayTokens?: number;
|
|
70
|
-
weekTokens?: number;
|
|
71
|
-
monthTokens?: number;
|
|
72
|
-
dayCost?: number;
|
|
73
|
-
weekCost?: number;
|
|
74
|
-
monthCost?: number;
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const DEFAULT_CONFIG: TokenBurnConfig = {
|
|
79
|
-
enabled: true,
|
|
80
|
-
liveRefresh: true,
|
|
81
|
-
refreshMs: 750,
|
|
82
|
-
includeCache: true,
|
|
83
|
-
chartColor: true,
|
|
84
|
-
showStatus: true,
|
|
85
|
-
showWidget: false,
|
|
86
|
-
statusWindow: "today",
|
|
87
|
-
budgets: {},
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
/** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
|
|
91
|
-
export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
|
|
92
|
-
if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
|
|
93
|
-
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
|
|
94
|
-
return join(home, ".pi", "agent");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Pi session logs: TOKENBURN_PI_SESSIONS, PI_CODING_AGENT_SESSION_DIR, then <agent dir>/sessions. */
|
|
98
|
-
export function resolveSessionsDir(env: Record<string, string | undefined>, home: string): string {
|
|
99
|
-
if (env.TOKENBURN_PI_SESSIONS) return env.TOKENBURN_PI_SESSIONS;
|
|
100
|
-
if (env.PI_CODING_AGENT_SESSION_DIR) return env.PI_CODING_AGENT_SESSION_DIR;
|
|
101
|
-
return join(resolveAgentDir(env, home), "sessions");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const getConfigDir = () => resolveAgentDir(process.env, homedir());
|
|
105
|
-
|
|
106
|
-
const getSessionsDir = () => resolveSessionsDir(process.env, homedir());
|
|
107
|
-
|
|
108
|
-
export async function loadConfig(): Promise<TokenBurnConfig> {
|
|
109
|
-
try {
|
|
110
|
-
const raw = await readFile(join(getConfigDir(), "tokenburn.json"), "utf8");
|
|
111
|
-
const parsed = JSON.parse(raw);
|
|
112
|
-
return {
|
|
113
|
-
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
|
|
114
|
-
liveRefresh: typeof parsed.liveRefresh === "boolean" ? parsed.liveRefresh : DEFAULT_CONFIG.liveRefresh,
|
|
115
|
-
refreshMs: sanitizeRefreshMs(parsed.refreshMs),
|
|
116
|
-
includeCache: typeof parsed.includeCache === "boolean" ? parsed.includeCache : DEFAULT_CONFIG.includeCache,
|
|
117
|
-
chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
|
|
118
|
-
showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
|
|
119
|
-
showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
|
|
120
|
-
statusWindow: (STATUS_WINDOWS as readonly string[]).includes(parsed.statusWindow)
|
|
121
|
-
? parsed.statusWindow
|
|
122
|
-
: DEFAULT_CONFIG.statusWindow,
|
|
123
|
-
budgets: sanitizeBudgets(parsed.budgets),
|
|
124
|
-
};
|
|
125
|
-
} catch {
|
|
126
|
-
return freshConfig();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Live-refresh delay: a finite number clamped to 250..60000 ms, else the default. */
|
|
131
|
-
export function sanitizeRefreshMs(value: unknown): number {
|
|
132
|
-
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CONFIG.refreshMs;
|
|
133
|
-
return Math.min(60_000, Math.max(250, Math.round(value)));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const BUDGET_KEYS = ["dayTokens", "weekTokens", "monthTokens", "dayCost", "weekCost", "monthCost"] as const;
|
|
137
|
-
|
|
138
|
-
/** Keep only known budget keys holding positive, finite numbers (the file is hand-editable). */
|
|
139
|
-
export function sanitizeBudgets(value: unknown): TokenBurnConfig["budgets"] {
|
|
140
|
-
const out: TokenBurnConfig["budgets"] = {};
|
|
141
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
|
|
142
|
-
for (const key of BUDGET_KEYS) {
|
|
143
|
-
const n = (value as Record<string, unknown>)[key];
|
|
144
|
-
if (typeof n === "number" && Number.isFinite(n) && n > 0) out[key] = n;
|
|
145
|
-
}
|
|
146
|
-
return out;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** A config that shares no mutable state with DEFAULT_CONFIG. */
|
|
150
|
-
export function freshConfig(): TokenBurnConfig {
|
|
151
|
-
return { ...DEFAULT_CONFIG, budgets: {} };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
|
|
155
|
-
await mkdir(getConfigDir(), { recursive: true });
|
|
156
|
-
await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export function formatNum(n: number): string {
|
|
160
|
-
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B";
|
|
161
|
-
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
|
162
|
-
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
|
163
|
-
return n.toString();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function formatCost(c: number): string {
|
|
167
|
-
return `$${c.toFixed(2)}`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
interface Turn {
|
|
171
|
-
ts: number;
|
|
172
|
-
input: number;
|
|
173
|
-
output: number;
|
|
174
|
-
cacheRead: number;
|
|
175
|
-
cacheWrite: number;
|
|
176
|
-
cost: number;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** What was read from disk, for tests and tuning: refreshes must cost O(new bytes). */
|
|
180
|
-
export const stats = { bytesRead: 0, fullParses: 0, incrementalParses: 0 };
|
|
181
|
-
|
|
182
|
-
export function resetStats(): void {
|
|
183
|
-
stats.bytesRead = 0;
|
|
184
|
-
stats.fullParses = 0;
|
|
185
|
-
stats.incrementalParses = 0;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Per-session-file parse state. Pi session logs are append-only JSONL, so after the first
|
|
190
|
-
* parse only the bytes added since then are read. The state is dropped (full reparse) when
|
|
191
|
-
* the file shrank, was rewritten in place, or its head / last-parsed bytes no longer match.
|
|
192
|
-
*/
|
|
193
|
-
interface FileEntry {
|
|
194
|
-
size: number;
|
|
195
|
-
mtimeMs: number;
|
|
196
|
-
/** End of the last COMPLETE (newline-terminated) line that was parsed. */
|
|
197
|
-
offset: number;
|
|
198
|
-
turns: Turn[];
|
|
199
|
-
/** Turn from an unterminated final line; counted now, re-parsed once the line completes. */
|
|
200
|
-
tail: Turn[];
|
|
201
|
-
/** First bytes of the file, to notice a rewrite. */
|
|
202
|
-
head: Buffer;
|
|
203
|
-
/** The bytes just before `offset`, same purpose. */
|
|
204
|
-
guard: Buffer;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const FILE_CACHE = new Map<string, FileEntry>();
|
|
208
|
-
const GUARD_BYTES = 64;
|
|
209
|
-
const HEAD_BYTES = 256;
|
|
210
|
-
const CHUNK_BYTES = 1 << 20;
|
|
211
|
-
|
|
212
|
-
/** A Turn from one JSONL line, or undefined when it carries no usage. */
|
|
213
|
-
function turnFromLine(line: Buffer): Turn | undefined {
|
|
214
|
-
if (!line.includes('"usage"')) return undefined; // most lines: skip without a JSON parse
|
|
215
|
-
try {
|
|
216
|
-
const obj = JSON.parse(line.toString("utf8"));
|
|
217
|
-
const usage = obj.message?.usage;
|
|
218
|
-
if (!obj.timestamp || !usage) return undefined;
|
|
219
|
-
const ts = new Date(obj.timestamp).getTime();
|
|
220
|
-
if (!Number.isFinite(ts)) return undefined;
|
|
221
|
-
return {
|
|
222
|
-
ts,
|
|
223
|
-
input: Number(usage.input) || 0,
|
|
224
|
-
output: Number(usage.output) || 0,
|
|
225
|
-
cacheRead: Number(usage.cacheRead) || 0,
|
|
226
|
-
cacheWrite: Number(usage.cacheWrite) || 0,
|
|
227
|
-
cost: Number(usage.cost?.total) || 0,
|
|
228
|
-
};
|
|
229
|
-
} catch {
|
|
230
|
-
return undefined;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function readAt(fd: FileHandle, buf: Buffer, position: number): Promise<number> {
|
|
235
|
-
const { bytesRead } = await fd.read(buf, 0, buf.length, position);
|
|
236
|
-
stats.bytesRead += bytesRead;
|
|
237
|
-
return bytesRead;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Parse complete lines in [start, end). Returns their turns, where they stop, and the unterminated rest. */
|
|
241
|
-
async function scan(
|
|
242
|
-
fd: FileHandle,
|
|
243
|
-
start: number,
|
|
244
|
-
end: number,
|
|
245
|
-
chunkSize: number,
|
|
246
|
-
): Promise<{ turns: Turn[]; consumed: number; rest: Buffer }> {
|
|
247
|
-
const turns: Turn[] = [];
|
|
248
|
-
const buf = Buffer.allocUnsafe(Math.min(chunkSize, Math.max(1, end - start)));
|
|
249
|
-
let pos = start;
|
|
250
|
-
let consumed = start;
|
|
251
|
-
let carry: Buffer = Buffer.alloc(0);
|
|
252
|
-
while (pos < end) {
|
|
253
|
-
const want = Math.min(buf.length, end - pos);
|
|
254
|
-
const n = await readAt(fd, want === buf.length ? buf : buf.subarray(0, want), pos);
|
|
255
|
-
if (n === 0) break;
|
|
256
|
-
const base = pos - carry.length;
|
|
257
|
-
const data = carry.length > 0 ? Buffer.concat([carry, buf.subarray(0, n)]) : buf.subarray(0, n);
|
|
258
|
-
let lineStart = 0;
|
|
259
|
-
for (let nl = data.indexOf(10, lineStart); nl !== -1; nl = data.indexOf(10, lineStart)) {
|
|
260
|
-
const t = turnFromLine(data.subarray(lineStart, nl));
|
|
261
|
-
if (t) turns.push(t);
|
|
262
|
-
lineStart = nl + 1;
|
|
263
|
-
}
|
|
264
|
-
if (lineStart > 0) consumed = base + lineStart;
|
|
265
|
-
carry = Buffer.from(data.subarray(lineStart)); // copy: `buf` is reused
|
|
266
|
-
pos += n;
|
|
267
|
-
}
|
|
268
|
-
return { turns, consumed, rest: carry };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function bytesAt(fd: FileHandle, position: number, length: number): Promise<Buffer> {
|
|
272
|
-
if (length <= 0) return Buffer.alloc(0);
|
|
273
|
-
const buf = Buffer.alloc(length);
|
|
274
|
-
const n = await readAt(fd, buf, position);
|
|
275
|
-
return buf.subarray(0, n);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** Is the prefix we parsed earlier still what is on disk? */
|
|
279
|
-
async function stillAppendOnly(fd: FileHandle, prev: FileEntry): Promise<boolean> {
|
|
280
|
-
const head = await bytesAt(fd, 0, prev.head.length);
|
|
281
|
-
if (!head.equals(prev.head)) return false;
|
|
282
|
-
const guard = await bytesAt(fd, prev.offset - prev.guard.length, prev.guard.length);
|
|
283
|
-
return guard.equals(prev.guard);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
async function refreshEntry(
|
|
287
|
-
file: string,
|
|
288
|
-
st: { size: number; mtimeMs: number },
|
|
289
|
-
prev: FileEntry | undefined,
|
|
290
|
-
chunkSize: number,
|
|
291
|
-
): Promise<FileEntry | undefined> {
|
|
292
|
-
let fd: FileHandle;
|
|
293
|
-
try {
|
|
294
|
-
fd = await open(file, "r");
|
|
295
|
-
} catch {
|
|
296
|
-
return undefined;
|
|
297
|
-
}
|
|
298
|
-
try {
|
|
299
|
-
const grew = prev !== undefined && st.size > prev.size;
|
|
300
|
-
const incremental = grew && (await stillAppendOnly(fd, prev!));
|
|
301
|
-
const start = incremental ? prev!.offset : 0;
|
|
302
|
-
const turns = incremental ? prev!.turns : [];
|
|
303
|
-
if (incremental) stats.incrementalParses++;
|
|
304
|
-
else stats.fullParses++;
|
|
305
|
-
|
|
306
|
-
const r = await scan(fd, start, st.size, chunkSize);
|
|
307
|
-
for (const t of r.turns) turns.push(t);
|
|
308
|
-
const tailTurn = r.rest.length > 0 ? turnFromLine(r.rest) : undefined;
|
|
309
|
-
return {
|
|
310
|
-
size: st.size,
|
|
311
|
-
mtimeMs: st.mtimeMs,
|
|
312
|
-
offset: r.consumed,
|
|
313
|
-
turns,
|
|
314
|
-
tail: tailTurn ? [tailTurn] : [],
|
|
315
|
-
head: incremental ? prev!.head : await bytesAt(fd, 0, Math.min(HEAD_BYTES, st.size)),
|
|
316
|
-
guard: await bytesAt(fd, Math.max(0, r.consumed - GUARD_BYTES), Math.min(GUARD_BYTES, r.consumed)),
|
|
317
|
-
};
|
|
318
|
-
} catch {
|
|
319
|
-
return undefined;
|
|
320
|
-
} finally {
|
|
321
|
-
await fd.close();
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** Size and mtime of a file, or undefined when it cannot be stat'ed. */
|
|
326
|
-
export function fileStat(file: string): { size: number; mtimeMs: number } | undefined {
|
|
327
|
-
try {
|
|
328
|
-
const st = statSync(file);
|
|
329
|
-
return { size: st.size, mtimeMs: st.mtimeMs };
|
|
330
|
-
} catch {
|
|
331
|
-
return undefined;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function listSessionFiles(root: string): string[] {
|
|
336
|
-
const files: string[] = [];
|
|
337
|
-
const walk = (dir: string) => {
|
|
338
|
-
let entries;
|
|
339
|
-
try {
|
|
340
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
341
|
-
} catch {
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
for (const e of entries) {
|
|
345
|
-
const full = join(dir, e.name);
|
|
346
|
-
if (e.isDirectory()) walk(full);
|
|
347
|
-
else if (e.isFile() && e.name.endsWith(".jsonl")) files.push(full);
|
|
348
|
-
}
|
|
349
|
-
};
|
|
350
|
-
walk(root);
|
|
351
|
-
return files;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const emptyStats = (): PeriodStats => ({
|
|
355
|
-
tokens: 0,
|
|
356
|
-
input: 0,
|
|
357
|
-
output: 0,
|
|
358
|
-
cacheRead: 0,
|
|
359
|
-
cacheWrite: 0,
|
|
360
|
-
cost: 0,
|
|
361
|
-
sessions: 0,
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
/** Bring the per-file cache up to date (reading only what changed) and return every file's turns. */
|
|
365
|
-
async function loadAll(sessionsRoot: string, chunkSize = CHUNK_BYTES): Promise<Turn[][]> {
|
|
366
|
-
const files = listSessionFiles(sessionsRoot);
|
|
367
|
-
const live = new Set(files);
|
|
368
|
-
for (const key of FILE_CACHE.keys()) if (!live.has(key)) FILE_CACHE.delete(key);
|
|
369
|
-
|
|
370
|
-
const out: Turn[][] = [];
|
|
371
|
-
for (const file of files) {
|
|
372
|
-
const st = fileStat(file);
|
|
373
|
-
if (st === undefined) continue; // vanished between listing and stat
|
|
374
|
-
let entry = FILE_CACHE.get(file);
|
|
375
|
-
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
376
|
-
const fresh = await refreshEntry(file, st, entry, chunkSize);
|
|
377
|
-
if (fresh) FILE_CACHE.set(file, fresh);
|
|
378
|
-
else FILE_CACHE.delete(file);
|
|
379
|
-
entry = fresh;
|
|
380
|
-
}
|
|
381
|
-
if (entry) out.push(entry.tail.length > 0 ? entry.turns.concat(entry.tail) : entry.turns);
|
|
382
|
-
}
|
|
383
|
-
return out;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** Test hooks: parse with a tiny chunk size (chunk boundaries) / refresh one file directly. */
|
|
387
|
-
export const __loadAllForTests = loadAll;
|
|
388
|
-
export const __refreshEntryForTests = refreshEntry;
|
|
389
|
-
|
|
390
|
-
const turnTokens = (t: Turn, includeCache: boolean) =>
|
|
391
|
-
t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
|
|
392
|
-
|
|
393
|
-
export async function collectUsage(
|
|
394
|
-
sessionsRoot: string,
|
|
395
|
-
now: Date = new Date(),
|
|
396
|
-
includeCache = true,
|
|
397
|
-
): Promise<TokenBurnReport> {
|
|
398
|
-
const startOfDay = bucketStart(now.getTime(), "day");
|
|
399
|
-
const startOfWeek = bucketStart(now.getTime(), "week");
|
|
400
|
-
const startOfMonth = bucketStart(now.getTime(), "month");
|
|
401
|
-
const startOfYear = bucketStart(now.getTime(), "year");
|
|
402
|
-
|
|
403
|
-
const report: TokenBurnReport = {
|
|
404
|
-
today: emptyStats(),
|
|
405
|
-
week: emptyStats(),
|
|
406
|
-
month: emptyStats(),
|
|
407
|
-
year: emptyStats(),
|
|
408
|
-
total: emptyStats(),
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
const add = (p: PeriodStats, t: Turn) => {
|
|
412
|
-
p.tokens += turnTokens(t, includeCache);
|
|
413
|
-
p.input += t.input;
|
|
414
|
-
p.output += t.output;
|
|
415
|
-
p.cacheRead += t.cacheRead;
|
|
416
|
-
p.cacheWrite += t.cacheWrite;
|
|
417
|
-
p.cost += t.cost;
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
for (const turns of await loadAll(sessionsRoot)) {
|
|
421
|
-
let maxTs = 0;
|
|
422
|
-
for (const t of turns) {
|
|
423
|
-
if (t.ts > maxTs) maxTs = t.ts;
|
|
424
|
-
add(report.total, t);
|
|
425
|
-
if (t.ts >= startOfYear) add(report.year, t);
|
|
426
|
-
if (t.ts >= startOfMonth) add(report.month, t);
|
|
427
|
-
if (t.ts >= startOfWeek) add(report.week, t);
|
|
428
|
-
if (t.ts >= startOfDay) add(report.today, t);
|
|
429
|
-
}
|
|
430
|
-
if (turns.length > 0) report.total.sessions++;
|
|
431
|
-
if (maxTs >= startOfYear) report.year.sessions++;
|
|
432
|
-
if (maxTs >= startOfMonth) report.month.sessions++;
|
|
433
|
-
if (maxTs >= startOfWeek) report.week.sessions++;
|
|
434
|
-
if (maxTs >= startOfDay) report.today.sessions++;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
return report;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// ---------------------------------------------------------------------------
|
|
441
|
-
// Time series + chart
|
|
442
|
-
// ---------------------------------------------------------------------------
|
|
443
|
-
|
|
444
|
-
export type Unit = "day" | "week" | "month" | "year";
|
|
445
|
-
|
|
446
|
-
/** Local-time start of the day / Monday-week / month containing `ts`. */
|
|
447
|
-
export function bucketStart(ts: number, unit: Unit): number {
|
|
448
|
-
const d = new Date(ts);
|
|
449
|
-
if (unit === "day") return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
450
|
-
if (unit === "week") {
|
|
451
|
-
const dow = (d.getDay() + 6) % 7; // Monday = 0
|
|
452
|
-
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
|
|
453
|
-
}
|
|
454
|
-
if (unit === "year") return new Date(d.getFullYear(), 0, 1).getTime();
|
|
455
|
-
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
459
|
-
|
|
460
|
-
export function bucketLabel(start: number, unit: Unit): string {
|
|
461
|
-
const d = new Date(start);
|
|
462
|
-
if (unit === "year") return String(d.getFullYear());
|
|
463
|
-
if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
|
|
464
|
-
const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
465
|
-
return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
export interface SeriesPoint {
|
|
469
|
-
start: number;
|
|
470
|
-
label: string;
|
|
471
|
-
tokens: number;
|
|
472
|
-
cost: number;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6, year: 5 };
|
|
476
|
-
|
|
477
|
-
/** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
|
|
478
|
-
export async function collectSeries(
|
|
479
|
-
sessionsRoot: string,
|
|
480
|
-
unit: Unit,
|
|
481
|
-
count = DEFAULT_COUNT[unit],
|
|
482
|
-
now: Date = new Date(),
|
|
483
|
-
includeCache = true,
|
|
484
|
-
): Promise<SeriesPoint[]> {
|
|
485
|
-
const starts: number[] = [];
|
|
486
|
-
for (let i = count - 1; i >= 0; i--) {
|
|
487
|
-
const y = now.getFullYear();
|
|
488
|
-
const m = now.getMonth();
|
|
489
|
-
const day = now.getDate();
|
|
490
|
-
const anchor =
|
|
491
|
-
unit === "day"
|
|
492
|
-
? new Date(y, m, day - i)
|
|
493
|
-
: unit === "week"
|
|
494
|
-
? new Date(y, m, day - i * 7)
|
|
495
|
-
: unit === "year"
|
|
496
|
-
? new Date(y - i, 0, 1)
|
|
497
|
-
: new Date(y, m - i, 1);
|
|
498
|
-
starts.push(bucketStart(anchor.getTime(), unit));
|
|
499
|
-
}
|
|
500
|
-
const points = new Map<number, SeriesPoint>(
|
|
501
|
-
starts.map((start) => [start, { start, label: bucketLabel(start, unit), tokens: 0, cost: 0 }]),
|
|
502
|
-
);
|
|
503
|
-
for (const turns of await loadAll(sessionsRoot)) {
|
|
504
|
-
for (const t of turns) {
|
|
505
|
-
const pt = points.get(bucketStart(t.ts, unit));
|
|
506
|
-
if (!pt) continue;
|
|
507
|
-
pt.tokens += turnTokens(t, includeCache);
|
|
508
|
-
pt.cost += t.cost;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
return starts.map((s) => points.get(s)!);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* Whole history: monthly columns while it spans <= 18 months, yearly after that.
|
|
516
|
-
* Always at least 2 columns so the chart has a shape.
|
|
517
|
-
*/
|
|
518
|
-
export async function collectAllSeries(
|
|
519
|
-
sessionsRoot: string,
|
|
520
|
-
now: Date = new Date(),
|
|
521
|
-
includeCache = true,
|
|
522
|
-
): Promise<{ unit: Unit; series: SeriesPoint[] }> {
|
|
523
|
-
let first = now.getTime();
|
|
524
|
-
for (const turns of await loadAll(sessionsRoot)) for (const t of turns) if (t.ts < first) first = t.ts;
|
|
525
|
-
const f = new Date(first);
|
|
526
|
-
const months = (now.getFullYear() - f.getFullYear()) * 12 + (now.getMonth() - f.getMonth()) + 1;
|
|
527
|
-
if (months <= 18) {
|
|
528
|
-
const unit: Unit = "month";
|
|
529
|
-
return { unit, series: await collectSeries(sessionsRoot, unit, Math.max(2, months), now, includeCache) };
|
|
530
|
-
}
|
|
531
|
-
const years = now.getFullYear() - f.getFullYear() + 1;
|
|
532
|
-
return { unit: "year", series: await collectSeries(sessionsRoot, "year", years, now, includeCache) };
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
536
|
-
const SPARK = "▁▂▃▄▅▆▇█";
|
|
537
|
-
|
|
538
|
-
/** Horizontal bar of `value/max` over `width` cells, with 1/8-cell precision. */
|
|
539
|
-
export function bar(value: number, max: number, width: number): string {
|
|
540
|
-
if (max <= 0 || value <= 0) return "";
|
|
541
|
-
const eighths = Math.max(1, Math.round((value / max) * width * 8));
|
|
542
|
-
const full = Math.floor(eighths / 8);
|
|
543
|
-
return "█".repeat(full) + EIGHTHS[eighths % 8];
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
export function sparkline(values: number[]): string {
|
|
547
|
-
const max = Math.max(...values, 0);
|
|
548
|
-
if (max <= 0) return SPARK[0].repeat(values.length);
|
|
549
|
-
return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" };
|
|
553
|
-
const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month", year: "year" };
|
|
554
|
-
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
555
|
-
|
|
556
|
-
export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
|
|
557
|
-
const plain: Paint = (_k, t) => t;
|
|
558
|
-
|
|
559
|
-
/** Two header rows for a column: [top, bottom]. */
|
|
560
|
-
function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
|
|
561
|
-
const d = new Date(p.start);
|
|
562
|
-
if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
|
|
563
|
-
if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
|
|
564
|
-
if (unit === "year") return [String(d.getFullYear()), ""];
|
|
565
|
-
return [MONTHS[d.getMonth()], String(d.getFullYear())];
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* Vertical column chart: y-axis, 1/8-row precision, optional budget line,
|
|
570
|
-
* highlighted current bucket and over-budget columns, summary footer.
|
|
571
|
-
*/
|
|
572
|
-
export function renderChart(
|
|
573
|
-
series: SeriesPoint[],
|
|
574
|
-
unit: Unit,
|
|
575
|
-
opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint; title?: string } = {},
|
|
576
|
-
): string[] {
|
|
577
|
-
const paint = opts.paint ?? plain;
|
|
578
|
-
const height = opts.height ?? 8;
|
|
579
|
-
const colW = unit === "day" ? 3 : 6; // bar cells + 1 gap
|
|
580
|
-
const barW = colW - 1;
|
|
581
|
-
|
|
582
|
-
const peakPoint = series.reduce((m, p) => (p.tokens > m.tokens ? p : m), series[0]);
|
|
583
|
-
const dataMax = peakPoint?.tokens ?? 0;
|
|
584
|
-
const budgetTokens = opts.budget?.tokens;
|
|
585
|
-
const scaleMax = Math.max(dataMax, budgetTokens ?? 0);
|
|
586
|
-
const total = series.reduce((a, p) => a + p.tokens, 0);
|
|
587
|
-
const totalCost = series.reduce((a, p) => a + p.cost, 0);
|
|
588
|
-
|
|
589
|
-
const yLabels = [formatNum(scaleMax), formatNum(scaleMax / 2), "0"];
|
|
590
|
-
const yW = Math.max(...yLabels.map((l) => l.length)) + 1;
|
|
591
|
-
const labelFor = (row: number) => (row === height ? yLabels[0] : row === Math.ceil(height / 2) ? yLabels[1] : "");
|
|
592
|
-
const budgetRow = budgetTokens && scaleMax > 0 ? Math.max(1, Math.ceil((budgetTokens / scaleMax) * height)) : 0;
|
|
593
|
-
|
|
594
|
-
const isOver = (p: SeriesPoint) =>
|
|
595
|
-
opts.budget ? isOverBudget({ tokens: p.tokens, cost: p.cost } as PeriodStats, opts.budget) : false;
|
|
596
|
-
const kindOf = (i: number): "bar" | "now" | "over" =>
|
|
597
|
-
isOver(series[i]) ? "over" : i === series.length - 1 ? "now" : "bar";
|
|
598
|
-
|
|
599
|
-
const lines: string[] = [];
|
|
600
|
-
lines.push(
|
|
601
|
-
paint("title", opts.title ?? `${UNIT_TITLE[unit]} tokens`) +
|
|
602
|
-
paint("dim", ` · ${opts.title ? "" : "last "}${series.length} ${UNIT_NOUN[unit]}${series.length === 1 ? "" : "s"}`) +
|
|
603
|
-
(budgetTokens ? paint("budget", ` ┄ budget ${formatNum(budgetTokens)}`) : ""),
|
|
604
|
-
);
|
|
605
|
-
|
|
606
|
-
for (let row = height; row >= 1; row--) {
|
|
607
|
-
let line = paint("axis", `${labelFor(row).padStart(yW)} │`);
|
|
608
|
-
for (let i = 0; i < series.length; i++) {
|
|
609
|
-
const h = scaleMax > 0 ? (series[i].tokens / scaleMax) * height : 0;
|
|
610
|
-
let cell: string;
|
|
611
|
-
if (h >= row) cell = "█";
|
|
612
|
-
else if (h > row - 1 && series[i].tokens > 0) cell = SPARK[Math.min(7, Math.max(0, Math.floor((h - (row - 1)) * 8) - 1))];
|
|
613
|
-
else if (row === 1 && series[i].tokens > 0) cell = "▁";
|
|
614
|
-
else cell = row === budgetRow ? "┄" : " ";
|
|
615
|
-
const filled = cell !== " " && cell !== "┄";
|
|
616
|
-
const body = cell.repeat(barW);
|
|
617
|
-
line += filled ? paint(kindOf(i), body) : cell === "┄" ? paint("budget", body) : body;
|
|
618
|
-
line += row === budgetRow ? paint("budget", "┄") : " ";
|
|
619
|
-
}
|
|
620
|
-
lines.push(line);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
lines.push(paint("axis", `${"".padStart(yW)} └${"─".repeat(series.length * colW)}`));
|
|
624
|
-
const pad = " ".repeat(yW + 2);
|
|
625
|
-
const cols = series.map((p, i) => ({ l: columnLabels(p, unit), now: i === series.length - 1 }));
|
|
626
|
-
lines.push(pad + cols.map((c) => c.l[0].padEnd(colW)).join(""));
|
|
627
|
-
lines.push(paint("dim", pad + cols.map((c) => c.l[1].padEnd(colW)).join("")));
|
|
628
|
-
lines.push(pad + cols.map((c) => (c.now ? paint("now", "▲".padEnd(colW)) : " ".repeat(colW))).join(""));
|
|
629
|
-
|
|
630
|
-
const avg = series.length ? total / series.length : 0;
|
|
631
|
-
lines.push(
|
|
632
|
-
paint(
|
|
633
|
-
"dim",
|
|
634
|
-
`Σ ${formatNum(total)} (${formatCost(totalCost)}) · avg ${formatNum(avg)}/${UNIT_NOUN[unit]}` +
|
|
635
|
-
(dataMax > 0 ? ` · peak ${formatNum(dataMax)} (${bucketLabel(peakPoint.start, unit)})` : ""),
|
|
636
|
-
),
|
|
637
|
-
);
|
|
638
|
-
return lines;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** Clear the per-file cache (tests). */
|
|
642
|
-
export function clearCache(): void {
|
|
643
|
-
FILE_CACHE.clear();
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
|
|
647
|
-
const b = cfg.budgets;
|
|
648
|
-
if (w === "today") return { tokens: b.dayTokens, cost: b.dayCost };
|
|
649
|
-
if (w === "week") return { tokens: b.weekTokens, cost: b.weekCost };
|
|
650
|
-
if (w === "month") return { tokens: b.monthTokens, cost: b.monthCost };
|
|
651
|
-
return {};
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
export function isOverBudget(p: PeriodStats, b: { tokens?: number; cost?: number }): boolean {
|
|
655
|
-
return (b.tokens !== undefined && p.tokens > b.tokens) || (b.cost !== undefined && p.cost > b.cost);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// ---------------------------------------------------------------------------
|
|
659
|
-
// Live refresh scheduling
|
|
660
|
-
// ---------------------------------------------------------------------------
|
|
661
|
-
|
|
662
|
-
export interface Timers {
|
|
663
|
-
set: (fn: () => void, ms: number) => unknown;
|
|
664
|
-
clear: (handle: unknown) => void;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const realTimers: Timers = {
|
|
668
|
-
set: (fn, ms) => setTimeout(fn, ms),
|
|
669
|
-
clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
670
|
-
};
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* Runs `run` at most once per `delayMs` window however often `schedule()` is called
|
|
674
|
-
* (trailing edge), and never two runs at once: a request that arrives mid-run triggers one
|
|
675
|
-
* more run afterwards. A burst of 50 messages costs one refresh. Failures are swallowed;
|
|
676
|
-
* the timer never keeps the process alive.
|
|
677
|
-
*/
|
|
678
|
-
export function createCoalescer(
|
|
679
|
-
run: () => Promise<void>,
|
|
680
|
-
delayMs: number | (() => number),
|
|
681
|
-
timers: Timers = realTimers,
|
|
682
|
-
) {
|
|
683
|
-
let timer: unknown;
|
|
684
|
-
let running = false;
|
|
685
|
-
let dirty = false;
|
|
686
|
-
|
|
687
|
-
const execute = async () => {
|
|
688
|
-
if (running) {
|
|
689
|
-
dirty = true;
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
running = true;
|
|
693
|
-
try {
|
|
694
|
-
await run();
|
|
695
|
-
} catch {
|
|
696
|
-
/* a failed refresh must never surface */
|
|
697
|
-
} finally {
|
|
698
|
-
running = false;
|
|
699
|
-
}
|
|
700
|
-
if (dirty) {
|
|
701
|
-
dirty = false;
|
|
702
|
-
schedule();
|
|
703
|
-
}
|
|
704
|
-
};
|
|
705
|
-
|
|
706
|
-
const schedule = () => {
|
|
707
|
-
if (timer !== undefined) return; // one is already pending: coalesce
|
|
708
|
-
const ms = typeof delayMs === "function" ? delayMs() : delayMs;
|
|
709
|
-
timer = timers.set(() => {
|
|
710
|
-
timer = undefined;
|
|
711
|
-
void execute();
|
|
712
|
-
}, ms);
|
|
713
|
-
(timer as { unref?: () => void } | undefined)?.unref?.();
|
|
714
|
-
};
|
|
715
|
-
|
|
716
|
-
const cancel = () => {
|
|
717
|
-
if (timer !== undefined) timers.clear(timer);
|
|
718
|
-
timer = undefined;
|
|
719
|
-
dirty = false;
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
/** Run now (dropping any pending delayed run). */
|
|
723
|
-
const flush = () => {
|
|
724
|
-
if (timer !== undefined) timers.clear(timer);
|
|
725
|
-
timer = undefined;
|
|
726
|
-
return execute();
|
|
727
|
-
};
|
|
728
|
-
|
|
729
|
-
return { schedule, cancel, flush, isPending: () => timer !== undefined };
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Map chart roles to pi theme colors. */
|
|
733
|
-
export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint {
|
|
734
|
-
const color: Record<string, string> = {
|
|
735
|
-
bar: "accent",
|
|
736
|
-
now: "success",
|
|
737
|
-
over: "error",
|
|
738
|
-
axis: "dim",
|
|
739
|
-
budget: "warning",
|
|
740
|
-
dim: "dim",
|
|
741
|
-
title: "accent",
|
|
742
|
-
};
|
|
743
|
-
return (kind, text) => theme.fg(color[kind], text);
|
|
744
|
-
}
|
|
20
|
+
import { handlers } from "./command/handlers.ts";
|
|
21
|
+
import type { Host } from "./command/host.ts";
|
|
22
|
+
import { completions } from "./command/completions.ts";
|
|
23
|
+
import { parseCommand } from "./command/parse.ts";
|
|
24
|
+
import { collectUsage } from "./core/aggregate.ts";
|
|
25
|
+
import { freshConfig, getSessionsDir, loadConfig, saveConfig, type TokenBurnConfig } from "./core/config.ts";
|
|
26
|
+
import type { UsageFilter, UsageOptions } from "./core/select.ts";
|
|
27
|
+
import { budgetWarnings } from "./features/budgets/budgets.ts";
|
|
28
|
+
import { createCoalescer } from "./features/live/coalescer.ts";
|
|
29
|
+
import { renderStatusLine, renderWidget } from "./features/status-line/render.ts";
|
|
30
|
+
|
|
31
|
+
// Public API (tests and other tools import from here).
|
|
32
|
+
export * from "./core/types.ts";
|
|
33
|
+
export * from "./core/config.ts";
|
|
34
|
+
export * from "./core/format.ts";
|
|
35
|
+
export * from "./core/parser.ts";
|
|
36
|
+
export * from "./core/time.ts";
|
|
37
|
+
export * from "./core/aggregate.ts";
|
|
38
|
+
export * from "./core/paint.ts";
|
|
39
|
+
export * from "./core/select.ts";
|
|
40
|
+
export * from "./command/parse.ts";
|
|
41
|
+
export * from "./command/completions.ts";
|
|
42
|
+
export * from "./features/report/chart.ts";
|
|
43
|
+
export * from "./features/report/report.ts";
|
|
44
|
+
export * from "./features/live/coalescer.ts";
|
|
45
|
+
export * from "./features/budgets/budgets.ts";
|
|
46
|
+
export * from "./features/status-line/status.ts";
|
|
47
|
+
export * from "./features/breakdown/query.ts";
|
|
48
|
+
export * from "./features/breakdown/table.ts";
|
|
49
|
+
export * from "./features/pricing/report.ts";
|
|
50
|
+
export * from "./features/export/export.ts";
|
|
745
51
|
|
|
746
52
|
export default function tokenBurnExtension(pi: ExtensionAPI) {
|
|
747
53
|
let config: TokenBurnConfig = freshConfig();
|
|
54
|
+
/** Budget warnings already shown (id = budget + period bucket + level): each shows once. */
|
|
55
|
+
const warned = new Set<string>();
|
|
56
|
+
|
|
57
|
+
/** Config filter (+ price table) merged with per-command filters. */
|
|
58
|
+
const usageOpts = (extra?: UsageFilter): UsageOptions => ({
|
|
59
|
+
filter: { ...config.filter, ...extra },
|
|
60
|
+
prices: config.prices,
|
|
61
|
+
priceMode: config.priceMode,
|
|
62
|
+
});
|
|
748
63
|
|
|
64
|
+
/** Redraw the status line and panel, and show any budget warning that is due. */
|
|
749
65
|
const updateUI = async (ctx: ExtensionContext) => {
|
|
750
66
|
if (!ctx.hasUI) return;
|
|
751
67
|
if (!config.enabled) {
|
|
@@ -754,49 +70,20 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
|
|
|
754
70
|
ctx.ui.setWidget("tokenburn-panel", []);
|
|
755
71
|
return;
|
|
756
72
|
}
|
|
757
|
-
const
|
|
73
|
+
const now = new Date();
|
|
74
|
+
const report = await collectUsage(getSessionsDir(), now, config.includeCache, usageOpts());
|
|
758
75
|
const theme = ctx.ui.theme;
|
|
759
76
|
|
|
760
|
-
|
|
761
|
-
if (config.showStatus) {
|
|
762
|
-
const stat = report[config.statusWindow];
|
|
763
|
-
const isOver = isOverBudget(stat, budgetsFor(config, config.statusWindow));
|
|
764
|
-
const fire = isOver ? theme.fg("error", "🔥 OVER BUDGET") : theme.fg("warning", "🔥");
|
|
765
|
-
const tokensText = theme.fg(isOver ? "error" : "accent", formatNum(stat.tokens));
|
|
766
|
-
const costText = theme.fg("dim", `(${formatCost(stat.cost)})`);
|
|
767
|
-
const label = theme.fg("dim", `[${config.statusWindow}]`);
|
|
77
|
+
ctx.ui.setStatus("tokenburn", config.showStatus ? renderStatusLine(report, config, theme, now.getTime()) : "");
|
|
768
78
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
79
|
+
for (const w of budgetWarnings(report, config, now.getTime())) {
|
|
80
|
+
if (warned.has(w.id)) continue;
|
|
81
|
+
warned.add(w.id);
|
|
82
|
+
ctx.ui.notify(w.message, "warning");
|
|
772
83
|
}
|
|
773
84
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const border = theme.fg("dim", "─".repeat(46));
|
|
777
|
-
const header = theme.fg("accent", " 🔥 TokenBurn Dashboard ");
|
|
778
|
-
const line = (label: string, p: PeriodStats, b: { tokens?: number; cost?: number } = {}) => {
|
|
779
|
-
const over = isOverBudget(p, b);
|
|
780
|
-
const tokStr = (over ? theme.fg("error", "!") : " ") + formatNum(p.tokens).padStart(8);
|
|
781
|
-
const inStr = `(in:${formatNum(p.input)} out:${formatNum(p.output)})`.padEnd(20);
|
|
782
|
-
const costStr = formatCost(p.cost).padStart(8);
|
|
783
|
-
return ` ${theme.fg("muted", label.padEnd(8))} ${tokStr} ${theme.fg("dim", inStr)} ${costStr}`;
|
|
784
|
-
};
|
|
785
|
-
|
|
786
|
-
const lines = [
|
|
787
|
-
`┌${header}${"─".repeat(21)}┐`,
|
|
788
|
-
line("Today", report.today, budgetsFor(config, "today")),
|
|
789
|
-
line("Week", report.week, budgetsFor(config, "week")),
|
|
790
|
-
line("Month", report.month, budgetsFor(config, "month")),
|
|
791
|
-
line("Year", report.year),
|
|
792
|
-
line("Total", report.total),
|
|
793
|
-
`└${"─".repeat(46)}┘`,
|
|
794
|
-
];
|
|
795
|
-
|
|
796
|
-
ctx.ui.setWidget("tokenburn-panel", lines, { placement: "belowEditor" });
|
|
797
|
-
} else {
|
|
798
|
-
ctx.ui.setWidget("tokenburn-panel", []);
|
|
799
|
-
}
|
|
85
|
+
if (config.showWidget) ctx.ui.setWidget("tokenburn-panel", renderWidget(report, config, theme), { placement: "belowEditor" });
|
|
86
|
+
else ctx.ui.setWidget("tokenburn-panel", []);
|
|
800
87
|
};
|
|
801
88
|
|
|
802
89
|
// The latest context, used by the (delayed) live refresh. Dropped at shutdown so a reloaded
|
|
@@ -839,264 +126,25 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
|
|
|
839
126
|
});
|
|
840
127
|
|
|
841
128
|
pi.registerCommand("tokenburn", {
|
|
842
|
-
description: "TokenBurn: /tokenburn [day|week|month|year|all] sets the status bar;
|
|
129
|
+
description: "TokenBurn: /tokenburn [hour|day|week|month|year|all] sets the status bar; models, providers, projects, sessions show tables; chart <view> shows the chart",
|
|
843
130
|
getArgumentCompletions: (prefix: string) => completions(prefix),
|
|
844
131
|
handler: async (args, ctx) => {
|
|
845
|
-
const cmd = parseCommand(args);
|
|
846
132
|
latestCtx = ctx;
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
133
|
+
const host: Host = {
|
|
134
|
+
ctx,
|
|
135
|
+
config,
|
|
136
|
+
usageOpts,
|
|
137
|
+
persist: async () => {
|
|
138
|
+
await saveConfig(config);
|
|
139
|
+
await updateUI(ctx);
|
|
140
|
+
},
|
|
141
|
+
// Settings changed while disabled are kept, but nothing shows until it is enabled.
|
|
142
|
+
note: (msg) => ctx.ui.notify(config.enabled ? msg : `${msg} (tokenburn is disabled; /tokenburn enable to show it)`, "info"),
|
|
143
|
+
cancelRefresh: () => refresher.cancel(),
|
|
144
|
+
resetWarnings: () => warned.clear(),
|
|
850
145
|
};
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
switch (cmd.kind) {
|
|
855
|
-
case "window":
|
|
856
|
-
config.showWidget = !config.showWidget;
|
|
857
|
-
await persist();
|
|
858
|
-
note(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`);
|
|
859
|
-
return;
|
|
860
|
-
|
|
861
|
-
case "status":
|
|
862
|
-
config.statusWindow = cmd.window;
|
|
863
|
-
await persist();
|
|
864
|
-
note(`tokenburn status window: ${cmd.window}`);
|
|
865
|
-
return;
|
|
866
|
-
|
|
867
|
-
case "cycle": {
|
|
868
|
-
const next = STATUS_WINDOWS[(STATUS_WINDOWS.indexOf(config.statusWindow) + 1) % STATUS_WINDOWS.length];
|
|
869
|
-
config.statusWindow = next;
|
|
870
|
-
await persist();
|
|
871
|
-
note(`tokenburn status window: ${next}`);
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
case "cache":
|
|
876
|
-
config.includeCache = !config.includeCache;
|
|
877
|
-
await persist();
|
|
878
|
-
note(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`);
|
|
879
|
-
return;
|
|
880
|
-
|
|
881
|
-
case "budget": {
|
|
882
|
-
const key = `${cmd.period}${cmd.isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
|
|
883
|
-
config.budgets[key] = cmd.amount;
|
|
884
|
-
await persist();
|
|
885
|
-
note(`tokenburn ${cmd.period} budget: ${cmd.isCost ? formatCost(cmd.amount) : formatNum(cmd.amount) + " tokens"}`);
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
case "enable":
|
|
890
|
-
if (config.enabled) {
|
|
891
|
-
ctx.ui.notify("tokenburn is already enabled", "info");
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
config.enabled = true;
|
|
895
|
-
await persist();
|
|
896
|
-
ctx.ui.notify("tokenburn enabled", "info");
|
|
897
|
-
return;
|
|
898
|
-
|
|
899
|
-
case "disable":
|
|
900
|
-
if (!config.enabled) {
|
|
901
|
-
ctx.ui.notify("tokenburn is already disabled", "info");
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
config.enabled = false;
|
|
905
|
-
refresher.cancel();
|
|
906
|
-
await persist(); // clears the status line and the panel
|
|
907
|
-
ctx.ui.notify("tokenburn disabled: nothing runs in the background. /tokenburn enable turns it back on (charts still work on demand)", "info");
|
|
908
|
-
return;
|
|
909
|
-
|
|
910
|
-
case "live":
|
|
911
|
-
config.liveRefresh = !config.liveRefresh;
|
|
912
|
-
if (!config.liveRefresh) refresher.cancel();
|
|
913
|
-
await persist();
|
|
914
|
-
note(`tokenburn live refresh ${config.liveRefresh ? "on (after each assistant message)" : "off (only when a turn ends)"}`);
|
|
915
|
-
return;
|
|
916
|
-
|
|
917
|
-
case "invalid":
|
|
918
|
-
ctx.ui.notify(cmd.message, "warning");
|
|
919
|
-
return;
|
|
920
|
-
|
|
921
|
-
case "report": {
|
|
922
|
-
const root = getSessionsDir();
|
|
923
|
-
const now = new Date();
|
|
924
|
-
const report = await collectUsage(root, now, config.includeCache);
|
|
925
|
-
const paint = config.chartColor ? themePaint(ctx.ui.theme) : undefined;
|
|
926
|
-
|
|
927
|
-
let chart: string[];
|
|
928
|
-
if (cmd.view === "all") {
|
|
929
|
-
const all = await collectAllSeries(root, now, config.includeCache);
|
|
930
|
-
chart = renderChart(all.series, all.unit, { paint, title: "All time" });
|
|
931
|
-
} else {
|
|
932
|
-
const series = await collectSeries(root, cmd.view, DEFAULT_COUNT[cmd.view], now, config.includeCache);
|
|
933
|
-
const budgetWindow: Window | undefined =
|
|
934
|
-
cmd.view === "day" ? "today" : cmd.view === "year" ? undefined : cmd.view;
|
|
935
|
-
chart = renderChart(series, cmd.view, {
|
|
936
|
-
paint,
|
|
937
|
-
budget: budgetWindow ? budgetsFor(config, budgetWindow) : undefined,
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
const row = (name: string, p: PeriodStats) =>
|
|
942
|
-
`${name.padEnd(10)} ${formatNum(p.tokens).padStart(8)} tokens ${formatCost(p.cost).padStart(9)} · in:${formatNum(p.input)} out:${formatNum(p.output)} cache:${formatNum(p.cacheRead + p.cacheWrite)} · ${p.sessions} sess`;
|
|
943
|
-
const rule = "─".repeat(60);
|
|
944
|
-
ctx.ui.notify(
|
|
945
|
-
[
|
|
946
|
-
"🔥 TokenBurn Report",
|
|
947
|
-
rule,
|
|
948
|
-
row("Today", report.today),
|
|
949
|
-
row("This Week", report.week),
|
|
950
|
-
row("This Month", report.month),
|
|
951
|
-
row("This Year", report.year),
|
|
952
|
-
row("All Time", report.total),
|
|
953
|
-
rule,
|
|
954
|
-
...chart,
|
|
955
|
-
rule,
|
|
956
|
-
"chart <view> · <view> sets the status bar · cycle · window · budget · cache · live · on|off",
|
|
957
|
-
].join("\n"),
|
|
958
|
-
"info",
|
|
959
|
-
);
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
}
|
|
146
|
+
const cmd = parseCommand(args);
|
|
147
|
+
await (handlers[cmd.kind] as (c: typeof cmd, h: Host) => Promise<void>)(cmd, host);
|
|
963
148
|
},
|
|
964
149
|
});
|
|
965
150
|
}
|
|
966
|
-
|
|
967
|
-
// ---------------------------------------------------------------------------
|
|
968
|
-
// Command parsing + completions (pure, unit-tested)
|
|
969
|
-
// ---------------------------------------------------------------------------
|
|
970
|
-
|
|
971
|
-
export const REPORT_VIEWS = ["day", "week", "month", "year", "all"] as const;
|
|
972
|
-
export type ReportView = (typeof REPORT_VIEWS)[number];
|
|
973
|
-
export const BUDGET_PERIODS = ["day", "week", "month"] as const;
|
|
974
|
-
|
|
975
|
-
export type Command =
|
|
976
|
-
| { kind: "report"; view: ReportView }
|
|
977
|
-
| { kind: "status"; window: Window }
|
|
978
|
-
| { kind: "cycle" }
|
|
979
|
-
| { kind: "window" }
|
|
980
|
-
| { kind: "cache" }
|
|
981
|
-
| { kind: "enable" }
|
|
982
|
-
| { kind: "disable" }
|
|
983
|
-
| { kind: "live" }
|
|
984
|
-
| { kind: "budget"; period: (typeof BUDGET_PERIODS)[number]; amount: number; isCost: boolean }
|
|
985
|
-
| { kind: "invalid"; message: string };
|
|
986
|
-
|
|
987
|
-
export const USAGE =
|
|
988
|
-
"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 · live · enable · disable";
|
|
989
|
-
|
|
990
|
-
/** Words that pick the status-bar period: `day` shows today, `all` shows the all-time total. */
|
|
991
|
-
const WORD_TO_WINDOW: Record<string, Window> = {
|
|
992
|
-
day: "today",
|
|
993
|
-
today: "today",
|
|
994
|
-
week: "week",
|
|
995
|
-
month: "month",
|
|
996
|
-
year: "year",
|
|
997
|
-
all: "total",
|
|
998
|
-
total: "total",
|
|
999
|
-
};
|
|
1000
|
-
|
|
1001
|
-
/** Views the chart can show. */
|
|
1002
|
-
const asView = (w: string | undefined): ReportView | undefined =>
|
|
1003
|
-
w === "today" ? "day" : w === "total" ? "all" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
|
|
1004
|
-
|
|
1005
|
-
export function parseCommand(args: string | undefined): Command {
|
|
1006
|
-
const [cmd, p1, p2] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1007
|
-
if (!cmd) return { kind: "report", view: "day" };
|
|
1008
|
-
|
|
1009
|
-
// /tokenburn day|week|month|year|all → what the bottom status line shows
|
|
1010
|
-
const window = WORD_TO_WINDOW[cmd];
|
|
1011
|
-
if (window) return { kind: "status", window };
|
|
1012
|
-
|
|
1013
|
-
switch (cmd) {
|
|
1014
|
-
case "chart":
|
|
1015
|
-
case "report": {
|
|
1016
|
-
if (p1 === undefined) return { kind: "report", view: "day" };
|
|
1017
|
-
const v = asView(p1);
|
|
1018
|
-
return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown chart "${p1}". ${USAGE}` };
|
|
1019
|
-
}
|
|
1020
|
-
case "status":
|
|
1021
|
-
return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
|
|
1022
|
-
? { kind: "status", window: p1 as Window }
|
|
1023
|
-
: { kind: "invalid", message: `Usage: /tokenburn status <${STATUS_WINDOWS.join("|")}>` };
|
|
1024
|
-
case "cycle":
|
|
1025
|
-
return { kind: "cycle" };
|
|
1026
|
-
case "window":
|
|
1027
|
-
case "widget":
|
|
1028
|
-
return { kind: "window" };
|
|
1029
|
-
case "cache":
|
|
1030
|
-
return { kind: "cache" };
|
|
1031
|
-
case "enable":
|
|
1032
|
-
case "on":
|
|
1033
|
-
return { kind: "enable" };
|
|
1034
|
-
case "disable":
|
|
1035
|
-
case "off":
|
|
1036
|
-
return { kind: "disable" };
|
|
1037
|
-
case "live":
|
|
1038
|
-
return { kind: "live" };
|
|
1039
|
-
case "budget": {
|
|
1040
|
-
const isCost = (p2 ?? "").startsWith("$");
|
|
1041
|
-
const amount = Number((p2 ?? "").replace(/^\$/, ""));
|
|
1042
|
-
if (!(BUDGET_PERIODS as readonly string[]).includes(p1 ?? "") || !Number.isFinite(amount) || amount <= 0) {
|
|
1043
|
-
return { kind: "invalid", message: "Usage: /tokenburn budget <day|week|month> <tokens | $cost>" };
|
|
1044
|
-
}
|
|
1045
|
-
return { kind: "budget", period: p1 as (typeof BUDGET_PERIODS)[number], amount, isCost };
|
|
1046
|
-
}
|
|
1047
|
-
default:
|
|
1048
|
-
return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
export interface CompletionItem {
|
|
1053
|
-
value: string;
|
|
1054
|
-
/** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
|
|
1055
|
-
label: string;
|
|
1056
|
-
description?: string;
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
const CHART_SPAN: Record<ReportView, string> = {
|
|
1060
|
-
day: "last 14 days",
|
|
1061
|
-
week: "last 8 weeks",
|
|
1062
|
-
month: "last 6 months",
|
|
1063
|
-
year: "last 5 years",
|
|
1064
|
-
all: "whole history",
|
|
1065
|
-
};
|
|
1066
|
-
|
|
1067
|
-
const TOP_LEVEL: CompletionItem[] = [
|
|
1068
|
-
{ value: "day", label: "day", description: "Status bar: today" },
|
|
1069
|
-
{ value: "week", label: "week", description: "Status bar: this week" },
|
|
1070
|
-
{ value: "month", label: "month", description: "Status bar: this month" },
|
|
1071
|
-
{ value: "year", label: "year", description: "Status bar: this year" },
|
|
1072
|
-
{ value: "all", label: "all", description: "Status bar: all-time total" },
|
|
1073
|
-
{ value: "chart ", label: "chart", description: "Report + chart: day, week, month, year or all" },
|
|
1074
|
-
{ value: "status ", label: "status", description: "Set the status-bar period explicitly" },
|
|
1075
|
-
{ value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
|
|
1076
|
-
{ value: "window", label: "window", description: "Toggle the panel below the editor" },
|
|
1077
|
-
{ value: "budget ", label: "budget", description: "Set a day/week/month budget" },
|
|
1078
|
-
{ value: "cache", label: "cache", description: "Toggle counting cache tokens" },
|
|
1079
|
-
{ value: "live", label: "live", description: "Toggle refreshing after each message" },
|
|
1080
|
-
{ value: "enable", label: "enable", description: "Turn tokenburn on (status line, panel, refresh)" },
|
|
1081
|
-
{ value: "disable", label: "disable", description: "Turn tokenburn off: nothing runs in the background" },
|
|
1082
|
-
];
|
|
1083
|
-
|
|
1084
|
-
/** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
|
|
1085
|
-
export function completions(prefix: string): CompletionItem[] | null {
|
|
1086
|
-
const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
|
|
1087
|
-
// `tail` = " " when the completed text still needs another word (a budget amount).
|
|
1088
|
-
const sub = (head: string, words: readonly string[], desc: (w: string) => string, tail = ""): CompletionItem[] =>
|
|
1089
|
-
words.map((w) => ({ value: `${head} ${w}${tail}`, label: w, description: desc(w) }));
|
|
1090
|
-
|
|
1091
|
-
let pool: CompletionItem[];
|
|
1092
|
-
if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
|
|
1093
|
-
else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
|
|
1094
|
-
pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`, " ");
|
|
1095
|
-
else if (/^(report|chart) /.test(text)) {
|
|
1096
|
-
const head = text.split(" ")[0];
|
|
1097
|
-
pool = sub(head, REPORT_VIEWS, (w) => `Chart: ${CHART_SPAN[w as ReportView]}`);
|
|
1098
|
-
} else pool = TOP_LEVEL;
|
|
1099
|
-
|
|
1100
|
-
const out = pool.filter((i) => i.value.startsWith(text));
|
|
1101
|
-
return out.length > 0 ? out : null;
|
|
1102
|
-
}
|