osdy-pi 1.9.0 → 1.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/README.md CHANGED
@@ -6,11 +6,12 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
6
6
 
7
7
  ## Release highlights
8
8
 
9
- **1.9.0 adds Halloween and Halloween Killer**, bringing the bundle to **20 themes**. The table below summarizes these additions and existing features.
9
+ **1.10.0 adds four-page local usage analytics** with shared period and profile/provider/model filters, keyboard navigation, and metadata-only history. The bundle retains **20 themes**. These highlights describe the 1.10.0 release source; verify npm availability and the resolved version before installing.
10
10
 
11
11
  | Area | What ships |
12
12
  | --- | --- |
13
- | New themes (1.9.0) | Halloween and Halloween Killer add two dark palettes, bringing the bundle to 20 themes. |
13
+ | Local analytics (1.10.0) | `/osdy-usage` opens Summary, Profiles, Models, and History pages for recorded tokens and estimated cost—not billing or subscription quota. |
14
+ | Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes, bringing the bundle to 20 themes. |
14
15
  | Themes introduced in 1.8.0 | Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute. |
15
16
  | Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
16
17
  | Header and mascot | A theme-aware `neon` header and selectable `bts` mascot, both responsive and persisted independently. |
@@ -38,7 +39,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
38
39
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
39
40
  ```
40
41
 
41
- Already using a pinned npm version? `pi update` does not move a pinned version. First-party TODO is included starting in 1.5.0. Starting in 1.6.0, `/osdy-pi uninstall` and explicit agent provider modes are available. Check the resolved version when installing from npm or use a 1.6.0-or-later checkout to try these changes. Installing Osdy does not automatically install Joker; see [explicit agent provider modes](#explicit-agent-provider-modes-in-normal-pi).
42
+ Check the resolved npm version: local analytics requires 1.10.0 or later. Once 1.10.0 is available on npm, `pi install npm:osdy-pi@1.10.0` selects it explicitly. For an unpinned installation, use `pi update`; it does not move a pinned version, so replace an older pin explicitly. First-party TODO is included starting in 1.5.0; `/osdy-pi uninstall` and explicit agent provider modes start in 1.6.0. Installing Osdy does not automatically install Joker; see [explicit agent provider modes](#explicit-agent-provider-modes-in-normal-pi).
42
43
 
43
44
  2. Start Pi:
44
45
 
@@ -46,7 +47,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
46
47
  pi
47
48
  ```
48
49
 
