@raingor/pi-web-switch 0.4.2 → 0.4.4
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 +18 -0
- package/index.html +2 -2
- package/package.json +7 -38
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.webmanifest +2 -2
- package/public/pi.svg +41 -6
- package/public/sw.js +51 -51
- package/server/pi-reader.ts +353 -237
- package/src/App.tsx +2 -0
- package/src/components/dashboard/DashboardPage.tsx +341 -56
- package/src/components/layout/AppShell.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +78 -85
- package/src/components/providers/ProvidersModelsPage.tsx +125 -131
- package/src/components/sessions/MemoryPage.tsx +34 -13
- package/src/components/sessions/SessionsPage.tsx +20 -40
- package/src/components/settings/SettingsPage.tsx +6 -1
- package/src/components/speedtest/ModelSpeedTestPage.tsx +429 -0
- package/src/components/ui/EmptyState.tsx +7 -6
- package/src/components/ui/Modal.tsx +33 -25
- package/src/components/ui/StatCard.tsx +10 -13
- package/src/data/builtin-providers.test.ts +109 -0
- package/src/data/builtin-providers.ts +67 -44
- package/src/data/model-catalog.test.ts +122 -0
- package/src/data/model-catalog.ts +697 -478
- package/src/index.css +624 -210
- package/src/lib/translations/en.ts +88 -14
- package/src/lib/translations/ja.ts +88 -14
- package/src/lib/translations/zh-CN.ts +88 -14
- package/src/lib/translations/zh-TW.ts +87 -14
- package/src/main.tsx +97 -44
- package/src/types/index.ts +0 -1
- package/vite.config.ts +65 -164
- package/dist-electron/main/main.cjs +0 -2453
- package/src/data/mock-config.ts +0 -247
- package/src/data/mock-usage.ts +0 -151
package/server/pi-reader.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync,
|
|
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() {
|
|
@@ -125,47 +294,6 @@ function getSessionDirs(): string[] {
|
|
|
125
294
|
.filter((dir) => statSync(dir).isDirectory());
|
|
126
295
|
}
|
|
127
296
|
|
|
128
|
-
/**
|
|
129
|
-
* Recursively walk a project directory to find all session JSONL files.
|
|
130
|
-
*
|
|
131
|
-
* Supports both the legacy flat layout and the current nested layout:
|
|
132
|
-
* Legacy: --project--/session.jsonl (any .jsonl file at the project root)
|
|
133
|
-
* Current: --project--/{sessionId}/{hash}/run-0/session.jsonl
|
|
134
|
-
*/
|
|
135
|
-
function walkSessionJsonl(dir: string, out: string[]): void {
|
|
136
|
-
let entries: string[];
|
|
137
|
-
try {
|
|
138
|
-
entries = readdirSync(dir);
|
|
139
|
-
} catch {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
for (const name of entries) {
|
|
143
|
-
const p = join(dir, name);
|
|
144
|
-
try {
|
|
145
|
-
const stat = statSync(p);
|
|
146
|
-
if (stat.isDirectory()) {
|
|
147
|
-
walkSessionJsonl(p, out);
|
|
148
|
-
} else if (name.endsWith(".jsonl")) {
|
|
149
|
-
out.push(p);
|
|
150
|
-
}
|
|
151
|
-
} catch {
|
|
152
|
-
// skip unreadable entries
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Return every session JSONL file across all project directories, handling
|
|
159
|
-
* both the legacy flat layout and the current nested run-0 layout.
|
|
160
|
-
*/
|
|
161
|
-
function getAllSessionFiles(): string[] {
|
|
162
|
-
const files: string[] = [];
|
|
163
|
-
for (const dir of getSessionDirs()) {
|
|
164
|
-
walkSessionJsonl(dir, files);
|
|
165
|
-
}
|
|
166
|
-
return files;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
297
|
// Usage stats are bucketed in China time (UTC+8) regardless of the machine's
|
|
170
298
|
// system timezone, so daily totals stay consistent for a Beijing-based user.
|
|
171
299
|
const CN_TZ = "Asia/Shanghai";
|
|
@@ -254,13 +382,22 @@ export function readAllUsage(): UsageRecord[] {
|
|
|
254
382
|
return usageCache.records;
|
|
255
383
|
}
|
|
256
384
|
const allRecords: UsageRecord[] = [];
|
|
257
|
-
const
|
|
385
|
+
const dirs = getSessionDirs();
|
|
258
386
|
|
|
259
|
-
for (const
|
|
260
|
-
|
|
261
|
-
|
|
387
|
+
for (const dir of dirs) {
|
|
388
|
+
try {
|
|
389
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
390
|
+
for (const file of files) {
|
|
391
|
+
const filePath = join(dir, file);
|
|
392
|
+
const records = parseSessionFile(filePath);
|
|
393
|
+
allRecords.push(...records);
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
// skip unreadable directories
|
|
397
|
+
}
|
|
262
398
|
}
|
|
263
399
|
|
|
400
|
+
// Sort by date ascending
|
|
264
401
|
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
265
402
|
usageCache = { records: allRecords, at: Date.now() };
|
|
266
403
|
return allRecords;
|
|
@@ -436,23 +573,125 @@ export function readCodexUsage(): UsageRecord[] {
|
|
|
436
573
|
return allRecords;
|
|
437
574
|
}
|
|
438
575
|
|
|
439
|
-
// ───
|
|
576
|
+
// ─── ChatGPT / Codex Desktop Usage ─────────────────────
|
|
440
577
|
|
|
441
578
|
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
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.
|
|
444
583
|
*/
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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,
|
|
453
618
|
];
|
|
454
|
-
|
|
455
|
-
|
|
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;
|
|
456
695
|
}
|
|
457
696
|
|
|
458
697
|
// ─── AtomCode Usage ────────────────────────────────────
|
|
@@ -667,9 +906,13 @@ export interface ProviderFilter {
|
|
|
667
906
|
|
|
668
907
|
export const PROVIDER_FILTERS: ProviderFilter[] = [
|
|
669
908
|
{
|
|
670
|
-
id: "
|
|
671
|
-
label: "
|
|
672
|
-
|
|
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],
|
|
673
916
|
},
|
|
674
917
|
{
|
|
675
918
|
id: "atomcode",
|
|
@@ -1094,12 +1337,13 @@ export function listSessions(): ProjectGroup[] {
|
|
|
1094
1337
|
}
|
|
1095
1338
|
|
|
1096
1339
|
const group = groups.get(projectPath)!;
|
|
1097
|
-
const files
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1340
|
+
const files = readdirSync(dir)
|
|
1341
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
1342
|
+
.sort()
|
|
1343
|
+
.reverse(); // newest first
|
|
1101
1344
|
|
|
1102
|
-
for (const
|
|
1345
|
+
for (const file of files) {
|
|
1346
|
+
const filePath = join(dir, file);
|
|
1103
1347
|
const session = parseSessionFileInfo(filePath);
|
|
1104
1348
|
if (session) {
|
|
1105
1349
|
group.sessions.push(session);
|
|
@@ -1108,10 +1352,11 @@ export function listSessions(): ProjectGroup[] {
|
|
|
1108
1352
|
|
|
1109
1353
|
group.totalSessions = group.sessions.length;
|
|
1110
1354
|
if (group.sessions.length > 0) {
|
|
1111
|
-
group.lastActive = group.sessions[0]?.timestamp ?? "";
|
|
1355
|
+
group.lastActive = group.sessions[0]?.timestamp ?? ""; // already sorted newest-first
|
|
1112
1356
|
}
|
|
1113
1357
|
}
|
|
1114
1358
|
|
|
1359
|
+
// Sort groups by lastActive descending
|
|
1115
1360
|
return Array.from(groups.values())
|
|
1116
1361
|
.filter((g) => g.sessions.length > 0)
|
|
1117
1362
|
.sort((a, b) => b.lastActive.localeCompare(a.lastActive));
|
|
@@ -1229,6 +1474,37 @@ function walkJsonl(dir: string, out: string[]): void {
|
|
|
1229
1474
|
}
|
|
1230
1475
|
}
|
|
1231
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
|
+
|
|
1232
1508
|
/** Move a session file into the trash, preserving its path relative to the sessions dir. */
|
|
1233
1509
|
export function trashSessionFile(filePath: string): boolean {
|
|
1234
1510
|
try {
|
|
@@ -1298,74 +1574,6 @@ export function permanentlyDeleteTrash(trashPath: string): boolean {
|
|
|
1298
1574
|
}
|
|
1299
1575
|
}
|
|
1300
1576
|
|
|
1301
|
-
// ─── Session Auto-Expiry ──────────────────────────────────
|
|
1302
|
-
|
|
1303
|
-
const AUTO_EXPIRE_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24h
|
|
1304
|
-
let autoExpireTimer: ReturnType<typeof setInterval> | null = null;
|
|
1305
|
-
|
|
1306
|
-
function getSessionExpiryDays(): number {
|
|
1307
|
-
const settings = readSettings();
|
|
1308
|
-
const val = settings?.sessionExpiryDays;
|
|
1309
|
-
const n = typeof val === "number" ? val : Number(val);
|
|
1310
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 7;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
interface ExpireResult {
|
|
1314
|
-
expired: string[];
|
|
1315
|
-
skipped: string[];
|
|
1316
|
-
errors: string[];
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
export function autoExpireSessions(): ExpireResult {
|
|
1320
|
-
const result: ExpireResult = { expired: [], skipped: [], errors: [] };
|
|
1321
|
-
const expiryDays = getSessionExpiryDays();
|
|
1322
|
-
const cutoffMs = Date.now() - expiryDays * 24 * 60 * 60 * 1000;
|
|
1323
|
-
|
|
1324
|
-
const dirs = getSessionDirs();
|
|
1325
|
-
for (const dir of dirs) {
|
|
1326
|
-
let files: string[];
|
|
1327
|
-
try {
|
|
1328
|
-
files = readdirSync(dir)
|
|
1329
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
1330
|
-
.map((f) => join(dir, f));
|
|
1331
|
-
} catch {
|
|
1332
|
-
continue;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
for (const filePath of files) {
|
|
1336
|
-
try {
|
|
1337
|
-
const info = parseSessionFileInfo(filePath);
|
|
1338
|
-
if (!info) { result.skipped.push(filePath); continue; }
|
|
1339
|
-
|
|
1340
|
-
const lastActiveTs = new Date(info.lastActive || info.timestamp).getTime();
|
|
1341
|
-
if (isNaN(lastActiveTs)) { result.skipped.push(filePath); continue; }
|
|
1342
|
-
|
|
1343
|
-
if (lastActiveTs < cutoffMs) {
|
|
1344
|
-
const ok = trashSessionFile(filePath);
|
|
1345
|
-
if (ok) {
|
|
1346
|
-
result.expired.push(filePath);
|
|
1347
|
-
} else {
|
|
1348
|
-
result.errors.push(`trash failed: ${filePath}`);
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
} catch {
|
|
1352
|
-
result.errors.push(`scan failed: ${filePath}`);
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
return result;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
export function startAutoExpiryTimer(): void {
|
|
1361
|
-
if (autoExpireTimer) return;
|
|
1362
|
-
// Run once at startup (fire-and-forget, swallow errors)
|
|
1363
|
-
try { autoExpireSessions(); } catch { /* ignore */ }
|
|
1364
|
-
autoExpireTimer = setInterval(() => {
|
|
1365
|
-
try { autoExpireSessions(); } catch { /* ignore */ }
|
|
1366
|
-
}, AUTO_EXPIRE_INTERVAL_MS);
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
1577
|
// ─── Session Preview ────────────────────────────────
|
|
1370
1578
|
|
|
1371
1579
|
export interface SessionPreviewMessage {
|
|
@@ -1647,93 +1855,6 @@ export interface ApplyUpdateResult {
|
|
|
1647
1855
|
message?: string;
|
|
1648
1856
|
}
|
|
1649
1857
|
|
|
1650
|
-
// ─── npm resolution ──────────────────────────────────────
|
|
1651
|
-
// GUI-launched apps (Finder / Launchpad) inherit a minimal PATH
|
|
1652
|
-
// (/usr/bin:/bin:/usr/sbin:/sbin) that does NOT contain the user's npm or
|
|
1653
|
-
// node, so `spawnSync("npm", ...)` fails with ENOENT in the packaged app.
|
|
1654
|
-
// Instead of relying on PATH we locate the real npm-cli.js + node binary
|
|
1655
|
-
// from common install locations and run npm via that explicit node.
|
|
1656
|
-
|
|
1657
|
-
function realpathOr(p: string): string | null {
|
|
1658
|
-
try {
|
|
1659
|
-
return realpathSync(p);
|
|
1660
|
-
} catch {
|
|
1661
|
-
return null;
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
/** Candidate npm binaries across common layouts (PATH + known locations). */
|
|
1666
|
-
function npmBinCandidates(): string[] {
|
|
1667
|
-
const home = homedir();
|
|
1668
|
-
const fromPath = (process.env.PATH || "")
|
|
1669
|
-
.split(":")
|
|
1670
|
-
.filter(Boolean)
|
|
1671
|
-
.map((d) => join(d, "npm"));
|
|
1672
|
-
return [
|
|
1673
|
-
...fromPath,
|
|
1674
|
-
`${home}/.npm-global/bin/npm`,
|
|
1675
|
-
`${home}/.npm-packages/bin/npm`,
|
|
1676
|
-
`${home}/.local/share/pnpm/npm`,
|
|
1677
|
-
"/usr/local/bin/npm",
|
|
1678
|
-
"/opt/homebrew/bin/npm",
|
|
1679
|
-
// pi-node bundles its own node/npm under ~/.local/share/pi-node/node-*/
|
|
1680
|
-
...(() => {
|
|
1681
|
-
const base = join(home, ".local", "share", "pi-node");
|
|
1682
|
-
try {
|
|
1683
|
-
return readdirSync(base)
|
|
1684
|
-
.filter((n) => n.startsWith("node-"))
|
|
1685
|
-
.map((n) => join(base, n, "bin", "npm"));
|
|
1686
|
-
} catch {
|
|
1687
|
-
return [];
|
|
1688
|
-
}
|
|
1689
|
-
})(),
|
|
1690
|
-
];
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
/** Resolve the npm CLI entry (npm-cli.js) — npm bins are symlinks to it. */
|
|
1694
|
-
function resolveNpmCliJs(): string | null {
|
|
1695
|
-
for (const bin of npmBinCandidates()) {
|
|
1696
|
-
const real = realpathOr(bin);
|
|
1697
|
-
if (real && existsSync(real) && /npm-cli\.js$/.test(real)) return real;
|
|
1698
|
-
}
|
|
1699
|
-
return null;
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
/** Candidate node binaries (PATH + common locations + pi-node bundle). */
|
|
1703
|
-
function resolveNodeBin(): string | null {
|
|
1704
|
-
const home = homedir();
|
|
1705
|
-
const fromPath = (process.env.PATH || "")
|
|
1706
|
-
.split(":")
|
|
1707
|
-
.filter(Boolean)
|
|
1708
|
-
.map((d) => join(d, "node"));
|
|
1709
|
-
const candidates = [
|
|
1710
|
-
...fromPath,
|
|
1711
|
-
`${home}/.npm-global/bin/node`,
|
|
1712
|
-
`${home}/.npm-packages/bin/node`,
|
|
1713
|
-
"/usr/local/bin/node",
|
|
1714
|
-
"/opt/homebrew/bin/node",
|
|
1715
|
-
...(() => {
|
|
1716
|
-
const base = join(home, ".local", "share", "pi-node");
|
|
1717
|
-
try {
|
|
1718
|
-
return readdirSync(base)
|
|
1719
|
-
.filter((n) => n.startsWith("node-"))
|
|
1720
|
-
.map((n) => join(base, n, "bin", "node"));
|
|
1721
|
-
} catch {
|
|
1722
|
-
return [];
|
|
1723
|
-
}
|
|
1724
|
-
})(),
|
|
1725
|
-
];
|
|
1726
|
-
for (const bin of candidates) {
|
|
1727
|
-
try {
|
|
1728
|
-
const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000 });
|
|
1729
|
-
if (out.status === 0 && out.stdout) return bin;
|
|
1730
|
-
} catch {
|
|
1731
|
-
// try next candidate
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
return null;
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
1858
|
/**
|
|
1738
1859
|
* One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
|
|
1739
1860
|
* Only packages already installed there are accepted (pi core is excluded —
|
|
@@ -1742,8 +1863,6 @@ function resolveNodeBin(): string | null {
|
|
|
1742
1863
|
export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
1743
1864
|
const dir = join(PI_DIR, "npm");
|
|
1744
1865
|
const installed = new Set(listInstalledExtensions().map((e) => e.name));
|
|
1745
|
-
const npmCliJs = resolveNpmCliJs();
|
|
1746
|
-
const nodeBin = resolveNodeBin();
|
|
1747
1866
|
|
|
1748
1867
|
return names.map((name) => {
|
|
1749
1868
|
if (!installed.has(name)) {
|
|
@@ -1752,19 +1871,15 @@ export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
|
1752
1871
|
try {
|
|
1753
1872
|
// --legacy-peer-deps: peer deps (e.g. pi core) are provided by the pi host,
|
|
1754
1873
|
// not installed here — strict resolution would fail with ERESOLVE.
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
out = spawnSync(nodeBin, [npmCliJs, ...args], {
|
|
1874
|
+
const out = spawnSync(
|
|
1875
|
+
"npm",
|
|
1876
|
+
["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"],
|
|
1877
|
+
{
|
|
1760
1878
|
cwd: dir,
|
|
1761
1879
|
encoding: "utf8",
|
|
1762
1880
|
timeout: 120000,
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
// Fall back to PATH resolution (dev / terminal environments)
|
|
1766
|
-
out = spawnSync("npm", args, { cwd: dir, encoding: "utf8", timeout: 120000 });
|
|
1767
|
-
}
|
|
1881
|
+
}
|
|
1882
|
+
);
|
|
1768
1883
|
if (out.status === 0) return { name, success: true };
|
|
1769
1884
|
const stderr = (out.stderr || "").trim().split("\n").slice(-3).join(" ");
|
|
1770
1885
|
return { name, success: false, message: stderr || `npm exited with ${out.status}` };
|
|
@@ -1872,7 +1987,8 @@ function heuristicFlags(id: string): { reasoning?: boolean; vision?: boolean; au
|
|
|
1872
1987
|
const vision = VISION_RE.test(k);
|
|
1873
1988
|
const audio = AUDIO_RE.test(k);
|
|
1874
1989
|
let contextWindow: number | undefined;
|
|
1875
|
-
if (/[-_](
|
|
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;
|
|
1876
1992
|
else if (/[-_](256k)\b/i.test(k)) contextWindow = 262_144;
|
|
1877
1993
|
else if (/[-_](128k)\b/i.test(k)) contextWindow = 131_072;
|
|
1878
1994
|
else if (/[-_](64k)\b/i.test(k)) contextWindow = 65_536;
|