@tmustier/pi-usage-extension 0.1.3 → 0.1.5
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 +8 -0
- package/README.md +4 -2
- package/index.ts +94 -82
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5 - 2026-04-09
|
|
4
|
+
- Keep recursive subagent session scanning in `/usage`
|
|
5
|
+
- Remove the deduped/raw mode toggle and keep the deduped view as the default behavior
|
|
6
|
+
|
|
7
|
+
## 0.1.4 - 2026-04-09
|
|
8
|
+
- Scan session files recursively so nested subagent runs are included in `/usage`
|
|
9
|
+
- Add deduped vs raw counting modes to compare copied branch history against raw file totals
|
|
10
|
+
|
|
3
11
|
## 0.1.3 - 2026-02-03
|
|
4
12
|
- Add preview image metadata for the extension listing.
|
|
5
13
|
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A Pi extension that displays aggregated usage statistics across all sessions.
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-
|
|
10
|
+
- **Last updated:** 2026-04-09
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -111,7 +111,9 @@ The "Cache" column combines both read and write tokens.
|
|
|
111
111
|
|
|
112
112
|
## Data Source
|
|
113
113
|
|
|
114
|
-
Statistics are parsed from session files in `~/.pi/agent/sessions
|
|
114
|
+
Statistics are parsed recursively from session files in `~/.pi/agent/sessions/`, including nested subagent runs such as `run-0/` directories. Each session is a JSONL file containing message entries with usage data.
|
|
115
|
+
|
|
116
|
+
Assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens, matching the extension's previous behavior while still including recursive subagent sessions.
|
|
115
117
|
|
|
116
118
|
Respects the `PI_CODING_AGENT_DIR` environment variable if set.
|
|
117
119
|
|
package/index.ts
CHANGED
|
@@ -96,31 +96,27 @@ function getSessionsDir(): string {
|
|
|
96
96
|
return join(agentDir, "sessions");
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
async function
|
|
100
|
-
const sessionsDir = getSessionsDir();
|
|
101
|
-
const files: string[] = [];
|
|
102
|
-
|
|
99
|
+
async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
|
|
103
100
|
try {
|
|
104
|
-
const
|
|
105
|
-
for (const
|
|
106
|
-
if (signal?.aborted) return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (file.endsWith(".jsonl")) {
|
|
113
|
-
files.push(join(cwdPath, file));
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// Skip directories we can't read
|
|
101
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (signal?.aborted) return;
|
|
104
|
+
const entryPath = join(dir, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
await collectSessionFilesRecursively(entryPath, files, signal);
|
|
107
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
108
|
+
files.push(entryPath);
|
|
118
109
|
}
|
|
119
110
|
}
|
|
120
111
|
} catch {
|
|
121
|
-
//
|
|
112
|
+
// Skip directories we can't read
|
|
122
113
|
}
|
|
114
|
+
}
|
|
123
115
|
|
|
116
|
+
async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
|
|
117
|
+
const files: string[] = [];
|
|
118
|
+
await collectSessionFilesRecursively(getSessionsDir(), files, signal);
|
|
119
|
+
files.sort();
|
|
124
120
|
return files;
|
|
125
121
|
}
|
|
126
122
|
|
|
@@ -135,11 +131,16 @@ interface SessionMessage {
|
|
|
135
131
|
timestamp: number;
|
|
136
132
|
}
|
|
137
133
|
|
|
134
|
+
interface ParsedSessionFile {
|
|
135
|
+
sessionId: string;
|
|
136
|
+
messages: SessionMessage[];
|
|
137
|
+
}
|
|
138
|
+
|
|
138
139
|
async function parseSessionFile(
|
|
139
140
|
filePath: string,
|
|
140
141
|
seenHashes: Set<string>,
|
|
141
142
|
signal?: AbortSignal
|
|
142
|
-
): Promise<
|
|
143
|
+
): Promise<ParsedSessionFile | null> {
|
|
143
144
|
try {
|
|
144
145
|
const content = await readFile(filePath, "utf8");
|
|
145
146
|
if (signal?.aborted) return null;
|
|
@@ -169,8 +170,8 @@ async function parseSessionFile(
|
|
|
169
170
|
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
170
171
|
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
171
172
|
|
|
172
|
-
// Deduplicate
|
|
173
|
-
//
|
|
173
|
+
// Deduplicate copied history across branched session files.
|
|
174
|
+
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
174
175
|
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
175
176
|
const hash = `${timestamp}:${totalTokens}`;
|
|
176
177
|
if (seenHashes.has(hash)) continue;
|
|
@@ -232,6 +233,73 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
|
232
233
|
};
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
function emptyUsageData(): UsageData {
|
|
237
|
+
return {
|
|
238
|
+
today: emptyTimeFilteredStats(),
|
|
239
|
+
thisWeek: emptyTimeFilteredStats(),
|
|
240
|
+
allTime: emptyTimeFilteredStats(),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number): TabName[] {
|
|
245
|
+
const periods: TabName[] = ["allTime"];
|
|
246
|
+
if (timestamp >= todayMs) periods.push("today");
|
|
247
|
+
if (timestamp >= weekStartMs) periods.push("thisWeek");
|
|
248
|
+
return periods;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function addMessagesToUsageData(
|
|
252
|
+
data: UsageData,
|
|
253
|
+
sessionId: string,
|
|
254
|
+
messages: SessionMessage[],
|
|
255
|
+
todayMs: number,
|
|
256
|
+
weekStartMs: number
|
|
257
|
+
): void {
|
|
258
|
+
const sessionContributed = { today: false, thisWeek: false, allTime: false };
|
|
259
|
+
|
|
260
|
+
for (const msg of messages) {
|
|
261
|
+
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs);
|
|
262
|
+
const tokens = {
|
|
263
|
+
// Total = input + output only. cacheRead/cacheWrite are tracked separately.
|
|
264
|
+
// cacheRead tokens were already counted when first sent, so including them
|
|
265
|
+
// would double-count and massively inflate totals (cache hits repeat every message).
|
|
266
|
+
total: msg.input + msg.output,
|
|
267
|
+
input: msg.input,
|
|
268
|
+
output: msg.output,
|
|
269
|
+
cache: msg.cacheRead + msg.cacheWrite,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
for (const period of periods) {
|
|
273
|
+
const stats = data[period];
|
|
274
|
+
|
|
275
|
+
let providerStats = stats.providers.get(msg.provider);
|
|
276
|
+
if (!providerStats) {
|
|
277
|
+
providerStats = emptyProviderStats();
|
|
278
|
+
stats.providers.set(msg.provider, providerStats);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let modelStats = providerStats.models.get(msg.model);
|
|
282
|
+
if (!modelStats) {
|
|
283
|
+
modelStats = emptyModelStats();
|
|
284
|
+
providerStats.models.set(msg.model, modelStats);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
modelStats.sessions.add(sessionId);
|
|
288
|
+
accumulateStats(modelStats, msg.cost, tokens);
|
|
289
|
+
|
|
290
|
+
providerStats.sessions.add(sessionId);
|
|
291
|
+
accumulateStats(providerStats, msg.cost, tokens);
|
|
292
|
+
|
|
293
|
+
accumulateStats(stats.totals, msg.cost, tokens);
|
|
294
|
+
sessionContributed[period] = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sessionContributed.today) data.today.totals.sessions++;
|
|
299
|
+
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
300
|
+
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
301
|
+
}
|
|
302
|
+
|
|
235
303
|
async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
|
|
236
304
|
const startOfToday = new Date();
|
|
237
305
|
startOfToday.setHours(0, 0, 0, 0);
|
|
@@ -245,15 +313,11 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
245
313
|
startOfWeek.setHours(0, 0, 0, 0);
|
|
246
314
|
const weekStartMs = startOfWeek.getTime();
|
|
247
315
|
|
|
248
|
-
const data
|
|
249
|
-
today: emptyTimeFilteredStats(),
|
|
250
|
-
thisWeek: emptyTimeFilteredStats(),
|
|
251
|
-
allTime: emptyTimeFilteredStats(),
|
|
252
|
-
};
|
|
316
|
+
const data = emptyUsageData();
|
|
253
317
|
|
|
254
318
|
const sessionFiles = await getAllSessionFiles(signal);
|
|
255
319
|
if (signal?.aborted) return null;
|
|
256
|
-
const seenHashes = new Set<string>();
|
|
320
|
+
const seenHashes = new Set<string>();
|
|
257
321
|
|
|
258
322
|
for (const filePath of sessionFiles) {
|
|
259
323
|
if (signal?.aborted) return null;
|
|
@@ -261,59 +325,7 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
261
325
|
if (signal?.aborted) return null;
|
|
262
326
|
if (!parsed) continue;
|
|
263
327
|
|
|
264
|
-
|
|
265
|
-
const sessionContributed = { today: false, thisWeek: false, allTime: false };
|
|
266
|
-
|
|
267
|
-
for (const msg of messages) {
|
|
268
|
-
if (signal?.aborted) return null;
|
|
269
|
-
const periods: TabName[] = ["allTime"];
|
|
270
|
-
if (msg.timestamp >= todayMs) periods.push("today");
|
|
271
|
-
if (msg.timestamp >= weekStartMs) periods.push("thisWeek");
|
|
272
|
-
|
|
273
|
-
const tokens = {
|
|
274
|
-
// Total = input + output only. cacheRead/cacheWrite are tracked separately.
|
|
275
|
-
// cacheRead tokens were already counted when first sent, so including them
|
|
276
|
-
// would double-count and massively inflate totals (cache hits repeat every message).
|
|
277
|
-
total: msg.input + msg.output,
|
|
278
|
-
input: msg.input,
|
|
279
|
-
output: msg.output,
|
|
280
|
-
cache: msg.cacheRead + msg.cacheWrite,
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
for (const period of periods) {
|
|
284
|
-
const stats = data[period];
|
|
285
|
-
|
|
286
|
-
// Get or create provider stats
|
|
287
|
-
let providerStats = stats.providers.get(msg.provider);
|
|
288
|
-
if (!providerStats) {
|
|
289
|
-
providerStats = emptyProviderStats();
|
|
290
|
-
stats.providers.set(msg.provider, providerStats);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Get or create model stats
|
|
294
|
-
let modelStats = providerStats.models.get(msg.model);
|
|
295
|
-
if (!modelStats) {
|
|
296
|
-
modelStats = emptyModelStats();
|
|
297
|
-
providerStats.models.set(msg.model, modelStats);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Accumulate stats at all levels
|
|
301
|
-
modelStats.sessions.add(sessionId);
|
|
302
|
-
accumulateStats(modelStats, msg.cost, tokens);
|
|
303
|
-
|
|
304
|
-
providerStats.sessions.add(sessionId);
|
|
305
|
-
accumulateStats(providerStats, msg.cost, tokens);
|
|
306
|
-
|
|
307
|
-
accumulateStats(stats.totals, msg.cost, tokens);
|
|
308
|
-
|
|
309
|
-
sessionContributed[period] = true;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Count unique sessions per period
|
|
314
|
-
if (sessionContributed.today) data.today.totals.sessions++;
|
|
315
|
-
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
316
|
-
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
328
|
+
addMessagesToUsageData(data, parsed.sessionId, parsed.messages, todayMs, weekStartMs);
|
|
317
329
|
|
|
318
330
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
319
331
|
}
|
|
@@ -463,7 +475,7 @@ class UsageComponent {
|
|
|
463
475
|
const label = TAB_LABELS[tab];
|
|
464
476
|
return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
|
|
465
477
|
}).join(" ");
|
|
466
|
-
return [tabs, ""];
|
|
478
|
+
return [tabs, th.fg("dim", "Dedupes copied branched-history messages. Recursive subagent sessions included."), ""];
|
|
467
479
|
}
|
|
468
480
|
|
|
469
481
|
private renderHeader(): string[] {
|