pi-cost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const express = require('express');
5
+ const path = require('node:path');
6
+ const fsp = require('node:fs/promises');
7
+ const os = require('node:os');
8
+
9
+ const parsers = require('./lib/pi-parsers-cost');
10
+ const pricing = require('./lib/pi-pricing');
11
+
12
+ // #region CLI_ARGS
13
+
14
+ function getArg(name) {
15
+ const eqIdx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
16
+ if (eqIdx === -1) return null;
17
+ const arg = process.argv[eqIdx];
18
+ if (arg.includes('=')) return arg.split('=').slice(1).join('=');
19
+ return process.argv[eqIdx + 1] || null;
20
+ }
21
+
22
+ const PORT = parseInt(getArg('port') || process.env.PORT || '5461', 10);
23
+
24
+ // #endregion
25
+
26
+ // #region THEMES
27
+
28
+ const BUILTIN_THEME_DIR = path.join(__dirname, 'themes');
29
+ const COST_DIR = path.join(os.homedir(), '.pi', 'agent', 'cost');
30
+ const SETTINGS_PATH = path.join(COST_DIR, 'settings.json');
31
+
32
+ function readSettings() {
33
+ try {
34
+ const raw = require('node:fs').readFileSync(SETTINGS_PATH, 'utf8');
35
+ const obj = JSON.parse(raw);
36
+ return obj && typeof obj === 'object' ? obj : {};
37
+ } catch (e) {
38
+ if (e.code !== 'ENOENT') console.warn(`themes: cannot read ${SETTINGS_PATH}: ${e.message}`);
39
+ return {};
40
+ }
41
+ }
42
+
43
+ const SETTINGS = readSettings();
44
+ const SETTINGS_THEMES = (SETTINGS && SETTINGS.themes) || {};
45
+
46
+ const USER_THEME_DIR = process.env.COST_THEME_DIR
47
+ || SETTINGS_THEMES.dir
48
+ || path.join(COST_DIR, 'themes');
49
+ const LIGHT_THEME_ID = process.env.COST_LIGHT_THEME || SETTINGS_THEMES.light || 'pi-light';
50
+ const DARK_THEME_ID = process.env.COST_DARK_THEME || SETTINGS_THEMES.dark || 'pi-dark';
51
+
52
+ const REQUIRED_COLOR_KEYS = [
53
+ 'bgDeep', 'bgSurface', 'bgElevated', 'bgHover',
54
+ 'border',
55
+ 'textPrimary', 'textSecondary', 'textTertiary', 'textMuted',
56
+ 'accent', 'accentText',
57
+ 'success', 'warning', 'error',
58
+ ];
59
+
60
+ const themes = new Map();
61
+
62
+ async function loadThemesFromDir(dir, builtin) {
63
+ let entries;
64
+ try { entries = await fsp.readdir(dir); }
65
+ catch (e) {
66
+ if (e.code !== 'ENOENT') console.warn(`themes: cannot read ${dir}: ${e.message}`);
67
+ return;
68
+ }
69
+ const jsons = entries.filter((f) => f.toLowerCase().endsWith('.json'));
70
+ await Promise.all(jsons.map(async (f) => {
71
+ const full = path.join(dir, f);
72
+ const id = f.replace(/\.json$/i, '');
73
+ try {
74
+ const raw = await fsp.readFile(full, 'utf8');
75
+ const obj = JSON.parse(raw);
76
+ if (obj.mode !== 'light' && obj.mode !== 'dark') {
77
+ console.warn(`themes: skip ${full}: invalid mode`); return;
78
+ }
79
+ const colors = obj.colors || {};
80
+ const missing = REQUIRED_COLOR_KEYS.filter((k) => typeof colors[k] !== 'string');
81
+ if (missing.length) {
82
+ console.warn(`themes: skip ${full}: missing colors ${missing.join(',')}`); return;
83
+ }
84
+ themes.set(id, {
85
+ id,
86
+ name: obj.name || id,
87
+ displayName: obj.displayName || obj.name || id,
88
+ mode: obj.mode,
89
+ colors,
90
+ builtin,
91
+ });
92
+ } catch (e) {
93
+ console.warn(`themes: skip ${full}: ${e.message}`);
94
+ }
95
+ }));
96
+ }
97
+
98
+ async function loadAllThemes() {
99
+ themes.clear();
100
+ await loadThemesFromDir(BUILTIN_THEME_DIR, true);
101
+ await loadThemesFromDir(USER_THEME_DIR, false);
102
+ }
103
+
104
+ function resolveActiveTheme(mode) {
105
+ const requestedId = mode === 'light' ? LIGHT_THEME_ID : DARK_THEME_ID;
106
+ const fallbackId = mode === 'light' ? 'pi-light' : 'pi-dark';
107
+ return themes.get(requestedId) || themes.get(fallbackId) || null;
108
+ }
109
+
110
+ // #endregion
111
+
112
+ // #region CACHE
113
+
114
+ const CACHE_TTL = 30_000;
115
+ let dataCache = new Map();
116
+
117
+ function cacheGet(key) {
118
+ const hit = dataCache.get(key);
119
+ if (!hit) return null;
120
+ if (Date.now() - hit.ts > CACHE_TTL) { dataCache.delete(key); return null; }
121
+ return hit.payload;
122
+ }
123
+ function cacheSet(key, payload) { dataCache.set(key, { ts: Date.now(), payload }); }
124
+ function cacheClear() { dataCache.clear(); }
125
+
126
+ // #endregion
127
+
128
+ // #region AGGREGATION
129
+
130
+ function rangeToCutoff(range, now) {
131
+ if (range === 'today') {
132
+ const d = new Date(now); d.setHours(0, 0, 0, 0); return d;
133
+ }
134
+ if (!range || !Number.isFinite(range)) return null;
135
+ const d = new Date(now); d.setDate(d.getDate() - range); return d;
136
+ }
137
+
138
+ function normalizePath(p) {
139
+ return p ? String(p).replace(/\//g, '\\').toLowerCase() : '';
140
+ }
141
+
142
+ function localDateStr(d) {
143
+ const y = d.getFullYear();
144
+ const m = String(d.getMonth() + 1).padStart(2, '0');
145
+ const day = String(d.getDate()).padStart(2, '0');
146
+ return `${y}-${m}-${day}`;
147
+ }
148
+
149
+ function fillDaily(daily, start, end) {
150
+ const out = [];
151
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
152
+ const ds = localDateStr(d);
153
+ out.push({ date: ds, cost: daily[ds]?.cost || 0 });
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // path(lowercased) → uuid. Populated lazily; refreshed when the underlying
159
+ // directory listing changes. Used to turn a subagent run's `sessionFile` path
160
+ // into a clickable session id.
161
+ let nestedIndexPromise = null;
162
+ function loadNestedIndex(force = false) {
163
+ if (!force && nestedIndexPromise) return nestedIndexPromise;
164
+ nestedIndexPromise = (async () => {
165
+ const files = await parsers.listNestedSubagentFiles();
166
+ const map = new Map();
167
+ await Promise.all(files.map(async (f) => {
168
+ try {
169
+ const { entries } = await parsers.getCachedSession(f.file, { mtimeMs: f.mtimeMs, size: f.size });
170
+ const sess = entries.find((e) => e.type === 'session');
171
+ if (sess && sess.id) map.set(normalizePath(f.file), { id: sess.id, ...f });
172
+ } catch {}
173
+ }));
174
+ return map;
175
+ })();
176
+ return nestedIndexPromise;
177
+ }
178
+
179
+ async function findSessionFileById(id) {
180
+ const top = await parsers.listSessionFiles();
181
+ for (const f of top) if (f.file.includes(id)) return f;
182
+ const nested = await loadNestedIndex();
183
+ for (const entry of nested.values()) {
184
+ if (entry.id === id) return { file: entry.file, projectDir: entry.projectDir, cwd: parsers.decodeProjectDir(entry.projectDir), mtime: entry.mtime, mtimeMs: entry.mtimeMs, size: entry.size };
185
+ }
186
+ for (const f of top) {
187
+ try {
188
+ const { entries } = await parsers.getCachedSession(f.file, { mtimeMs: f.mtimeMs, size: f.size });
189
+ const sess = entries.find((e) => e.type === 'session');
190
+ if (sess && sess.id === id) return f;
191
+ } catch {}
192
+ }
193
+ return null;
194
+ }
195
+
196
+ async function buildSessionDetail(meta, pricingMap) {
197
+ const cached = await parsers.getCachedSession(meta.file, { mtimeMs: meta.mtimeMs, size: meta.size });
198
+ const entries = cached.entries;
199
+ const summary = cached.summary;
200
+ const messages = [];
201
+ const seen = new Set();
202
+ let cumulative = 0;
203
+ let totalCost = 0;
204
+ const modelsSeen = new Set();
205
+ let sourceCounts = { jsonl: 0, pricing: 0, none: 0 };
206
+
207
+ for (const u of parsers.enumerateMessageUsages(entries)) {
208
+ if (u.messageId) {
209
+ if (seen.has(u.messageId)) continue;
210
+ seen.add(u.messageId);
211
+ }
212
+ const est = pricing.estimate(pricingMap, u.provider, u.model, u.usage);
213
+ cumulative += est.cost;
214
+ totalCost += est.cost;
215
+ sourceCounts[est.source]++;
216
+ if (u.model) modelsSeen.add(u.model);
217
+ messages.push({
218
+ ts: u.ts,
219
+ provider: u.provider,
220
+ model: u.model,
221
+ input: u.usage.input || 0,
222
+ output: u.usage.output || 0,
223
+ cacheRead: u.usage.cacheRead || 0,
224
+ cacheWrite: u.usage.cacheWrite || 0,
225
+ cost: est.cost,
226
+ cumulativeCost: cumulative,
227
+ costSource: est.source,
228
+ modelKey: est.modelKey,
229
+ });
230
+ }
231
+
232
+ // Inline subagent runs (pi >= v3 records each run inside the parent's
233
+ // JSONL as a `subagent` toolResult). Merged into `messages` as pseudo-rows
234
+ // so the UI shows a single unified timeline. Subscription providers report
235
+ // usage.cost = 0, so we always estimate.
236
+ const stripSuffix = (m) => (m ? String(m).replace(/:(low|medium|high)$/, '') : m);
237
+ let inlineSubCost = 0;
238
+ let inlineSubTokens = 0;
239
+ let inlineSubCount = 0;
240
+ const nestedIndex = await loadNestedIndex();
241
+ for (const r of parsers.enumerateSubagentRuns(entries)) {
242
+ const model = stripSuffix(r.model);
243
+ const est = pricing.estimate(pricingMap, null, model, r.usage);
244
+ const nestedEntry = r.jsonlPath ? nestedIndex.get(normalizePath(r.jsonlPath)) : null;
245
+ const subagentSessionId = nestedEntry ? nestedEntry.id : null;
246
+ inlineSubCost += est.cost;
247
+ const tk = (r.usage.input || 0) + (r.usage.output || 0)
248
+ + (r.usage.cacheRead || 0) + (r.usage.cacheWrite || 0);
249
+ inlineSubTokens += tk;
250
+ inlineSubCount++;
251
+ sourceCounts[est.source]++;
252
+ if (model) modelsSeen.add(model);
253
+ messages.push({
254
+ ts: r.ts,
255
+ provider: null,
256
+ model,
257
+ input: r.usage.input || 0,
258
+ output: r.usage.output || 0,
259
+ cacheRead: r.usage.cacheRead || 0,
260
+ cacheWrite: r.usage.cacheWrite || 0,
261
+ cost: est.cost,
262
+ cumulativeCost: 0, // recomputed below after sort
263
+ costSource: est.source,
264
+ modelKey: est.modelKey,
265
+ agent: r.agent,
266
+ task: r.task,
267
+ subagentSessionId,
268
+ turns: r.turns,
269
+ });
270
+ }
271
+
272
+ // Reorder by timestamp and recompute cumulative cost so subagent runs slot
273
+ // into the timeline between assistant messages.
274
+ messages.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
275
+ let cum = 0;
276
+ for (const m of messages) { cum += m.cost; m.cumulativeCost = cum; }
277
+ totalCost += inlineSubCost;
278
+
279
+ return {
280
+ id: (summary.sessionEntry && summary.sessionEntry.id) || parsers.slugFromFile(meta.file),
281
+ parentSession: summary.parentSession,
282
+ inlineSubCost,
283
+ inlineSubTokens,
284
+ inlineSubCount,
285
+ customTitle: summary.customTitle,
286
+ firstUserText: summary.firstUserText,
287
+ provider: summary.provider,
288
+ model: summary.model,
289
+ models: [...modelsSeen],
290
+ totalCost,
291
+ totalInput: summary.totalInput,
292
+ totalOutput: summary.totalOutput,
293
+ totalCacheRead: summary.totalCacheRead,
294
+ totalCacheWrite: summary.totalCacheWrite,
295
+ totalTokens: summary.totalTokens,
296
+ messageCount: messages.length,
297
+ firstTimestamp: summary.firstTimestamp,
298
+ lastTimestamp: summary.lastTimestamp || meta.mtime.toISOString(),
299
+ sourceCounts,
300
+ messages,
301
+ projectDir: meta.projectDir,
302
+ cwd: (summary.sessionEntry && summary.sessionEntry.cwd) || meta.cwd,
303
+ jsonlPath: meta.file,
304
+ };
305
+ }
306
+
307
+ // Aggregate everything. Caller filters by range.
308
+ async function buildAll(range) {
309
+ const cacheKey = `all_${range || 'all'}`;
310
+ const cached = cacheGet(cacheKey);
311
+ if (cached) return cached;
312
+
313
+ const now = new Date();
314
+ const cutoff = rangeToCutoff(range, now);
315
+ const cutoffMs = cutoff ? cutoff.getTime() : 0;
316
+ const pricingMap = await pricing.fetchPricing();
317
+
318
+ const files = await parsers.listSessionFiles();
319
+ // Use mtime as a quick file-level filter — sessions older than cutoff can't have in-range messages.
320
+ const eligible = cutoffMs ? files.filter((f) => f.mtimeMs >= cutoffMs - 24 * 3600 * 1000) : files;
321
+
322
+ // Bounded-concurrency parse — JSONL files are I/O bound; >8 risks FD pressure.
323
+ const CONCURRENCY = 8;
324
+ const sessions = [];
325
+ for (let i = 0; i < eligible.length; i += CONCURRENCY) {
326
+ const chunk = eligible.slice(i, i + CONCURRENCY);
327
+ const settled = await Promise.all(chunk.map(async (meta) => {
328
+ try { return { meta, detail: await buildSessionDetail(meta, pricingMap) }; }
329
+ catch (err) { console.warn('[pi-cost] failed to read', meta.file, err.message); return null; }
330
+ }));
331
+ for (const s of settled) if (s) sessions.push(s);
332
+ }
333
+
334
+ // Project rollup.
335
+ const projects = new Map();
336
+ const daily = {};
337
+ const modelTotals = {};
338
+ let totalCost = 0;
339
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
340
+ let totalSessions = 0;
341
+ let totalMessages = 0;
342
+ const sourceCounts = { jsonl: 0, pricing: 0, none: 0 };
343
+
344
+ const sessionSummaries = [];
345
+
346
+ for (const { meta, detail } of sessions) {
347
+ const inRange = cutoff
348
+ ? detail.messages.filter((m) => m.ts && new Date(m.ts).getTime() >= cutoffMs)
349
+ : detail.messages;
350
+ if (inRange.length === 0 && cutoff) continue;
351
+
352
+ let sCost = 0, sIn = 0, sOut = 0, sCR = 0, sCW = 0;
353
+ const projDaily = projects.get(meta.projectDir)?.daily || {};
354
+ for (const m of inRange) {
355
+ sCost += m.cost;
356
+ sIn += m.input;
357
+ sOut += m.output;
358
+ sCR += m.cacheRead;
359
+ sCW += m.cacheWrite;
360
+ const day = localDateStr(new Date(m.ts));
361
+ daily[day] = daily[day] || { cost: 0 };
362
+ daily[day].cost += m.cost;
363
+ projDaily[day] = (projDaily[day] || 0) + m.cost;
364
+ if (m.model) modelTotals[m.model] = (modelTotals[m.model] || 0) + m.cost;
365
+ sourceCounts[m.costSource] = (sourceCounts[m.costSource] || 0) + 1;
366
+ }
367
+
368
+ totalCost += sCost;
369
+ totalInput += sIn; totalOutput += sOut; totalCacheRead += sCR; totalCacheWrite += sCW;
370
+ totalSessions++;
371
+ totalMessages += inRange.length;
372
+
373
+ const proj = projects.get(meta.projectDir) || {
374
+ projectDir: meta.projectDir,
375
+ cwd: detail.cwd,
376
+ name: path.basename(detail.cwd) || detail.cwd,
377
+ totalCost: 0,
378
+ sessions: 0,
379
+ totalTokens: 0,
380
+ lastActive: null,
381
+ models: new Map(),
382
+ daily: projDaily,
383
+ };
384
+ proj.totalCost += sCost;
385
+ proj.sessions++;
386
+ proj.totalTokens += sIn + sOut + sCR + sCW;
387
+ const last = inRange[inRange.length - 1]?.ts || detail.lastTimestamp;
388
+ if (!proj.lastActive || (last && last > proj.lastActive)) proj.lastActive = last;
389
+ for (const m of inRange) {
390
+ if (!m.model) continue;
391
+ proj.models.set(m.model, (proj.models.get(m.model) || 0) + m.cost);
392
+ }
393
+ projects.set(meta.projectDir, proj);
394
+
395
+ sessionSummaries.push({
396
+ id: detail.id,
397
+ projectDir: meta.projectDir,
398
+ cwd: detail.cwd,
399
+ customTitle: detail.customTitle,
400
+ firstUserText: detail.firstUserText,
401
+ provider: detail.provider,
402
+ model: detail.model,
403
+ models: detail.models,
404
+ totalCost: sCost,
405
+ totalTokens: sIn + sOut + sCR + sCW,
406
+ messageCount: inRange.length,
407
+ firstTimestamp: detail.firstTimestamp,
408
+ lastTimestamp: last,
409
+ parentSession: detail.parentSession,
410
+ jsonlPath: meta.file,
411
+ inlineSubCost: detail.inlineSubCost || 0,
412
+ inlineSubTokens: detail.inlineSubTokens || 0,
413
+ inlineSubCount: detail.inlineSubCount || 0,
414
+ });
415
+ }
416
+
417
+ // Subagent rollup: subagents are separate JSONL files whose `parentSession`
418
+ // points to the parent's file path. Project/overview totals already include
419
+ // them; here we surface a per-parent aggregate so the detail view can show
420
+ // "self + spawned" cost.
421
+ const childrenByParent = new Map();
422
+ for (const s of sessionSummaries) {
423
+ if (!s.parentSession) continue;
424
+ const key = normalizePath(s.parentSession);
425
+ if (!childrenByParent.has(key)) childrenByParent.set(key, []);
426
+ childrenByParent.get(key).push(s);
427
+ }
428
+ for (const s of sessionSummaries) {
429
+ const kids = childrenByParent.get(normalizePath(s.jsonlPath)) || [];
430
+ s.subagentCount = kids.length + (s.inlineSubCount || 0);
431
+ s.subagentCost = kids.reduce((a, c) => a + c.totalCost, 0) + (s.inlineSubCost || 0);
432
+ s.subagentTokens = kids.reduce((a, c) => a + c.totalTokens, 0) + (s.inlineSubTokens || 0);
433
+ s.subagentIds = kids.map((c) => c.id);
434
+ }
435
+
436
+ const todayStr = localDateStr(now);
437
+ const todayCost = daily[todayStr]?.cost || 0;
438
+
439
+ const dailyStart = cutoff ? new Date(cutoff) : (() => {
440
+ const dates = Object.keys(daily).sort();
441
+ return dates.length ? new Date(dates[0] + 'T00:00:00') : new Date(now);
442
+ })();
443
+ dailyStart.setHours(0, 0, 0, 0);
444
+ const dailyArr = fillDaily(daily, dailyStart, now);
445
+
446
+ const modelDistribution = Object.entries(modelTotals)
447
+ .filter(([, c]) => c > 0)
448
+ .map(([model, cost]) => ({ model, cost }))
449
+ .sort((a, b) => b.cost - a.cost);
450
+
451
+ const projectsArr = [...projects.values()].map((p) => ({
452
+ projectDir: p.projectDir,
453
+ cwd: p.cwd,
454
+ name: p.name,
455
+ totalCost: p.totalCost,
456
+ sessions: p.sessions,
457
+ totalTokens: p.totalTokens,
458
+ lastActive: p.lastActive,
459
+ primaryModel: [...p.models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown',
460
+ models: [...p.models.keys()],
461
+ daily: p.daily,
462
+ modelTotals: Object.fromEntries(p.models),
463
+ })).sort((a, b) => b.totalCost - a.totalCost);
464
+
465
+ const cacheInputAll = totalInput + totalCacheRead + totalCacheWrite;
466
+ const cacheEfficiency = cacheInputAll > 0 ? totalCacheRead / cacheInputAll : 0;
467
+
468
+ const result = {
469
+ summary: {
470
+ totalCost, todayCost,
471
+ totalSessions, totalMessages,
472
+ totalInput, totalOutput, totalCacheRead, totalCacheWrite,
473
+ totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
474
+ cacheEfficiency,
475
+ sourceCounts,
476
+ },
477
+ daily: dailyArr,
478
+ modelDistribution,
479
+ projects: projectsArr,
480
+ sessions: sessionSummaries,
481
+ pricingMeta: pricing.getMeta(),
482
+ generatedAt: new Date().toISOString(),
483
+ };
484
+
485
+ cacheSet(cacheKey, result);
486
+ return result;
487
+ }
488
+
489
+ // #endregion
490
+
491
+ // #region EXPRESS
492
+
493
+ const app = express();
494
+ app.use(express.json());
495
+ app.use(express.static(path.join(__dirname, 'public')));
496
+
497
+ function parseRange(raw, fallback = null) {
498
+ if (raw === 'today') return 'today';
499
+ const n = parseInt(raw, 10);
500
+ return Number.isFinite(n) ? n : fallback;
501
+ }
502
+
503
+ app.get('/api/themes', (_req, res) => {
504
+ res.json({
505
+ light: resolveActiveTheme('light'),
506
+ dark: resolveActiveTheme('dark'),
507
+ config: { lightId: LIGHT_THEME_ID, darkId: DARK_THEME_ID, themeDir: USER_THEME_DIR },
508
+ });
509
+ });
510
+
511
+ app.get('/pi-config', (_req, res) => {
512
+ res.json({
513
+ piDir: parsers.getPiDir(),
514
+ sessionsDir: parsers.getSessionsDir(),
515
+ port: PORT,
516
+ });
517
+ });
518
+
519
+ app.get('/api/overview', async (req, res) => {
520
+ try {
521
+ const range = parseRange(req.query.range, 30);
522
+ const all = await buildAll(range);
523
+ res.json({
524
+ summary: all.summary,
525
+ daily: all.daily,
526
+ modelDistribution: all.modelDistribution,
527
+ projects: all.projects.map(stripInternal),
528
+ pricingMeta: all.pricingMeta,
529
+ generatedAt: all.generatedAt,
530
+ });
531
+ } catch (err) {
532
+ console.error('[api] overview:', err);
533
+ res.status(500).json({ error: err.message });
534
+ }
535
+ });
536
+
537
+ function stripInternal(p) {
538
+ const { daily: _d, modelTotals: _m, ...rest } = p;
539
+ return rest;
540
+ }
541
+
542
+ app.get('/api/projects', async (req, res) => {
543
+ try {
544
+ const range = parseRange(req.query.range);
545
+ const all = await buildAll(range);
546
+ res.json({ projects: all.projects.map(stripInternal) });
547
+ } catch (err) {
548
+ console.error('[api] projects:', err);
549
+ res.status(500).json({ error: err.message });
550
+ }
551
+ });
552
+
553
+ app.get('/api/projects/:encoded/sessions', async (req, res) => {
554
+ try {
555
+ const range = parseRange(req.query.range);
556
+ const all = await buildAll(range);
557
+ const decoded = decodeURIComponent(req.params.encoded);
558
+ const project = all.projects.find((p) => p.projectDir === decoded);
559
+ const sessions = all.sessions
560
+ .filter((s) => s.projectDir === decoded)
561
+ .sort((a, b) => (b.lastTimestamp || '').localeCompare(a.lastTimestamp || ''));
562
+
563
+ const now = new Date();
564
+ const cutoff = rangeToCutoff(range, now);
565
+ const dailyStart = cutoff ? new Date(cutoff) : new Date(now.getTime() - 30 * 86400 * 1000);
566
+ dailyStart.setHours(0, 0, 0, 0);
567
+ const dailyMap = {};
568
+ for (const [day, cost] of Object.entries(project?.daily || {})) dailyMap[day] = { cost };
569
+ const dailyArr = fillDaily(dailyMap, dailyStart, now);
570
+
571
+ const modelDistribution = Object.entries(project?.modelTotals || {})
572
+ .map(([model, cost]) => ({ model, cost }))
573
+ .sort((a, b) => b.cost - a.cost);
574
+
575
+ res.json({ project, sessions, daily: dailyArr, modelDistribution });
576
+ } catch (err) {
577
+ console.error('[api] project sessions:', err);
578
+ res.status(500).json({ error: err.message });
579
+ }
580
+ });
581
+
582
+ app.get('/api/sessions/:id', async (req, res) => {
583
+ try {
584
+ const meta = await findSessionFileById(req.params.id);
585
+ if (!meta) return res.status(404).json({ error: 'Session not found' });
586
+ const pricingMap = await pricing.fetchPricing();
587
+ const detail = await buildSessionDetail(meta, pricingMap);
588
+ detail.pricingMeta = pricing.getMeta();
589
+ detail.encodedProjectDir = encodeURIComponent(meta.projectDir);
590
+
591
+ // Sibling subagents (legacy: separate JSONL files with parentSession).
592
+ // Inline runs (pi v3 nested format) are already merged into detail.messages.
593
+ const all = await buildAll(null);
594
+ const self = all.sessions.find((s) => s.id === detail.id);
595
+ detail.childSessions = self
596
+ ? all.sessions
597
+ .filter((s) => s.parentSession && normalizePath(s.parentSession) === normalizePath(self.jsonlPath))
598
+ .map((c) => ({
599
+ id: c.id, customTitle: c.customTitle, firstUserText: c.firstUserText,
600
+ totalCost: c.totalCost, totalTokens: c.totalTokens, messageCount: c.messageCount,
601
+ model: c.model, provider: c.provider, lastTimestamp: c.lastTimestamp,
602
+ }))
603
+ : [];
604
+
605
+ res.json(detail);
606
+ } catch (err) {
607
+ console.error('[api] session detail:', err);
608
+ res.status(500).json({ error: err.message });
609
+ }
610
+ });
611
+
612
+ app.get('/api/pricing', async (_req, res) => {
613
+ try {
614
+ const map = await pricing.fetchPricing();
615
+ const models = [];
616
+ for (const [key, info] of map) {
617
+ models.push({
618
+ key,
619
+ input: info.input_cost_per_token,
620
+ output: info.output_cost_per_token,
621
+ cacheRead: info.cache_read_input_token_cost,
622
+ cacheWrite: info.cache_creation_input_token_cost,
623
+ });
624
+ }
625
+ res.json({ models, ...pricing.getMeta() });
626
+ } catch (err) {
627
+ res.status(500).json({ error: err.message });
628
+ }
629
+ });
630
+
631
+ app.post('/api/refresh', async (_req, res) => {
632
+ cacheClear();
633
+ parsers.invalidateSessionCache();
634
+ pricing.refresh();
635
+ nestedIndexPromise = null;
636
+ res.json({ ok: true });
637
+ });
638
+
639
+ // #endregion
640
+
641
+ // #region STARTUP
642
+
643
+ loadAllThemes().then(() => {
644
+ console.log(`themes loaded: ${themes.size} (user dir: ${USER_THEME_DIR})`);
645
+ }).catch((e) => console.warn('themes: load failed:', e.message));
646
+
647
+ const server = app.listen(PORT, () => {
648
+ const actualPort = server.address().port;
649
+ console.log(`pi-cost dashboard running at http://localhost:${actualPort}`);
650
+ console.log(`Sessions dir: ${parsers.getSessionsDir()}`);
651
+ if (process.argv.includes('--open')) {
652
+ import('open').then((mod) => mod.default(`http://localhost:${actualPort}`)).catch(() => {});
653
+ }
654
+ });
655
+
656
+ server.on('error', (err) => {
657
+ if (err.code === 'EADDRINUSE') {
658
+ console.log(`Port ${PORT} in use, trying random port...`);
659
+ const fallback = app.listen(0, () => {
660
+ const p = fallback.address().port;
661
+ console.log(`pi-cost dashboard running at http://localhost:${p}`);
662
+ if (process.argv.includes('--open')) {
663
+ import('open').then((mod) => mod.default(`http://localhost:${p}`)).catch(() => {});
664
+ }
665
+ });
666
+ } else {
667
+ throw err;
668
+ }
669
+ });
670
+
671
+ pricing.fetchPricing().catch(() => {});
672
+
673
+ // #endregion
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "classic-dark",
3
+ "displayName": "Classic Dark",
4
+ "mode": "dark",
5
+ "colors": {
6
+ "bgDeep": "#101114",
7
+ "bgSurface": "#16181c",
8
+ "bgElevated": "#1e2025",
9
+ "bgHover": "#282a30",
10
+ "border": "#363840",
11
+ "textPrimary": "#f0f1f3",
12
+ "textSecondary": "#c2c4c9",
13
+ "textTertiary": "#9a9da5",
14
+ "textMuted": "#7d808a",
15
+ "accent": "#e86f33",
16
+ "accentText": "#f0a070",
17
+ "success": "#3ecf8e",
18
+ "warning": "#f0b429",
19
+ "error": "#ef4444"
20
+ }
21
+ }