@raingor/pi-web-switch 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +32 -8
- package/README.md +58 -10
- package/README.zh-CN.md +31 -7
- package/dist-electron/main/main.cjs +2453 -0
- package/package.json +22 -5
- package/pi-package/index.ts +228 -4
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/pi.svg +6 -41
- package/public/sw.js +28 -5
- package/public/trayIconTemplate.png +0 -0
- package/server/pi-reader.ts +707 -27
- package/src/components/dashboard/DashboardPage.tsx +88 -16
- package/src/components/layout/AppShell.tsx +12 -5
- package/src/components/layout/Sidebar.tsx +15 -4
- package/src/components/providers/ProvidersModelsPage.tsx +84 -14
- package/src/components/sessions/SessionsPage.tsx +501 -149
- package/src/components/settings/SettingsPage.tsx +72 -0
- package/src/index.css +24 -0
- package/src/lib/translations/en.ts +31 -0
- package/src/lib/translations/ja.ts +31 -0
- package/src/lib/translations/zh-CN.ts +31 -0
- package/src/lib/translations/zh-TW.ts +31 -0
- package/src/main.tsx +31 -5
- package/src/store/config-store.ts +41 -0
- package/src/types/chat.ts +217 -0
- package/src/types/index.ts +2 -0
- package/vite.config.ts +240 -1
package/server/pi-reader.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
|
|
2
|
-
import { homedir } from "os";
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, realpathSync } from "fs";
|
|
2
|
+
import { homedir, platform } from "os";
|
|
3
3
|
import { join, resolve, dirname, relative, sep } from "path";
|
|
4
4
|
import { spawnSync } from "child_process";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
6
|
|
|
6
7
|
const PI_DIR = join(homedir(), ".pi", "agent");
|
|
7
8
|
|
|
9
|
+
// ─── Cindy Pi-Agent Sessions ───────────────────────────
|
|
10
|
+
// When Cindy (the AI assistant) delegates to a pi coding agent, sessions
|
|
11
|
+
// are stored under its own data directory instead of ~/.pi/agent/sessions.
|
|
12
|
+
|
|
13
|
+
function getCindySessionsDir(): string {
|
|
14
|
+
const home = homedir();
|
|
15
|
+
if (platform() === "darwin") {
|
|
16
|
+
return join(home, "Library", "Application Support", "Cindy", "pi-agent-home", "sessions");
|
|
17
|
+
}
|
|
18
|
+
// Linux / Windows fallback
|
|
19
|
+
return join(home, ".config", "cindy", "pi-agent-home", "sessions");
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
// ─── Config File Paths ───────────────────────────────────
|
|
9
23
|
|
|
10
24
|
function piPath(filename: string): string {
|
|
@@ -111,6 +125,70 @@ function getSessionDirs(): string[] {
|
|
|
111
125
|
.filter((dir) => statSync(dir).isDirectory());
|
|
112
126
|
}
|
|
113
127
|
|
|
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
|
+
// Usage stats are bucketed in China time (UTC+8) regardless of the machine's
|
|
170
|
+
// system timezone, so daily totals stay consistent for a Beijing-based user.
|
|
171
|
+
const CN_TZ = "Asia/Shanghai";
|
|
172
|
+
|
|
173
|
+
function cnDateParts(ts: string | number): { date: string; hour: number } {
|
|
174
|
+
const d = new Date(ts);
|
|
175
|
+
if (isNaN(d.getTime())) return { date: "unknown", hour: 0 };
|
|
176
|
+
const date = new Intl.DateTimeFormat("en-CA", {
|
|
177
|
+
timeZone: CN_TZ,
|
|
178
|
+
year: "numeric",
|
|
179
|
+
month: "2-digit",
|
|
180
|
+
day: "2-digit",
|
|
181
|
+
}).format(d); // "2026-08-06"
|
|
182
|
+
const hour = Number(
|
|
183
|
+
new Intl.DateTimeFormat("en-US", {
|
|
184
|
+
timeZone: CN_TZ,
|
|
185
|
+
hour: "2-digit",
|
|
186
|
+
hour12: false,
|
|
187
|
+
}).format(d)
|
|
188
|
+
);
|
|
189
|
+
return { date, hour: hour === 24 ? 0 : hour };
|
|
190
|
+
}
|
|
191
|
+
|
|
114
192
|
function parseSessionFile(filePath: string): UsageRecord[] {
|
|
115
193
|
const records: UsageRecord[] = [];
|
|
116
194
|
try {
|
|
@@ -136,9 +214,7 @@ function parseSessionFile(filePath: string): UsageRecord[] {
|
|
|
136
214
|
if (!usage || !usage.input) continue;
|
|
137
215
|
|
|
138
216
|
const timestamp = obj.timestamp || obj.message.timestamp;
|
|
139
|
-
const
|
|
140
|
-
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
141
|
-
const hour = d.getHours();
|
|
217
|
+
const { date, hour } = cnDateParts(timestamp);
|
|
142
218
|
|
|
143
219
|
records.push({
|
|
144
220
|
date,
|
|
@@ -163,20 +239,142 @@ function parseSessionFile(filePath: string): UsageRecord[] {
|
|
|
163
239
|
return records;
|
|
164
240
|
}
|
|
165
241
|
|
|
242
|
+
// Scanned session usage is cached briefly: the same 150MB+ of JSONL gets
|
|
243
|
+
// re-read on every dashboard request otherwise, which stalls the page.
|
|
244
|
+
const USAGE_CACHE_TTL_MS = 30_000;
|
|
245
|
+
let usageCache: { records: UsageRecord[]; at: number } | null = null;
|
|
246
|
+
|
|
247
|
+
/** Drop cached session usage — called when the UI requests a forced refresh. */
|
|
248
|
+
export function clearUsageCache(): void {
|
|
249
|
+
usageCache = null;
|
|
250
|
+
}
|
|
251
|
+
|
|
166
252
|
export function readAllUsage(): UsageRecord[] {
|
|
253
|
+
if (usageCache && Date.now() - usageCache.at < USAGE_CACHE_TTL_MS) {
|
|
254
|
+
return usageCache.records;
|
|
255
|
+
}
|
|
167
256
|
const allRecords: UsageRecord[] = [];
|
|
168
|
-
const
|
|
257
|
+
const files = getAllSessionFiles();
|
|
169
258
|
|
|
170
|
-
for (const
|
|
259
|
+
for (const filePath of files) {
|
|
260
|
+
const records = parseSessionFile(filePath);
|
|
261
|
+
allRecords.push(...records);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
265
|
+
usageCache = { records: allRecords, at: Date.now() };
|
|
266
|
+
return allRecords;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Cindy Pi-Agent Usage ──────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read usage records from Cindy's pi-agent sessions.
|
|
273
|
+
* Cindy stores pi-agent sessions in its own data directory
|
|
274
|
+
* (~/Library/Application Support/Cindy/pi-agent-home/sessions/)
|
|
275
|
+
* rather than ~/.pi/agent/sessions/. The JSONL format is identical,
|
|
276
|
+
* so we reuse the same parseSessionFile() function.
|
|
277
|
+
*/
|
|
278
|
+
export function readCindyUsage(): UsageRecord[] {
|
|
279
|
+
const allRecords: UsageRecord[] = [];
|
|
280
|
+
const cindyDir = getCindySessionsDir();
|
|
281
|
+
|
|
282
|
+
if (!existsSync(cindyDir)) return allRecords;
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
// Cindy's sessions are flat (no subdirectory per project)
|
|
286
|
+
const files = readdirSync(cindyDir).filter((f) => f.endsWith(".jsonl"));
|
|
287
|
+
for (const file of files) {
|
|
288
|
+
const filePath = join(cindyDir, file);
|
|
289
|
+
if (!statSync(filePath).isFile()) continue;
|
|
290
|
+
const records = parseSessionFile(filePath);
|
|
291
|
+
allRecords.push(...records);
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
// skip unreadable directory
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Sort by date ascending
|
|
298
|
+
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
299
|
+
return allRecords;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── Claude Usage (from Cindy SQLite) ──────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Find all Cindy SQLite database files that may contain usage data.
|
|
306
|
+
* Cindy stores session/usage data in cindy-cms*.db files under its
|
|
307
|
+
* application support directory.
|
|
308
|
+
*/
|
|
309
|
+
function getCindyDbPaths(): string[] {
|
|
310
|
+
const home = homedir();
|
|
311
|
+
let cindyAppDir: string;
|
|
312
|
+
if (platform() === "darwin") {
|
|
313
|
+
cindyAppDir = join(home, "Library", "Application Support", "Cindy");
|
|
314
|
+
} else if (platform() === "win32") {
|
|
315
|
+
cindyAppDir = join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Cindy");
|
|
316
|
+
} else {
|
|
317
|
+
cindyAppDir = join(home, ".config", "Cindy");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!existsSync(cindyAppDir)) return [];
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
return readdirSync(cindyAppDir)
|
|
324
|
+
.filter((f) => f.startsWith("cindy-cms") && f.endsWith(".db"))
|
|
325
|
+
.map((f) => join(cindyAppDir, f));
|
|
326
|
+
} catch {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Read Claude usage records from Cindy's daily_model_usage table.
|
|
333
|
+
* Cindy tracks every agent_kind (claude-code, pi, codex) in the same
|
|
334
|
+
* table; we filter to agent_kind = 'claude-code' for Claude stats.
|
|
335
|
+
*/
|
|
336
|
+
export function readClaudeUsage(): UsageRecord[] {
|
|
337
|
+
const allRecords: UsageRecord[] = [];
|
|
338
|
+
const dbPaths = getCindyDbPaths();
|
|
339
|
+
if (dbPaths.length === 0) return allRecords;
|
|
340
|
+
|
|
341
|
+
for (const dbPath of dbPaths) {
|
|
171
342
|
try {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
343
|
+
const query = "SELECT day, model, cost_usd, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens FROM daily_model_usage WHERE agent_kind = 'claude-code' ORDER BY day";
|
|
344
|
+
const result = spawnSync("sqlite3", [dbPath, "-json", query], {
|
|
345
|
+
encoding: "utf8",
|
|
346
|
+
timeout: 10000,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (result.status !== 0) continue;
|
|
350
|
+
const output = result.stdout?.trim();
|
|
351
|
+
if (!output) continue;
|
|
352
|
+
|
|
353
|
+
const rows = JSON.parse(output) as Array<{
|
|
354
|
+
day: string;
|
|
355
|
+
model: string;
|
|
356
|
+
cost_usd: number;
|
|
357
|
+
input_tokens: number;
|
|
358
|
+
output_tokens: number;
|
|
359
|
+
cache_read_tokens: number;
|
|
360
|
+
cache_create_tokens: number;
|
|
361
|
+
}>;
|
|
362
|
+
|
|
363
|
+
for (const row of rows) {
|
|
364
|
+
allRecords.push({
|
|
365
|
+
date: row.day,
|
|
366
|
+
providerId: "claude",
|
|
367
|
+
modelId: row.model,
|
|
368
|
+
inputTokens: row.input_tokens ?? 0,
|
|
369
|
+
outputTokens: row.output_tokens ?? 0,
|
|
370
|
+
cacheReadTokens: row.cache_read_tokens ?? 0,
|
|
371
|
+
cacheWriteTokens: row.cache_create_tokens ?? 0,
|
|
372
|
+
requests: 1,
|
|
373
|
+
cost: row.cost_usd ?? 0,
|
|
374
|
+
});
|
|
177
375
|
}
|
|
178
376
|
} catch {
|
|
179
|
-
// skip
|
|
377
|
+
// skip this db
|
|
180
378
|
}
|
|
181
379
|
}
|
|
182
380
|
|
|
@@ -185,6 +383,327 @@ export function readAllUsage(): UsageRecord[] {
|
|
|
185
383
|
return allRecords;
|
|
186
384
|
}
|
|
187
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Read Codex usage records from Cindy's daily_model_usage table.
|
|
388
|
+
* Filters to agent_kind = 'codex'.
|
|
389
|
+
*/
|
|
390
|
+
export function readCodexUsage(): UsageRecord[] {
|
|
391
|
+
const allRecords: UsageRecord[] = [];
|
|
392
|
+
const dbPaths = getCindyDbPaths();
|
|
393
|
+
if (dbPaths.length === 0) return allRecords;
|
|
394
|
+
|
|
395
|
+
for (const dbPath of dbPaths) {
|
|
396
|
+
try {
|
|
397
|
+
const query = "SELECT day, model, cost_usd, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens FROM daily_model_usage WHERE agent_kind = 'codex' ORDER BY day";
|
|
398
|
+
const result = spawnSync("sqlite3", [dbPath, "-json", query], {
|
|
399
|
+
encoding: "utf8",
|
|
400
|
+
timeout: 10000,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
if (result.status !== 0) continue;
|
|
404
|
+
const output = result.stdout?.trim();
|
|
405
|
+
if (!output) continue;
|
|
406
|
+
|
|
407
|
+
const rows = JSON.parse(output) as Array<{
|
|
408
|
+
day: string;
|
|
409
|
+
model: string;
|
|
410
|
+
cost_usd: number;
|
|
411
|
+
input_tokens: number;
|
|
412
|
+
output_tokens: number;
|
|
413
|
+
cache_read_tokens: number;
|
|
414
|
+
cache_create_tokens: number;
|
|
415
|
+
}>;
|
|
416
|
+
|
|
417
|
+
for (const row of rows) {
|
|
418
|
+
allRecords.push({
|
|
419
|
+
date: row.day,
|
|
420
|
+
providerId: "codex",
|
|
421
|
+
modelId: row.model,
|
|
422
|
+
inputTokens: row.input_tokens ?? 0,
|
|
423
|
+
outputTokens: row.output_tokens ?? 0,
|
|
424
|
+
cacheReadTokens: row.cache_read_tokens ?? 0,
|
|
425
|
+
cacheWriteTokens: row.cache_create_tokens ?? 0,
|
|
426
|
+
requests: 1,
|
|
427
|
+
cost: row.cost_usd ?? 0,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
// skip this db
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
436
|
+
return allRecords;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Combined All Sources ──────────────────────────────
|
|
440
|
+
|
|
441
|
+
/**
|
|
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.
|
|
444
|
+
*/
|
|
445
|
+
export function readAllCombinedUsage(): UsageRecord[] {
|
|
446
|
+
const all: UsageRecord[] = [
|
|
447
|
+
...readAllUsage(),
|
|
448
|
+
...readCindyUsage(),
|
|
449
|
+
...readClaudeUsage(),
|
|
450
|
+
...readCodexUsage(),
|
|
451
|
+
...readAtomcodeUsage(),
|
|
452
|
+
...readCopilotUsage(),
|
|
453
|
+
];
|
|
454
|
+
all.sort((a, b) => a.date.localeCompare(b.date));
|
|
455
|
+
return all;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ─── AtomCode Usage ────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
const ATOMCODE_DIR = join(homedir(), ".atomcode");
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Read usage records from AtomCode sessions under ~/.atomcode/sessions.
|
|
464
|
+
* Each JSONL line carries an optional usage object:
|
|
465
|
+
* { prompt, completion, cached } — mapped to input/output/cacheRead.
|
|
466
|
+
* Model id is sniffed from the snapshot system prompt (e.g.
|
|
467
|
+
* "running the deepseek-v4-flash model"), defaulting to "atomcode".
|
|
468
|
+
*/
|
|
469
|
+
export function readAtomcodeUsage(): UsageRecord[] {
|
|
470
|
+
const allRecords: UsageRecord[] = [];
|
|
471
|
+
const sessionsDir = join(ATOMCODE_DIR, "sessions");
|
|
472
|
+
if (!existsSync(sessionsDir)) return allRecords;
|
|
473
|
+
|
|
474
|
+
let sessionDirs: string[] = [];
|
|
475
|
+
try {
|
|
476
|
+
sessionDirs = readdirSync(sessionsDir)
|
|
477
|
+
.map((name) => join(sessionsDir, name))
|
|
478
|
+
.filter((dir) => statSync(dir).isDirectory());
|
|
479
|
+
} catch {
|
|
480
|
+
return allRecords;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const dir of sessionDirs) {
|
|
484
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
485
|
+
for (const file of files) {
|
|
486
|
+
const filePath = join(dir, file);
|
|
487
|
+
try {
|
|
488
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
489
|
+
const model = sniffAtomcodeModel(join(dir, file.replace(/\.jsonl$/, ".snapshot")));
|
|
490
|
+
for (const line of raw.split("\n").filter((l) => l.trim())) {
|
|
491
|
+
try {
|
|
492
|
+
const obj = JSON.parse(line);
|
|
493
|
+
const usage = obj.usage;
|
|
494
|
+
if (!usage || typeof usage.prompt !== "number") continue;
|
|
495
|
+
const { date, hour } = cnDateParts(obj.iso ?? obj.ts ?? "");
|
|
496
|
+
allRecords.push({
|
|
497
|
+
date,
|
|
498
|
+
hour,
|
|
499
|
+
providerId: "atomcode",
|
|
500
|
+
modelId: model,
|
|
501
|
+
inputTokens: usage.prompt ?? 0,
|
|
502
|
+
outputTokens: usage.completion ?? 0,
|
|
503
|
+
cacheReadTokens: usage.cached ?? 0,
|
|
504
|
+
cacheWriteTokens: 0,
|
|
505
|
+
requests: 1,
|
|
506
|
+
cost: 0,
|
|
507
|
+
});
|
|
508
|
+
} catch {
|
|
509
|
+
// skip malformed lines
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
// skip unreadable files
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
519
|
+
return allRecords;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Extract the model name from an AtomCode snapshot system prompt. */
|
|
523
|
+
function sniffAtomcodeModel(snapshotPath: string): string {
|
|
524
|
+
try {
|
|
525
|
+
if (!existsSync(snapshotPath)) return "atomcode";
|
|
526
|
+
const txt = readFileSync(snapshotPath, "utf-8");
|
|
527
|
+
const m = txt.match(/running the ([\w.-]+) model/i);
|
|
528
|
+
return m?.[1] ?? "atomcode";
|
|
529
|
+
} catch {
|
|
530
|
+
return "atomcode";
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ─── Copilot Usage (Local session-store.db) ────────────
|
|
535
|
+
//
|
|
536
|
+
// The Copilot CLI records every assistant turn's token usage in a local
|
|
537
|
+
// SQLite database (~/.copilot/session-store.db, table
|
|
538
|
+
// assistant_usage_events). We read it directly — no GitHub Billing REST
|
|
539
|
+
// API or PAT required, and it works even when the account isn't on the
|
|
540
|
+
// enhanced billing platform.
|
|
541
|
+
|
|
542
|
+
const COPILOT_CONFIG_PATH = join(PI_DIR, "copilot.json");
|
|
543
|
+
|
|
544
|
+
interface CopilotConfig {
|
|
545
|
+
username?: string;
|
|
546
|
+
token?: string;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function readCopilotConfig(): CopilotConfig {
|
|
550
|
+
try {
|
|
551
|
+
if (!existsSync(COPILOT_CONFIG_PATH)) return {};
|
|
552
|
+
const raw = readFileSync(COPILOT_CONFIG_PATH, "utf-8");
|
|
553
|
+
const parsed = JSON.parse(raw) as CopilotConfig;
|
|
554
|
+
return {
|
|
555
|
+
username: typeof parsed.username === "string" ? parsed.username : undefined,
|
|
556
|
+
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
557
|
+
};
|
|
558
|
+
} catch {
|
|
559
|
+
return {};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function writeCopilotConfig(cfg: CopilotConfig): boolean {
|
|
564
|
+
try {
|
|
565
|
+
const clean: CopilotConfig = {
|
|
566
|
+
username: cfg.username?.trim() || undefined,
|
|
567
|
+
token: cfg.token?.trim() || undefined,
|
|
568
|
+
};
|
|
569
|
+
writeFileSync(COPILOT_CONFIG_PATH, JSON.stringify(clean, null, 2), "utf-8");
|
|
570
|
+
return true;
|
|
571
|
+
} catch {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const COPILOT_STORE_PATH = join(homedir(), ".copilot", "session-store.db");
|
|
577
|
+
|
|
578
|
+
function copilotNum(v: unknown): number {
|
|
579
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Read all usage events from the local Copilot session-store.db and map them
|
|
584
|
+
* to UsageRecords bucketed in China time (UTC+8) like every other source.
|
|
585
|
+
* Each event = one assistant turn → one request. Local DB has no cost info,
|
|
586
|
+
* so cost is always 0 (token counts are still fully populated).
|
|
587
|
+
*/
|
|
588
|
+
function readCopilotStore(): UsageRecord[] {
|
|
589
|
+
const records: UsageRecord[] = [];
|
|
590
|
+
let db: DatabaseSync | null = null;
|
|
591
|
+
try {
|
|
592
|
+
db = new DatabaseSync(COPILOT_STORE_PATH, { readOnly: true });
|
|
593
|
+
const rows = db
|
|
594
|
+
.prepare(
|
|
595
|
+
`SELECT model, input_tokens, output_tokens, cache_read_tokens,
|
|
596
|
+
cache_write_tokens, created_at
|
|
597
|
+
FROM assistant_usage_events`
|
|
598
|
+
)
|
|
599
|
+
.all() as Array<Record<string, unknown>>;
|
|
600
|
+
|
|
601
|
+
for (const row of rows) {
|
|
602
|
+
const ts = row.created_at;
|
|
603
|
+
if (typeof ts !== "string" || !ts) continue;
|
|
604
|
+
const { date, hour } = cnDateParts(ts);
|
|
605
|
+
records.push({
|
|
606
|
+
date,
|
|
607
|
+
hour,
|
|
608
|
+
providerId: "copilot",
|
|
609
|
+
modelId: typeof row.model === "string" && row.model ? row.model : "copilot",
|
|
610
|
+
inputTokens: copilotNum(row.input_tokens),
|
|
611
|
+
outputTokens: copilotNum(row.output_tokens),
|
|
612
|
+
cacheReadTokens: copilotNum(row.cache_read_tokens),
|
|
613
|
+
cacheWriteTokens: copilotNum(row.cache_write_tokens),
|
|
614
|
+
requests: 1,
|
|
615
|
+
cost: 0,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
// DB missing, locked by a running Copilot CLI, or node:sqlite not
|
|
620
|
+
// available — report zero usage instead of failing the dashboard.
|
|
621
|
+
} finally {
|
|
622
|
+
try {
|
|
623
|
+
db?.close();
|
|
624
|
+
} catch {
|
|
625
|
+
/* ignore */
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
records.sort((a, b) => a.date.localeCompare(b.date));
|
|
630
|
+
return records;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Brief cache so the dashboard's auto-refresh doesn't re-open the SQLite DB
|
|
634
|
+
// on every request. Reads are cheap (~700 rows), so a short TTL is fine.
|
|
635
|
+
const COPILOT_USAGE_TTL_MS = 30_000;
|
|
636
|
+
let copilotUsageCache: { records: UsageRecord[]; at: number } | null = null;
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Synchronous accessor used by the combined view and the Copilot tab.
|
|
640
|
+
* Returns the latest local Copilot records, re-reading the DB when stale.
|
|
641
|
+
*/
|
|
642
|
+
export function readCopilotUsage(): UsageRecord[] {
|
|
643
|
+
if (copilotUsageCache && Date.now() - copilotUsageCache.at < COPILOT_USAGE_TTL_MS) {
|
|
644
|
+
return copilotUsageCache.records;
|
|
645
|
+
}
|
|
646
|
+
const records = readCopilotStore();
|
|
647
|
+
copilotUsageCache = { records, at: Date.now() };
|
|
648
|
+
return records;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Drop cached Copilot data — called after config changes so usage refetches. */
|
|
652
|
+
export function clearCopilotCaches(): void {
|
|
653
|
+
copilotUsageCache = null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ─── Provider-Based Filtering ──────────────────────────
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Provider filter patterns. Each provider has a list of regex patterns
|
|
660
|
+
* that match against providerId and modelId to classify records.
|
|
661
|
+
*/
|
|
662
|
+
export interface ProviderFilter {
|
|
663
|
+
id: string;
|
|
664
|
+
label: string;
|
|
665
|
+
patterns: RegExp[];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export const PROVIDER_FILTERS: ProviderFilter[] = [
|
|
669
|
+
{
|
|
670
|
+
id: "copilot",
|
|
671
|
+
label: "Copilot",
|
|
672
|
+
patterns: [/^copilot$/i],
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
id: "atomcode",
|
|
676
|
+
label: "AtomCode",
|
|
677
|
+
patterns: [/^atomcode$/i],
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
id: "opencode",
|
|
681
|
+
label: "OpenCode",
|
|
682
|
+
patterns: [/^opencode$/, /^opencode-go$/i],
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
id: "gemini",
|
|
686
|
+
label: "Gemini",
|
|
687
|
+
patterns: [/^google$/, /gemini/i],
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
id: "grok",
|
|
691
|
+
label: "Grok",
|
|
692
|
+
patterns: [/^xai$/, /grok/i],
|
|
693
|
+
},
|
|
694
|
+
];
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Filter usage records by provider. Matches against both providerId and modelId.
|
|
698
|
+
*/
|
|
699
|
+
export function filterByProvider(records: UsageRecord[], providerId: string): UsageRecord[] {
|
|
700
|
+
const filter = PROVIDER_FILTERS.find((f) => f.id === providerId);
|
|
701
|
+
if (!filter) return records;
|
|
702
|
+
return records.filter((r) =>
|
|
703
|
+
filter.patterns.some((p) => p.test(r.providerId) || p.test(r.modelId))
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
188
707
|
// ─── Aggregation Helpers ────────────────────────────────
|
|
189
708
|
|
|
190
709
|
export function getDailyAggregates(records: UsageRecord[]) {
|
|
@@ -320,6 +839,8 @@ export function getUsageByRange(records: UsageRecord[], fromDate: string, toDate
|
|
|
320
839
|
totalRequests += r.requests;
|
|
321
840
|
}
|
|
322
841
|
|
|
842
|
+
// totalTokens counts all processed tokens including cached context reads.
|
|
843
|
+
// Cache hits are billed at a lower rate, but they still count as usage.
|
|
323
844
|
const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
|
|
324
845
|
const cacheHitRate = totalTokens > 0 ? ((totalCacheRead + totalCacheWrite) / totalTokens) * 100 : 0;
|
|
325
846
|
|
|
@@ -573,13 +1094,12 @@ export function listSessions(): ProjectGroup[] {
|
|
|
573
1094
|
}
|
|
574
1095
|
|
|
575
1096
|
const group = groups.get(projectPath)!;
|
|
576
|
-
const files =
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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();
|
|
580
1101
|
|
|
581
|
-
for (const
|
|
582
|
-
const filePath = join(dir, file);
|
|
1102
|
+
for (const filePath of files) {
|
|
583
1103
|
const session = parseSessionFileInfo(filePath);
|
|
584
1104
|
if (session) {
|
|
585
1105
|
group.sessions.push(session);
|
|
@@ -588,11 +1108,10 @@ export function listSessions(): ProjectGroup[] {
|
|
|
588
1108
|
|
|
589
1109
|
group.totalSessions = group.sessions.length;
|
|
590
1110
|
if (group.sessions.length > 0) {
|
|
591
|
-
group.lastActive = group.sessions[0]?.timestamp ?? "";
|
|
1111
|
+
group.lastActive = group.sessions[0]?.timestamp ?? "";
|
|
592
1112
|
}
|
|
593
1113
|
}
|
|
594
1114
|
|
|
595
|
-
// Sort groups by lastActive descending
|
|
596
1115
|
return Array.from(groups.values())
|
|
597
1116
|
.filter((g) => g.sessions.length > 0)
|
|
598
1117
|
.sort((a, b) => b.lastActive.localeCompare(a.lastActive));
|
|
@@ -779,6 +1298,74 @@ export function permanentlyDeleteTrash(trashPath: string): boolean {
|
|
|
779
1298
|
}
|
|
780
1299
|
}
|
|
781
1300
|
|
|
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
|
+
|
|
782
1369
|
// ─── Session Preview ────────────────────────────────
|
|
783
1370
|
|
|
784
1371
|
export interface SessionPreviewMessage {
|
|
@@ -1060,6 +1647,93 @@ export interface ApplyUpdateResult {
|
|
|
1060
1647
|
message?: string;
|
|
1061
1648
|
}
|
|
1062
1649
|
|
|
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
|
+
|
|
1063
1737
|
/**
|
|
1064
1738
|
* One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
|
|
1065
1739
|
* Only packages already installed there are accepted (pi core is excluded —
|
|
@@ -1068,6 +1742,8 @@ export interface ApplyUpdateResult {
|
|
|
1068
1742
|
export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
1069
1743
|
const dir = join(PI_DIR, "npm");
|
|
1070
1744
|
const installed = new Set(listInstalledExtensions().map((e) => e.name));
|
|
1745
|
+
const npmCliJs = resolveNpmCliJs();
|
|
1746
|
+
const nodeBin = resolveNodeBin();
|
|
1071
1747
|
|
|
1072
1748
|
return names.map((name) => {
|
|
1073
1749
|
if (!installed.has(name)) {
|
|
@@ -1076,15 +1752,19 @@ export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
|
1076
1752
|
try {
|
|
1077
1753
|
// --legacy-peer-deps: peer deps (e.g. pi core) are provided by the pi host,
|
|
1078
1754
|
// not installed here — strict resolution would fail with ERESOLVE.
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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], {
|
|
1083
1760
|
cwd: dir,
|
|
1084
1761
|
encoding: "utf8",
|
|
1085
1762
|
timeout: 120000,
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1763
|
+
});
|
|
1764
|
+
} else {
|
|
1765
|
+
// Fall back to PATH resolution (dev / terminal environments)
|
|
1766
|
+
out = spawnSync("npm", args, { cwd: dir, encoding: "utf8", timeout: 120000 });
|
|
1767
|
+
}
|
|
1088
1768
|
if (out.status === 0) return { name, success: true };
|
|
1089
1769
|
const stderr = (out.stderr || "").trim().split("\n").slice(-3).join(" ");
|
|
1090
1770
|
return { name, success: false, message: stderr || `npm exited with ${out.status}` };
|