49
- Osdy Pi's own extension and themes are bundled—do not install them separately. Version **1.9.0** bundles **20 themes**: Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in 1.8.0; Halloween and Halloween Killer are added in 1.9.0. Check your installed npm version to see which themes are available. Launch through `osdy-pi` (or `npm run pi:dev`) to activate a valid Osdy default account into Pi's shared agent directory before Pi starts. With no default, Pi remains unmanaged; run `/osdy-account` to create a profile and establish a default. On session start, Osdy Pi restores its persisted enabled state when a UI is available and preserves your selected Pi theme. When disabled, it leaves Gentle Shell (or Pi's native UI) untouched.
50
+ Osdy Pi's own extension and themes are bundled—do not install them separately. Version **1.10.0** bundles **20 themes**: Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in 1.8.0; Halloween and Halloween Killer are added in 1.9.0. Check your installed npm version to see which themes are available. Launch through `osdy-pi` (or `npm run pi:dev`) to activate a valid Osdy default account into Pi's shared agent directory before Pi starts. With no default, Pi remains unmanaged; run `/osdy-account` to create a profile and establish a default. On session start, Osdy Pi restores its persisted enabled state when a UI is available and preserves your selected Pi theme. When disabled, it leaves Gentle Shell (or Pi's native UI) untouched.
50
51
 
51
52
  ### Optional: isolated `osdy` command
52
53
 
@@ -255,6 +256,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
255
256
  | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
256
257
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
257
258
  | Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
259
+ | Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
258
260
  | Git | Working-tree summary and a centered, filterable diff panel |
259
261
  | Audio | Optional event sounds on macOS and Windows |
260
262
 
@@ -264,9 +266,9 @@ Before rename or removal, close this Pi process when it uses the target and **ma
264
266
 
265
267
  ### Themes
266
268
 
267
- Version **1.9.0** contains **20 themes**. Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in **1.8.0**; **1.9.0** adds Halloween and Halloween Killer. To try the themes from a checkout:
269
+ Version **1.10.0** contains **20 themes**. Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in **1.8.0**; Halloween and Halloween Killer were added in **1.9.0**. To try the themes from a checkout:
268
270
 
269
- 1. Run `npm run pi:dev` from the 1.9.0 checkout, or `/reload` if that checkout is already loaded in Pi.
271
+ 1. Run `npm run pi:dev` from the 1.10.0 checkout, or `/reload` if that checkout is already loaded in Pi.
270
272
  2. Open `/settings` → **Theme** and select `osdy-pi-gruvbox-dark`, `osdy-pi-nord`, `osdy-pi-rose-pine`, `osdy-pi-daniela-cute`, `osdy-pi-halloween`, `osdy-pi-halloween-killer`, or another name below.
271
273
 
272
274
  | Theme | Use |
@@ -358,6 +360,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
358
360
  | Diff | `/osdy-pi diff` |
359
361
  | Package | `/osdy-pi uninstall` |
360
362
  | Codex subscription | `/usage` |
363
+ | Local usage analytics (starting in 1.10.0) | `/osdy-usage` |
361
364
 
362
365
  `/osdy-pi uninstall` finds a unique Osdy Pi package in Pi's configured package list and asks you to confirm its exact source and user/project scope before calling Pi's `remove` command. Run it from a trusted project with interactive UI. Git registrations are checked against Pi's host/path identity; a local registration must point to this running extension's package root and have a matching manifest. Ambiguous or changed registrations, unrecognized sources, and cancelled confirmation remove nothing. It removes only the Pi package registration, not Osdy profiles, accounts, the globally installed CLI, or other packages. Restart Pi afterward to unload the extension. If no unique source is found, inspect `pi list` and use `pi remove <source> [-l]` manually.
363
366
 
@@ -375,9 +378,41 @@ Usage loads once when the session starts and refreshes when the modal opens or `
375
378
 
376
379
  > **Privacy:** OAuth is resolved only through Pi's `modelRegistry`. Osdy Pi does not read `auth.json`, persist or log credentials, or display account IDs, tokens, response bodies, or endpoint internals in the UI. Requests use a fixed HTTPS endpoint with bounded timeout, response size, and redirects.
377
380
 
381
+ ### Local usage analytics (starting in 1.10.0)
382
+
383
+ Run `/osdy-usage` in interactive terminal Pi. **Summary** opens by default; use Tab / Shift+Tab or `1`–`4` to choose a focused page. Period, filters and the loaded snapshot are shared; switching pages resets scroll without reading history or making network requests.
384
+
385
+ | Page | Content |
386
+ | --- | --- |
387
+ | `1` Summary | Total tokens, recorded turns, estimated USD and compact timeline |
388
+ | `2` Profiles | Every profile's consumption ranking |
389
+ | `3` Models | Every provider/model ranking and exact input/output/cache composition of the selection; use `f` to inspect one model |
390
+ | `4` History | Timeline and exact local bucket values, timezone and repeated DST hours with UTC offsets |
391
+
392
+ Narrow terminals stack metrics and wrap names; every group remains reachable by scrolling. Shares use the selected total tokens; bars scale to each ranking's maximum. Loading, stale, scan-limit/warning and unpriced/partial-cost states remain on every page and in help. Sticky status lines wrap instead of concealing cost status behind scan warnings, including at End. When height is scarce, statuses take priority over optional headings, charts and footer controls; the active page remains visible when space permits. Press `?` for methodology and full controls, then `?` again to return. Choosing a page also leaves help without discarding filters.
393
+
394
+ This is separate from the unchanged `/usage` subscription quota panel. RPC, JSON and print modes receive a short notification instead of charts. The four-page UI passed independent scripted synthetic PTY checks for navigation, shared selection and responsive statuses at 12/32/100 columns, including a five-row terminal. Human visual confirmation and provider-backed capture remain unverified.
395
+
396
+ | Control | Action |
397
+ | --- | --- |
398
+ | Tab / Shift+Tab, `1` / `2` / `3` / `4` | Cycle pages with wrap; jump to Summary / Profiles / Models / History |
399
+ | `?` | Toggle methodology/control help; `?` returns to the selected page |
400
+ | `d` / `w` / `m`, left / right | Local calendar day, Monday-based week or month; previous / next range (not pages) |
401
+ | `p` / `v` / `f`, `x` | Cycle profile / provider / model filters; reset filters |
402
+ | `r`, arrows / Page Up / Page Down / Home / End | Refresh history; scroll |
403
+ | Esc / `q` / Ctrl+C | Close, including from help |
404
+
405
+ **Collection starts with new assistant turns begun while Osdy is enabled**, after session settings finish loading. `/osdy-pi off` stops collection; `/osdy-pi on` enables it for subsequent turns. Existing history remains viewable while disabled. No old-session import or replay/compaction backfill occurs. Processes without this extension and non-message/background usage (including summaries and cache warming) are not recorded.
406
+
407
+ **Storage and privacy:** metadata-only private JSONL shards live at `<active agent dir>/extensions/osdy-pi/usage-analytics/`: an absolute `PI_CODING_AGENT_DIR`, otherwise `~/.pi/agent/extensions/osdy-pi/usage-analytics/`. No files are created until an actual append. Same-agent profiles share history; isolated agent directories keep separate histories. Records contain timestamps, session/entry IDs, the selected Codex profile label, actual provider/model, token counts and nullable estimated cost—no prompts, responses, credentials, account IDs or session paths. Analytics never reads auth files. No automatic deletion or retention engine is provided.
408
+
409
+ Codex labels are snapshotted at turn start, not verified account identities; later switches cannot reattribute that turn. Other providers or missing labels are **unmanaged**. Rename/delete does not rewrite old labels; case-only differences share profile identity. Calendar ranges use the machine's local timezone (shown in the panel), including daylight-saving boundaries.
410
+
411
+ Costs are Pi's per-message estimates, **not quota, a provider bill, or confirmed spending**. No matching records, entirely unavailable costs and reported zero are distinct states. Partial estimates show the unpriced turn count. Zero-priced models remain zero; absent prices remain unknown. History scans are bounded and prefer recent data, so limited/corrupt coverage warnings mean totals may omit records. Write failures produce generic warnings; subsequent reads report unavailable/incomplete coverage rather than presenting those totals as complete.
412
+
378
413
  ## Native message cards
379
414
 
380
- Pi renders the colored user card and assistant Markdown natively. User messages have no emoji; Osdy Pi places a separate `🦝` transcript entry before each assistant response with visible text, not inside message content. Previously saved user `👤` entries remain in session history but render no row on replay. Selecting only the native message keeps its body clean; a wide selection that includes the emoji row copies the emoji too. Assistant markers persist with the session but do not enter model context. The installed Pi 0.87.1 does not use `assistantMessage*` theme palette keys, so these keys do not create a native assistant card. Osdy does not install a Markdown transformer or change streaming. The current user card colors remain unchanged. Try both roles in your selected theme with `npm run pi:dev`; terminal rendering and selection still need a live visual check. When disabled, Osdy does not add new markers.
415
+ Pi renders the colored user card and assistant Markdown natively. User messages have no emoji; Osdy Pi places a separate `🦝` transcript entry before each assistant response with visible text, not inside message content. Previously saved user `👤` entries remain in session history but render no row on replay. Selecting only the native message keeps its body clean; a wide selection that includes the emoji row copies the emoji too. Assistant markers persist with the session but do not enter model context. Earlier checks on Pi 0.87.1 found that it did not use `assistantMessage*` theme palette keys; these keys alone should not be assumed to create a native assistant card. Osdy does not install a Markdown transformer or change streaming. The current user card colors remain unchanged. Try both roles in your selected theme with `npm run pi:dev`; terminal rendering and selection still need a live visual check. When disabled, Osdy does not add new markers.
381
416
 
382
417
  ## Editor and working indicator
383
418
 
@@ -62,6 +62,7 @@ import { registerTodosCommand } from "./todo-command.js";
62
62
  import { createTodoSessionStore } from "./todo-session.js";
63
63
  import { registerMessageRoleMarkers } from "./message-role-markers.js";
64
64
  import { registerTodoWidget } from "./todo-widget.js";
65
+ import { registerUsageAnalytics } from "./usage-analytics.js";
65
66
 
66
67
  function scheduleOsdyRefresh(
67
68
  delayMs: number,
@@ -946,6 +947,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
946
947
  | ReturnType<typeof createResponsiveCoordinator>
947
948
  | undefined;
948
949
  let sessionContext: ExtensionContext | undefined;
950
+ let usageSettingsReady = false;
949
951
  let codexUsageAbort: AbortController | undefined;
950
952
  const refreshCurrentCodexUsage = async (
951
953
  ctx: ExtensionContext,
@@ -1026,6 +1028,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
1026
1028
  }
1027
1029
  });
1028
1030
  pi.on("session_shutdown", () => {
1031
+ usageSettingsReady = false;
1029
1032
  codexUsageAbort?.abort();
1030
1033
  codexUsageAbort = undefined;
1031
1034
  state.codexUsage = { kind: "idle" };
@@ -1036,6 +1039,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
1036
1039
  sessionContext = undefined;
1037
1040
  });
1038
1041
  pi.on("session_start", async (_event, ctx) => {
1042
+ usageSettingsReady = false;
1039
1043
  cancelOsdyRefreshes();
1040
1044
  stopResponsive();
1041
1045
  sessionContext = ctx;
@@ -1043,6 +1047,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
1043
1047
  if (sessionContext !== ctx) return;
1044
1048
  state.fallbackEditorFactory = ctx.ui.getEditorComponent();
1045
1049
  state.enabled = editorSettings.enabled;
1050
+ usageSettingsReady = true;
1046
1051
  state.editorMode = editorSettings.editorMode;
1047
1052
  state.workingTreeEnabled = editorSettings.workingTreeEnabled;
1048
1053
  state.headerVariant = editorSettings.headerVariant;
@@ -1065,6 +1070,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
1065
1070
  void refreshCurrentCodexUsage(ctx);
1066
1071
  });
1067
1072
 
1073
+ registerUsageAnalytics(pi, { isEnabled: () => usageSettingsReady && state.enabled });
1068
1074
  registerUsageCommand(
1069
1075
  pi,
1070
1076
  state,
@@ -0,0 +1,208 @@
1
+ export interface UsageRecord {
2
+ version: 1;
3
+ timestamp: number;
4
+ sessionId: string;
5
+ entryId: string;
6
+ profile: string | null;
7
+ provider: string;
8
+ model: string;
9
+ input: number;
10
+ output: number;
11
+ cacheRead: number;
12
+ cacheWrite: number;
13
+ estimatedCost: number | null;
14
+ }
15
+
16
+ function text(value: unknown): value is string {
17
+ return typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(value);
18
+ }
19
+ function identifier(value: unknown): value is string {
20
+ return text(value) && !value.includes("/") && !value.includes("\\");
21
+ }
22
+ function count(value: unknown): value is number {
23
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
24
+ }
25
+ function validProfile(value: unknown): value is string | null {
26
+ return value === null || (
27
+ typeof value === "string" &&
28
+ /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(value) &&
29
+ !["default", "profiles", "auth.json"].includes(value.toLowerCase())
30
+ );
31
+ }
32
+
33
+ /** Narrow unknown input and copy only approved metadata; absent cost means unavailable. */
34
+ export function parseUsageRecord(value: unknown): UsageRecord | null {
35
+ if (typeof value !== "object" || value === null) return null;
36
+ if (
37
+ !("version" in value) || value.version !== 1 ||
38
+ !("timestamp" in value) || !count(value.timestamp) || value.timestamp > 8.64e15
39
+ ) return null;
40
+ if (
41
+ !("sessionId" in value) || !identifier(value.sessionId) ||
42
+ !("entryId" in value) || !identifier(value.entryId)
43
+ ) return null;
44
+ if (
45
+ !("profile" in value) || !validProfile(value.profile) ||
46
+ !("provider" in value) || !text(value.provider) ||
47
+ !("model" in value) || !text(value.model)
48
+ ) return null;
49
+ if (
50
+ !("input" in value) || !count(value.input) ||
51
+ !("output" in value) || !count(value.output) ||
52
+ !("cacheRead" in value) || !count(value.cacheRead) ||
53
+ !("cacheWrite" in value) || !count(value.cacheWrite)
54
+ ) return null;
55
+ const cost = "estimatedCost" in value ? value.estimatedCost : null;
56
+ if (cost !== null && cost !== undefined && (
57
+ typeof cost !== "number" || !Number.isFinite(cost) || cost < 0 || cost > Number.MAX_SAFE_INTEGER
58
+ )) return null;
59
+ return {
60
+ version: 1,
61
+ timestamp: value.timestamp,
62
+ sessionId: value.sessionId,
63
+ entryId: value.entryId,
64
+ profile: value.profile,
65
+ provider: value.provider,
66
+ model: value.model,
67
+ input: value.input,
68
+ output: value.output,
69
+ cacheRead: value.cacheRead,
70
+ cacheWrite: value.cacheWrite,
71
+ estimatedCost: cost ?? null,
72
+ };
73
+ }
74
+
75
+ /** Tagged keys prevent a named "unmanaged" profile colliding with missing attribution. */
76
+ export function profileKey(profile: string | null): string {
77
+ if (!validProfile(profile)) throw new Error("Invalid profile label");
78
+ return profile === null ? "unmanaged:" : `profile:${profile.toLowerCase()}`;
79
+ }
80
+
81
+ export type UsagePeriodKind = "day" | "week" | "month";
82
+ export interface UsagePeriod { kind: UsagePeriodKind; start: number; end: number }
83
+
84
+ function civilMidnight(year: number, month: number, day: number): Date {
85
+ // Build each boundary independently: a skipped midnight may normalize to 01:00,
86
+ // but that hour must not carry into the next civil date. setFullYear handles 0–99.
87
+ const boundary = new Date(0);
88
+ boundary.setFullYear(year, month, day);
89
+ boundary.setHours(0, 0, 0, 0);
90
+ return boundary;
91
+ }
92
+
93
+ export function usagePeriod(kind: UsagePeriodKind, anchor: Date): UsagePeriod {
94
+ if (!["day", "week", "month"].includes(kind) || !Number.isFinite(anchor.getTime())) throw new Error("Invalid calendar period");
95
+ const year = anchor.getFullYear();
96
+ const month = anchor.getMonth();
97
+ const day = kind === "month" ? 1 : anchor.getDate() - (kind === "week" ? (anchor.getDay() + 6) % 7 : 0);
98
+ const start = civilMidnight(year, month, day);
99
+ const end = kind === "month" ? civilMidnight(year, month + 1, 1) : civilMidnight(year, month, day + (kind === "week" ? 7 : 1));
100
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) throw new Error("Calendar period out of range");
101
+ return { kind, start: start.getTime(), end: end.getTime() };
102
+ }
103
+ function validatePeriod(period: UsagePeriod): void {
104
+ const canonical = usagePeriod(period.kind, new Date(period.start));
105
+ if (canonical.start !== period.start || canonical.end !== period.end) throw new Error("Invalid calendar period boundaries");
106
+ }
107
+ export function adjacentPeriod(period: UsagePeriod, direction: -1 | 1): UsagePeriod {
108
+ validatePeriod(period);
109
+ if (direction !== -1 && direction !== 1) throw new Error("Invalid navigation direction");
110
+ const anchor = new Date(period.start);
111
+ if (period.kind === "month") anchor.setMonth(anchor.getMonth() + direction);
112
+ else anchor.setDate(anchor.getDate() + direction * (period.kind === "week" ? 7 : 1));
113
+ return usagePeriod(period.kind, anchor);
114
+ }
115
+
116
+ export interface UsageTotals {
117
+ records: number;
118
+ input: number;
119
+ output: number;
120
+ cacheRead: number;
121
+ cacheWrite: number;
122
+ /** Recorded estimates, never authoritative billing or subscription quota. */
123
+ estimatedCost: number;
124
+ unavailableCostRecords: number;
125
+ }
126
+ export interface UsageBucket { start: number; end: number; totals: UsageTotals }
127
+ export interface UsageProfileGroup { key: string; profile: string | null; totals: UsageTotals }
128
+ export interface UsageModelGroup { provider: string; model: string; totals: UsageTotals }
129
+ export interface UsageFilter { profile?: string | null; provider?: string; model?: string }
130
+ export interface UsageAggregation {
131
+ period: UsagePeriod;
132
+ totals: UsageTotals;
133
+ buckets: UsageBucket[];
134
+ profiles: UsageProfileGroup[];
135
+ models: UsageModelGroup[];
136
+ }
137
+ function emptyTotals(): UsageTotals {
138
+ return { records: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, estimatedCost: 0, unavailableCostRecords: 0 };
139
+ }
140
+ function add(totals: UsageTotals, record: UsageRecord): void {
141
+ totals.records++;
142
+ totals.input += record.input;
143
+ totals.output += record.output;
144
+ totals.cacheRead += record.cacheRead;
145
+ totals.cacheWrite += record.cacheWrite;
146
+ if (record.estimatedCost === null) totals.unavailableCostRecords++;
147
+ else totals.estimatedCost += record.estimatedCost;
148
+ for (const value of [totals.records, totals.input, totals.output, totals.cacheRead, totals.cacheWrite, totals.estimatedCost]) {
149
+ if (!Number.isFinite(value) || value > Number.MAX_SAFE_INTEGER) throw new Error("Usage totals exceed safe numeric range");
150
+ }
151
+ }
152
+ function compare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; }
153
+
154
+ /** Local timezone calendar boundaries, half-open intervals, and explicit empty buckets.
155
+ * Repeated fall-back hours are separate timestamped buckets; missing spring hours are absent.
156
+ */
157
+ export function aggregateUsage(records: readonly UsageRecord[], period: UsagePeriod, filter: UsageFilter = {}): UsageAggregation {
158
+ validatePeriod(period);
159
+ if (filter.profile !== undefined) profileKey(filter.profile);
160
+ if ((filter.provider !== undefined && !text(filter.provider)) || (filter.model !== undefined && !text(filter.model))) throw new Error("Invalid model filter");
161
+ const buckets: UsageBucket[] = [];
162
+ let cursor = period.start;
163
+ while (cursor < period.end) {
164
+ const next = new Date(cursor);
165
+ // Local hour boundaries realign after fractional-hour DST jumps; elapsed hours
166
+ // retain repeated fall-back hours instead of skipping straight to the next label.
167
+ if (period.kind === "day") {
168
+ next.setHours(next.getHours() + 1, 0, 0, 0);
169
+ next.setTime(Math.min(next.getTime(), cursor + 3600000));
170
+ } else next.setTime(civilMidnight(next.getFullYear(), next.getMonth(), next.getDate() + 1).getTime());
171
+ const end = Math.min(next.getTime(), period.end);
172
+ buckets.push({ start: cursor, end, totals: emptyTotals() });
173
+ cursor = end;
174
+ }
175
+ const totals = emptyTotals();
176
+ const profiles = new Map<string, UsageProfileGroup>();
177
+ const models = new Map<string, UsageModelGroup>();
178
+ for (const input of records) {
179
+ const record = parseUsageRecord(input);
180
+ if (!record) throw new Error("Invalid usage record");
181
+ if (record.timestamp < period.start || record.timestamp >= period.end) continue;
182
+ const key = profileKey(record.profile);
183
+ if (filter.profile !== undefined && key !== profileKey(filter.profile)) continue;
184
+ if (filter.provider !== undefined && record.provider !== filter.provider) continue;
185
+ if (filter.model !== undefined && record.model !== filter.model) continue;
186
+ add(totals, record);
187
+ const bucket = buckets.find((item) => record.timestamp >= item.start && record.timestamp < item.end);
188
+ if (bucket) add(bucket.totals, record);
189
+ let profile = profiles.get(key);
190
+ if (!profile) {
191
+ profile = { key, profile: record.profile, totals: emptyTotals() };
192
+ profiles.set(key, profile);
193
+ }
194
+ add(profile.totals, record);
195
+ const modelKey = JSON.stringify([record.provider, record.model]);
196
+ let model = models.get(modelKey);
197
+ if (!model) {
198
+ model = { provider: record.provider, model: record.model, totals: emptyTotals() };
199
+ models.set(modelKey, model);
200
+ }
201
+ add(model.totals, record);
202
+ }
203
+ return {
204
+ period: { ...period }, totals, buckets,
205
+ profiles: [...profiles.values()].sort((a, b) => compare(a.key, b.key)),
206
+ models: [...models.values()].sort((a, b) => compare(a.provider, b.provider) || compare(a.model, b.model)),
207
+ };
208
+ }