@tmustier/pi-usage-extension 0.3.2 → 0.5.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/CHANGELOG.md +17 -0
- package/README.md +10 -1
- package/data.ts +872 -0
- package/index.ts +11 -555
- package/package.json +3 -3
package/index.ts
CHANGED
|
@@ -5,89 +5,17 @@
|
|
|
5
5
|
* - Tab cycles: Today → This Week → Last Week → All Time
|
|
6
6
|
* - Arrow keys navigate providers
|
|
7
7
|
* - Enter expands/collapses to show models
|
|
8
|
+
*
|
|
9
|
+
* Data collection and caching live in ./data.ts.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
13
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
13
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
|
-
|
|
17
|
-
// =============================================================================
|
|
18
|
-
// Types
|
|
19
|
-
// =============================================================================
|
|
20
|
-
|
|
21
|
-
interface TokenStats {
|
|
22
|
-
total: number;
|
|
23
|
-
input: number;
|
|
24
|
-
output: number;
|
|
25
|
-
cacheRead: number;
|
|
26
|
-
cacheWrite: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface BaseStats {
|
|
30
|
-
messages: number;
|
|
31
|
-
cost: number;
|
|
32
|
-
tokens: TokenStats;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface ModelStats extends BaseStats {
|
|
36
|
-
sessions: Set<string>;
|
|
37
|
-
}
|
|
38
15
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
models: Map<string, ModelStats>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface TotalStats extends BaseStats {
|
|
45
|
-
sessions: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface Insight {
|
|
49
|
-
percent: number; // 0-100
|
|
50
|
-
headline: string;
|
|
51
|
-
advice: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface PeriodInsights {
|
|
55
|
-
insights: Insight[];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
interface RawMessage {
|
|
59
|
-
sessionId: string;
|
|
60
|
-
timestamp: number;
|
|
61
|
-
cost: number;
|
|
62
|
-
input: number;
|
|
63
|
-
cacheRead: number;
|
|
64
|
-
cacheWrite: number;
|
|
65
|
-
}
|
|
16
|
+
import { collectUsageData, TAB_ORDER } from "./data";
|
|
17
|
+
import type { BaseStats, TabName, UsageData } from "./data";
|
|
66
18
|
|
|
67
|
-
interface PeriodRawData {
|
|
68
|
-
messages: RawMessage[];
|
|
69
|
-
sessionCosts: Map<string, number>;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface GlobalSessionSpan {
|
|
73
|
-
startMs: number;
|
|
74
|
-
endMs: number;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
interface TimeFilteredStats {
|
|
78
|
-
providers: Map<string, ProviderStats>;
|
|
79
|
-
totals: TotalStats;
|
|
80
|
-
insights: PeriodInsights;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface UsageData {
|
|
84
|
-
today: TimeFilteredStats;
|
|
85
|
-
thisWeek: TimeFilteredStats;
|
|
86
|
-
lastWeek: TimeFilteredStats;
|
|
87
|
-
allTime: TimeFilteredStats;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
|
|
91
19
|
type ViewMode = "table" | "insights";
|
|
92
20
|
|
|
93
21
|
// =============================================================================
|
|
@@ -182,482 +110,6 @@ const TABLE_LAYOUTS: TableLayoutCandidate[] = [
|
|
|
182
110
|
{ columns: [COST_COLUMN], minNameWidth: 8, compact: true },
|
|
183
111
|
];
|
|
184
112
|
|
|
185
|
-
// =============================================================================
|
|
186
|
-
// Data Collection
|
|
187
|
-
// =============================================================================
|
|
188
|
-
|
|
189
|
-
function getSessionsDir(): string {
|
|
190
|
-
// Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
|
|
191
|
-
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
192
|
-
return join(agentDir, "sessions");
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
|
|
196
|
-
try {
|
|
197
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
198
|
-
for (const entry of entries) {
|
|
199
|
-
if (signal?.aborted) return;
|
|
200
|
-
const entryPath = join(dir, entry.name);
|
|
201
|
-
if (entry.isDirectory()) {
|
|
202
|
-
await collectSessionFilesRecursively(entryPath, files, signal);
|
|
203
|
-
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
204
|
-
files.push(entryPath);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
} catch {
|
|
208
|
-
// Skip directories we can't read
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
|
|
213
|
-
const files: string[] = [];
|
|
214
|
-
await collectSessionFilesRecursively(getSessionsDir(), files, signal);
|
|
215
|
-
files.sort();
|
|
216
|
-
return files;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
interface SessionMessage {
|
|
220
|
-
provider: string;
|
|
221
|
-
model: string;
|
|
222
|
-
cost: number;
|
|
223
|
-
input: number;
|
|
224
|
-
output: number;
|
|
225
|
-
cacheRead: number;
|
|
226
|
-
cacheWrite: number;
|
|
227
|
-
timestamp: number;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
interface ParsedSessionFile {
|
|
231
|
-
sessionId: string;
|
|
232
|
-
messages: SessionMessage[];
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function parseSessionFile(
|
|
236
|
-
filePath: string,
|
|
237
|
-
seenHashes: Set<string>,
|
|
238
|
-
signal?: AbortSignal
|
|
239
|
-
): Promise<ParsedSessionFile | null> {
|
|
240
|
-
try {
|
|
241
|
-
const content = await readFile(filePath, "utf8");
|
|
242
|
-
if (signal?.aborted) return null;
|
|
243
|
-
const lines = content.trim().split("\n");
|
|
244
|
-
const messages: SessionMessage[] = [];
|
|
245
|
-
let sessionId = "";
|
|
246
|
-
|
|
247
|
-
for (let i = 0; i < lines.length; i++) {
|
|
248
|
-
if (signal?.aborted) return null;
|
|
249
|
-
if (i % 500 === 0) {
|
|
250
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
251
|
-
}
|
|
252
|
-
const line = lines[i]!;
|
|
253
|
-
if (!line.trim()) continue;
|
|
254
|
-
try {
|
|
255
|
-
const entry = JSON.parse(line);
|
|
256
|
-
|
|
257
|
-
if (entry.type === "session") {
|
|
258
|
-
sessionId = entry.id;
|
|
259
|
-
} else if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
260
|
-
const msg = entry.message;
|
|
261
|
-
if (msg.usage && msg.provider && msg.model) {
|
|
262
|
-
const input = msg.usage.input || 0;
|
|
263
|
-
const output = msg.usage.output || 0;
|
|
264
|
-
const cacheRead = msg.usage.cacheRead || 0;
|
|
265
|
-
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
266
|
-
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
267
|
-
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
268
|
-
|
|
269
|
-
// Deduplicate copied history across branched session files.
|
|
270
|
-
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
271
|
-
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
272
|
-
const hash = `${timestamp}:${totalTokens}`;
|
|
273
|
-
if (seenHashes.has(hash)) continue;
|
|
274
|
-
seenHashes.add(hash);
|
|
275
|
-
|
|
276
|
-
messages.push({
|
|
277
|
-
provider: msg.provider,
|
|
278
|
-
model: msg.model,
|
|
279
|
-
cost: msg.usage.cost?.total || 0,
|
|
280
|
-
input,
|
|
281
|
-
output,
|
|
282
|
-
cacheRead,
|
|
283
|
-
cacheWrite,
|
|
284
|
-
timestamp,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
} catch {
|
|
289
|
-
// Skip malformed lines
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return sessionId ? { sessionId, messages } : null;
|
|
294
|
-
} catch {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Helper to accumulate stats into a target
|
|
300
|
-
function accumulateStats(
|
|
301
|
-
target: BaseStats,
|
|
302
|
-
cost: number,
|
|
303
|
-
tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
304
|
-
): void {
|
|
305
|
-
target.messages++;
|
|
306
|
-
target.cost += cost;
|
|
307
|
-
target.tokens.total += tokens.total;
|
|
308
|
-
target.tokens.input += tokens.input;
|
|
309
|
-
target.tokens.output += tokens.output;
|
|
310
|
-
target.tokens.cacheRead += tokens.cacheRead;
|
|
311
|
-
target.tokens.cacheWrite += tokens.cacheWrite;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function emptyTokens(): TokenStats {
|
|
315
|
-
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function emptyModelStats(): ModelStats {
|
|
319
|
-
return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens() };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function emptyProviderStats(): ProviderStats {
|
|
323
|
-
return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens(), models: new Map() };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
327
|
-
return {
|
|
328
|
-
providers: new Map(),
|
|
329
|
-
totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
|
|
330
|
-
insights: { insights: [] },
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function emptyPeriodRawData(): PeriodRawData {
|
|
335
|
-
return { messages: [], sessionCosts: new Map() };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function emptyUsageData(): UsageData {
|
|
339
|
-
return {
|
|
340
|
-
today: emptyTimeFilteredStats(),
|
|
341
|
-
thisWeek: emptyTimeFilteredStats(),
|
|
342
|
-
lastWeek: emptyTimeFilteredStats(),
|
|
343
|
-
allTime: emptyTimeFilteredStats(),
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number, lastWeekStartMs: number): TabName[] {
|
|
348
|
-
const periods: TabName[] = ["allTime"];
|
|
349
|
-
if (timestamp >= todayMs) periods.push("today");
|
|
350
|
-
if (timestamp >= weekStartMs) {
|
|
351
|
-
periods.push("thisWeek");
|
|
352
|
-
} else if (timestamp >= lastWeekStartMs) {
|
|
353
|
-
periods.push("lastWeek");
|
|
354
|
-
}
|
|
355
|
-
return periods;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function addMessagesToUsageData(
|
|
359
|
-
data: UsageData,
|
|
360
|
-
sessionId: string,
|
|
361
|
-
messages: SessionMessage[],
|
|
362
|
-
todayMs: number,
|
|
363
|
-
weekStartMs: number,
|
|
364
|
-
lastWeekStartMs: number,
|
|
365
|
-
rawByPeriod: Record<TabName, PeriodRawData>,
|
|
366
|
-
globalSessionSpans: Map<string, GlobalSessionSpan>
|
|
367
|
-
): void {
|
|
368
|
-
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
|
|
369
|
-
|
|
370
|
-
for (const msg of messages) {
|
|
371
|
-
// Track real per-session lifetime across every message we see, regardless of
|
|
372
|
-
// which period the message falls into. Used later for the "8h+ session" insight.
|
|
373
|
-
if (msg.timestamp > 0) {
|
|
374
|
-
const span = globalSessionSpans.get(sessionId);
|
|
375
|
-
if (!span) {
|
|
376
|
-
globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
|
|
377
|
-
} else {
|
|
378
|
-
if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
|
|
379
|
-
if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
|
|
384
|
-
const tokens = {
|
|
385
|
-
// Count fresh tokens processed this turn.
|
|
386
|
-
// Include cacheWrite because those prompt tokens were newly written and billed.
|
|
387
|
-
// Exclude cacheRead because repeated cache hits would otherwise dominate totals.
|
|
388
|
-
total: msg.input + msg.output + msg.cacheWrite,
|
|
389
|
-
input: msg.input,
|
|
390
|
-
output: msg.output,
|
|
391
|
-
cacheRead: msg.cacheRead,
|
|
392
|
-
cacheWrite: msg.cacheWrite,
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
for (const period of periods) {
|
|
396
|
-
const stats = data[period];
|
|
397
|
-
|
|
398
|
-
let providerStats = stats.providers.get(msg.provider);
|
|
399
|
-
if (!providerStats) {
|
|
400
|
-
providerStats = emptyProviderStats();
|
|
401
|
-
stats.providers.set(msg.provider, providerStats);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
let modelStats = providerStats.models.get(msg.model);
|
|
405
|
-
if (!modelStats) {
|
|
406
|
-
modelStats = emptyModelStats();
|
|
407
|
-
providerStats.models.set(msg.model, modelStats);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
modelStats.sessions.add(sessionId);
|
|
411
|
-
accumulateStats(modelStats, msg.cost, tokens);
|
|
412
|
-
|
|
413
|
-
providerStats.sessions.add(sessionId);
|
|
414
|
-
accumulateStats(providerStats, msg.cost, tokens);
|
|
415
|
-
|
|
416
|
-
accumulateStats(stats.totals, msg.cost, tokens);
|
|
417
|
-
sessionContributed[period] = true;
|
|
418
|
-
|
|
419
|
-
const raw = rawByPeriod[period];
|
|
420
|
-
raw.messages.push({
|
|
421
|
-
sessionId,
|
|
422
|
-
timestamp: msg.timestamp,
|
|
423
|
-
cost: msg.cost,
|
|
424
|
-
input: msg.input,
|
|
425
|
-
cacheRead: msg.cacheRead,
|
|
426
|
-
cacheWrite: msg.cacheWrite,
|
|
427
|
-
});
|
|
428
|
-
raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (sessionContributed.today) data.today.totals.sessions++;
|
|
433
|
-
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
434
|
-
if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
|
|
435
|
-
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
|
|
439
|
-
const startOfToday = new Date();
|
|
440
|
-
startOfToday.setHours(0, 0, 0, 0);
|
|
441
|
-
const todayMs = startOfToday.getTime();
|
|
442
|
-
|
|
443
|
-
// Start of current week (Monday 00:00)
|
|
444
|
-
const startOfWeek = new Date();
|
|
445
|
-
const dayOfWeek = startOfWeek.getDay(); // 0 = Sunday, 1 = Monday, ...
|
|
446
|
-
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
447
|
-
startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
|
|
448
|
-
startOfWeek.setHours(0, 0, 0, 0);
|
|
449
|
-
const weekStartMs = startOfWeek.getTime();
|
|
450
|
-
|
|
451
|
-
// Start of last week (previous Monday 00:00)
|
|
452
|
-
const startOfLastWeek = new Date(startOfWeek);
|
|
453
|
-
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
|
454
|
-
const lastWeekStartMs = startOfLastWeek.getTime();
|
|
455
|
-
|
|
456
|
-
const data = emptyUsageData();
|
|
457
|
-
const rawByPeriod: Record<TabName, PeriodRawData> = {
|
|
458
|
-
today: emptyPeriodRawData(),
|
|
459
|
-
thisWeek: emptyPeriodRawData(),
|
|
460
|
-
lastWeek: emptyPeriodRawData(),
|
|
461
|
-
allTime: emptyPeriodRawData(),
|
|
462
|
-
};
|
|
463
|
-
const globalSessionSpans = new Map<string, GlobalSessionSpan>();
|
|
464
|
-
|
|
465
|
-
const sessionFiles = await getAllSessionFiles(signal);
|
|
466
|
-
if (signal?.aborted) return null;
|
|
467
|
-
const seenHashes = new Set<string>();
|
|
468
|
-
|
|
469
|
-
for (const filePath of sessionFiles) {
|
|
470
|
-
if (signal?.aborted) return null;
|
|
471
|
-
const parsed = await parseSessionFile(filePath, seenHashes, signal);
|
|
472
|
-
if (signal?.aborted) return null;
|
|
473
|
-
if (!parsed) continue;
|
|
474
|
-
|
|
475
|
-
addMessagesToUsageData(
|
|
476
|
-
data,
|
|
477
|
-
parsed.sessionId,
|
|
478
|
-
parsed.messages,
|
|
479
|
-
todayMs,
|
|
480
|
-
weekStartMs,
|
|
481
|
-
lastWeekStartMs,
|
|
482
|
-
rawByPeriod,
|
|
483
|
-
globalSessionSpans
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Classify sessions that are globally long-running once, then reuse across periods.
|
|
490
|
-
const longSessionIds = new Set<string>();
|
|
491
|
-
for (const [id, span] of globalSessionSpans) {
|
|
492
|
-
if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
for (const period of TAB_ORDER) {
|
|
496
|
-
data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return data;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// =============================================================================
|
|
503
|
-
// Insights
|
|
504
|
-
// =============================================================================
|
|
505
|
-
|
|
506
|
-
const PARALLEL_WINDOW_MS = 2 * 60_000; // exact ±N milliseconds around each message
|
|
507
|
-
const PARALLEL_SESSION_THRESHOLD = 4;
|
|
508
|
-
const LARGE_CONTEXT_THRESHOLD = 150_000;
|
|
509
|
-
const LARGE_CACHE_MISS_THRESHOLD = 100_000;
|
|
510
|
-
const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
|
|
511
|
-
const TOP_SESSION_COUNT = 5;
|
|
512
|
-
const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
|
|
513
|
-
const MIN_PERCENT_TO_SHOW = 1;
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Insights are weighted by recorded API cost. Periods with zero total cost produce
|
|
517
|
-
* an empty `insights` list — the UI renders a distinct empty-state for that case.
|
|
518
|
-
* Long-running-session classification is passed in from a global pass so that a
|
|
519
|
-
* session's real lifetime is used rather than the slice visible inside this period.
|
|
520
|
-
*/
|
|
521
|
-
function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
|
|
522
|
-
if (raw.messages.length === 0) {
|
|
523
|
-
return { insights: [] };
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
|
|
527
|
-
if (total <= 0) {
|
|
528
|
-
return { insights: [] };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const candidates: Insight[] = [];
|
|
532
|
-
|
|
533
|
-
// 1. Parallel sessions — ≥ N unique sessions active within an exact ±W ms window.
|
|
534
|
-
const parallelWeight = computeParallelCostWeight(raw.messages);
|
|
535
|
-
if (parallelWeight !== null) {
|
|
536
|
-
candidates.push({
|
|
537
|
-
percent: (parallelWeight / total) * 100,
|
|
538
|
-
headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
|
|
539
|
-
advice:
|
|
540
|
-
"All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// 2. Large context — input + cacheRead + cacheWrite > threshold.
|
|
545
|
-
const largeContextWeight = raw.messages
|
|
546
|
-
.filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD)
|
|
547
|
-
.reduce((sum, m) => sum + m.cost, 0);
|
|
548
|
-
candidates.push({
|
|
549
|
-
percent: (largeContextWeight / total) * 100,
|
|
550
|
-
headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
|
|
551
|
-
advice:
|
|
552
|
-
"Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks.",
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
// 3. Large uncached prompt — fresh (non-cached) input > threshold, per the v0.2.0 formula.
|
|
556
|
-
const uncachedWeight = raw.messages
|
|
557
|
-
.filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD)
|
|
558
|
-
.reduce((sum, m) => sum + m.cost, 0);
|
|
559
|
-
candidates.push({
|
|
560
|
-
percent: (uncachedWeight / total) * 100,
|
|
561
|
-
headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
|
|
562
|
-
advice:
|
|
563
|
-
"Uncached input is expensive, and often happens when sending a message to a session that has gone idle. /compact before stepping away keeps the cold-start small.",
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
// 4. Long-running sessions — classification comes from the global pass so we use
|
|
567
|
-
// true session lifetime, not just the span visible inside this period slice.
|
|
568
|
-
const longWeight = raw.messages
|
|
569
|
-
.filter((m) => longSessionIds.has(m.sessionId))
|
|
570
|
-
.reduce((sum, m) => sum + m.cost, 0);
|
|
571
|
-
if (longWeight > 0) {
|
|
572
|
-
candidates.push({
|
|
573
|
-
percent: (longWeight / total) * 100,
|
|
574
|
-
headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3_600_000}+ hours`,
|
|
575
|
-
advice:
|
|
576
|
-
"These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// 5. Top-N session concentration.
|
|
581
|
-
if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
|
|
582
|
-
const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
|
|
583
|
-
const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
|
|
584
|
-
const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
|
|
585
|
-
candidates.push({
|
|
586
|
-
percent: (topWeight / total) * 100,
|
|
587
|
-
headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
|
|
588
|
-
advice:
|
|
589
|
-
"A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
|
|
594
|
-
return { insights };
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Two-pointer sweep of messages sorted by timestamp. For each message, count the
|
|
599
|
-
* number of distinct session IDs whose messages fall within an exact ± window.
|
|
600
|
-
* Returns the total cost attributable to moments when ≥ threshold sessions were
|
|
601
|
-
* active, or null if the period has too few sessions/messages to call it.
|
|
602
|
-
*
|
|
603
|
-
* Messages with missing/invalid timestamps (parsed as 0) are filtered out first —
|
|
604
|
-
* otherwise they would collapse into a single synthetic instant and inflate the
|
|
605
|
-
* parallel count on older or incomplete logs.
|
|
606
|
-
*/
|
|
607
|
-
function computeParallelCostWeight(messages: RawMessage[]): number | null {
|
|
608
|
-
const timed = messages.filter((m) => m.timestamp > 0);
|
|
609
|
-
if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT) return null;
|
|
610
|
-
const distinctSessions = new Set(timed.map((m) => m.sessionId));
|
|
611
|
-
if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD) return null;
|
|
612
|
-
|
|
613
|
-
const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
614
|
-
const sidCount = new Map<string, number>();
|
|
615
|
-
let uniqueCount = 0;
|
|
616
|
-
let left = 0;
|
|
617
|
-
let right = 0;
|
|
618
|
-
let parallelCost = 0;
|
|
619
|
-
|
|
620
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
621
|
-
const current = sorted[i]!;
|
|
622
|
-
const high = current.timestamp + PARALLEL_WINDOW_MS;
|
|
623
|
-
const low = current.timestamp - PARALLEL_WINDOW_MS;
|
|
624
|
-
|
|
625
|
-
while (right < sorted.length && sorted[right]!.timestamp <= high) {
|
|
626
|
-
const sid = sorted[right]!.sessionId;
|
|
627
|
-
const next = (sidCount.get(sid) ?? 0) + 1;
|
|
628
|
-
sidCount.set(sid, next);
|
|
629
|
-
if (next === 1) uniqueCount++;
|
|
630
|
-
right++;
|
|
631
|
-
}
|
|
632
|
-
while (left < right && sorted[left]!.timestamp < low) {
|
|
633
|
-
const sid = sorted[left]!.sessionId;
|
|
634
|
-
const next = (sidCount.get(sid) ?? 0) - 1;
|
|
635
|
-
if (next === 0) {
|
|
636
|
-
sidCount.delete(sid);
|
|
637
|
-
uniqueCount--;
|
|
638
|
-
} else {
|
|
639
|
-
sidCount.set(sid, next);
|
|
640
|
-
}
|
|
641
|
-
left++;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
return parallelCost;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function formatThresholdTokens(n: number): string {
|
|
651
|
-
if (n >= 1_000_000) return `${n / 1_000_000}M`;
|
|
652
|
-
if (n >= 1_000) return `${n / 1_000}k`;
|
|
653
|
-
return String(n);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
function formatInsightPercent(p: number): string {
|
|
657
|
-
if (p >= 10) return `${Math.round(p)}%`;
|
|
658
|
-
return `${Math.round(p * 10) / 10}%`;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
113
|
// =============================================================================
|
|
662
114
|
// Formatting Helpers
|
|
663
115
|
// =============================================================================
|
|
@@ -685,6 +137,11 @@ function formatNumber(n: number): string {
|
|
|
685
137
|
return n.toLocaleString();
|
|
686
138
|
}
|
|
687
139
|
|
|
140
|
+
function formatInsightPercent(p: number): string {
|
|
141
|
+
if (p >= 10) return `${Math.round(p)}%`;
|
|
142
|
+
return `${Math.round(p * 10) / 10}%`;
|
|
143
|
+
}
|
|
144
|
+
|
|
688
145
|
function padLeft(s: string, len: number): string {
|
|
689
146
|
const vis = visibleWidth(s);
|
|
690
147
|
if (vis >= len) return s;
|
|
@@ -753,11 +210,10 @@ const TAB_LABELS: Record<TabName, string> = {
|
|
|
753
210
|
today: "Today",
|
|
754
211
|
thisWeek: "This Week",
|
|
755
212
|
lastWeek: "Last Week",
|
|
213
|
+
last30Days: "Last 30 Days",
|
|
756
214
|
allTime: "All Time",
|
|
757
215
|
};
|
|
758
216
|
|
|
759
|
-
const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
|
|
760
|
-
|
|
761
217
|
class UsageComponent {
|
|
762
218
|
private activeTab: TabName = "allTime";
|
|
763
219
|
private viewMode: ViewMode = "table";
|
|
@@ -1090,7 +546,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1090
546
|
|
|
1091
547
|
loader.onAbort = () => finish(null);
|
|
1092
548
|
|
|
1093
|
-
collectUsageData(loader.signal)
|
|
549
|
+
collectUsageData({ signal: loader.signal })
|
|
1094
550
|
.then(finish)
|
|
1095
551
|
.catch(() => finish(null));
|
|
1096
552
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tmustier/pi-usage-extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Usage statistics dashboard for Pi sessions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas Mustier",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@earendil-works/pi-tui": "*"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
29
|
-
"@earendil-works/pi-tui": "^0.
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
29
|
+
"@earendil-works/pi-tui": "^0.80.3"
|
|
30
30
|
}
|
|
31
31
|
}
|