pi-cost 0.1.0 → 0.2.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/CHANGELOG.md +29 -0
- package/README.md +13 -1
- package/extensions/cost.ts +53 -83
- package/lib/app.js +113 -0
- package/lib/args.js +17 -0
- package/lib/cli.js +593 -0
- package/lib/cost-dir.js +62 -0
- package/lib/costs.js +799 -0
- package/lib/net-guard.js +178 -0
- package/lib/pi-parsers-cost.js +234 -66
- package/lib/pi-pricing.js +35 -8
- package/lib/themes.js +90 -0
- package/package.json +16 -7
- package/public/app.js +2232 -448
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +68 -46
- package/public/style.css +319 -8
- package/public/sw.js +2 -2
- package/server.js +58 -659
- package/themes/ayu-dark.json +21 -0
- package/themes/ayu-light.json +21 -0
- package/themes/catppuccin-dark.json +21 -0
- package/themes/catppuccin-light.json +21 -0
- package/themes/dracula-dark.json +21 -0
- package/themes/dracula-light.json +21 -0
- package/themes/everforest-dark.json +21 -0
- package/themes/everforest-light.json +21 -0
- package/themes/github-dark.json +21 -0
- package/themes/github-light.json +21 -0
- package/themes/gruvbox-dark.json +21 -0
- package/themes/gruvbox-light.json +21 -0
- package/themes/kanagawa-dark.json +21 -0
- package/themes/kanagawa-light.json +21 -0
- package/themes/monokai-dark.json +21 -0
- package/themes/monokai-light.json +21 -0
- package/themes/night-owl-dark.json +21 -0
- package/themes/night-owl-light.json +21 -0
- package/themes/nord-dark.json +21 -0
- package/themes/nord-light.json +21 -0
- package/themes/one-dark-dark.json +21 -0
- package/themes/one-dark-light.json +21 -0
- package/themes/rose-pine-dark.json +21 -0
- package/themes/rose-pine-light.json +21 -0
- package/themes/solarized-dark.json +21 -0
- package/themes/solarized-light.json +21 -0
- package/themes/synthwave-dark.json +21 -0
- package/themes/synthwave-light.json +21 -0
- package/themes/tokyo-night-dark.json +21 -0
- package/themes/tokyo-night-light.json +21 -0
- package/themes/vitesse-dark.json +21 -0
- package/themes/vitesse-light.json +21 -0
package/server.js
CHANGED
|
@@ -1,673 +1,72 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
const fsp = require('node:fs/promises');
|
|
7
|
-
const os = require('node:os');
|
|
8
|
-
|
|
4
|
+
const { getArg, hasFlag } = require('./lib/args');
|
|
9
5
|
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
6
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
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
|
-
}
|
|
7
|
+
parsers.configure({ sessionDir: getArg('session-dir') });
|
|
8
|
+
|
|
9
|
+
const cli = require('./lib/cli');
|
|
10
|
+
if (cli.isCommand(process.argv[2])) {
|
|
11
|
+
cli.main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
12
|
+
} else {
|
|
13
|
+
startServer();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function startServer() {
|
|
17
|
+
const pricing = require('./lib/pi-pricing');
|
|
18
|
+
const { createApp } = require('./lib/app');
|
|
19
|
+
const { createNetGuard } = require('./lib/net-guard');
|
|
20
|
+
const { createThemeStore } = require('./lib/themes');
|
|
21
|
+
const { writeServerRecord, removeServerRecord } = require('./lib/cost-dir');
|
|
22
|
+
|
|
23
|
+
const portArg = getArg('port');
|
|
24
|
+
const PORT = parseInt(portArg || process.env.PORT || '5461', 10);
|
|
25
|
+
// An explicit --port binds exactly, so a host that picked the port (pi-kanban) can rely on it.
|
|
26
|
+
const STRICT_PORT = portArg !== null;
|
|
27
|
+
const OPEN = hasFlag('open') && !hasFlag('no-open');
|
|
28
|
+
const COST_DIR = parsers.getCostDir();
|
|
29
|
+
|
|
30
|
+
const net = createNetGuard({
|
|
31
|
+
appName: 'pi-cost',
|
|
32
|
+
bindHost: getArg('host') || process.env.HOST || '127.0.0.1',
|
|
33
|
+
allowedHosts: getArg('allowed-hosts') || process.env.ALLOWED_HOSTS || '',
|
|
34
|
+
});
|
|
35
|
+
const themes = createThemeStore(COST_DIR);
|
|
36
|
+
const app = createApp({ net, themes });
|
|
231
37
|
|
|
232
|
-
|
|
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
|
-
}
|
|
38
|
+
themes.ready.then(() => console.log(`themes loaded: ${themes.themes.size} (user dir: ${themes.config.dir})`));
|
|
271
39
|
|
|
272
|
-
|
|
273
|
-
|
|
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;
|
|
40
|
+
process.on('exit', () => removeServerRecord(COST_DIR, process.pid));
|
|
41
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => process.exit(0));
|
|
278
42
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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,
|
|
43
|
+
const onReady = (port) => {
|
|
44
|
+
console.log(`pi-cost listening on http://localhost:${port}`);
|
|
45
|
+
console.log(`Sessions dir: ${parsers.getSessionRoots().map((r) => r.dir).join(', ')}`);
|
|
46
|
+
const warning = net.exposureWarning();
|
|
47
|
+
if (warning) console.log(warning);
|
|
48
|
+
writeServerRecord(COST_DIR, { pid: process.pid, port, sessionRoots: parsers.getSessionRoots() });
|
|
49
|
+
if (OPEN) import('open').then((mod) => mod.default(`http://localhost:${port}`)).catch(() => {});
|
|
304
50
|
};
|
|
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
51
|
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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,
|
|
52
|
+
const listen = (port) => {
|
|
53
|
+
const server = net.listenLoopback(app, port, onReady, { maxHeaderSize: 32768 });
|
|
54
|
+
server.on('error', (err) => {
|
|
55
|
+
if (err.code !== 'EADDRINUSE') throw err;
|
|
56
|
+
if (STRICT_PORT) {
|
|
57
|
+
console.error(`Port ${port} in use`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
console.log(`Port ${port} in use, trying random port...`);
|
|
61
|
+
listen(0);
|
|
414
62
|
});
|
|
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
63
|
};
|
|
484
64
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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;
|
|
65
|
+
listen(PORT);
|
|
66
|
+
pricing.fetchPricing()
|
|
67
|
+
.then(() => {
|
|
68
|
+
const meta = pricing.getMeta();
|
|
69
|
+
console.log(`pricing: ${meta.modelCount} models (${meta.source})`);
|
|
70
|
+
})
|
|
71
|
+
.catch(() => {});
|
|
540
72
|
}
|
|
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
|