@tmustier/pi-usage-extension 0.9.5 → 0.10.1
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 +16 -0
- package/README.md +5 -4
- package/data.ts +70 -15
- package/graph.ts +2 -0
- package/index.ts +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.1] - 2026-10-28
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Read sessions from the directory Pi uses. `/usage` always read `<agentDir>/sessions`, so with a custom session directory it showed sessions Pi no longer used and missed the ones it did. It now follows Pi's order: `--session-dir`, then `PI_CODING_AGENT_SESSION_DIR`, then the `sessionDir` setting (project over global). Thanks to @JeremieAlcaraz for the report ([#98](https://github.com/tmustier/pi-extensions/issues/98)).
|
|
7
|
+
|
|
8
|
+
## [0.10.0] - 2026-10-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **This Month period.** New tab between Last Week and Last 30 Days covering the current calendar month from local midnight on the 1st, in the graph, table, insights, and exports. Useful when a usage budget resets on the 1st of each month.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Collapse Windows home directories in project labels. Backslash paths are normalised, `C:\Users\<name>\...` collapses to `~/...` like `/Users/<name>` and `/home/<name>`, and other drive paths drop the drive letter (`D:\work\repo` becomes `work/repo`). Previously the project mix insight showed raw Windows paths.
|
|
15
|
+
- Make the usage tests pass on Windows.
|
|
16
|
+
|
|
17
|
+
Thanks to @mturley for This Month ([#114](https://github.com/tmustier/pi-extensions/pull/114), requested in [#107](https://github.com/tmustier/pi-extensions/issues/107)) and @dawidmachon for the Windows fix ([#110](https://github.com/tmustier/pi-extensions/pull/110)).
|
|
18
|
+
|
|
3
19
|
## [0.9.5] - 2026-09-22
|
|
4
20
|
|
|
5
21
|
### Changed
|
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ The **Graphs** view plots usage over time for the active period as a braille lin
|
|
|
136
136
|
- **Grouping** (`g` to cycle): by provider, by model, by thinking level, or total only. The top 6 series are shown individually; the rest merge into an `other` series. A bold **Total** line is always drawn.
|
|
137
137
|
- **Cumulative vs per-bucket** (`c` to toggle): running total across the period (default), or the raw per-bucket rate.
|
|
138
138
|
- **Filtering**: move the legend cursor with `↑`/`↓` and toggle series visibility with `Enter`/`Space` (`a` shows all again). The y-axis rescales to the visible series — hide the big lines to zoom into the small ones.
|
|
139
|
-
- **Buckets**: hourly for Today / This Week / Last Week, daily for Last 30 Days / All Time.
|
|
139
|
+
- **Buckets**: hourly for Today / This Week / Last Week (and This Month during its first 8 days), daily for This Month after its first 8 days / Last 30 Days / All Time.
|
|
140
140
|
- **Line clipping**: every series (provider, model, thinking level, `other`, Total) is drawn only between its first and last bucket with usage, so late-starting or retired series don't drag a flat zero/flat tail across the whole period.
|
|
141
141
|
|
|
142
142
|
Thinking levels are replayed from `thinking_level_change` entries in each session file; messages before the first recorded change appear as `unknown`. Auxiliary usage has no reliable thinking-level attribution and appears as `Tools/summaries` in that grouping. Reasoning token counts come from `usage.reasoning` where providers report them; pi only records this field since **pi 0.80.3 (30 June 2026)**, so earlier sessions show zero reasoning tokens even though thinking models were in use.
|
|
@@ -148,6 +148,7 @@ Thinking levels are replayed from `thinking_level_change` entries in each sessio
|
|
|
148
148
|
| **Today** | From midnight (00:00) today |
|
|
149
149
|
| **This Week** | From Monday 00:00 of the current week |
|
|
150
150
|
| **Last Week** | Previous week (Monday 00:00 → this Monday 00:00) |
|
|
151
|
+
| **This Month** | From local midnight on the first day of the current month |
|
|
151
152
|
| **Last 30 Days** | Rolling window: the last 30 calendar days including today (from midnight 29 days back) |
|
|
152
153
|
| **All Time** | All recorded sessions |
|
|
153
154
|
|
|
@@ -193,7 +194,7 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
|
|
|
193
194
|
|
|
194
195
|
## Performance & Caching
|
|
195
196
|
|
|
196
|
-
`/usage` builds its stats from every session JSONL file
|
|
197
|
+
`/usage` builds its stats from every session JSONL file in Pi's session directory (see [Data Source](#data-source)). To keep opens fast on large histories (multi-GB, thousands of files):
|
|
197
198
|
|
|
198
199
|
- **On-disk cache.** Per-file extraction results are cached in `<agentDir>/usage-extension-cache.json` (respects `PI_CODING_AGENT_DIR`), keyed by file size + mtime. Warm opens only re-parse session files that changed since the last run — on a 5.2 GB / 3,310-file corpus that takes the open from ~17 s to ~0.3 s.
|
|
199
200
|
- **First open** after install (or after deleting the cache) does a one-off full build, showing the usual cancellable loader. Cancelling saves partial progress, so the next open resumes where it left off.
|
|
@@ -225,11 +226,11 @@ The "Cache" column combines both read and write tokens.
|
|
|
225
226
|
|
|
226
227
|
## Data Source
|
|
227
228
|
|
|
228
|
-
Statistics are parsed recursively from session files in
|
|
229
|
+
Statistics are parsed recursively from session files in Pi's session directory, including nested subagent runs such as `run-0/` directories. Each session is a JSONL file containing assistant messages and, on Pi 0.81.0+, optional usage on tool-result, compaction, and branch-summary entries.
|
|
229
230
|
|
|
230
231
|
Assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens. Auxiliary usage is deduplicated by its stable session entry id, which avoids collapsing parallel tools that happen to report identical usage. Nested-agent reports are suppressed when their child session files are part of the scan (resolved across all copies of the parent entry, since branch copies change runId-derived paths); reports whose children are missing stay under `Tools / summaries`. Tool and summary usage contributes cost and tokens to totals, graphs, project/session mix, and burn trend, but does not count as an assistant message or distort conversation context/cache insights.
|
|
231
232
|
|
|
232
|
-
|
|
233
|
+
The session directory is the one Pi uses: `--session-dir`, then the `PI_CODING_AGENT_SESSION_DIR` environment variable, then the `sessionDir` setting (project `.pi/settings.json` over the global `settings.json`), and otherwise `~/.pi/agent/sessions/`. The agent directory respects the `PI_CODING_AGENT_DIR` environment variable if set.
|
|
233
234
|
|
|
234
235
|
## Changelog
|
|
235
236
|
|
package/data.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* re-parses files that changed since the last run.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
14
15
|
import { readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
15
16
|
import { homedir } from "node:os";
|
|
16
17
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -149,6 +150,7 @@ export interface PeriodBounds {
|
|
|
149
150
|
todayMs: number;
|
|
150
151
|
weekStartMs: number;
|
|
151
152
|
lastWeekStartMs: number;
|
|
153
|
+
thisMonthStartMs: number;
|
|
152
154
|
last30DaysStartMs: number;
|
|
153
155
|
nowMs: number;
|
|
154
156
|
}
|
|
@@ -157,6 +159,7 @@ export interface UsageData {
|
|
|
157
159
|
today: TimeFilteredStats;
|
|
158
160
|
thisWeek: TimeFilteredStats;
|
|
159
161
|
lastWeek: TimeFilteredStats;
|
|
162
|
+
thisMonth: TimeFilteredStats;
|
|
160
163
|
last30Days: TimeFilteredStats;
|
|
161
164
|
allTime: TimeFilteredStats;
|
|
162
165
|
/** Deduped usage bucketed by hour start (ms) → series key → metrics. */
|
|
@@ -164,9 +167,9 @@ export interface UsageData {
|
|
|
164
167
|
bounds: PeriodBounds;
|
|
165
168
|
}
|
|
166
169
|
|
|
167
|
-
export type TabName = "today" | "thisWeek" | "lastWeek" | "last30Days" | "allTime";
|
|
170
|
+
export type TabName = "today" | "thisWeek" | "lastWeek" | "thisMonth" | "last30Days" | "allTime";
|
|
168
171
|
|
|
169
|
-
export const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
|
|
172
|
+
export const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "thisMonth", "last30Days", "allTime"];
|
|
170
173
|
|
|
171
174
|
export type UsageSource = "assistant" | "auxiliary";
|
|
172
175
|
|
|
@@ -237,8 +240,24 @@ export function getAgentDir(): string {
|
|
|
237
240
|
return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
238
241
|
}
|
|
239
242
|
|
|
240
|
-
|
|
241
|
-
|
|
243
|
+
function sessionDirSetting(settingsPath: string): string | undefined {
|
|
244
|
+
try {
|
|
245
|
+
const { sessionDir } = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
246
|
+
return typeof sessionDir === "string" ? sessionDir : undefined;
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined; // No settings file, or one Pi cannot read either.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Where Pi keeps sessions: `--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, then `sessionDir` from project over global settings. */
|
|
253
|
+
function getSessionsDir(): string {
|
|
254
|
+
const flag = process.argv.lastIndexOf("--session-dir");
|
|
255
|
+
const configured =
|
|
256
|
+
(flag >= 0 ? process.argv[flag + 1] : undefined) ||
|
|
257
|
+
process.env.PI_CODING_AGENT_SESSION_DIR ||
|
|
258
|
+
sessionDirSetting(join(".pi", "settings.json")) ||
|
|
259
|
+
sessionDirSetting(join(getAgentDir(), "settings.json"));
|
|
260
|
+
return configured ? resolve(configured.replace(/^~(?=\/|$)/, homedir())) : join(getAgentDir(), "sessions");
|
|
242
261
|
}
|
|
243
262
|
|
|
244
263
|
export function getDefaultCachePath(): string {
|
|
@@ -1038,23 +1057,32 @@ function emptyPeriodRawData(): PeriodRawData {
|
|
|
1038
1057
|
*/
|
|
1039
1058
|
export function projectLabelFromCwd(cwd: string): string {
|
|
1040
1059
|
if (!cwd) return "(unknown)";
|
|
1060
|
+
// Normalize Windows separators so backslash paths (C:\Users\me\...) match the
|
|
1061
|
+
// forward-slash forms compared below. Path text after the home prefix keeps
|
|
1062
|
+
// its normalized forward slashes, which is what the label displays.
|
|
1063
|
+
const normalized = cwd.replaceAll("\\", "/");
|
|
1041
1064
|
// Collapse any home-directory prefix to "~", not just the current user's —
|
|
1042
1065
|
// session stores merged from other machines can carry a different username.
|
|
1043
|
-
const home = homedir();
|
|
1066
|
+
const home = homedir().replaceAll("\\", "/");
|
|
1044
1067
|
let homePrefix: string | null = null;
|
|
1045
|
-
if (
|
|
1068
|
+
if (normalized === home || normalized.startsWith(home + "/")) {
|
|
1046
1069
|
homePrefix = home;
|
|
1047
1070
|
} else {
|
|
1048
|
-
const m = /^(\/Users\/[^/]+|\/home\/[^/]+)(?=\/|$)/.exec(
|
|
1071
|
+
const m = /^(\/Users\/[^/]+|\/home\/[^/]+|[A-Za-z]:\/Users\/[^/]+)(?=\/|$)/.exec(normalized);
|
|
1049
1072
|
if (m) homePrefix = m[1]!;
|
|
1050
1073
|
}
|
|
1051
|
-
if (homePrefix !== null &&
|
|
1052
|
-
let rel = homePrefix !== null ?
|
|
1074
|
+
if (homePrefix !== null && normalized.length <= homePrefix.length) return "~";
|
|
1075
|
+
let rel = homePrefix !== null ? normalized.slice(homePrefix.length + 1) : normalized;
|
|
1053
1076
|
const wt = rel.indexOf("/.worktrees/");
|
|
1054
1077
|
if (wt !== -1) rel = rel.slice(0, wt);
|
|
1055
1078
|
const parts = rel.split("/").filter(Boolean);
|
|
1056
|
-
|
|
1057
|
-
|
|
1079
|
+
if (homePrefix !== null) return `~/${parts.slice(0, 2).join("/")}`;
|
|
1080
|
+
// Windows drive paths outside a home (D:/work/repo/sub): drop the drive segment
|
|
1081
|
+
// so the label keeps meaningful segments (work/repo) and stays slash-separated.
|
|
1082
|
+
const segments = /^[A-Za-z]:$/.test(parts[0] ?? "") ? parts.slice(1) : parts;
|
|
1083
|
+
const label = segments.slice(0, 2).join("/");
|
|
1084
|
+
if (/^[A-Za-z]:\//.test(normalized)) return label;
|
|
1085
|
+
return `/${label}`;
|
|
1058
1086
|
}
|
|
1059
1087
|
|
|
1060
1088
|
function emptyUsageData(bounds: PeriodBounds): UsageData {
|
|
@@ -1062,6 +1090,7 @@ function emptyUsageData(bounds: PeriodBounds): UsageData {
|
|
|
1062
1090
|
today: emptyTimeFilteredStats(),
|
|
1063
1091
|
thisWeek: emptyTimeFilteredStats(),
|
|
1064
1092
|
lastWeek: emptyTimeFilteredStats(),
|
|
1093
|
+
thisMonth: emptyTimeFilteredStats(),
|
|
1065
1094
|
last30Days: emptyTimeFilteredStats(),
|
|
1066
1095
|
allTime: emptyTimeFilteredStats(),
|
|
1067
1096
|
hourly: new Map(),
|
|
@@ -1115,6 +1144,7 @@ function getPeriodsForTimestamp(
|
|
|
1115
1144
|
todayMs: number,
|
|
1116
1145
|
weekStartMs: number,
|
|
1117
1146
|
lastWeekStartMs: number,
|
|
1147
|
+
thisMonthStartMs: number,
|
|
1118
1148
|
last30DaysStartMs: number
|
|
1119
1149
|
): TabName[] {
|
|
1120
1150
|
const periods: TabName[] = ["allTime"];
|
|
@@ -1124,6 +1154,7 @@ function getPeriodsForTimestamp(
|
|
|
1124
1154
|
} else if (timestamp >= lastWeekStartMs) {
|
|
1125
1155
|
periods.push("lastWeek");
|
|
1126
1156
|
}
|
|
1157
|
+
if (timestamp >= thisMonthStartMs) periods.push("thisMonth");
|
|
1127
1158
|
if (timestamp >= last30DaysStartMs) periods.push("last30Days");
|
|
1128
1159
|
return periods;
|
|
1129
1160
|
}
|
|
@@ -1140,11 +1171,12 @@ function addMessagesToUsageData(
|
|
|
1140
1171
|
todayMs: number,
|
|
1141
1172
|
weekStartMs: number,
|
|
1142
1173
|
lastWeekStartMs: number,
|
|
1174
|
+
thisMonthStartMs: number,
|
|
1143
1175
|
last30DaysStartMs: number,
|
|
1144
1176
|
rawByPeriod: Record<TabName, PeriodRawData>,
|
|
1145
1177
|
costByDayIdx: Map<number, number>
|
|
1146
1178
|
): void {
|
|
1147
|
-
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, last30Days: false, allTime: false };
|
|
1179
|
+
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, thisMonth: false, last30Days: false, allTime: false };
|
|
1148
1180
|
|
|
1149
1181
|
for (let mi = 0; mi < messages.length; mi++) {
|
|
1150
1182
|
const msg = messages[mi]!;
|
|
@@ -1161,7 +1193,14 @@ function addMessagesToUsageData(
|
|
|
1161
1193
|
|
|
1162
1194
|
addToHourlyBuckets(data.hourly, msg);
|
|
1163
1195
|
|
|
1164
|
-
const periods = getPeriodsForTimestamp(
|
|
1196
|
+
const periods = getPeriodsForTimestamp(
|
|
1197
|
+
msg.timestamp,
|
|
1198
|
+
todayMs,
|
|
1199
|
+
weekStartMs,
|
|
1200
|
+
lastWeekStartMs,
|
|
1201
|
+
thisMonthStartMs,
|
|
1202
|
+
last30DaysStartMs
|
|
1203
|
+
);
|
|
1165
1204
|
const tokens = {
|
|
1166
1205
|
// Count fresh tokens processed this turn.
|
|
1167
1206
|
// Include cacheWrite because those prompt tokens were newly written and billed.
|
|
@@ -1240,6 +1279,7 @@ function addMessagesToUsageData(
|
|
|
1240
1279
|
if (sessionContributed.today) data.today.totals.sessions++;
|
|
1241
1280
|
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
1242
1281
|
if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
|
|
1282
|
+
if (sessionContributed.thisMonth) data.thisMonth.totals.sessions++;
|
|
1243
1283
|
if (sessionContributed.last30Days) data.last30Days.totals.sessions++;
|
|
1244
1284
|
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
1245
1285
|
}
|
|
@@ -1364,7 +1404,7 @@ export interface CollectUsageOptions {
|
|
|
1364
1404
|
signal?: AbortSignal;
|
|
1365
1405
|
/** Called once before parsing begins and periodically while files are parsed. */
|
|
1366
1406
|
onProgress?: (progress: CollectProgress) => void;
|
|
1367
|
-
/** Defaults to
|
|
1407
|
+
/** Defaults to the directory where Pi keeps sessions. */
|
|
1368
1408
|
sessionsDir?: string;
|
|
1369
1409
|
/** Defaults to `<agentDir>/usage-extension-cache.json`. Pass `null` to disable the on-disk cache. */
|
|
1370
1410
|
cachePath?: string | null;
|
|
@@ -1397,6 +1437,12 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
1397
1437
|
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
|
1398
1438
|
const lastWeekStartMs = startOfLastWeek.getTime();
|
|
1399
1439
|
|
|
1440
|
+
// Calendar month to date, from local midnight on the first.
|
|
1441
|
+
const startOfThisMonth = new Date(now);
|
|
1442
|
+
startOfThisMonth.setDate(1);
|
|
1443
|
+
startOfThisMonth.setHours(0, 0, 0, 0);
|
|
1444
|
+
const thisMonthStartMs = startOfThisMonth.getTime();
|
|
1445
|
+
|
|
1400
1446
|
// Rolling 30-day window: the last 30 calendar days including today,
|
|
1401
1447
|
// i.e. from midnight 29 days before today. setDate handles DST correctly.
|
|
1402
1448
|
const startOfLast30Days = new Date(startOfToday);
|
|
@@ -1526,11 +1572,19 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
1526
1572
|
}
|
|
1527
1573
|
|
|
1528
1574
|
// 6. Aggregate in sorted path order with cross-file dedupe.
|
|
1529
|
-
const data = emptyUsageData({
|
|
1575
|
+
const data = emptyUsageData({
|
|
1576
|
+
todayMs,
|
|
1577
|
+
weekStartMs,
|
|
1578
|
+
lastWeekStartMs,
|
|
1579
|
+
thisMonthStartMs,
|
|
1580
|
+
last30DaysStartMs,
|
|
1581
|
+
nowMs: now.getTime(),
|
|
1582
|
+
});
|
|
1530
1583
|
const rawByPeriod: Record<TabName, PeriodRawData> = {
|
|
1531
1584
|
today: emptyPeriodRawData(),
|
|
1532
1585
|
thisWeek: emptyPeriodRawData(),
|
|
1533
1586
|
lastWeek: emptyPeriodRawData(),
|
|
1587
|
+
thisMonth: emptyPeriodRawData(),
|
|
1534
1588
|
last30Days: emptyPeriodRawData(),
|
|
1535
1589
|
allTime: emptyPeriodRawData(),
|
|
1536
1590
|
};
|
|
@@ -1597,6 +1651,7 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
1597
1651
|
todayMs,
|
|
1598
1652
|
weekStartMs,
|
|
1599
1653
|
lastWeekStartMs,
|
|
1654
|
+
thisMonthStartMs,
|
|
1600
1655
|
last30DaysStartMs,
|
|
1601
1656
|
rawByPeriod,
|
|
1602
1657
|
costByDayIdx
|
package/graph.ts
CHANGED
|
@@ -118,6 +118,8 @@ function domainFor(period: TabName, bounds: PeriodBounds, hourly: Map<number, Ma
|
|
|
118
118
|
return { startMs: bounds.weekStartMs, endMs: bounds.nowMs };
|
|
119
119
|
case "lastWeek":
|
|
120
120
|
return { startMs: bounds.lastWeekStartMs, endMs: bounds.weekStartMs };
|
|
121
|
+
case "thisMonth":
|
|
122
|
+
return { startMs: bounds.thisMonthStartMs, endMs: bounds.nowMs };
|
|
121
123
|
case "last30Days":
|
|
122
124
|
return { startMs: bounds.last30DaysStartMs, endMs: bounds.nowMs };
|
|
123
125
|
case "allTime": {
|
package/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* /usage - Usage statistics dashboard
|
|
3
3
|
*
|
|
4
4
|
* Shows an inline view with usage stats grouped by provider.
|
|
5
|
-
* - Tab cycles: Today → This Week → Last Week → All Time
|
|
5
|
+
* - Tab cycles: Today → This Week → Last Week → This Month → All Time
|
|
6
6
|
* - Arrow keys navigate providers
|
|
7
7
|
* - Enter expands/collapses to show models
|
|
8
8
|
*
|
|
@@ -273,6 +273,7 @@ const TAB_LABELS: Record<TabName, string> = {
|
|
|
273
273
|
today: "Today",
|
|
274
274
|
thisWeek: "This Week",
|
|
275
275
|
lastWeek: "Last Week",
|
|
276
|
+
thisMonth: "This Month",
|
|
276
277
|
last30Days: "Last 30 Days",
|
|
277
278
|
allTime: "All Time",
|
|
278
279
|
};
|