@raingor/pi-web-switch 0.4.3 → 0.5.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/dist/index.html CHANGED
@@ -7,12 +7,12 @@
7
7
  <link rel="icon" type="image/svg+xml" href="./pi.svg" />
8
8
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
9
9
  <link rel="manifest" href="./manifest.webmanifest" />
10
- <meta name="theme-color" content="#2563eb" />
10
+ <meta name="theme-color" content="#05090d" />
11
11
  <meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
12
- <script type="module" crossorigin src="./assets/main-eom_4fyA.js"></script>
13
- <link rel="stylesheet" crossorigin href="./assets/main-M_eVNxKU.css">
12
+ <script type="module" crossorigin src="./assets/main-rMGV7BZ7.js"></script>
13
+ <link rel="stylesheet" crossorigin href="./assets/main-BtntHFgX.css">
14
14
  </head>
15
- <body class="bg-gray-950 text-gray-100 antialiased">
15
+ <body>
16
16
  <div id="root"></div>
17
17
  </body>
18
18
  </html>
package/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="icon" type="image/svg+xml" href="/pi.svg" />
8
8
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
9
9
  <link rel="manifest" href="/manifest.webmanifest" />
10
- <meta name="theme-color" content="#2563eb" />
10
+ <meta name="theme-color" content="#05090d" />
11
11
  <meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
12
12
  </head>
13
- <body class="bg-gray-950 text-gray-100 antialiased">
13
+ <body>
14
14
  <div id="root"></div>
15
15
  <script type="module" src="/src/main.tsx"></script>
16
16
  </body>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@raingor/pi-web-switch",
3
3
  "private": false,
4
- "version": "0.4.3",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.html",
7
7
  "description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
@@ -26,7 +26,8 @@
26
26
  "scripts": {
27
27
  "dev": "vite",
28
28
  "build": "tsc -b && vite build",
29
- "preview": "vite preview"
29
+ "preview": "vite preview",
30
+ "test": "vitest run"
30
31
  },
31
32
  "files": [
32
33
  "pi-package",
@@ -69,7 +70,8 @@
69
70
  "typescript": "~5.8.3",
70
71
  "undici": "^8.9.0",
71
72
  "vite": "^6.3.2",
72
- "vite-plugin-pwa": "^1.3.0"
73
+ "vite-plugin-pwa": "^1.3.0",
74
+ "vitest": "3.2.4"
73
75
  },
