osdy-pi 1.8.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,13 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
6
6
 
7
7
  ## Release highlights
8
8
 
9
- The **1.8.0 release candidate** includes the changes below; npm publication is not yet confirmed.
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 (included starting in 1.8.0) | Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute bring the bundle to 18 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. |
15
+ | Themes introduced in 1.8.0 | Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute. |
14
16
  | Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
15
17
  | Header and mascot | A theme-aware `neon` header and selectable `bts` mascot, both responsive and persisted independently. |
16
18
  | Accounts | `/osdy-account` switches Codex profiles in place with atomic activation, rollback, bounded auth files, and process-safe locks. |
@@ -37,7 +39,7 @@ The **1.8.0 release candidate** includes the changes below; npm publication is n
37
39
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
38
40
  ```
39
41
 
40
- 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).
41
43
 
42
44
  2. Start Pi:
43
45
 
@@ -45,7 +47,7 @@ The **1.8.0 release candidate** includes the changes below; npm publication is n
45
47
  pi
46
48
  ```
47
49
 
48
- Osdy Pi's own extension and themes are bundled—do not install them separately. The **1.8.0 release candidate** bundles **18 themes**, including Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute starting in 1.8.0. Check your installed npm version; publication of 1.8.0 is not yet confirmed. 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.
49
51
 
50
52
  ### Optional: isolated `osdy` command
51
53
 
@@ -249,11 +251,12 @@ Before rename or removal, close this Pi process when it uses the target and **ma
249
251
 
250
252
  | Area | Included behavior |
251
253
  | --- | --- |
252
- | Themes | 18 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Catppuccin, Matrix, and Lucent Orange palettes |
254
+ | Themes | 20 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Halloween, Halloween Killer, Catppuccin, Matrix, and Lucent Orange palettes |
253
255
  | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
254
256
  | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
255
257
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
256
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 |
257
260
  | Git | Working-tree summary and a centered, filterable diff panel |
258
261
  | Audio | Optional event sounds on macOS and Windows |
259
262
 
@@ -263,10 +266,10 @@ Before rename or removal, close this Pi process when it uses the target and **ma
263
266
 
264
267
  ### Themes
265
268
 
266
- The bundle contains **18 themes**; Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute are included starting in **1.8.0**. To try the **1.8.0 release candidate** before npm publication is confirmed:
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:
267
270
 
268
- 1. Run `npm run pi:dev` from the 1.8.0 checkout, or `/reload` if that checkout is already loaded in Pi.
269
- 2. Open `/settings` → **Theme** and select `osdy-pi-gruvbox-dark`, `osdy-pi-nord`, `osdy-pi-rose-pine`, `osdy-pi-daniela-cute`, or another name below.
271
+ 1. Run `npm run pi:dev` from the 1.10.0 checkout, or `/reload` if that checkout is already loaded in Pi.
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.
270
273
 
271
274
  | Theme | Use |
272
275
  | --- | --- |
