pi-cost 0.1.1 → 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 +10 -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 -660
- 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/cli.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const costs = require('./costs');
|
|
4
|
+
const parsers = require('./pi-parsers-cost');
|
|
5
|
+
const { readCostSettings, readServerRecord } = require('./cost-dir');
|
|
6
|
+
|
|
7
|
+
// #region WINDOW
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RANGE = '7d';
|
|
10
|
+
const LONG_PARSE_DAYS = 30;
|
|
11
|
+
const MAX_DAYS = 365;
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const WINDOW_HINT = `today, 24h or <n>d up to ${MAX_DAYS}d`;
|
|
14
|
+
|
|
15
|
+
// The server's range values, without all time, and at most MAX_DAYS days.
|
|
16
|
+
function parseWindowValue(raw) {
|
|
17
|
+
const range = costs.parseRange(String(raw).trim().toLowerCase().replace(/^(\d+)d$/, '$1'));
|
|
18
|
+
if (range === 'today') return { range, days: 1, label: 'Today' };
|
|
19
|
+
if (range === '24h') return { range, days: 1, label: 'Last 24 hours' };
|
|
20
|
+
if (!Number.isInteger(range) || range > MAX_DAYS) return null;
|
|
21
|
+
return { range, days: range, label: `Last ${range} days` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function customWindow(from, to, days) {
|
|
25
|
+
return { range: { from, to }, days, label: from === to ? from : `${from} – ${to}`, query: `from=${from}&to=${to}` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The same number of whole days, ending the day before `win` starts. The API has no rolling window
|
|
29
|
+
// that ends in the past, so 24h has no window before it.
|
|
30
|
+
function previousWindow(win, now = new Date()) {
|
|
31
|
+
if (win.range === '24h') throw usageError('--compare needs today, <n>d or --from/--to, not 24h');
|
|
32
|
+
const start = costs.resolveWindow(win.range, now).cutoff;
|
|
33
|
+
const day = (delta) => costs.localDateStr(new Date(start.getFullYear(), start.getMonth(), start.getDate() + delta));
|
|
34
|
+
return customWindow(day(-win.days), day(-1), win.days);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The flag wins, then PI_COST_RANGE, then cli.defaultRange in <agent dir>/cost/settings.json, then
|
|
38
|
+
// 7 days. A bad flag is an error; a bad env or settings value is skipped with a warning, so a typo
|
|
39
|
+
// in a file does not break every command.
|
|
40
|
+
function resolveWindow(flags, { costDir, env = process.env, warn = console.error }) {
|
|
41
|
+
if (flags.from !== undefined || flags.to !== undefined) {
|
|
42
|
+
const custom = costs.parseCustomRange({ from: flags.from, to: flags.to });
|
|
43
|
+
if (!custom) throw usageError('--from and --to take local dates (YYYY-MM-DD) and come as a pair');
|
|
44
|
+
const days = Math.round((Date.parse(custom.to) - Date.parse(custom.from)) / DAY_MS) + 1;
|
|
45
|
+
if (days > MAX_DAYS) throw usageError(`--from/--to: at most ${MAX_DAYS} days`);
|
|
46
|
+
return customWindow(custom.from, custom.to, days);
|
|
47
|
+
}
|
|
48
|
+
const sources = [
|
|
49
|
+
[flags.range, '--range'],
|
|
50
|
+
[env.PI_COST_RANGE, 'PI_COST_RANGE'],
|
|
51
|
+
[readCostSettings(costDir).cli?.defaultRange, 'cli.defaultRange'],
|
|
52
|
+
[DEFAULT_RANGE, 'default'],
|
|
53
|
+
];
|
|
54
|
+
for (const [raw, source] of sources) {
|
|
55
|
+
if (raw === undefined || raw === null || raw === '') continue;
|
|
56
|
+
const w = parseWindowValue(raw);
|
|
57
|
+
if (w) return { ...w, source, query: `range=${w.range}` };
|
|
58
|
+
if (source === '--range') throw usageError(`--range ${raw}: use ${WINDOW_HINT}`);
|
|
59
|
+
warn(`${source}: "${raw}" is not a window (${WINDOW_HINT}); skipped`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// #endregion
|
|
64
|
+
|
|
65
|
+
// #region BACKEND
|
|
66
|
+
|
|
67
|
+
const rootsKey = (roots) => roots.map((r) => parsers.pathKey(r.dir)).join('|');
|
|
68
|
+
|
|
69
|
+
// A server of the same agent dir can read other session roots (--session-dir), so its roots must
|
|
70
|
+
// match ours.
|
|
71
|
+
function findServer(costDir, env = process.env, roots = parsers.getSessionRoots()) {
|
|
72
|
+
if (env.COST_URL) return env.COST_URL.replace(/\/+$/, '');
|
|
73
|
+
const rec = readServerRecord(costDir);
|
|
74
|
+
if (!rec) return null;
|
|
75
|
+
if (Array.isArray(rec.sessionRoots) && rootsKey(rec.sessionRoots) !== rootsKey(roots)) return null;
|
|
76
|
+
return `http://localhost:${rec.port}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const enc = encodeURIComponent;
|
|
80
|
+
|
|
81
|
+
const scoped = (route) => (p) => `${route}?${p.win.query}${p.project ? `&project=${enc(p.project)}` : ''}`;
|
|
82
|
+
|
|
83
|
+
const ENDPOINTS = {
|
|
84
|
+
overview: {
|
|
85
|
+
path: scoped('/api/overview'),
|
|
86
|
+
local: (c, p) => c.getOverviewData(p.win.range, p.project),
|
|
87
|
+
},
|
|
88
|
+
insights: {
|
|
89
|
+
path: scoped('/api/insights'),
|
|
90
|
+
local: (c, p) => c.getInsightsData(p.win.range, p.project),
|
|
91
|
+
},
|
|
92
|
+
projects: {
|
|
93
|
+
path: (p) => `/api/projects?${p.win.query}`,
|
|
94
|
+
local: (c, p) => c.getProjectsData(p.win.range),
|
|
95
|
+
},
|
|
96
|
+
sessions: {
|
|
97
|
+
path: scoped('/api/sessions'),
|
|
98
|
+
local: (c, p) => c.getAllSessionsData(p.win.range, p.project),
|
|
99
|
+
},
|
|
100
|
+
agents: {
|
|
101
|
+
path: scoped('/api/agents'),
|
|
102
|
+
local: (c, p) => c.getAgentsData(p.win.range, p.project),
|
|
103
|
+
},
|
|
104
|
+
session: {
|
|
105
|
+
path: (p) => `/api/sessions/${enc(p.session)}`,
|
|
106
|
+
local: async (c, p) => {
|
|
107
|
+
const base = await c.getSessionDetailData(p.session);
|
|
108
|
+
return base && c.withSessionSlices(base, null);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
class HttpError extends Error {
|
|
114
|
+
constructor(status, message) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.status = status;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchServer(base, url) {
|
|
121
|
+
const res = await fetch(base + url, { signal: AbortSignal.timeout(60_000) });
|
|
122
|
+
if (res.status === 404) return null;
|
|
123
|
+
const body = await res.json().catch(() => null);
|
|
124
|
+
if (!res.ok) throw new HttpError(res.status, body?.error || `HTTP ${res.status}`);
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Asks the server of this agent dir; with none, or one that does not answer, parses the files in
|
|
129
|
+
// this process. Notes go to stderr, so --json output stays clean.
|
|
130
|
+
async function query(name, params, ctx) {
|
|
131
|
+
const ep = ENDPOINTS[name];
|
|
132
|
+
const base = ctx.server;
|
|
133
|
+
if (base) {
|
|
134
|
+
try {
|
|
135
|
+
return await fetchServer(base, ep.path(params));
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err instanceof HttpError) throw err;
|
|
138
|
+
ctx.note(`cost server at ${base} did not answer (${err.cause?.code || err.message}); parsing files`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const win = params.win;
|
|
142
|
+
if (win && win.days > LONG_PARSE_DAYS) ctx.note(`parsing ${win.days} days of sessions, this can take a while`);
|
|
143
|
+
const started = Date.now();
|
|
144
|
+
const data = await ep.local(costs, params);
|
|
145
|
+
ctx.note(`no cost server: parsed files in ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
|
146
|
+
return data;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// #endregion
|
|
150
|
+
|
|
151
|
+
// #region FORMAT
|
|
152
|
+
|
|
153
|
+
const WIDTH = 54;
|
|
154
|
+
|
|
155
|
+
function usd(n) {
|
|
156
|
+
const v = Number(n) || 0;
|
|
157
|
+
if (v >= 1000) return `$${Math.round(v).toLocaleString('en-US')}`;
|
|
158
|
+
return v > 0 && v < 0.01 ? `$${v.toFixed(4)}` : `$${v.toFixed(2)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pct(part, total) {
|
|
162
|
+
return total > 0 ? `${Math.round((part / total) * 100)}%` : '0%';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function tokens(n) {
|
|
166
|
+
const v = Number(n) || 0;
|
|
167
|
+
if (v >= 1e9) return `${(v / 1e9).toFixed(2)}B`;
|
|
168
|
+
if (v >= 1e6) return `${(v / 1e6).toFixed(2)}M`;
|
|
169
|
+
if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
|
|
170
|
+
return String(v);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function clip(s, n) {
|
|
174
|
+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
175
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const shortModel = (m) => String(m || '?').replace(/^claude-/, '');
|
|
179
|
+
|
|
180
|
+
function localStamp(iso, withDate = true) {
|
|
181
|
+
if (!iso) return '?';
|
|
182
|
+
const d = new Date(iso);
|
|
183
|
+
const p = (x) => String(x).padStart(2, '0');
|
|
184
|
+
const time = `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
185
|
+
return withDate ? `${costs.localDateStr(d)} ${time}` : time;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function span(first, last) {
|
|
189
|
+
if (!first) return '?';
|
|
190
|
+
const sameDay = costs.localDateStr(new Date(first)) === costs.localDateStr(new Date(last));
|
|
191
|
+
return `${localStamp(first)} – ${localStamp(last, !sameDay)}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function dupNames(list) {
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
const dups = new Set();
|
|
197
|
+
for (const p of list) (seen.has(p.name) ? dups : seen).add(p.name);
|
|
198
|
+
return dups;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const headline = (win, s) => `${win.label} · ${usd(s.totalCost)} · ${s.totalSessions} sessions`;
|
|
202
|
+
|
|
203
|
+
function sessionTitle(s) {
|
|
204
|
+
return clip(s.customTitle || s.firstUserText || '(untitled)', 40);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const NAME_WIDTH = 30;
|
|
208
|
+
|
|
209
|
+
function row(left, cost, extra = '') {
|
|
210
|
+
return ` ${clip(left, NAME_WIDTH).padEnd(NAME_WIDTH)} ${usd(cost).padStart(9)}${extra ? ` ${extra}` : ''}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function bucketLabel(key, bucket) {
|
|
214
|
+
return bucket === 'hour' ? `${key.slice(0, 10)} ${key.slice(11)}:00` : key;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function topTurns(messages, top) {
|
|
218
|
+
return messages
|
|
219
|
+
.filter((m) => !m.subagent)
|
|
220
|
+
.sort((a, b) => b.cost - a.cost)
|
|
221
|
+
.slice(0, top)
|
|
222
|
+
.map((m) => {
|
|
223
|
+
const what = m.compaction ? 'compaction' : (m.tools || []).slice(0, 3).join(', ');
|
|
224
|
+
return ` #${String(m.index).padEnd(4)} ${usd(m.cost).padStart(7)} ${localStamp(m.ts, false)} ${clip(what, WIDTH - 24)}`;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function section(lines, title, items, toRow) {
|
|
229
|
+
if (items.length) lines.push('', title, ...items.map(toRow));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function compactionLine(c) {
|
|
233
|
+
return `Compactions ${c.count} · ${usd(c.totalCost)} · summarized ${tokens(c.tokensBefore)} tokens`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function matchSessions(d, text) {
|
|
237
|
+
const lower = text.toLowerCase();
|
|
238
|
+
const hit = (s) => (s.customTitle || s.firstUserText || '').toLowerCase().includes(lower);
|
|
239
|
+
return { ...d, sessions: (d.sessions || []).filter(hit) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const FORMAT = {
|
|
243
|
+
overview(d, { win, top }) {
|
|
244
|
+
const s = d.summary;
|
|
245
|
+
const lines = [headline(win, s)];
|
|
246
|
+
if (s.todayCost !== null && s.todayCost !== undefined && win.range !== 'today') lines.push(`Today ${usd(s.todayCost)}`);
|
|
247
|
+
lines.push(`Tokens ${tokens(s.totalTokens)} · cache hit ${Math.round((s.cacheEfficiency || 0) * 100)}%`);
|
|
248
|
+
section(lines, 'By model', (d.modelDistribution || []).slice(0, top), (m) => row(shortModel(m.model), m.cost, pct(m.cost, s.totalCost)));
|
|
249
|
+
const bucket = d.costSeries?.bucket;
|
|
250
|
+
const points = (d.costSeries?.points || []).filter((p) => p.cost > 0);
|
|
251
|
+
const bucketRow = (p) => row(bucketLabel(p.key, bucket), p.cost);
|
|
252
|
+
if (points.length <= 14) section(lines, `By ${bucket}`, points, bucketRow);
|
|
253
|
+
else section(lines, `Costliest ${bucket}s`, [...points].sort((a, b) => b.cost - a.cost).slice(0, top), bucketRow);
|
|
254
|
+
section(lines, 'Top projects', (d.projects || []).slice(0, top), (p) => row(p.name, p.totalCost, `${p.sessions} sess`));
|
|
255
|
+
return lines;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
insights(d, { win, top }) {
|
|
259
|
+
const lines = [`${win.label} · ${usd(d.totalCost)}`];
|
|
260
|
+
if (d.runRate) lines.push(`Run rate ${usd(d.runRate.dailyAvg)}/day · ${usd(d.runRate.projectedMonthly)}/month (${d.runRate.days} days)`);
|
|
261
|
+
if (d.cacheSavings) lines.push(`Cache saved ${usd(d.cacheSavings)}`);
|
|
262
|
+
const sa = d.subagents;
|
|
263
|
+
if (sa?.runs) {
|
|
264
|
+
const total = sa.mainCost + sa.subagentCost;
|
|
265
|
+
lines.push(`Subagents ${usd(sa.subagentCost)} (${pct(sa.subagentCost, total)}) in ${sa.runs} run${sa.runs === 1 ? '' : 's'}`);
|
|
266
|
+
}
|
|
267
|
+
if (d.compactions?.count) lines.push(compactionLine(d.compactions));
|
|
268
|
+
const blocks = [...(d.blocks || [])].sort((a, b) => b.cost - a.cost).slice(0, top);
|
|
269
|
+
section(lines, 'Costliest 5h blocks', blocks, (b) => row(localStamp(b.start), b.cost, `${b.messageCount} turns`));
|
|
270
|
+
if (d.tools?.length) lines.push('', `Top tools: ${d.tools.slice(0, top).map((t) => `${t.name} ${t.count}`).join(', ')}`);
|
|
271
|
+
return lines;
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
projects(d, { win, top }) {
|
|
275
|
+
const list = (d.projects || []).filter((p) => p.totalCost > 0);
|
|
276
|
+
const lines = [`${win.label} · ${list.length} projects · ${usd(list.reduce((a, p) => a + p.totalCost, 0))}`];
|
|
277
|
+
const dups = dupNames(list);
|
|
278
|
+
for (const p of list.slice(0, top)) {
|
|
279
|
+
lines.push(row(p.name, p.totalCost, `${p.sessions} sess`));
|
|
280
|
+
if (dups.has(p.name)) lines.push(` ${p.projectDir}`);
|
|
281
|
+
}
|
|
282
|
+
return lines;
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
sessions(d, { win, top, project, match }) {
|
|
286
|
+
const list = d.sessions || [];
|
|
287
|
+
const total = list.reduce((a, s) => a + s.totalCost, 0);
|
|
288
|
+
const matching = match ? ` matching "${match}"` : '';
|
|
289
|
+
const lines = [`${project ? project.name : 'All projects'} · ${win.label} · ${list.length} sessions${matching} · ${usd(total)}`];
|
|
290
|
+
for (const s of list.slice(0, top)) {
|
|
291
|
+
lines.push(`${usd(s.totalCost).padStart(9)} ${sessionTitle(s)}`);
|
|
292
|
+
const where = !project && s.projectName ? `${clip(s.projectName, NAME_WIDTH)} · ` : '';
|
|
293
|
+
lines.push(`${' '.repeat(11)}${where}${localStamp(s.firstTimestamp).slice(0, 10)} · ${s.durationMinutes} min · ${s.messageCount} turns`);
|
|
294
|
+
lines.push(`${' '.repeat(11)}${s.id}`);
|
|
295
|
+
}
|
|
296
|
+
return lines;
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
agents(d, { win, top, project }) {
|
|
300
|
+
const list = d.agents || [];
|
|
301
|
+
const total = list.reduce((a, g) => a + g.totalCost, 0);
|
|
302
|
+
const runs = list.reduce((a, g) => a + g.runs, 0);
|
|
303
|
+
const lines = [`${project ? project.name : 'All projects'} · ${win.label} · ${runs} subagent runs · ${usd(total)}`];
|
|
304
|
+
for (const g of list.slice(0, top)) {
|
|
305
|
+
const failed = g.failures ? ` · ${g.failures} failed` : '';
|
|
306
|
+
lines.push(row(g.name, g.totalCost, `${g.runs} run${g.runs === 1 ? '' : 's'} · avg ${usd(g.avgCost)}${failed}`));
|
|
307
|
+
const c = g.costliest;
|
|
308
|
+
lines.push(` costliest ${usd(c.cost)} · ${clip(c.task, WIDTH - 20)}`);
|
|
309
|
+
lines.push(` ${c.runSessionId || c.sessionId}`);
|
|
310
|
+
}
|
|
311
|
+
return lines;
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
session(d, { top }) {
|
|
315
|
+
const turns = d.messages.filter((m) => !m.subagent);
|
|
316
|
+
const runs = d.messages.filter((m) => m.subagent).sort((a, b) => b.cost - a.cost);
|
|
317
|
+
const lines = [
|
|
318
|
+
sessionTitle(d),
|
|
319
|
+
`${d.cwd} · ${span(d.firstTimestamp, d.lastTimestamp)}`,
|
|
320
|
+
`${usd(d.totalCost)} · ${turns.length} turns · ${(d.models || []).map(shortModel).join(', ')}`,
|
|
321
|
+
`Tokens in ${tokens(d.input)} · out ${tokens(d.output)} · cache write ${tokens(d.cacheWrite)} · read ${tokens(d.cacheRead)}`,
|
|
322
|
+
];
|
|
323
|
+
if (d.compactions?.count) lines.push(compactionLine(d.compactions));
|
|
324
|
+
if (runs.length) {
|
|
325
|
+
lines.push('', `Subagents ${runs.length} · ${usd(runs.reduce((a, m) => a + m.cost, 0))}`);
|
|
326
|
+
for (const m of runs.slice(0, top)) {
|
|
327
|
+
const sa = m.subagent;
|
|
328
|
+
lines.push(` ${usd(m.cost).padStart(7)} ${clip(`${sa.agent || 'agent'} · ${sa.task || ''}`, WIDTH - 11)}`);
|
|
329
|
+
if (sa.sessionId) lines.push(` ${sa.sessionId}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
section(lines, 'Costliest turns', topTurns(turns, top), (t) => t);
|
|
333
|
+
return lines;
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
function change(now, before) {
|
|
338
|
+
const d = now - before;
|
|
339
|
+
const sign = d < 0 ? '-' : '+';
|
|
340
|
+
let rel = '';
|
|
341
|
+
if (before > 0) rel = ` (${sign}${pct(Math.abs(d), before)})`;
|
|
342
|
+
else if (now > 0) rel = ' (new)';
|
|
343
|
+
return `${sign}${usd(Math.abs(d))}${rel}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Rows of both windows by key, biggest change first, so a model or project that stopped shows too.
|
|
347
|
+
function diffRows(cur, prev, key, cost, top) {
|
|
348
|
+
const rows = new Map();
|
|
349
|
+
for (const [list, side] of [[cur, 'now'], [prev, 'before']]) {
|
|
350
|
+
for (const x of list || []) {
|
|
351
|
+
const k = key(x);
|
|
352
|
+
if (!rows.has(k)) rows.set(k, { x, now: 0, before: 0 });
|
|
353
|
+
rows.get(k)[side] += cost(x);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return [...rows.values()].sort((a, b) => Math.abs(b.now - b.before) - Math.abs(a.now - a.before)).slice(0, top);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const diffTitle = (title) => `${title.padEnd(NAME_WIDTH + 2)} ${'now'.padStart(9)} ${'before'.padStart(9)} change`;
|
|
360
|
+
const diffRow = (name, r) => row(name, r.now, `${usd(r.before).padStart(9)} ${change(r.now, r.before)}`);
|
|
361
|
+
|
|
362
|
+
const COMPARE = {
|
|
363
|
+
overview({ current, previous }, { win, before, top }) {
|
|
364
|
+
const c = current.summary;
|
|
365
|
+
const p = previous.summary;
|
|
366
|
+
const lines = [headline(win, c), headline(before, p), `Change ${change(c.totalCost, p.totalCost)}`];
|
|
367
|
+
const models = diffRows(current.modelDistribution, previous.modelDistribution, (m) => m.model, (m) => m.cost, top);
|
|
368
|
+
section(lines, diffTitle('By model'), models, (r) => diffRow(shortModel(r.x.model), r));
|
|
369
|
+
const projects = diffRows(current.projects, previous.projects, (p) => p.projectDir, (p) => p.totalCost, top);
|
|
370
|
+
const dups = dupNames(projects.map((r) => r.x));
|
|
371
|
+
section(lines, diffTitle('Projects'), projects, (r) => diffRow(r.x.name, r) + (dups.has(r.x.name) ? `\n ${r.x.projectDir}` : ''));
|
|
372
|
+
return lines;
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// #endregion
|
|
377
|
+
|
|
378
|
+
// #region COMMANDS
|
|
379
|
+
|
|
380
|
+
const BIN = 'pi-cost';
|
|
381
|
+
|
|
382
|
+
const WINDOW_FLAGS = {
|
|
383
|
+
'--range <r>': WINDOW_HINT,
|
|
384
|
+
'--from <date>': 'Window start, YYYY-MM-DD (needs --to)',
|
|
385
|
+
'--to <date>': 'Window end, YYYY-MM-DD',
|
|
386
|
+
};
|
|
387
|
+
const COMMON_FLAGS = {
|
|
388
|
+
'--top <n>': 'Rows per list (default 5)',
|
|
389
|
+
'--json': 'Print the API response as JSON',
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const SCOPED_FLAGS = { ...WINDOW_FLAGS, '--project <name>': 'One project (name or encoded dir)', ...COMMON_FLAGS };
|
|
393
|
+
|
|
394
|
+
const COMMANDS = {
|
|
395
|
+
overview: {
|
|
396
|
+
summary: 'Total cost, by model, by day and top projects; --compare to the window before',
|
|
397
|
+
usage: `${BIN} overview [--range <r>] [--project <name>] [--compare] [--json]`,
|
|
398
|
+
flags: { ...SCOPED_FLAGS, '--compare': 'Also the window before it, and the change' },
|
|
399
|
+
notes: 'The window before has as many days and ends the day before. Today counts so far.',
|
|
400
|
+
examples: [`${BIN} overview --range today`, `${BIN} overview --range 30d --project pi-cost`, `${BIN} overview --range 7d --compare`],
|
|
401
|
+
window: true,
|
|
402
|
+
project: true,
|
|
403
|
+
},
|
|
404
|
+
insights: {
|
|
405
|
+
summary: 'Run rate, 5-hour blocks, subagent share, compactions, top tools',
|
|
406
|
+
usage: `${BIN} insights [--range <r>] [--project <name>] [--json]`,
|
|
407
|
+
flags: SCOPED_FLAGS,
|
|
408
|
+
notes: 'A compaction counts only when pi recorded the usage of its summary call.',
|
|
409
|
+
window: true,
|
|
410
|
+
project: true,
|
|
411
|
+
},
|
|
412
|
+
projects: {
|
|
413
|
+
summary: 'Projects by cost',
|
|
414
|
+
usage: `${BIN} projects [--range <r>] [--json]`,
|
|
415
|
+
flags: { ...WINDOW_FLAGS, ...COMMON_FLAGS },
|
|
416
|
+
window: true,
|
|
417
|
+
},
|
|
418
|
+
sessions: {
|
|
419
|
+
summary: 'Sessions by cost, with their ids: all projects or one; --match <title>',
|
|
420
|
+
usage: `${BIN} sessions [<project>] [--match <text>] [--range <r>] [--json]`,
|
|
421
|
+
flags: {
|
|
422
|
+
'<project>': 'Project name, or the encoded dir from projects --json',
|
|
423
|
+
'--match <text>': 'Only sessions whose name or first prompt has <text> (ignores case)',
|
|
424
|
+
...WINDOW_FLAGS,
|
|
425
|
+
...COMMON_FLAGS,
|
|
426
|
+
},
|
|
427
|
+
notes: 'The title is the session name, else the first prompt. The total covers the matching sessions.',
|
|
428
|
+
examples: [`${BIN} sessions --range 30d --top 10`, `${BIN} sessions pi-cost --range 30d`, `${BIN} sessions --match refactor --range 30d`],
|
|
429
|
+
match: matchSessions,
|
|
430
|
+
window: true,
|
|
431
|
+
project: true,
|
|
432
|
+
optionalArgs: ['project'],
|
|
433
|
+
},
|
|
434
|
+
agents: {
|
|
435
|
+
summary: 'Subagent runs by agent name: runs, total, average',
|
|
436
|
+
usage: `${BIN} agents [--range <r>] [--project <name>] [--json]`,
|
|
437
|
+
flags: SCOPED_FLAGS,
|
|
438
|
+
notes: `Each row names its costliest run; open it with ${BIN} session <id>.`,
|
|
439
|
+
examples: [`${BIN} agents --range 30d`, `${BIN} agents --project pi-cost --top 10`],
|
|
440
|
+
window: true,
|
|
441
|
+
project: true,
|
|
442
|
+
},
|
|
443
|
+
session: {
|
|
444
|
+
summary: 'One session: totals, subagent runs, costliest turns',
|
|
445
|
+
usage: `${BIN} session <id> [--json]`,
|
|
446
|
+
flags: { '<id>': 'Full session id', ...COMMON_FLAGS },
|
|
447
|
+
notes: `Covers the whole session; --range does not apply. Find an id with ${BIN} sessions --match <text>.`,
|
|
448
|
+
args: ['session'],
|
|
449
|
+
notFound: 'Session not found',
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const BOOLEAN_FLAGS = new Set(['--json', '--compare', '--help', '-h']);
|
|
454
|
+
const HELP = new Set(['help', '--help', '-h']);
|
|
455
|
+
|
|
456
|
+
function parseArgs(args) {
|
|
457
|
+
const flags = {};
|
|
458
|
+
const positional = [];
|
|
459
|
+
for (let i = 0; i < args.length; i++) {
|
|
460
|
+
const a = args[i];
|
|
461
|
+
if (!a.startsWith('-')) {
|
|
462
|
+
positional.push(a);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const eq = a.indexOf('=');
|
|
466
|
+
const name = eq === -1 ? a : a.slice(0, eq);
|
|
467
|
+
const key = name.replace(/^-+/, '');
|
|
468
|
+
if (BOOLEAN_FLAGS.has(name)) flags[key] = true;
|
|
469
|
+
else if (eq !== -1) flags[key] = a.slice(eq + 1);
|
|
470
|
+
else flags[key] = args[++i];
|
|
471
|
+
}
|
|
472
|
+
return { flags, positional };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function usageError(message) {
|
|
476
|
+
const err = new Error(message);
|
|
477
|
+
err.usage = true;
|
|
478
|
+
return err;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function topHelp() {
|
|
482
|
+
const names = Object.keys(COMMANDS);
|
|
483
|
+
const w = Math.max(...names.map((n) => n.length));
|
|
484
|
+
return [
|
|
485
|
+
`${BIN} — pi coding agent costs from the command line`,
|
|
486
|
+
'',
|
|
487
|
+
`Usage: ${BIN} <command> [flags]`,
|
|
488
|
+
` ${BIN} [--port <n>] [--host <h>] [--open | --no-open] start the dashboard`,
|
|
489
|
+
'',
|
|
490
|
+
'Commands:',
|
|
491
|
+
...names.map((n) => ` ${n.padEnd(w)} ${COMMANDS[n].summary}`),
|
|
492
|
+
'',
|
|
493
|
+
'Window: --range, else PI_COST_RANGE, else cli.defaultRange in',
|
|
494
|
+
'<agent dir>/cost/settings.json, else 7d.',
|
|
495
|
+
'Asks the running dashboard of the agent dir (COST_URL, else',
|
|
496
|
+
'<agent dir>/cost/server.json); with none, parses the session files.',
|
|
497
|
+
'The agent dir is PI_CODING_AGENT_DIR, else ~/.pi/agent. --session-dir <dir>',
|
|
498
|
+
`or PI_CODING_AGENT_SESSION_DIR reads another session dir. Details: ${BIN} help <command>`,
|
|
499
|
+
].join('\n');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function leafHelp(name) {
|
|
503
|
+
const c = COMMANDS[name];
|
|
504
|
+
const lines = [c.summary, '', `Usage: ${c.usage}`];
|
|
505
|
+
const flags = Object.entries(c.flags || {});
|
|
506
|
+
if (flags.length) {
|
|
507
|
+
const w = Math.max(...flags.map(([f]) => f.length));
|
|
508
|
+
lines.push('', 'Flags:', ...flags.map(([f, d]) => ` ${f.padEnd(w)} ${d}`));
|
|
509
|
+
}
|
|
510
|
+
if (c.notes) lines.push('', c.notes);
|
|
511
|
+
if (c.examples?.length) lines.push('', 'Examples:', ...c.examples.map((e) => ` ${e}`));
|
|
512
|
+
return lines.join('\n');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function isCommand(arg) {
|
|
516
|
+
return HELP.has(arg) || Object.hasOwn(COMMANDS, arg || '');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function resolveProject(list, raw) {
|
|
520
|
+
const exact = list.find((p) => p.projectDir === raw);
|
|
521
|
+
if (exact) return exact;
|
|
522
|
+
const lower = raw.toLowerCase();
|
|
523
|
+
const named = list.filter((p) => p.name.toLowerCase() === lower);
|
|
524
|
+
const hits = named.length ? named : list.filter((p) => p.name.toLowerCase().includes(lower));
|
|
525
|
+
if (hits.length === 1) return hits[0];
|
|
526
|
+
if (!hits.length) throw usageError(`No project matches "${raw}" in this window. Try a wider --range, or ${BIN} projects`);
|
|
527
|
+
throw usageError(`"${raw}" matches ${hits.length} projects; pass the encoded dir:\n${hits.map((p) => ` ${p.name} ${p.projectDir}`).join('\n')}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function main(args, { out = (s) => process.stdout.write(`${s}\n`), err = (s) => process.stderr.write(`${s}\n`) } = {}) {
|
|
531
|
+
const [name, ...rest] = args;
|
|
532
|
+
if (HELP.has(name)) {
|
|
533
|
+
out(rest[0] && Object.hasOwn(COMMANDS, rest[0]) ? leafHelp(rest[0]) : topHelp());
|
|
534
|
+
return 0;
|
|
535
|
+
}
|
|
536
|
+
const cmd = COMMANDS[name];
|
|
537
|
+
const { flags, positional } = parseArgs(rest);
|
|
538
|
+
if (flags.help || flags.h) {
|
|
539
|
+
out(leafHelp(name));
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
if ((cmd.args || []).length > positional.length) {
|
|
543
|
+
err(leafHelp(name));
|
|
544
|
+
return 1;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const top = flags.top === undefined ? 5 : Number.parseInt(flags.top, 10);
|
|
548
|
+
if (!Number.isInteger(top) || top < 1) throw usageError('--top takes a positive number');
|
|
549
|
+
if (cmd.match && Object.hasOwn(flags, 'match') && !flags.match) throw usageError('--match takes text');
|
|
550
|
+
const match = cmd.match && flags.match;
|
|
551
|
+
const costDir = parsers.getCostDir();
|
|
552
|
+
const ctx = { server: findServer(costDir), note: (s) => err(`(${s})`) };
|
|
553
|
+
const params = {};
|
|
554
|
+
[...(cmd.args || []), ...(cmd.optionalArgs || [])].forEach((a, i) => {
|
|
555
|
+
params[a] = positional[i];
|
|
556
|
+
});
|
|
557
|
+
if (cmd.window) params.win = resolveWindow(flags, { costDir, warn: err });
|
|
558
|
+
const view = { win: params.win, top, match };
|
|
559
|
+
|
|
560
|
+
const raw = params.project ?? flags.project;
|
|
561
|
+
if (cmd.project && raw) {
|
|
562
|
+
if (typeof raw !== 'string') throw usageError('--project takes a project name');
|
|
563
|
+
const data = await query('projects', { win: params.win }, ctx);
|
|
564
|
+
view.project = resolveProject(data?.projects || [], raw);
|
|
565
|
+
params.project = view.project.projectDir;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const compare = flags.compare && COMPARE[name];
|
|
569
|
+
let data;
|
|
570
|
+
if (compare) {
|
|
571
|
+
view.before = previousWindow(params.win);
|
|
572
|
+
const [current, previous] = await Promise.all([query(name, params, ctx), query(name, { ...params, win: view.before }, ctx)]);
|
|
573
|
+
data = current && previous && { current, previous, previousWindow: view.before.range };
|
|
574
|
+
} else {
|
|
575
|
+
data = await query(name, params, ctx);
|
|
576
|
+
}
|
|
577
|
+
if (!data) {
|
|
578
|
+
err(cmd.notFound || 'No data');
|
|
579
|
+
return 1;
|
|
580
|
+
}
|
|
581
|
+
if (match) data = cmd.match(data, match);
|
|
582
|
+
if (flags.json) out(JSON.stringify(data, null, 2));
|
|
583
|
+
else out((compare || FORMAT[name])(data, view).join('\n'));
|
|
584
|
+
return 0;
|
|
585
|
+
} catch (e) {
|
|
586
|
+
err(e.usage ? e.message : `Error: ${e.message}`);
|
|
587
|
+
return 1;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// #endregion
|
|
592
|
+
|
|
593
|
+
module.exports = { isCommand, main, resolveWindow, previousWindow, parseWindowValue, findServer, parseArgs, matchSessions, resolveProject };
|
package/lib/cost-dir.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
// Files under <agent dir>/cost: settings.json (themes, cli defaults) and server.json, which the
|
|
7
|
+
// server writes so the CLI and the pi extension can find it.
|
|
8
|
+
|
|
9
|
+
function readJson(file) {
|
|
10
|
+
try {
|
|
11
|
+
const obj = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
12
|
+
return obj && typeof obj === 'object' ? obj : {};
|
|
13
|
+
} catch (e) {
|
|
14
|
+
if (e.code !== 'ENOENT') console.warn(`pi-cost: cannot read ${file}: ${e.message}`);
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readCostSettings(costDir) {
|
|
20
|
+
return readJson(path.join(costDir, 'settings.json'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isAlive(pid) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return err.code === 'EPERM';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A killed server leaves the file behind, so a record counts only while its pid is alive.
|
|
33
|
+
function readServerRecord(costDir) {
|
|
34
|
+
let rec;
|
|
35
|
+
try {
|
|
36
|
+
rec = JSON.parse(fs.readFileSync(path.join(costDir, 'server.json'), 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (!Number.isInteger(rec?.pid) || !Number.isInteger(rec?.port) || !isAlive(rec.pid)) return null;
|
|
41
|
+
return rec;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeServerRecord(costDir, rec) {
|
|
45
|
+
const file = path.join(costDir, 'server.json');
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(costDir, { recursive: true });
|
|
48
|
+
fs.writeFileSync(file, JSON.stringify(rec));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn(`cannot write ${file}: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Only the server that wrote the record removes it; a newer server may have replaced it.
|
|
55
|
+
function removeServerRecord(costDir, pid) {
|
|
56
|
+
const file = path.join(costDir, 'server.json');
|
|
57
|
+
try {
|
|
58
|
+
if (JSON.parse(fs.readFileSync(file, 'utf8')).pid === pid) fs.unlinkSync(file);
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { readCostSettings, readServerRecord, writeServerRecord, removeServerRecord, isAlive };
|