@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.
Files changed (37) hide show
  1. package/dist/index.html +18 -0
  2. package/index.html +2 -2
  3. package/package.json +7 -38
  4. package/public/apple-touch-icon.png +0 -0
  5. package/public/icon-192.png +0 -0
  6. package/public/icon-512.png +0 -0
  7. package/public/manifest.webmanifest +2 -2
  8. package/public/pi.svg +41 -6
  9. package/public/sw.js +51 -51
  10. package/server/pi-reader.ts +353 -237
  11. package/src/App.tsx +2 -0
  12. package/src/components/dashboard/DashboardPage.tsx +341 -56
  13. package/src/components/layout/AppShell.tsx +45 -13
  14. package/src/components/layout/Sidebar.tsx +78 -85
  15. package/src/components/providers/ProvidersModelsPage.tsx +125 -131
  16. package/src/components/sessions/MemoryPage.tsx +34 -13
  17. package/src/components/sessions/SessionsPage.tsx +20 -40
  18. package/src/components/settings/SettingsPage.tsx +6 -1
  19. package/src/components/speedtest/ModelSpeedTestPage.tsx +429 -0
  20. package/src/components/ui/EmptyState.tsx +7 -6
  21. package/src/components/ui/Modal.tsx +33 -25
  22. package/src/components/ui/StatCard.tsx +10 -13
  23. package/src/data/builtin-providers.test.ts +109 -0
  24. package/src/data/builtin-providers.ts +67 -44
  25. package/src/data/model-catalog.test.ts +122 -0
  26. package/src/data/model-catalog.ts +697 -478
  27. package/src/index.css +624 -210
  28. package/src/lib/translations/en.ts +88 -14
  29. package/src/lib/translations/ja.ts +88 -14
  30. package/src/lib/translations/zh-CN.ts +88 -14
  31. package/src/lib/translations/zh-TW.ts +87 -14
  32. package/src/main.tsx +97 -44
  33. package/src/types/index.ts +0 -1
  34. package/vite.config.ts +65 -164
  35. package/dist-electron/main/main.cjs +0 -2453
  36. package/src/data/mock-config.ts +0 -247
  37. package/src/data/mock-usage.ts +0 -151
@@ -1,10 +1,11 @@
1
- import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, realpathSync } 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() {
@@ -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 files = getAllSessionFiles();
385
+ const dirs = getSessionDirs();
258
386
 
259
- for (const filePath of files) {
260
- const records = parseSessionFile(filePath);
261
- allRecords.push(...records);
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
- // ─── Combined All Sources ──────────────────────────────
576
+ // ─── ChatGPT / Codex Desktop Usage ─────────────────────
440
577
 
441
578
  /**
442
- * Combine usage from all sources: local pi, Cindy pi-agent, Claude, Codex.
443
- * 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.
444
583
  */
445
- export function readAllCombinedUsage(): UsageRecord[] {
446
- const all: UsageRecord[] = [
447
- ...readAllUsage(),
448
- ...readCindyUsage(),
449
- ...readClaudeUsage(),
450
- ...readCodexUsage(),
451
- ...readAtomcodeUsage(),
452
- ...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,
453
618
  ];
454
- all.sort((a, b) => a.date.localeCompare(b.date));
455
- 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;
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: "copilot",
671
- label: "Copilot",
672
- 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],
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: string[] = [];
1098
- walkSessionJsonl(dir, files);
1099
- // Sort by path descending (newest session directories first as a rough proxy)
1100
- files.sort().reverse();
1340
+ const files = readdirSync(dir)
1341
+ .filter((f) => f.endsWith(".jsonl"))
1342
+ .sort()
1343
+ .reverse(); // newest first
1101
1344
 
1102
- for (const filePath of files) {
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 args = ["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"];
1756
- let out;
1757
- if (nodeBin && npmCliJs) {
1758
- // Explicit node + npm-cli.js — works even when PATH lacks npm
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
- } else {
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 (/[-_](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;
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;