74
76
  "build": {
75
77
  "appId": "com.raingor.pi-web-switch",
@@ -6,8 +6,8 @@
6
6
  "scope": "/",
7
7
  "display": "standalone",
8
8
  "orientation": "any",
9
- "background_color": "#0a0a0a",
10
- "theme_color": "#2563eb",
9
+ "background_color": "#05090d",
10
+ "theme_color": "#05090d",
11
11
  "icons": [
12
12
  {
13
13
  "src": "/icon-192.png",
package/public/sw.js CHANGED
@@ -1,27 +1,29 @@
1
1
  // ─── pi-web-switch Service Worker ───────────────────────
2
2
  // Strategy:
3
- // - HTML navigation (index.html / SPA routes): NETWORK-FIRST.
4
- // Every refresh must hit the network so deploys show up on a plain
5
- // Cmd+R reload; the cached copy is only a fallback for offline use.
6
- // - Static assets (hashed js/css): CACHE-FIRST — safe because Vite
7
- // fingerprints filenames, so a new deploy means new URLs.
8
- // - API calls: NETWORK-FIRST, fallback to cache.
9
- // Bump CACHE_VERSION when the SW logic changes to purge old caches.
3
+ // - HTML navigation: NETWORK-FIRST with `cache: no-store` so Cmd+R always
4
+ // receives the current index.html instead of an HTTP/SW cached shell.
5
+ // - API calls: NETWORK-ONLY. Configuration/auth data must never be restored
6
+ // from a stale service-worker cache.
7
+ // - Static assets: CACHE-FIRST. Vite fingerprints JS/CSS filenames, so a new
8
+ // build receives a new URL and cannot collide with an older asset.
9
+ //
10
+ // v3 also purges the v1 cache-first HTML shell that can remain registered on
11
+ // the same localhost origin and cause a blank page after a normal refresh.
10
12
 
11
- const CACHE_VERSION = "pi-web-switch-v2";
13
+ const CACHE_VERSION = "pi-web-switch-v3";
12
14
  const STATIC_CACHE = `${CACHE_VERSION}-static`;
13
15
  const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
16
+ const CACHE_PREFIX = "pi-web-switch-";
17
+ const SCOPE_URL = self.registration.scope;
18
+ const OFFLINE_INDEX_URL = new URL("./index.html", SCOPE_URL).href;
14
19
 
15
- // NOTE: index.html is intentionally NOT precached. Pre-caching it would
16
- // make Cmd+R reloads serve the stale page after a deploy.
17
20
  const PRECACHE_URLS = [
18
- "/manifest.webmanifest",
19
- "/icon-192.png",
20
- "/icon-512.png",
21
- "/apple-touch-icon.png",
22
- ];
21
+ "./manifest.webmanifest",
22
+ "./icon-192.png",
23
+ "./icon-512.png",
24
+ "./apple-touch-icon.png",
25
+ ].map((path) => new URL(path, SCOPE_URL).href);
23
26
 
24
- // ─── Install: precache core assets ─────────────────────
25
27
  self.addEventListener("install", (event) => {
26
28
  event.waitUntil(
27
29
  caches
@@ -31,7 +33,6 @@ self.addEventListener("install", (event) => {
31
33
  );
32
34
  });
33
35
 
34
- // ─── Activate: clean old caches ────────────────────────
35
36
  self.addEventListener("activate", (event) => {
36
37
  event.waitUntil(
37
38
  caches
@@ -39,66 +40,65 @@ self.addEventListener("activate", (event) => {
39
40
  .then((keys) =>
40
41
  Promise.all(
41
42
  keys
42
- .filter((k) => !k.startsWith(CACHE_VERSION))
43
- .map((k) => caches.delete(k))
43
+ .filter((key) => key.startsWith(CACHE_PREFIX) && !key.startsWith(CACHE_VERSION))
44
+ .map((key) => caches.delete(key))
44
45
  )
45
46
  )
46
47
  .then(() => self.clients.claim())
47
48
  );
48
49
  });
49
50
 
50
- // ─── Fetch: strategy by request type ───────────────────
51
51
  self.addEventListener("fetch", (event) => {
52
52
  const { request } = event;
53
-
54
- // Only handle GET; ignore cross-origin and chrome-extension.
55
53
  if (request.method !== "GET") return;
54
+
56
55
  const url = new URL(request.url);
57
56
  if (url.origin !== self.location.origin) return;
58
57
 
59
- // API calls: network-first, fallback to cache.
60
- if (url.pathname.startsWith("/api/")) {
61
- event.respondWith(
62
- fetch(request)
63
- .then((response) => {
64
- const copy = response.clone();
65
- caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy));
66
- return response;
67
- })
68
- .catch(() => caches.match(request))
69
- );
58
+ // Never cache local configuration, authentication, or usage responses.
59
+ if (url.pathname.includes("/api/")) {
60
+ event.respondWith(fetch(request, { cache: "no-store" }));
70
61
  return;
71
62
  }
72
63
 
73
- // HTML navigation: network-first so a plain refresh always gets the
74
- // latest build. Fall back to the last cached copy only when offline.
64
+ // Always revalidate the SPA shell. The cached copy is offline fallback only.
75
65
  if (request.mode === "navigate") {
76
66
  event.respondWith(
77
- fetch(request)
67
+ fetch(request, { cache: "no-store" })
78
68
  .then((response) => {
79
- if (response && response.status === 200 && response.type === "basic") {
69
+ if (response && response.ok && response.type === "basic") {
80
70
  const copy = response.clone();
81
- caches.open(RUNTIME_CACHE).then((cache) => cache.put("/index.html", copy));
71
+ event.waitUntil(
72
+ caches.open(RUNTIME_CACHE).then((cache) => cache.put(OFFLINE_INDEX_URL, copy))
73
+ );
82
74
  }
83
75
  return response;
84
76
  })
85
- .catch(() => caches.match("/index.html"))
77
+ .catch(async () => {
78
+ const cached = await caches.match(OFFLINE_INDEX_URL);
79
+ return cached ?? new Response("Offline", {
80
+ status: 503,
81
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
82
+ });
83
+ })
86
84
  );
87
85
  return;
88
86
  }
89
87
 
90
- // Static assets: cache-first, then network (and cache the result).
91
- event.respondWith(
92
- caches.match(request).then((cached) => {
93
- if (cached) return cached;
94
- return fetch(request).then((response) => {
95
- if (!response || response.status !== 200 || response.type !== "basic") {
96
- return response;
88
+ // Fingerprinted build assets are safe to cache by URL.
89
+ if (url.pathname.includes("/assets/")) {
90
+ event.respondWith(
91
+ caches.match(request).then(async (cached) => {
92
+ if (cached) return cached;
93
+ const response = await fetch(request);
94
+ if (response && response.ok && response.type === "basic") {
95
+ const copy = response.clone();
96
+ event.waitUntil(
97
+ caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy))
98
+ );
97
99
  }
98
- const copy = response.clone();
99
- caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy));
100
100
  return response;
101
- });
102
- })
103
- );
101
+ })
102
+ );
103
+ }
104
104
  });
@@ -1,10 +1,11 @@
1
- import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
1
+ import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "fs";
2
2
  import { homedir, platform } from "os";
3
3
  import { join, resolve, dirname, relative, sep } from "path";
4
4
  import { spawnSync } from "child_process";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
 
7
7
  const PI_DIR = join(homedir(), ".pi", "agent");
8
+ const CODEX_DIR = join(homedir(), ".codex");
8
9
 
9
10
  // ─── Cindy Pi-Agent Sessions ───────────────────────────
10
11
  // When Cindy (the AI assistant) delegates to a pi coding agent, sessions
@@ -45,7 +46,6 @@ export function readSettings() {
45
46
  export function writeSettings(settings: any): boolean {
46
47
  try {
47
48
  const path = piPath("settings.json");
48
- const backup = existsSync(path) ? readFileSync(path, "utf-8") : null;
49
49
  const raw = JSON.stringify(settings, null, 2);
50
50
  writeFileSync(path, raw, "utf-8");
51
51
  return true;
@@ -54,6 +54,175 @@ export function writeSettings(settings: any): boolean {
54
54
  }
55
55
  }
56
56
 
57
+ // ─── Official Usage Query ──────────────────────────────
58
+
59
+ export type OfficialUsageAuthMode = "auto" | "bearer" | "x-api-key" | "api-key";
60
+
61
+ export interface OfficialUsageConfig {
62
+ endpoint: string;
63
+ apiKeys: string[];
64
+ authMode: OfficialUsageAuthMode;
65
+ }
66
+
67
+ export interface OfficialUsageSummary {
68
+ total: number;
69
+ used: number;
70
+ remaining: number;
71
+ remainingPercent: number;
72
+ unit: string;
73
+ source: string;
74
+ checkedAt: string;
75
+ }
76
+
77
+ const OFFICIAL_USAGE_CONFIG_FILE = "official-usage.json";
78
+
79
+ function officialUsagePath(): string {
80
+ return piPath(OFFICIAL_USAGE_CONFIG_FILE);
81
+ }
82
+
83
+ function normalizeOfficialUsageConfig(value: any): OfficialUsageConfig {
84
+ const endpoint = typeof value?.endpoint === "string" ? value.endpoint.trim() : "";
85
+ const apiKeys = Array.isArray(value?.apiKeys)
86
+ ? value.apiKeys.filter((key: unknown): key is string => typeof key === "string").map((key) => key.trim()).filter(Boolean)
87
+ : typeof value?.apiKey === "string" && value.apiKey.trim()
88
+ ? [value.apiKey.trim()]
89
+ : [];
90
+ const authMode: OfficialUsageAuthMode = ["auto", "bearer", "x-api-key", "api-key"].includes(value?.authMode)
91
+ ? value.authMode
92
+ : "auto";
93
+ return { endpoint, apiKeys: Array.from(new Set(apiKeys)), authMode };
94
+ }
95
+
96
+ export function readOfficialUsageConfig(): OfficialUsageConfig {
97
+ try {
98
+ if (!existsSync(officialUsagePath())) return { endpoint: "", apiKeys: [], authMode: "auto" };
99
+ return normalizeOfficialUsageConfig(JSON.parse(readFileSync(officialUsagePath(), "utf-8")));
100
+ } catch {
101
+ return { endpoint: "", apiKeys: [], authMode: "auto" };
102
+ }
103
+ }
104
+
105
+ export function writeOfficialUsageConfig(config: OfficialUsageConfig): boolean {
106
+ try {
107
+ const normalized = normalizeOfficialUsageConfig(config);
108
+ if (!normalized.endpoint || normalized.apiKeys.length === 0) return false;
109
+ const url = new URL(normalized.endpoint);
110
+ if (!/^https?:$/.test(url.protocol)) return false;
111
+ mkdirSync(PI_DIR, { recursive: true });
112
+ writeFileSync(officialUsagePath(), JSON.stringify(normalized, null, 2), { encoding: "utf-8", mode: 0o600 });
113
+ chmodSync(officialUsagePath(), 0o600);
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ function officialNumber(value: unknown): number | null {
121
+ if (typeof value === "number" && Number.isFinite(value)) return value;
122
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value.replace(/[, ]/g, "")))) return Number(value.replace(/[, ]/g, ""));
123
+ return null;
124
+ }
125
+
126
+ function findOfficialMetric(root: unknown, names: string[]): { value: number; unit?: string } | null {
127
+ const wanted = new Set(names.map((name) => name.toLowerCase().replace(/[^a-z0-9]/g, "")));
128
+ const queue: Array<{ value: unknown; path: string[] }> = [{ value: root, path: [] }];
129
+ while (queue.length) {
130
+ const current = queue.shift()!;
131
+ if (!current.value || typeof current.value !== "object") continue;
132
+ for (const [key, value] of Object.entries(current.value as Record<string, unknown>)) {
133
+ const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
134
+ const number = officialNumber(value);
135
+ if (number !== null && wanted.has(normalizedKey)) {
136
+ const parent = current.value as Record<string, unknown>;
137
+ const unit = typeof parent.unit === "string"
138
+ ? parent.unit
139
+ : typeof parent.currency === "string"
140
+ ? parent.currency
141
+ : normalizedKey.includes("usd") ? "USD" : undefined;
142
+ return { value: number, unit };
143
+ }
144
+ if (value && typeof value === "object") queue.push({ value, path: [...current.path, key] });
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+
150
+ function parseOfficialUsagePayload(payload: unknown, endpoint: string): OfficialUsageSummary {
151
+ const total = findOfficialMetric(payload, ["total", "totalquota", "quota", "limit", "usagelimit", "monthlylimit", "included"])?.value ?? null;
152
+ const used = findOfficialMetric(payload, ["used", "usage", "currentusage", "consumed", "spend", "spent", "utilized"])?.value ?? null;
153
+ const explicitRemaining = findOfficialMetric(payload, ["remaining", "remainingquota", "balance", "available", "left"])?.value ?? null;
154
+ const resolvedTotal = total !== null && (used !== null || explicitRemaining !== null)
155
+ ? total
156
+ : used !== null && explicitRemaining !== null
157
+ ? used + explicitRemaining
158
+ : Number.NaN;
159
+ const resolvedUsed = used ?? (resolvedTotal - (explicitRemaining ?? 0));
160
+ const resolvedRemaining = explicitRemaining ?? Math.max(resolvedTotal - resolvedUsed, 0);
161
+ if (!Number.isFinite(resolvedTotal) || resolvedTotal <= 0 || !Number.isFinite(resolvedUsed) || !Number.isFinite(resolvedRemaining)) {
162
+ throw new Error("Unable to find total/used/remaining quota fields in the response");
163
+ }
164
+ const remainingPercent = Math.min(100, Math.max(0, (resolvedRemaining / resolvedTotal) * 100));
165
+ return {
166
+ total: resolvedTotal,
167
+ used: Math.max(0, resolvedUsed),
168
+ remaining: Math.max(0, resolvedRemaining),
169
+ remainingPercent,
170
+ unit: findOfficialMetric(payload, ["total", "totalquota", "quota", "limit", "usagelimit", "monthlylimit", "included"])?.unit ?? "units",
171
+ source: endpoint,
172
+ checkedAt: new Date().toISOString(),
173
+ };
174
+ }
175
+
176
+ function officialEndpoint(endpoint: string, apiKey: string): string {
177
+ return endpoint.replace(/\{apiKey\}/gi, encodeURIComponent(apiKey));
178
+ }
179
+
180
+ export async function queryOfficialUsage(configInput: OfficialUsageConfig): Promise<OfficialUsageSummary> {
181
+ const config = normalizeOfficialUsageConfig(configInput);
182
+ if (!config.endpoint || config.apiKeys.length === 0) throw new Error("Endpoint and at least one API key are required");
183
+ const errors: string[] = [];
184
+ const results: OfficialUsageSummary[] = [];
185
+ for (const apiKey of config.apiKeys) {
186
+ const modes: OfficialUsageAuthMode[] = config.authMode === "auto" ? ["bearer", "x-api-key", "api-key"] : [config.authMode];
187
+ let keySucceeded = false;
188
+ for (const mode of modes) {
189
+ try {
190
+ const headers: Record<string, string> = { Accept: "application/json" };
191
+ if (mode === "bearer") headers.Authorization = `Bearer ${apiKey}`;
192
+ if (mode === "x-api-key") headers["x-api-key"] = apiKey;
193
+ if (mode === "api-key") headers["api-key"] = apiKey;
194
+ const response = await fetch(officialEndpoint(config.endpoint, apiKey), { headers, signal: AbortSignal.timeout(15_000) });
195
+ const text = await response.text();
196
+ let payload: unknown;
197
+ try { payload = JSON.parse(text); } catch { payload = text; }
198
+ if (!response.ok) {
199
+ errors.push(`${response.status} ${response.statusText}`);
200
+ continue;
201
+ }
202
+ results.push(parseOfficialUsagePayload(payload, config.endpoint));
203
+ keySucceeded = true;
204
+ break;
205
+ } catch (error) {
206
+ errors.push(error instanceof Error ? error.message : "request failed");
207
+ }
208
+ }
209
+ if (!keySucceeded) continue;
210
+ }
211
+ if (results.length === 0) throw new Error(errors[0] || "Official usage query failed");
212
+ const total = results.reduce((sum, result) => sum + result.total, 0);
213
+ const used = results.reduce((sum, result) => sum + result.used, 0);
214
+ const remaining = results.reduce((sum, result) => sum + result.remaining, 0);
215
+ return {
216
+ total,
217
+ used,
218
+ remaining,
219
+ remainingPercent: total > 0 ? Math.min(100, Math.max(0, (remaining / total) * 100)) : 0,
220
+ unit: results.find((result) => result.unit)?.unit ?? "units",
221
+ source: config.endpoint,
222
+ checkedAt: new Date().toISOString(),
223
+ };
224
+ }
225
+
57
226
  // ─── Auth ───────────────────────────────────────────────
58
227
 
59
228
  export function readAuth() {
@@ -404,23 +573,125 @@ export function readCodexUsage(): UsageRecord[] {
404
573
  return allRecords;
405
574
  }
406
575
 
407
- // ─── Combined All Sources ──────────────────────────────
576
+ // ─── ChatGPT / Codex Desktop Usage ─────────────────────
408
577
 
409
578
  /**
410
- * Combine usage from all sources: local pi, Cindy pi-agent, Claude, Codex.
411
- * Used for the "All" tab that shows everything in one view.
579
+ * ChatGPT/Codex Desktop stores local rollout sessions as JSONL under
580
+ * ~/.codex/sessions and ~/.codex/archived_sessions. Each `token_count` event
581
+ * contains the usage for the latest model response, so it can be normalized
582
+ * into the same UsageRecord shape used by Pi sessions.
412
583
  */
413
- export function readAllCombinedUsage(): UsageRecord[] {
414
- const all: UsageRecord[] = [
415
- ...readAllUsage(),
416
- ...readCindyUsage(),
417
- ...readClaudeUsage(),
418
- ...readCodexUsage(),
419
- ...readAtomcodeUsage(),
420
- ...readCopilotUsage(),
584
+ function collectJsonlFiles(dir: string, out: string[] = []): string[] {
585
+ if (!existsSync(dir)) return out;
586
+ try {
587
+ for (const name of readdirSync(dir)) {
588
+ const path = join(dir, name);
589
+ try {
590
+ if (statSync(path).isDirectory()) collectJsonlFiles(path, out);
591
+ else if (name.endsWith(".jsonl")) out.push(path);
592
+ } catch {
593
+ // Ignore files that disappear while the desktop app is writing.
594
+ }
595
+ }
596
+ } catch {
597
+ // Ignore inaccessible directories.
598
+ }
599
+ return out;
600
+ }
601
+
602
+ function numericUsage(value: unknown): number {
603
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
604
+ }
605
+
606
+ function modelFromCodexPayload(payload: any): string | undefined {
607
+ const candidates = [
608
+ payload?.model,
609
+ payload?.model_id,
610
+ payload?.modelId,
611
+ payload?.state?.model,
612
+ payload?.thread_settings?.model,
613
+ payload?.thread_settings?.collaboration_mode?.settings?.model,
614
+ payload?.collaboration_mode?.settings?.model,
615
+ payload?.item?.model,
616
+ payload?.item?.content?.model,
617
+ payload?.base_instructions?.provenance?.model,
421
618
  ];
422
- all.sort((a, b) => a.date.localeCompare(b.date));
423
- return all;
619
+ return candidates.find((value) => typeof value === "string" && value.trim())?.trim();
620
+ }
621
+
622
+ function parseCodexSessionFile(filePath: string): UsageRecord[] {
623
+ const records: UsageRecord[] = [];
624
+ let currentModel = "chatgpt";
625
+ try {
626
+ for (const line of readFileSync(filePath, "utf-8").split("\n")) {
627
+ if (!line.trim()) continue;
628
+ let envelope: any;
629
+ try {
630
+ envelope = JSON.parse(line);
631
+ } catch {
632
+ continue;
633
+ }
634
+
635
+ const payload = envelope?.payload;
636
+ if (!payload || typeof payload !== "object") continue;
637
+ currentModel = modelFromCodexPayload(payload) || currentModel;
638
+ if (payload.type !== "token_count") continue;
639
+
640
+ const usage = payload.info?.last_token_usage;
641
+ if (!usage || typeof usage !== "object") continue;
642
+ const timestamp = typeof envelope.timestamp === "string" ? envelope.timestamp : "";
643
+ if (!timestamp) continue;
644
+ const { date, hour } = cnDateParts(timestamp);
645
+ const rawInputTokens = numericUsage(usage.input_tokens);
646
+ const cachedInputTokens = numericUsage(usage.cached_input_tokens);
647
+ const cacheWriteTokens = numericUsage(usage.cache_write_input_tokens);
648
+ // Codex reports cached/cache-write tokens as subsets of input_tokens.
649
+ // Split them out so the dashboard total remains raw input + output,
650
+ // rather than counting cached context twice.
651
+ const inputTokens = Math.max(rawInputTokens - cachedInputTokens - cacheWriteTokens, 0);
652
+ // reasoning_output_tokens is informational and already included in
653
+ // output_tokens (total_tokens = input_tokens + output_tokens).
654
+ const outputTokens = numericUsage(usage.output_tokens);
655
+
656
+ // The local format has no per-call price. Keep cost at zero rather than
657
+ // inventing a price for a ChatGPT subscription/Codex plan.
658
+ records.push({
659
+ date,
660
+ hour,
661
+ providerId: "chatgpt",
662
+ modelId: currentModel,
663
+ inputTokens,
664
+ outputTokens,
665
+ cacheReadTokens: cachedInputTokens,
666
+ cacheWriteTokens,
667
+ requests: 1,
668
+ cost: 0,
669
+ });
670
+ }
671
+ } catch {
672
+ // Ignore unreadable or partially-written rollout files.
673
+ }
674
+ return records;
675
+ }
676
+
677
+ const CODEX_USAGE_TTL_MS = 30_000;
678
+ let codexUsageCache: { records: UsageRecord[]; at: number } | null = null;
679
+
680
+ export function readChatgptUsage(): UsageRecord[] {
681
+ if (codexUsageCache && Date.now() - codexUsageCache.at < CODEX_USAGE_TTL_MS) {
682
+ return codexUsageCache.records;
683
+ }
684
+ const files = [
685
+ ...collectJsonlFiles(join(CODEX_DIR, "sessions")),
686
+ ...collectJsonlFiles(join(CODEX_DIR, "archived_sessions")),
687
+ ];
688
+ const records = files.flatMap(parseCodexSessionFile).sort((a, b) => a.date.localeCompare(b.date));
689
+ codexUsageCache = { records, at: Date.now() };
690
+ return records;
691
+ }
692
+
693
+ export function clearChatgptUsageCache(): void {
694
+ codexUsageCache = null;
424
695
  }
425
696
 
426
697
  // ─── AtomCode Usage ────────────────────────────────────
@@ -635,9 +906,13 @@ export interface ProviderFilter {
635
906
 
636
907
  export const PROVIDER_FILTERS: ProviderFilter[] = [
637
908
  {
638
- id: "copilot",
639
- label: "Copilot",
640
- patterns: [/^copilot$/i],
909
+ id: "chatgpt",
910
+ label: "ChatGPT",
911
+ // ChatGPT/OpenAI model calls can be recorded under a direct OpenAI
912
+ // provider or behind a compatible gateway. Match provider and model names
913
+ // so the dashboard can surface them as one source without changing the
914
+ // original records used by the default Pi view.
915
+ patterns: [/^(openai|chatgpt|openai-chatgpt)$/i, /chatgpt/i, /openai/i, /^gpt[-_]/i, /^o[1345](?:[-_]|$)/i],
641
916
  },
642
917
  {
643
918
  id: "atomcode",
@@ -1199,6 +1474,37 @@ function walkJsonl(dir: string, out: string[]): void {
1199
1474
  }
1200
1475
  }
1201
1476
 
1477
+ const STALE_SESSION_DAYS = 14;
1478
+
1479
+ /**
1480
+ * Move sessions with no activity for more than 14 days into the recoverable
1481
+ * trash. The last message/session timestamp is preferred; file mtime is the
1482
+ * fallback for malformed or very old session files without timestamps.
1483
+ */
1484
+ export function autoTrashStaleSessions(maxAgeDays = STALE_SESSION_DAYS): { moved: number; paths: string[] } {
1485
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
1486
+ const files: string[] = [];
1487
+ walkJsonl(SESSIONS_DIR, files);
1488
+ const moved: string[] = [];
1489
+
1490
+ for (const filePath of files) {
1491
+ let lastActiveMs = 0;
1492
+ const info = parseSessionFileInfo(filePath);
1493
+ if (info?.lastActive) lastActiveMs = new Date(info.lastActive).getTime();
1494
+ if (!Number.isFinite(lastActiveMs) || lastActiveMs <= 0) {
1495
+ try {
1496
+ lastActiveMs = statSync(filePath).mtimeMs;
1497
+ } catch {
1498
+ continue;
1499
+ }
1500
+ }
1501
+ if (lastActiveMs >= cutoff) continue;
1502
+ if (trashSessionFile(filePath)) moved.push(filePath);
1503
+ }
1504
+
1505
+ return { moved: moved.length, paths: moved };
1506
+ }
1507
+
1202
1508
  /** Move a session file into the trash, preserving its path relative to the sessions dir. */
1203
1509
  export function trashSessionFile(filePath: string): boolean {
1204
1510
  try {
@@ -1681,7 +1987,8 @@ function heuristicFlags(id: string): { reasoning?: boolean; vision?: boolean; au
1681
1987
  const vision = VISION_RE.test(k);
1682
1988
  const audio = AUDIO_RE.test(k);
1683
1989
  let contextWindow: number | undefined;
1684
- if (/[-_](1m|1024k|1048576)\b/i.test(k)) contextWindow = 1_048_576;
1990
+ if (/deepseek[-_]v4[-_](flash|chat)(?:[-_:]|$)/i.test(k)) contextWindow = 1_048_576;
1991
+ else if (/[-_](1m|1024k|1048576)\b/i.test(k)) contextWindow = 1_048_576;
1685
1992
  else if (/[-_](256k)\b/i.test(k)) contextWindow = 262_144;
1686
1993
  else if (/[-_](128k)\b/i.test(k)) contextWindow = 131_072;
1687
1994
  else if (/[-_](64k)\b/i.test(k)) contextWindow = 65_536;
package/src/App.tsx CHANGED
@@ -6,6 +6,7 @@ import { MemoryPage } from "@/components/sessions/MemoryPage";
6
6
  import { ProvidersModelsPage } from "@/components/providers/ProvidersModelsPage";
7
7
  import { SubagentsPage } from "@/components/subagents/SubagentsPage";
8
8
  import { SettingsPage } from "@/components/settings/SettingsPage";
9
+ import { ModelSpeedTestPage } from "@/components/speedtest/ModelSpeedTestPage";
9
10
 
10
11
  export default function App() {
11
12
  return (
@@ -19,6 +20,7 @@ export default function App() {
19
20
  <Route path="/models" element={<ProvidersModelsPage />} />
20
21
  <Route path="/subagents" element={<SubagentsPage />} />
21
22
  <Route path="/settings" element={<SettingsPage />} />
23
+ <Route path="/speed-test" element={<ModelSpeedTestPage />} />
22
24
  </Route>
23
25
  </Routes>
24
26
  </BrowserRouter>