@tmustier/pi-usage-extension 0.9.5 → 0.10.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-10-26
4
+
5
+ ### Added
6
+ - **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.
7
+
8
+ ### Fixed
9
+ - 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.
10
+ - Make the usage tests pass on Windows.
11
+
12
+ 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)).
13
+
3
14
  ## [0.9.5] - 2026-09-22
4
15
 
5
16
  ### 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
 
package/data.ts CHANGED
@@ -149,6 +149,7 @@ export interface PeriodBounds {
149
149
  todayMs: number;
150
150
  weekStartMs: number;
151
151
  lastWeekStartMs: number;
152
+ thisMonthStartMs: number;
152
153
  last30DaysStartMs: number;
153
154
  nowMs: number;
154
155
  }
@@ -157,6 +158,7 @@ export interface UsageData {
157
158
  today: TimeFilteredStats;
158
159
  thisWeek: TimeFilteredStats;
159
160
  lastWeek: TimeFilteredStats;
161
+ thisMonth: TimeFilteredStats;
160
162
  last30Days: TimeFilteredStats;
161
163
  allTime: TimeFilteredStats;
162
164
  /** Deduped usage bucketed by hour start (ms) → series key → metrics. */
@@ -164,9 +166,9 @@ export interface UsageData {
164
166
  bounds: PeriodBounds;
165
167
  }
166
168
 
167
- export type TabName = "today" | "thisWeek" | "lastWeek" | "last30Days" | "allTime";
169
+ export type TabName = "today" | "thisWeek" | "lastWeek" | "thisMonth" | "last30Days" | "allTime";
168
170
 
169
- export const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
171
+ export const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "thisMonth", "last30Days", "allTime"];
170
172
 
171
173
  export type UsageSource = "assistant" | "auxiliary";
172
174
 
