osdy-pi 1.9.0 → 1.11.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
@@ -1,16 +1,18 @@
1
1
  # Osdy Pi — a themed, responsive Pi workspace
2
2
 
3
- Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive terminal presentation with a header, editor, working indicator, and Git view. Visit the [Osdy landing page](https://landing-osdy.vercel.app/).
3
+ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive terminal presentation with a header, editor, working indicator, and Git view. Visit the [Osdy landing page](https://osdy-pi.vercel.app/).
4
4
 
5
5
  <img width="1857" height="847" alt="Osdy Pi interface" src="https://github.com/user-attachments/assets/028eeb14-3f43-4f1c-9603-0c55a8d2856d" />
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.11.0 adds three Spider-Man-inspired dark palettes**, bringing the bundle to **23 themes**. It retains the four-page local usage analytics introduced in 1.10.0, with shared period and profile/provider/model filters, keyboard navigation, and metadata-only history. These highlights describe the 1.11.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.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
15
+ | Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
14
16
  | Themes introduced in 1.8.0 | Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute. |
15
17
  | Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
16
18
  | Header and mascot | A theme-aware `neon` header and selectable `bts` mascot, both responsive and persisted independently. |
@@ -38,7 +40,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
38
40
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
39
41
  ```
40
42
 
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).
43
+ Check the resolved npm version: the Spider-Man-inspired themes require 1.11.0 or later; local analytics requires 1.10.0 or later. To select this release explicitly, use `pi install npm:osdy-pi@1.11.0` once it is available on npm. 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
44
 
43
45
  2. Start Pi:
44
46
 
@@ -46,7 +48,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
46
48
  pi
47
49
  ```
48
50
 
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.
51
+ Osdy Pi's own extension and themes are bundled—do not install them separately. Version **1.11.0** bundles **23 themes**, including Spider-Man Classic, Miles Morales, and Spider-Verse: 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. 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
52
 
51
53
  ### Optional: isolated `osdy` command
52
54
 
@@ -250,11 +252,12 @@ Before rename or removal, close this Pi process when it uses the target and **ma
250
252
 
251
253
  | Area | Included behavior |
252
254
  | --- | --- |
253
- | Themes | 20 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Halloween, Halloween Killer, Catppuccin, Matrix, and Lucent Orange palettes |
255
+ | Themes | 23 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Halloween, Halloween Killer, Catppuccin, Matrix, Lucent Orange, and three Spider-Man-inspired palettes |
254
256
  | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
255
257
  | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
256
258
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
257
259
  | Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
260
+ | Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
258
261
  | Git | Working-tree summary and a centered, filterable diff panel |
259
262
  | Audio | Optional event sounds on macOS and Windows |
260
263
 
@@ -264,10 +267,10 @@ Before rename or removal, close this Pi process when it uses the target and **ma
264
267
 
265
268
  ### Themes
266
269
 
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:
270
+ Version **1.11.0** contains **23 themes**, including the new Spider-Man Classic, Miles Morales, and Spider-Verse palettes. 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
271
 
269
- 1. Run `npm run pi:dev` from the 1.9.0 checkout, or `/reload` if that checkout is already loaded in Pi.
270
- 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.
272
+ 1. Run `npm run pi:dev` from the 1.11.0 checkout, or `/reload` if that checkout is already loaded in Pi.
273
+ 2. Open `/settings` → **Theme** and select `osdy-pi-spider-man-classic`, `osdy-pi-miles-morales`, `osdy-pi-spider-verse`, or another name below.
271
274
 
272
275
  | Theme | Use |
273
276
  | --- | --- |
@@ -291,6 +294,9 @@ Version **1.9.0** contains **20 themes**. Gruvbox Dark, Nord, Rosé Pine base da
291
294
  | `osdy-pi-catppuccin-mocha` | Catppuccin Mocha dark palette. |
292
295
  | `osdy-pi-matrix` | OpenCode Matrix dark palette. |
293
296
  | `osdy-pi-lucent-orange` | Lucent Orange dark palette with terminal-background passthrough. |
297
+ | `osdy-pi-spider-man-classic` | Classic Peter Parker: night-blue panels, red accents, and web-white text, with blue links and distinct green success/yellow warning indicators. |
298
+ | `osdy-pi-miles-morales` | Miles Morales: black and charcoal panels with intense red accents, pale text, and distinct green success/yellow warning indicators. |
299
+ | `osdy-pi-spider-verse` | Spider-Verse: dark violet panels, red, electric-blue, and magenta highlights, with readable syntax and distinct green success/yellow warning indicators. |
294
300
 
295
301
  Osdy Pi preserves your selected Pi theme when it enables, reapplies, or disables its UI. Choose any theme in Pi:
296
302
 
@@ -358,6 +364,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
358
364
  | Diff | `/osdy-pi diff` |
359
365
  | Package | `/osdy-pi uninstall` |
360
366
  | Codex subscription | `/usage` |
367
+ | Local usage analytics (starting in 1.10.0) | `/osdy-usage` |
361
368
 
362
369
  `/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
370
 
@@ -375,9 +382,41 @@ Usage loads once when the session starts and refreshes when the modal opens or `
375
382
 
376
383
  > **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
384
 
385
+ ### Local usage analytics (starting in 1.10.0)
386
+
387
+ 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.
388
+
389
+ | Page | Content |
390
+ | --- | --- |
391
+ | `1` Summary | Total tokens, recorded turns, estimated USD and compact timeline |
392
+ | `2` Profiles | Every profile's consumption ranking |
393
+ | `3` Models | Every provider/model ranking and exact input/output/cache composition of the selection; use `f` to inspect one model |
394
+ | `4` History | Timeline and exact local bucket values, timezone and repeated DST hours with UTC offsets |
395
+
396
+ 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.
397
+
398
+ 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.
399
+
400
+ | Control | Action |
401
+ | --- | --- |
402
+ | Tab / Shift+Tab, `1` / `2` / `3` / `4` | Cycle pages with wrap; jump to Summary / Profiles / Models / History |
403
+ | `?` | Toggle methodology/control help; `?` returns to the selected page |
404
+ | `d` / `w` / `m`, left / right | Local calendar day, Monday-based week or month; previous / next range (not pages) |
405
+ | `p` / `v` / `f`, `x` | Cycle profile / provider / model filters; reset filters |
406
+ | `r`, arrows / Page Up / Page Down / Home / End | Refresh history; scroll |
407
+ | Esc / `q` / Ctrl+C | Close, including from help |
408
+
409
+ **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.
410
+
411
+ **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.
412
+
413
+ 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.
414
+
415
+ 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.
416
+
378
417
  ## Native message cards
379
418
 
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.
419
+ 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
420
 
382
421
  ## Editor and working indicator
383
422
 
@@ -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, 20);
412
- assert.equal(new Set(manifest.pi.themes).size, 20, "theme registrations must be unique");
411
+ assert.equal(manifest.pi.themes.length, 23);
412
+ assert.equal(new Set(manifest.pi.themes).size, 23, "theme registrations must be unique");
413
413
  for (const themePath of manifest.pi.themes) {
414
414
  const theme = JSON.parse(
415
415
  readFileSync(
@@ -438,18 +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", "osdy-pi-halloween", "osdy-pi-halloween-killer"]) {
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", "osdy-pi-spider-man-classic", "osdy-pi-miles-morales", "osdy-pi-spider-verse"]) {
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 20 themes including both Halloween palettes", () => {
447
+ void test("README documents all 23 themes including three Spider-Man-inspired palettes", () => {
448
448
  const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8");
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);
449
+ assert.match(readme, /\*\*23 themes\*\*/);
450
+ assert.match(readme, /23 built-in themes/);
451
+ assert.doesNotMatch(readme, /\b(?:18|20) (?:built-in )?themes\b/);
452
+ assert.equal(readme.match(/^\| `osdy-pi-[^`]+` \|/gm)?.length, 23);
453
+ for (const name of ["osdy-pi-spider-man-classic", "osdy-pi-miles-morales", "osdy-pi-spider-verse"]) {
454
+ assert.ok(readme.includes(`| \`${name}\` |`), `${name}: documented theme row`);
455
+ }
453
456
  assert.match(readme, /\| `osdy-pi-daniela-cute` \| Daniela Cute/);
454
457
  assert.match(readme, /\| `osdy-pi-halloween` \|.*[Pp]umpkin.*violet.*lime/);
455
458
  assert.match(readme, /\| `osdy-pi-halloween-killer` \|.*[Gg]othic.*blood.red.*violet/);
@@ -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
+ }