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/README.md +46 -0
- package/extensions/cost.ts +181 -0
- package/extensions/package.json +1 -0
- package/extensions/tsconfig.json +16 -0
- package/lib/pi-parsers-cost.js +297 -0
- package/lib/pi-pricing.js +197 -0
- package/package.json +61 -0
- package/public/app.js +1290 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +7 -0
- package/public/index.html +109 -0
- package/public/manifest.json +26 -0
- package/public/style.css +420 -0
- package/public/sw.js +60 -0
- package/server.js +673 -0
- package/themes/classic-dark.json +21 -0
- package/themes/classic-light.json +21 -0
- package/themes/pi-dark.json +21 -0
- package/themes/pi-light.json +21 -0
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
|
+
}
|