@@ -1038,23 +1040,32 @@ function emptyPeriodRawData(): PeriodRawData {
1038
1040
  */
1039
1041
  export function projectLabelFromCwd(cwd: string): string {
1040
1042
  if (!cwd) return "(unknown)";
1043
+ // Normalize Windows separators so backslash paths (C:\Users\me\...) match the
1044
+ // forward-slash forms compared below. Path text after the home prefix keeps
1045
+ // its normalized forward slashes, which is what the label displays.
1046
+ const normalized = cwd.replaceAll("\\", "/");
1041
1047
  // Collapse any home-directory prefix to "~", not just the current user's —
1042
1048
  // session stores merged from other machines can carry a different username.
1043
- const home = homedir();
1049
+ const home = homedir().replaceAll("\\", "/");
1044
1050
  let homePrefix: string | null = null;
1045
- if (cwd === home || cwd.startsWith(home + "/")) {
1051
+ if (normalized === home || normalized.startsWith(home + "/")) {
1046
1052
  homePrefix = home;
1047
1053
  } else {
1048
- const m = /^(\/Users\/[^/]+|\/home\/[^/]+)(?=\/|$)/.exec(cwd);
1054
+ const m = /^(\/Users\/[^/]+|\/home\/[^/]+|[A-Za-z]:\/Users\/[^/]+)(?=\/|$)/.exec(normalized);
1049
1055
  if (m) homePrefix = m[1]!;
1050
1056
  }
1051
- if (homePrefix !== null && cwd.length <= homePrefix.length) return "~";
1052
- let rel = homePrefix !== null ? cwd.slice(homePrefix.length + 1) : cwd;
1057
+ if (homePrefix !== null && normalized.length <= homePrefix.length) return "~";
1058
+ let rel = homePrefix !== null ? normalized.slice(homePrefix.length + 1) : normalized;
1053
1059
  const wt = rel.indexOf("/.worktrees/");
1054
1060
  if (wt !== -1) rel = rel.slice(0, wt);
1055
1061
  const parts = rel.split("/").filter(Boolean);
1056
- const label = parts.slice(0, 2).join("/");
1057
- return homePrefix !== null ? `~/${label}` : `/${label}`;
1062
+ if (homePrefix !== null) return `~/${parts.slice(0, 2).join("/")}`;
1063
+ // Windows drive paths outside a home (D:/work/repo/sub): drop the drive segment
1064
+ // so the label keeps meaningful segments (work/repo) and stays slash-separated.
1065
+ const segments = /^[A-Za-z]:$/.test(parts[0] ?? "") ? parts.slice(1) : parts;
1066
+ const label = segments.slice(0, 2).join("/");
1067
+ if (/^[A-Za-z]:\//.test(normalized)) return label;
1068
+ return `/${label}`;
1058
1069
  }
1059
1070
 
1060
1071
  function emptyUsageData(bounds: PeriodBounds): UsageData {
@@ -1062,6 +1073,7 @@ function emptyUsageData(bounds: PeriodBounds): UsageData {
1062
1073
  today: emptyTimeFilteredStats(),
1063
1074
  thisWeek: emptyTimeFilteredStats(),
1064
1075
  lastWeek: emptyTimeFilteredStats(),
1076
+ thisMonth: emptyTimeFilteredStats(),
1065
1077
  last30Days: emptyTimeFilteredStats(),
1066
1078
  allTime: emptyTimeFilteredStats(),
1067
1079
  hourly: new Map(),
@@ -1115,6 +1127,7 @@ function getPeriodsForTimestamp(
1115
1127
  todayMs: number,
1116
1128
  weekStartMs: number,
1117
1129
  lastWeekStartMs: number,
1130
+ thisMonthStartMs: number,
1118
1131
  last30DaysStartMs: number
1119
1132
  ): TabName[] {
1120
1133
  const periods: TabName[] = ["allTime"];
@@ -1124,6 +1137,7 @@ function getPeriodsForTimestamp(
1124
1137
  } else if (timestamp >= lastWeekStartMs) {
1125
1138
  periods.push("lastWeek");
1126
1139
  }
1140
+ if (timestamp >= thisMonthStartMs) periods.push("thisMonth");
1127
1141
  if (timestamp >= last30DaysStartMs) periods.push("last30Days");
1128
1142
  return periods;
1129
1143
  }
@@ -1140,11 +1154,12 @@ function addMessagesToUsageData(
1140
1154
  todayMs: number,
1141
1155
  weekStartMs: number,
1142
1156
  lastWeekStartMs: number,
1157
+ thisMonthStartMs: number,
1143
1158
  last30DaysStartMs: number,
1144
1159
  rawByPeriod: Record<TabName, PeriodRawData>,
1145
1160
  costByDayIdx: Map<number, number>
1146
1161
  ): void {
1147
- const sessionContributed = { today: false, thisWeek: false, lastWeek: false, last30Days: false, allTime: false };
1162
+ const sessionContributed = { today: false, thisWeek: false, lastWeek: false, thisMonth: false, last30Days: false, allTime: false };
1148
1163
 
1149
1164
  for (let mi = 0; mi < messages.length; mi++) {
1150
1165
  const msg = messages[mi]!;
@@ -1161,7 +1176,14 @@ function addMessagesToUsageData(
1161
1176
 
1162
1177
  addToHourlyBuckets(data.hourly, msg);
1163
1178
 
1164
- const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs);
1179
+ const periods = getPeriodsForTimestamp(
1180
+ msg.timestamp,
1181
+ todayMs,
1182
+ weekStartMs,
1183
+ lastWeekStartMs,
1184
+ thisMonthStartMs,
1185
+ last30DaysStartMs
1186
+ );
1165
1187
  const tokens = {
1166
1188
  // Count fresh tokens processed this turn.
1167
1189
  // Include cacheWrite because those prompt tokens were newly written and billed.
@@ -1240,6 +1262,7 @@ function addMessagesToUsageData(
1240
1262
  if (sessionContributed.today) data.today.totals.sessions++;
1241
1263
  if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
1242
1264
  if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
1265
+ if (sessionContributed.thisMonth) data.thisMonth.totals.sessions++;
1243
1266
  if (sessionContributed.last30Days) data.last30Days.totals.sessions++;
1244
1267
  if (sessionContributed.allTime) data.allTime.totals.sessions++;
1245
1268
  }
@@ -1397,6 +1420,12 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
1397
1420
  startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
1398
1421
  const lastWeekStartMs = startOfLastWeek.getTime();
1399
1422
 
1423
+ // Calendar month to date, from local midnight on the first.
1424
+ const startOfThisMonth = new Date(now);
1425
+ startOfThisMonth.setDate(1);
1426
+ startOfThisMonth.setHours(0, 0, 0, 0);
1427
+ const thisMonthStartMs = startOfThisMonth.getTime();
1428
+
1400
1429
  // Rolling 30-day window: the last 30 calendar days including today,
1401
1430
  // i.e. from midnight 29 days before today. setDate handles DST correctly.
1402
1431
  const startOfLast30Days = new Date(startOfToday);
@@ -1526,11 +1555,19 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
1526
1555
  }
1527
1556
 
1528
1557
  // 6. Aggregate in sorted path order with cross-file dedupe.
1529
- const data = emptyUsageData({ todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs, nowMs: now.getTime() });
1558
+ const data = emptyUsageData({
1559
+ todayMs,
1560
+ weekStartMs,
1561
+ lastWeekStartMs,
1562
+ thisMonthStartMs,
1563
+ last30DaysStartMs,
1564
+ nowMs: now.getTime(),
1565
+ });
1530
1566
  const rawByPeriod: Record<TabName, PeriodRawData> = {
1531
1567
  today: emptyPeriodRawData(),
1532
1568
  thisWeek: emptyPeriodRawData(),
1533
1569
  lastWeek: emptyPeriodRawData(),
1570
+ thisMonth: emptyPeriodRawData(),
1534
1571
  last30Days: emptyPeriodRawData(),
1535
1572
  allTime: emptyPeriodRawData(),
1536
1573
  };
@@ -1597,6 +1634,7 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
1597
1634
  todayMs,
1598
1635
  weekStartMs,
1599
1636
  lastWeekStartMs,
1637
+ thisMonthStartMs,
1600
1638
  last30DaysStartMs,
1601
1639
  rawByPeriod,
1602
1640
  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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",