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/lib/costs.js
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const parsers = require('./pi-parsers-cost');
|
|
5
|
+
const pricing = require('./pi-pricing');
|
|
6
|
+
|
|
7
|
+
// #region CACHE
|
|
8
|
+
|
|
9
|
+
const CACHE_TTL = 30_000;
|
|
10
|
+
// Custom from/to windows make the key space unbounded, so the cache is capped.
|
|
11
|
+
const MAX_CACHE_ENTRIES = 50;
|
|
12
|
+
const dataCache = new Map();
|
|
13
|
+
|
|
14
|
+
function getCache(key) {
|
|
15
|
+
const hit = dataCache.get(key);
|
|
16
|
+
if (!hit) return undefined;
|
|
17
|
+
if (Date.now() - hit.ts >= CACHE_TTL) {
|
|
18
|
+
dataCache.delete(key);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return hit.data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setCache(key, data) {
|
|
25
|
+
dataCache.delete(key);
|
|
26
|
+
dataCache.set(key, { data, ts: Date.now() });
|
|
27
|
+
while (dataCache.size > MAX_CACHE_ENTRIES) dataCache.delete(dataCache.keys().next().value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Keyed by file; a hit needs the same mtime, size and pricing fetch. Holds the build promise, so
|
|
31
|
+
// concurrent requests share one parse.
|
|
32
|
+
const sessionModelCache = new Map();
|
|
33
|
+
|
|
34
|
+
function invalidateAllCache() {
|
|
35
|
+
dataCache.clear();
|
|
36
|
+
sessionModelCache.clear();
|
|
37
|
+
nestedIndexPromise = null;
|
|
38
|
+
parsers.invalidateSessionCache();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// #endregion
|
|
42
|
+
|
|
43
|
+
// #region RANGE
|
|
44
|
+
|
|
45
|
+
function localDateStr(d) {
|
|
46
|
+
const y = d.getFullYear();
|
|
47
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
48
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
49
|
+
return `${y}-${m}-${day}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function localHourStr(d) {
|
|
53
|
+
return `${localDateStr(d)}T${String(d.getHours()).padStart(2, '0')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// An hour key extends the day key, so a startsWith test on a day string matches either granularity.
|
|
57
|
+
const BUCKETS = {
|
|
58
|
+
day: { key: localDateStr, floor: (d) => d.setHours(0, 0, 0, 0), next: (d) => d.setDate(d.getDate() + 1) },
|
|
59
|
+
hour: { key: localHourStr, floor: (d) => d.setMinutes(0, 0, 0), next: (d) => d.setHours(d.getHours() + 1) },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// A range is a preset ('today', '24h', N calendar days, null for all time) or a custom window of
|
|
63
|
+
// two local YYYY-MM-DD dates.
|
|
64
|
+
function isCustomRange(range) {
|
|
65
|
+
return !!range && typeof range === 'object' && !!range.from && !!range.to;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rangeBucket(range) {
|
|
69
|
+
if (isCustomRange(range)) return range.from === range.to ? 'hour' : 'day';
|
|
70
|
+
return range === 'today' || range === '24h' ? 'hour' : 'day';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function localDayStart(dateStr) {
|
|
74
|
+
const [y, m, d] = dateStr.split('-').map(Number);
|
|
75
|
+
return new Date(y, m - 1, d, 0, 0, 0, 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function localDayEnd(dateStr) {
|
|
79
|
+
const d = localDayStart(dateStr);
|
|
80
|
+
d.setHours(23, 59, 59, 999);
|
|
81
|
+
return d;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rangeToCutoff(range, now) {
|
|
85
|
+
if (isCustomRange(range)) return localDayStart(range.from);
|
|
86
|
+
if (range === 'today') {
|
|
87
|
+
const c = new Date(now);
|
|
88
|
+
c.setHours(0, 0, 0, 0);
|
|
89
|
+
return c;
|
|
90
|
+
}
|
|
91
|
+
if (range === '24h') return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
92
|
+
if (!range) return null;
|
|
93
|
+
// N calendar days ending today, so the chart draws exactly N buckets.
|
|
94
|
+
const c = new Date(now);
|
|
95
|
+
c.setDate(c.getDate() - (range - 1));
|
|
96
|
+
c.setHours(0, 0, 0, 0);
|
|
97
|
+
return c;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Anchored to one captured `now` so the bounds of a request cannot disagree. Presets keep an open
|
|
101
|
+
// upper bound (endStr null), so a row a live session writes mid-request is not dropped.
|
|
102
|
+
function resolveWindow(range, now = new Date()) {
|
|
103
|
+
const cutoff = rangeToCutoff(range, now);
|
|
104
|
+
const end = isCustomRange(range) ? localDayEnd(range.to) : new Date(now);
|
|
105
|
+
const todayStr = localDateStr(now);
|
|
106
|
+
return {
|
|
107
|
+
now,
|
|
108
|
+
cutoff,
|
|
109
|
+
end,
|
|
110
|
+
cutoffStr: cutoff ? cutoff.toISOString() : null,
|
|
111
|
+
endStr: isCustomRange(range) ? end.toISOString() : null,
|
|
112
|
+
hasToday: (!cutoff || localDateStr(cutoff) <= todayStr) && localDateStr(end) >= todayStr,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// pi writes ISO UTC timestamps, so string order is time order.
|
|
117
|
+
function filterWindow(messages, fromStr, toStr) {
|
|
118
|
+
if (!fromStr && !toStr) return messages;
|
|
119
|
+
return messages.filter((m) => m.ts && (!fromStr || m.ts >= fromStr) && (!toStr || m.ts <= toStr));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rangeKey(range) {
|
|
123
|
+
if (isCustomRange(range)) return `${range.from}_${range.to}`;
|
|
124
|
+
return range || 'all';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const MAX_RANGE_DAYS = 3660;
|
|
128
|
+
|
|
129
|
+
// One calendar day is today, so it is returned as 'today'.
|
|
130
|
+
function parseRange(raw, fallback = null) {
|
|
131
|
+
if (raw === 'today' || raw === '24h') return raw;
|
|
132
|
+
if (raw === 'all') return null;
|
|
133
|
+
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return fallback;
|
|
134
|
+
const n = Number(raw);
|
|
135
|
+
if (n === 0) return null;
|
|
136
|
+
if (n === 1) return 'today';
|
|
137
|
+
return Math.min(n, MAX_RANGE_DAYS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const DATE_PARAM = /^\d{4}-\d{2}-\d{2}$/;
|
|
141
|
+
|
|
142
|
+
// undefined for absent, null for malformed.
|
|
143
|
+
function parseDateParam(raw) {
|
|
144
|
+
if (raw === undefined || raw === '') return undefined;
|
|
145
|
+
if (typeof raw !== 'string' || !DATE_PARAM.test(raw)) return null;
|
|
146
|
+
return localDateStr(localDayStart(raw)) === raw ? raw : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// `from`/`to` win over `range`. A reversed pair is ordered and both ends are clamped to today.
|
|
150
|
+
// undefined when no window was asked for, null when one was but is malformed.
|
|
151
|
+
function parseCustomRange(query) {
|
|
152
|
+
const from = parseDateParam(query.from);
|
|
153
|
+
const to = parseDateParam(query.to);
|
|
154
|
+
if (from === null || to === null) return null;
|
|
155
|
+
if (from === undefined && to === undefined) return undefined;
|
|
156
|
+
if (from === undefined || to === undefined) return null;
|
|
157
|
+
const today = localDateStr(new Date());
|
|
158
|
+
const [lo, hi] = from <= to ? [from, to] : [to, from];
|
|
159
|
+
return { from: lo > today ? today : lo, to: hi > today ? today : hi };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// An encoded project dir. Only used as a key, never joined into a path.
|
|
163
|
+
function parseProject(raw) {
|
|
164
|
+
if (raw === undefined || raw === '') return undefined;
|
|
165
|
+
if (typeof raw !== 'string' || raw.length > 1024 || /[\u0000-\u001f]/.test(raw)) return null;
|
|
166
|
+
return raw;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// #endregion
|
|
170
|
+
|
|
171
|
+
// #region SESSION_MODEL
|
|
172
|
+
|
|
173
|
+
const stripEffort = (m) => (m ? String(m).replace(/:(low|medium|high|xhigh|minimal|off)$/, '') : m);
|
|
174
|
+
const tokensOf = (m) => m.input + m.output + m.cacheRead + m.cacheWrite;
|
|
175
|
+
|
|
176
|
+
// pi-subagents records a run's model as `<provider>/<model id>`; the id itself may hold a slash.
|
|
177
|
+
function splitRunModel(raw) {
|
|
178
|
+
const i = raw ? raw.indexOf('/') : -1;
|
|
179
|
+
return i > 0 ? { provider: raw.slice(0, i), model: raw.slice(i + 1) } : { provider: null, model: raw || null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const IO_CONCURRENCY = 16;
|
|
183
|
+
|
|
184
|
+
// Runs fn over items with at most `limit` in flight; results keep the input order.
|
|
185
|
+
async function mapLimit(items, limit, fn) {
|
|
186
|
+
const out = new Array(items.length);
|
|
187
|
+
let next = 0;
|
|
188
|
+
const worker = async () => {
|
|
189
|
+
while (next < items.length) {
|
|
190
|
+
const i = next++;
|
|
191
|
+
out[i] = await fn(items[i]);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Nested subagent session file (by pathKey) -> its session id, so a run row can link to it.
|
|
199
|
+
let nestedIndexPromise = null;
|
|
200
|
+
function loadNestedIndex() {
|
|
201
|
+
if (nestedIndexPromise) return nestedIndexPromise;
|
|
202
|
+
nestedIndexPromise = (async () => {
|
|
203
|
+
const files = await parsers.listNestedSubagentFiles();
|
|
204
|
+
const map = new Map();
|
|
205
|
+
await mapLimit(files, IO_CONCURRENCY, async (f) => {
|
|
206
|
+
const header = await parsers.readSessionHeader(f.file);
|
|
207
|
+
if (header) map.set(parsers.pathKey(f.file), { ...f, id: header.id });
|
|
208
|
+
});
|
|
209
|
+
return map;
|
|
210
|
+
})();
|
|
211
|
+
return nestedIndexPromise;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function usageRow(u, est, pricingMap) {
|
|
215
|
+
return {
|
|
216
|
+
ts: u.ts,
|
|
217
|
+
provider: u.provider || null,
|
|
218
|
+
model: u.model || null,
|
|
219
|
+
input: u.usage.input || 0,
|
|
220
|
+
output: u.usage.output || 0,
|
|
221
|
+
cacheRead: u.usage.cacheRead || 0,
|
|
222
|
+
cacheWrite: u.usage.cacheWrite || 0,
|
|
223
|
+
cost: est.cost,
|
|
224
|
+
costSource: est.source,
|
|
225
|
+
modelKey: est.modelKey,
|
|
226
|
+
cacheSavings: pricing.cacheReadSaving(pricingMap, u.provider, u.model, u.usage),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// One session, independent of any range: every billed row (assistant turn, compaction summary,
|
|
231
|
+
// inline subagent run) in time order with a running total.
|
|
232
|
+
async function buildSessionModel(meta, { pricingMap, nestedIndex }) {
|
|
233
|
+
const entries = await parsers.readSessionEntriesUncached(meta.file);
|
|
234
|
+
const summary = parsers.summarize(entries);
|
|
235
|
+
const rows = [];
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
|
|
238
|
+
for (const u of parsers.enumerateMessageUsages(entries)) {
|
|
239
|
+
if (u.messageId) {
|
|
240
|
+
if (seen.has(u.messageId)) continue;
|
|
241
|
+
seen.add(u.messageId);
|
|
242
|
+
}
|
|
243
|
+
const row = usageRow(u, pricing.estimate(pricingMap, u.provider, u.model, u.usage), pricingMap);
|
|
244
|
+
if (u.compaction) row.compaction = u.compaction;
|
|
245
|
+
if (u.tools?.length) row.tools = u.tools;
|
|
246
|
+
if (u.durationMs) row.durationMs = u.durationMs;
|
|
247
|
+
rows.push(row);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const r of parsers.enumerateSubagentRuns(entries)) {
|
|
251
|
+
const { provider, model } = splitRunModel(stripEffort(r.model));
|
|
252
|
+
const u = { ts: r.ts, provider, model, usage: r.usage };
|
|
253
|
+
const row = usageRow(u, pricing.estimate(pricingMap, provider, model, r.usage), pricingMap);
|
|
254
|
+
const nested = r.jsonlPath ? nestedIndex.get(parsers.pathKey(r.jsonlPath)) : null;
|
|
255
|
+
row.subagent = {
|
|
256
|
+
agent: r.agent,
|
|
257
|
+
task: r.task,
|
|
258
|
+
sessionId: nested ? nested.id : null,
|
|
259
|
+
turns: r.turns,
|
|
260
|
+
runId: r.runId,
|
|
261
|
+
exitCode: r.exitCode,
|
|
262
|
+
};
|
|
263
|
+
rows.push(row);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
rows.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
|
|
267
|
+
let cumulative = 0;
|
|
268
|
+
rows.forEach((m, i) => {
|
|
269
|
+
cumulative += m.cost;
|
|
270
|
+
m.index = i + 1;
|
|
271
|
+
m.cumulativeCost = cumulative;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const header = summary.sessionEntry || {};
|
|
275
|
+
const cwd = header.cwd || meta.cwd;
|
|
276
|
+
return {
|
|
277
|
+
id: header.id || parsers.slugFromFile(meta.file),
|
|
278
|
+
projectDir: meta.projectDir,
|
|
279
|
+
cwd,
|
|
280
|
+
projectName: path.basename(cwd) || cwd,
|
|
281
|
+
jsonlPath: meta.file,
|
|
282
|
+
parentSession: summary.parentSession || null,
|
|
283
|
+
customTitle: summary.customTitle,
|
|
284
|
+
firstUserText: summary.firstUserText,
|
|
285
|
+
provider: summary.provider,
|
|
286
|
+
model: summary.model,
|
|
287
|
+
firstTimestamp: summary.firstTimestamp,
|
|
288
|
+
lastTimestamp: summary.lastTimestamp || meta.mtime.toISOString(),
|
|
289
|
+
compactionEvents: [...parsers.enumerateCompactions(entries)],
|
|
290
|
+
messages: rows,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const MTIME_SLACK_MS = 24 * 3600 * 1000;
|
|
295
|
+
const PARSE_CONCURRENCY = 8;
|
|
296
|
+
|
|
297
|
+
async function modelContext() {
|
|
298
|
+
const pricingMap = await pricing.fetchPricing();
|
|
299
|
+
return { pricingMap, stamp: pricing.getMeta().fetchedAt, nestedIndex: await loadNestedIndex() };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sessionModel(meta, ctx) {
|
|
303
|
+
const key = `${meta.mtimeMs}:${meta.size}:${ctx.stamp}`;
|
|
304
|
+
const hit = sessionModelCache.get(meta.file);
|
|
305
|
+
if (hit && hit.key === key) return hit.promise;
|
|
306
|
+
const promise = buildSessionModel(meta, ctx).catch((err) => {
|
|
307
|
+
console.warn('[pi-cost] failed to read', meta.file, err.message);
|
|
308
|
+
if (sessionModelCache.get(meta.file)?.promise === promise) sessionModelCache.delete(meta.file);
|
|
309
|
+
return null;
|
|
310
|
+
});
|
|
311
|
+
sessionModelCache.set(meta.file, { key, promise });
|
|
312
|
+
return promise;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Sessions whose file was last written before the cutoff cannot hold rows inside the window.
|
|
316
|
+
async function loadSessions(cutoff) {
|
|
317
|
+
const ctx = await modelContext();
|
|
318
|
+
const files = await parsers.listSessionFiles();
|
|
319
|
+
const cutoffMs = cutoff ? cutoff.getTime() - MTIME_SLACK_MS : 0;
|
|
320
|
+
const eligible = files.filter((f) => f.mtimeMs >= cutoffMs);
|
|
321
|
+
return (await mapLimit(eligible, PARSE_CONCURRENCY, (meta) => sessionModel(meta, ctx))).filter(Boolean);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function findSessionMeta(id, top, headers, nestedIndex) {
|
|
325
|
+
const bySlug = top.find((f) => parsers.slugFromFile(f.file) === id);
|
|
326
|
+
if (bySlug) return bySlug;
|
|
327
|
+
for (const entry of nestedIndex.values()) if (entry.id === id) return entry;
|
|
328
|
+
const i = headers.findIndex((h) => h?.id === id);
|
|
329
|
+
return i === -1 ? null : top[i];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// #endregion
|
|
333
|
+
|
|
334
|
+
// #region AGGREGATION
|
|
335
|
+
|
|
336
|
+
function summarizeMessages(msgs) {
|
|
337
|
+
const sum = { messageCount: msgs.length, totalCost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
338
|
+
for (const m of msgs) {
|
|
339
|
+
sum.totalCost += m.cost;
|
|
340
|
+
sum.input += m.input;
|
|
341
|
+
sum.output += m.output;
|
|
342
|
+
sum.cacheRead += m.cacheRead;
|
|
343
|
+
sum.cacheWrite += m.cacheWrite;
|
|
344
|
+
}
|
|
345
|
+
sum.totalTokens = sum.input + sum.output + sum.cacheRead + sum.cacheWrite;
|
|
346
|
+
return sum;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Share of input tokens served from cache.
|
|
350
|
+
function cacheEfficiency(sum) {
|
|
351
|
+
const inputAll = sum.input + sum.cacheRead + sum.cacheWrite;
|
|
352
|
+
return inputAll > 0 ? sum.cacheRead / inputAll : 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Only compactions that recorded usage are billed rows; tokensBefore is what the summary replaced.
|
|
356
|
+
function summarizeCompactions(msgs) {
|
|
357
|
+
const sum = { count: 0, totalCost: 0, tokensBefore: 0 };
|
|
358
|
+
for (const m of msgs) {
|
|
359
|
+
if (!m.compaction) continue;
|
|
360
|
+
sum.count++;
|
|
361
|
+
sum.totalCost += m.cost;
|
|
362
|
+
sum.tokensBefore += m.compaction.tokensBefore;
|
|
363
|
+
}
|
|
364
|
+
return sum;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function countBy(msgs, keyFn) {
|
|
368
|
+
const counts = {};
|
|
369
|
+
for (const m of msgs) {
|
|
370
|
+
const k = keyFn(m);
|
|
371
|
+
if (k) counts[k] = (counts[k] || 0) + 1;
|
|
372
|
+
}
|
|
373
|
+
return counts;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function topKey(counts) {
|
|
377
|
+
let best = null;
|
|
378
|
+
for (const [k, n] of Object.entries(counts)) if (!best || n > counts[best]) best = k;
|
|
379
|
+
return best;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function addBucketModelCost(acc, key, model, cost) {
|
|
383
|
+
const mk = model || 'other';
|
|
384
|
+
acc[key] ||= {};
|
|
385
|
+
acc[key][mk] = (acc[key][mk] || 0) + cost;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Gap-filled so empty buckets stay visible. The bucket total is derived from the per-model map.
|
|
389
|
+
function buildCostSeries(modelCostsByBucket, start, end, bucket) {
|
|
390
|
+
const b = BUCKETS[bucket];
|
|
391
|
+
const points = [];
|
|
392
|
+
if (Object.keys(modelCostsByBucket).length === 0) return points;
|
|
393
|
+
const d = new Date(start);
|
|
394
|
+
b.floor(d);
|
|
395
|
+
for (; d <= end; b.next(d)) {
|
|
396
|
+
const k = b.key(d);
|
|
397
|
+
const byModel = modelCostsByBucket[k] || {};
|
|
398
|
+
points.push({ key: k, cost: Object.values(byModel).reduce((s, v) => s + v, 0), byModel });
|
|
399
|
+
}
|
|
400
|
+
return points;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function buildModelDistribution(modelCosts) {
|
|
404
|
+
return Object.entries(modelCosts)
|
|
405
|
+
.filter(([, cost]) => cost > 0)
|
|
406
|
+
.map(([model, cost]) => ({ model, cost }))
|
|
407
|
+
.sort((a, b) => b.cost - a.cost);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Series start for the all-time range: the first bucket that has cost.
|
|
411
|
+
function seriesStart(w, bucketCosts) {
|
|
412
|
+
if (w.cutoff) return w.cutoff;
|
|
413
|
+
const first = Object.keys(bucketCosts).sort()[0];
|
|
414
|
+
return first ? localDayStart(first.slice(0, 10)) : w.now;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function sessionRow(session, msgs) {
|
|
418
|
+
const sum = summarizeMessages(msgs);
|
|
419
|
+
const sub = msgs.filter((m) => m.subagent);
|
|
420
|
+
const durationMs = session.firstTimestamp && session.lastTimestamp
|
|
421
|
+
? new Date(session.lastTimestamp) - new Date(session.firstTimestamp)
|
|
422
|
+
: 0;
|
|
423
|
+
return {
|
|
424
|
+
id: session.id,
|
|
425
|
+
projectDir: session.projectDir,
|
|
426
|
+
projectName: session.projectName,
|
|
427
|
+
cwd: session.cwd,
|
|
428
|
+
customTitle: session.customTitle,
|
|
429
|
+
firstUserText: session.firstUserText,
|
|
430
|
+
provider: session.provider,
|
|
431
|
+
model: session.model,
|
|
432
|
+
models: [...new Set(msgs.map((m) => m.model).filter(Boolean))],
|
|
433
|
+
primaryModel: topKey(countBy(msgs, (m) => m.model)) || session.model || 'unknown',
|
|
434
|
+
...sum,
|
|
435
|
+
sourceCounts: { jsonl: 0, pricing: 0, none: 0, ...countBy(msgs, (m) => m.costSource) },
|
|
436
|
+
firstTimestamp: session.firstTimestamp,
|
|
437
|
+
lastTimestamp: msgs.at(-1)?.ts || session.lastTimestamp,
|
|
438
|
+
durationMinutes: Math.round(Math.max(0, durationMs) / 60000),
|
|
439
|
+
compactions: summarizeCompactions(msgs),
|
|
440
|
+
subagentCount: sub.length,
|
|
441
|
+
subagentCost: sub.reduce((s, m) => s + m.cost, 0),
|
|
442
|
+
subagentTokens: sub.reduce((s, m) => s + tokensOf(m), 0),
|
|
443
|
+
parentSession: session.parentSession,
|
|
444
|
+
jsonlPath: session.jsonlPath,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Rows scoped to the window, one per session that has any.
|
|
449
|
+
async function windowedSessions(range, projectFilter) {
|
|
450
|
+
const w = resolveWindow(range);
|
|
451
|
+
const sessions = await loadSessions(w.cutoff);
|
|
452
|
+
const out = [];
|
|
453
|
+
for (const s of sessions) {
|
|
454
|
+
if (projectFilter && s.projectDir !== projectFilter) continue;
|
|
455
|
+
const msgs = filterWindow(s.messages, w.cutoffStr, w.endStr);
|
|
456
|
+
if (msgs.length) out.push({ session: s, msgs });
|
|
457
|
+
}
|
|
458
|
+
return { w, scoped: out };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function seriesAndModels(scoped, range, w) {
|
|
462
|
+
const bucket = rangeBucket(range);
|
|
463
|
+
const bucketKey = BUCKETS[bucket].key;
|
|
464
|
+
const bucketCosts = {};
|
|
465
|
+
const modelCosts = {};
|
|
466
|
+
for (const { msgs } of scoped) {
|
|
467
|
+
for (const m of msgs) {
|
|
468
|
+
addBucketModelCost(bucketCosts, bucketKey(new Date(m.ts)), m.model, m.cost);
|
|
469
|
+
if (m.model) modelCosts[m.model] = (modelCosts[m.model] || 0) + m.cost;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
costSeries: { bucket, points: buildCostSeries(bucketCosts, seriesStart(w, bucketCosts), w.end, bucket) },
|
|
474
|
+
modelDistribution: buildModelDistribution(modelCosts),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function projectSummaries(scoped) {
|
|
479
|
+
const projects = new Map();
|
|
480
|
+
for (const { session, msgs } of scoped) {
|
|
481
|
+
let p = projects.get(session.projectDir);
|
|
482
|
+
if (!p) {
|
|
483
|
+
p = { projectDir: session.projectDir, cwd: session.cwd, name: session.projectName, totalCost: 0, sessions: 0, totalTokens: 0, lastActive: null, modelCosts: {} };
|
|
484
|
+
projects.set(session.projectDir, p);
|
|
485
|
+
}
|
|
486
|
+
const sum = summarizeMessages(msgs);
|
|
487
|
+
p.totalCost += sum.totalCost;
|
|
488
|
+
p.totalTokens += sum.totalTokens;
|
|
489
|
+
p.sessions++;
|
|
490
|
+
const last = msgs.at(-1).ts;
|
|
491
|
+
if (!p.lastActive || last > p.lastActive) p.lastActive = last;
|
|
492
|
+
for (const m of msgs) if (m.model) p.modelCosts[m.model] = (p.modelCosts[m.model] || 0) + m.cost;
|
|
493
|
+
}
|
|
494
|
+
return [...projects.values()].map(({ modelCosts, ...p }) => {
|
|
495
|
+
const models = Object.keys(modelCosts).sort((a, b) => modelCosts[b] - modelCosts[a]);
|
|
496
|
+
return { ...p, models, primaryModel: models[0] || 'unknown' };
|
|
497
|
+
}).sort((a, b) => b.totalCost - a.totalCost);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function cached(key, build) {
|
|
501
|
+
const hit = getCache(key);
|
|
502
|
+
if (hit !== undefined) return hit;
|
|
503
|
+
const data = await build();
|
|
504
|
+
setCache(key, data);
|
|
505
|
+
return data;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function getOverviewData(range, projectFilter) {
|
|
509
|
+
return cached(`overview_${rangeKey(range)}_${projectFilter || 'all'}`, async () => {
|
|
510
|
+
const { w, scoped } = await windowedSessions(range, projectFilter);
|
|
511
|
+
const { costSeries, modelDistribution } = seriesAndModels(scoped, range, w);
|
|
512
|
+
const all = scoped.flatMap((s) => s.msgs);
|
|
513
|
+
const sum = summarizeMessages(all);
|
|
514
|
+
const todayStr = localDateStr(w.now);
|
|
515
|
+
const todayCost = w.hasToday
|
|
516
|
+
? all.filter((m) => localDateStr(new Date(m.ts)) === todayStr).reduce((s, m) => s + m.cost, 0)
|
|
517
|
+
: null;
|
|
518
|
+
return {
|
|
519
|
+
summary: {
|
|
520
|
+
totalCost: sum.totalCost,
|
|
521
|
+
todayCost,
|
|
522
|
+
totalSessions: scoped.length,
|
|
523
|
+
totalMessages: sum.messageCount,
|
|
524
|
+
totalInput: sum.input,
|
|
525
|
+
totalOutput: sum.output,
|
|
526
|
+
totalCacheRead: sum.cacheRead,
|
|
527
|
+
totalCacheWrite: sum.cacheWrite,
|
|
528
|
+
totalTokens: sum.totalTokens,
|
|
529
|
+
cacheEfficiency: cacheEfficiency(sum),
|
|
530
|
+
sourceCounts: { jsonl: 0, pricing: 0, none: 0, ...countBy(all, (m) => m.costSource) },
|
|
531
|
+
},
|
|
532
|
+
costSeries,
|
|
533
|
+
modelDistribution,
|
|
534
|
+
projects: projectSummaries(scoped),
|
|
535
|
+
pricingMeta: pricing.getMeta(),
|
|
536
|
+
generatedAt: new Date().toISOString(),
|
|
537
|
+
};
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function getProjectsData(range) {
|
|
542
|
+
return cached(`projects_${rangeKey(range)}`, async () => {
|
|
543
|
+
const { scoped } = await windowedSessions(range);
|
|
544
|
+
return { projects: projectSummaries(scoped) };
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// One row per scoped session. Forked sessions point at their parent's file through `parentSession`.
|
|
549
|
+
function sessionRows(scoped) {
|
|
550
|
+
const rows = scoped.map(({ session, msgs }) => sessionRow(session, msgs));
|
|
551
|
+
const byParent = new Map();
|
|
552
|
+
for (const r of rows) {
|
|
553
|
+
if (!r.parentSession) continue;
|
|
554
|
+
const k = parsers.pathKey(r.parentSession);
|
|
555
|
+
if (!byParent.has(k)) byParent.set(k, []);
|
|
556
|
+
byParent.get(k).push(r);
|
|
557
|
+
}
|
|
558
|
+
for (const r of rows) {
|
|
559
|
+
const kids = byParent.get(parsers.pathKey(r.jsonlPath)) || [];
|
|
560
|
+
r.childIds = kids.map((k) => k.id);
|
|
561
|
+
r.childCost = kids.reduce((s, k) => s + k.totalCost, 0);
|
|
562
|
+
}
|
|
563
|
+
return rows;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function getProjectSessionsData(projectDir, range) {
|
|
567
|
+
return cached(`sessions_${projectDir}_${rangeKey(range)}`, async () => {
|
|
568
|
+
const { w, scoped } = await windowedSessions(range, projectDir);
|
|
569
|
+
const { costSeries, modelDistribution } = seriesAndModels(scoped, range, w);
|
|
570
|
+
const sessions = sessionRows(scoped).sort((a, b) => (b.lastTimestamp || '').localeCompare(a.lastTimestamp || ''));
|
|
571
|
+
return { project: projectSummaries(scoped)[0] || null, sessions, costSeries, modelDistribution };
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function getAllSessionsData(range, projectFilter) {
|
|
576
|
+
return cached(`sessions_all_${rangeKey(range)}_${projectFilter || 'all'}`, async () => {
|
|
577
|
+
const { scoped } = await windowedSessions(range, projectFilter);
|
|
578
|
+
return { sessions: sessionRows(scoped).sort((a, b) => b.totalCost - a.totalCost) };
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// pi-subagents runs grouped by agent name.
|
|
583
|
+
function getAgentsData(range, projectFilter) {
|
|
584
|
+
return cached(`agents_${rangeKey(range)}_${projectFilter || 'all'}`, async () => {
|
|
585
|
+
const { scoped } = await windowedSessions(range, projectFilter);
|
|
586
|
+
const groups = new Map();
|
|
587
|
+
for (const { session, msgs } of scoped) {
|
|
588
|
+
for (const m of msgs) {
|
|
589
|
+
if (!m.subagent) continue;
|
|
590
|
+
const name = m.subagent.agent || 'unknown';
|
|
591
|
+
let g = groups.get(name);
|
|
592
|
+
if (!g) groups.set(name, (g = { name, runs: 0, totalCost: 0, totalTokens: 0, failures: 0, sessions: new Set(), models: new Set(), costliest: null }));
|
|
593
|
+
g.runs++;
|
|
594
|
+
g.totalCost += m.cost;
|
|
595
|
+
g.totalTokens += tokensOf(m);
|
|
596
|
+
if (m.subagent.exitCode) g.failures++;
|
|
597
|
+
if (m.model) g.models.add(m.model);
|
|
598
|
+
g.sessions.add(session.id);
|
|
599
|
+
if (!g.costliest || m.cost > g.costliest.cost) {
|
|
600
|
+
g.costliest = { cost: m.cost, sessionId: session.id, runSessionId: m.subagent.sessionId, task: m.subagent.task, projectName: session.projectName };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const agents = [...groups.values()]
|
|
605
|
+
.map(({ sessions, models, ...g }) => ({ ...g, models: [...models], sessionCount: sessions.size, avgCost: g.totalCost / g.runs }))
|
|
606
|
+
.sort((a, b) => b.totalCost - a.totalCost);
|
|
607
|
+
return { agents };
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Parent and forks come from the session headers, so only the session and its relatives are parsed.
|
|
612
|
+
function getSessionDetailData(id) {
|
|
613
|
+
return cached(`detail_${id}`, async () => {
|
|
614
|
+
const ctx = await modelContext();
|
|
615
|
+
const top = await parsers.listSessionFiles();
|
|
616
|
+
const headers = await mapLimit(top, IO_CONCURRENCY, (f) => parsers.readSessionHeader(f.file));
|
|
617
|
+
const meta = findSessionMeta(id, top, headers, ctx.nestedIndex);
|
|
618
|
+
if (!meta) return null;
|
|
619
|
+
const session = await sessionModel(meta, ctx);
|
|
620
|
+
if (!session) return null;
|
|
621
|
+
const self = parsers.pathKey(session.jsonlPath);
|
|
622
|
+
const parentKey = session.parentSession ? parsers.pathKey(session.parentSession) : null;
|
|
623
|
+
const childMetas = top.filter((f, i) => headers[i]?.parentSession && parsers.pathKey(headers[i].parentSession) === self);
|
|
624
|
+
const parentMeta = parentKey ? top.find((f) => parsers.pathKey(f.file) === parentKey) : null;
|
|
625
|
+
const [children, parent] = await Promise.all([
|
|
626
|
+
Promise.all(childMetas.map((m) => sessionModel(m, ctx))),
|
|
627
|
+
parentMeta ? sessionModel(parentMeta, ctx) : null,
|
|
628
|
+
]);
|
|
629
|
+
return {
|
|
630
|
+
...sessionRow(session, session.messages),
|
|
631
|
+
encodedProjectDir: encodeURIComponent(session.projectDir),
|
|
632
|
+
parent: parent ? { id: parent.id, title: parent.customTitle || parent.firstUserText || parent.id } : null,
|
|
633
|
+
compactionEvents: session.compactionEvents,
|
|
634
|
+
childSessions: children.filter(Boolean).map((c) => sessionRow(c, c.messages)),
|
|
635
|
+
messages: session.messages,
|
|
636
|
+
pricingMeta: pricing.getMeta(),
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// The detail view is the whole session. `inRange` / `today` reconcile it with the range-scoped
|
|
642
|
+
// row that linked here. Derived outside the cache so switching ranges does not re-read the file.
|
|
643
|
+
function withSessionSlices(base, range) {
|
|
644
|
+
const w = resolveWindow(range);
|
|
645
|
+
const slice = (fromStr) => summarizeMessages(filterWindow(base.messages, fromStr, w.endStr));
|
|
646
|
+
const inRange = w.cutoffStr || w.endStr ? slice(w.cutoffStr) : null;
|
|
647
|
+
const todayStart = rangeToCutoff('today', w.now).toISOString();
|
|
648
|
+
return {
|
|
649
|
+
...base,
|
|
650
|
+
range,
|
|
651
|
+
inRange,
|
|
652
|
+
today: w.hasToday ? slice(todayStart) : null,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// #endregion
|
|
657
|
+
|
|
658
|
+
// #region INSIGHTS
|
|
659
|
+
|
|
660
|
+
// pi has no rate-limit data, so a block is the ccusage heuristic only: it opens at the first
|
|
661
|
+
// activity floored to the hour and spans 5 h. No block is marked as the current one.
|
|
662
|
+
const BLOCK_MS = 5 * 60 * 60 * 1000;
|
|
663
|
+
const HEATMAP_DAYS = 7;
|
|
664
|
+
const MAX_BLOCKS = 24;
|
|
665
|
+
const MAX_TOOLS = 20;
|
|
666
|
+
|
|
667
|
+
function computeBlocks(messages) {
|
|
668
|
+
const blocks = [];
|
|
669
|
+
let cur = null;
|
|
670
|
+
for (const m of messages) {
|
|
671
|
+
const t = new Date(m.ts).getTime();
|
|
672
|
+
if (!Number.isFinite(t)) continue;
|
|
673
|
+
if (!cur || t >= cur.startMs + BLOCK_MS) {
|
|
674
|
+
const start = new Date(t);
|
|
675
|
+
start.setMinutes(0, 0, 0);
|
|
676
|
+
cur = { startMs: start.getTime(), lastMs: t, cost: 0, tokens: 0, messageCount: 0, models: new Set() };
|
|
677
|
+
blocks.push(cur);
|
|
678
|
+
}
|
|
679
|
+
if (t > cur.lastMs) cur.lastMs = t;
|
|
680
|
+
cur.cost += m.cost;
|
|
681
|
+
cur.tokens += tokensOf(m);
|
|
682
|
+
cur.messageCount++;
|
|
683
|
+
if (m.model) cur.models.add(m.model);
|
|
684
|
+
}
|
|
685
|
+
return blocks.reverse().slice(0, MAX_BLOCKS).map((b) => ({
|
|
686
|
+
start: new Date(b.startMs).toISOString(),
|
|
687
|
+
end: new Date(b.startMs + BLOCK_MS).toISOString(),
|
|
688
|
+
lastActivity: new Date(b.lastMs).toISOString(),
|
|
689
|
+
cost: b.cost,
|
|
690
|
+
tokens: b.tokens,
|
|
691
|
+
messageCount: b.messageCount,
|
|
692
|
+
models: [...b.models],
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function getInsightsData(range, projectFilter) {
|
|
697
|
+
return cached(`insights_${rangeKey(range)}_${projectFilter || 'all'}`, async () => {
|
|
698
|
+
const w = resolveWindow(range);
|
|
699
|
+
// The heatmap is the last 7 real days ending at the window end, so a short range keeps a week of context.
|
|
700
|
+
const windowEnd = w.end < w.now ? w.end : w.now;
|
|
701
|
+
const hmStart = new Date(windowEnd);
|
|
702
|
+
hmStart.setHours(0, 0, 0, 0);
|
|
703
|
+
hmStart.setDate(hmStart.getDate() - (HEATMAP_DAYS - 1));
|
|
704
|
+
const hmStartStr = hmStart.toISOString();
|
|
705
|
+
const scanCutoff = w.cutoff === null ? null : w.cutoff < hmStart ? w.cutoff : hmStart;
|
|
706
|
+
const sessions = (await loadSessions(scanCutoff)).filter((s) => !projectFilter || s.projectDir === projectFilter);
|
|
707
|
+
|
|
708
|
+
const byTs = (a, b) => a.ts.localeCompare(b.ts);
|
|
709
|
+
const all = sessions.flatMap((s) => filterWindow(s.messages, w.cutoffStr, w.endStr)).sort(byTs);
|
|
710
|
+
const hmMsgs = sessions.flatMap((s) => filterWindow(s.messages, hmStartStr, w.endStr));
|
|
711
|
+
|
|
712
|
+
const sum = summarizeMessages(all);
|
|
713
|
+
const heatmap = Array.from({ length: HEATMAP_DAYS }, () => new Array(24).fill(0));
|
|
714
|
+
const heatmapDays = Array.from({ length: HEATMAP_DAYS }, (_, i) => {
|
|
715
|
+
const d = new Date(hmStart);
|
|
716
|
+
d.setDate(d.getDate() + i);
|
|
717
|
+
return localDateStr(d);
|
|
718
|
+
});
|
|
719
|
+
const toolCounts = {};
|
|
720
|
+
let cacheSavings = 0;
|
|
721
|
+
let mainCost = 0;
|
|
722
|
+
let subagentCost = 0;
|
|
723
|
+
let subagentRuns = 0;
|
|
724
|
+
const weekly = {};
|
|
725
|
+
|
|
726
|
+
for (const m of all) {
|
|
727
|
+
cacheSavings += m.cacheSavings || 0;
|
|
728
|
+
const monday = new Date(m.ts);
|
|
729
|
+
monday.setHours(0, 0, 0, 0);
|
|
730
|
+
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
731
|
+
const wk = localDateStr(monday);
|
|
732
|
+
weekly[wk] = (weekly[wk] || 0) + m.cost;
|
|
733
|
+
if (m.subagent) {
|
|
734
|
+
subagentCost += m.cost;
|
|
735
|
+
subagentRuns++;
|
|
736
|
+
} else {
|
|
737
|
+
mainCost += m.cost;
|
|
738
|
+
if (m.tools) for (const t of m.tools) toolCounts[t] = (toolCounts[t] || 0) + 1;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
for (const m of hmMsgs) {
|
|
743
|
+
const d = new Date(m.ts);
|
|
744
|
+
const day = new Date(d);
|
|
745
|
+
day.setHours(0, 0, 0, 0);
|
|
746
|
+
// Rounded so a 23 h or 25 h DST day still lands on its row.
|
|
747
|
+
const idx = Math.round((day - hmStart) / 86400000);
|
|
748
|
+
if (idx >= 0 && idx < HEATMAP_DAYS) heatmap[idx][d.getHours()] += m.cost;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const windowStart = w.cutoff || (all.length ? new Date(all[0].ts) : w.now);
|
|
752
|
+
const dayCount = Math.max(1, Math.ceil((windowEnd - windowStart) / 86400000));
|
|
753
|
+
const dailyAvg = sum.totalCost / dayCount;
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
tokens: { input: sum.input, output: sum.output, cacheRead: sum.cacheRead, cacheWrite: sum.cacheWrite },
|
|
757
|
+
cacheEfficiency: cacheEfficiency(sum),
|
|
758
|
+
totalCost: sum.totalCost,
|
|
759
|
+
cacheSavings,
|
|
760
|
+
runRate: { dailyAvg, projectedMonthly: dailyAvg * 30, days: dayCount },
|
|
761
|
+
blocks: computeBlocks(all),
|
|
762
|
+
heatmap,
|
|
763
|
+
heatmapDays,
|
|
764
|
+
heatmapToday: localDateStr(w.now),
|
|
765
|
+
tools: Object.entries(toolCounts)
|
|
766
|
+
.map(([name, count]) => ({ name, count }))
|
|
767
|
+
.sort((a, b) => b.count - a.count)
|
|
768
|
+
.slice(0, MAX_TOOLS),
|
|
769
|
+
subagents: { mainCost, subagentCost, runs: subagentRuns },
|
|
770
|
+
compactions: summarizeCompactions(all),
|
|
771
|
+
weekly: Object.entries(weekly)
|
|
772
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
773
|
+
.map(([weekStart, cost]) => ({ weekStart, cost })),
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// #endregion
|
|
779
|
+
|
|
780
|
+
module.exports = {
|
|
781
|
+
invalidateAllCache,
|
|
782
|
+
getOverviewData,
|
|
783
|
+
getInsightsData,
|
|
784
|
+
getProjectsData,
|
|
785
|
+
getProjectSessionsData,
|
|
786
|
+
getAllSessionsData,
|
|
787
|
+
getAgentsData,
|
|
788
|
+
getSessionDetailData,
|
|
789
|
+
withSessionSlices,
|
|
790
|
+
parseRange,
|
|
791
|
+
parseCustomRange,
|
|
792
|
+
parseProject,
|
|
793
|
+
resolveWindow,
|
|
794
|
+
rangeBucket,
|
|
795
|
+
filterWindow,
|
|
796
|
+
buildCostSeries,
|
|
797
|
+
computeBlocks,
|
|
798
|
+
localDateStr,
|
|
799
|
+
};
|