@@ -282,6 +285,8 @@ The bundle contains **18 themes**; Gruvbox Dark, Nord, Rosé Pine base dark, and
282
285
  | `osdy-pi-nord` | [Nord](https://www.nordtheme.com/docs/colors-and-palettes) dark palette: cool, restrained Polar Night, Snow Storm, Frost, and Aurora colors. |
283
286
  | `osdy-pi-rose-pine` | [Rosé Pine](https://rosepinetheme.com/palette/ingredients/) base dark palette: soft colors, not Moon or Dawn. |
284
287
  | `osdy-pi-daniela-cute` | Daniela Cute: navy panels/export page (`#0B1220`), blue cards (`#111F33`), borders (`#253A55`), blue accents (`#3584E4`), main text (`#D8E2EF`), secondary text (`#93A6BE`), and selection (`#193655`). Distinct status and syntax colors remain; the terminal background remains unchanged. |
288
+ | `osdy-pi-halloween` | Pumpkin orange, spectral violet, and lime accents on dark plum panels, with warm text and distinct success/error colors. |
289
+ | `osdy-pi-halloween-killer` | Gothic/slasher dark palette: near-black panels, blood-red and violet accents, bone-colored text, and sage success indicators distinct from red errors. |
285
290
  | `osdy-pi-catppuccin-latte` | Catppuccin Latte light palette. |
286
291
  | `osdy-pi-catppuccin-frappe` | Catppuccin Frappé dark palette. |
287
292
  | `osdy-pi-catppuccin-macchiato` | Catppuccin Macchiato dark palette. |
@@ -355,6 +360,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
355
360
  | Diff | `/osdy-pi diff` |
356
361
  | Package | `/osdy-pi uninstall` |
357
362
  | Codex subscription | `/usage` |
363
+ | Local usage analytics (starting in 1.10.0) | `/osdy-usage` |
358
364
 
359
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.
360
366
 
@@ -372,9 +378,41 @@ Usage loads once when the session starts and refreshes when the modal opens or `
372
378
 
373
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.
374
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
+
375
413
  ## Native message cards
376
414
 
377
- 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.
378
416
 
379
417
  ## Editor and working indicator
380
418
 
@@ -408,8 +408,8 @@ void test("every registered theme defines native user message colors", () => {
408
408
  readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
409
409
  ) as { pi: { themes: string[] } };
410
410
 
411
- assert.equal(manifest.pi.themes.length, 18);
412
- assert.equal(new Set(manifest.pi.themes).size, 18, "theme registrations must be unique");
411
+ assert.equal(manifest.pi.themes.length, 20);
412
+ assert.equal(new Set(manifest.pi.themes).size, 20, "theme registrations must be unique");
413
413
  for (const themePath of manifest.pi.themes) {
414
414
  const theme = JSON.parse(
415
415
  readFileSync(
@@ -438,17 +438,21 @@ void test("new dark themes are explicitly registered with unique matching names"
438
438
  return theme.name;
439
439
  });
440
440
  assert.equal(new Set(names).size, names.length, "theme names must be unique");
441
- for (const name of ["osdy-pi-gruvbox-dark", "osdy-pi-nord", "osdy-pi-rose-pine", "osdy-pi-daniela-cute"]) {
441
+ for (const name of ["osdy-pi-gruvbox-dark", "osdy-pi-nord", "osdy-pi-rose-pine", "osdy-pi-daniela-cute", "osdy-pi-halloween", "osdy-pi-halloween-killer"]) {
442
442
  assert.equal(manifest.pi.themes.filter((path) => path === `./themes/${name}.json`).length, 1);
443
443
  assert.equal(names.filter((registered) => registered === name).length, 1);
444
444
  }
445
445
  });
446
446
 
447
- void test("README documents all 18 themes including Daniela Cute", () => {
447
+ void test("README documents all 20 themes including both Halloween palettes", () => {
448
448
  const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8");
449
- assert.match(readme, /\*\*18 themes\*\*/);
450
- assert.match(readme, /18 built-in themes/);
449
+ assert.match(readme, /\*\*20 themes\*\*/);
450
+ assert.match(readme, /20 built-in themes/);
451
+ assert.doesNotMatch(readme, /\b18 (?:built-in )?themes\b/);
452
+ assert.equal(readme.match(/^\| `osdy-pi-[^`]+` \|/gm)?.length, 20);
451
453
  assert.match(readme, /\| `osdy-pi-daniela-cute` \| Daniela Cute/);
454
+ assert.match(readme, /\| `osdy-pi-halloween` \|.*[Pp]umpkin.*violet.*lime/);
455
+ assert.match(readme, /\| `osdy-pi-halloween-killer` \|.*[Gg]othic.*blood.red.*violet/);
452
456
  });
453
457
 
454
458
  void test("Tokyo Night retains its approved native user card colors", () => {
@@ -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
+ }