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.
Files changed (72) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +13 -1
  3. package/extensions/cost.ts +53 -83
  4. package/lib/app.js +113 -0
  5. package/lib/args.js +17 -0
  6. package/lib/cli.js +593 -0
  7. package/lib/cost-dir.js +62 -0
  8. package/lib/costs.js +799 -0
  9. package/lib/net-guard.js +178 -0
  10. package/lib/pi-parsers-cost.js +234 -66
  11. package/lib/pi-pricing.js +35 -8
  12. package/lib/themes.js +90 -0
  13. package/package.json +16 -7
  14. package/public/app.js +2232 -448
  15. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  16. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  17. package/public/fonts/fonts.css +244 -0
  18. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  19. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  33. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  34. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  35. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  36. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  37. package/public/index.html +68 -46
  38. package/public/style.css +319 -8
  39. package/public/sw.js +2 -2
  40. package/server.js +58 -659
  41. package/themes/ayu-dark.json +21 -0
  42. package/themes/ayu-light.json +21 -0
  43. package/themes/catppuccin-dark.json +21 -0
  44. package/themes/catppuccin-light.json +21 -0
  45. package/themes/dracula-dark.json +21 -0
  46. package/themes/dracula-light.json +21 -0
  47. package/themes/everforest-dark.json +21 -0
  48. package/themes/everforest-light.json +21 -0
  49. package/themes/github-dark.json +21 -0
  50. package/themes/github-light.json +21 -0
  51. package/themes/gruvbox-dark.json +21 -0
  52. package/themes/gruvbox-light.json +21 -0
  53. package/themes/kanagawa-dark.json +21 -0
  54. package/themes/kanagawa-light.json +21 -0
  55. package/themes/monokai-dark.json +21 -0
  56. package/themes/monokai-light.json +21 -0
  57. package/themes/night-owl-dark.json +21 -0
  58. package/themes/night-owl-light.json +21 -0
  59. package/themes/nord-dark.json +21 -0
  60. package/themes/nord-light.json +21 -0
  61. package/themes/one-dark-dark.json +21 -0
  62. package/themes/one-dark-light.json +21 -0
  63. package/themes/rose-pine-dark.json +21 -0
  64. package/themes/rose-pine-light.json +21 -0
  65. package/themes/solarized-dark.json +21 -0
  66. package/themes/solarized-light.json +21 -0
  67. package/themes/synthwave-dark.json +21 -0
  68. package/themes/synthwave-light.json +21 -0
  69. package/themes/tokyo-night-dark.json +21 -0
  70. package/themes/tokyo-night-light.json +21 -0
  71. package/themes/vitesse-dark.json +21 -0
  72. package/themes/vitesse-light.json +21 -0
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ // Network boundary for a local-only server, adapted from claude-code-cost.
4
+ //
5
+ // Binding loopback stops LAN peers. Two attacks still reach a loopback bind:
6
+ // * DNS rebinding: a page on evil.com re-resolves its name to 127.0.0.1 and reads the API
7
+ // same-origin. Only the Host header differs, and script cannot forge it, so Host is allowlisted.
8
+ // * CSRF: evil.com POSTs straight at 127.0.0.1. Host is real, so state-changing methods also need
9
+ // a loopback Origin (or no Origin and no cross-site Sec-Fetch-Site).
10
+ //
11
+ // pi-kanban frames this page from its own loopback port, which is dynamic, so any loopback port
12
+ // may frame it and X-Frame-Options (which has no multi-origin form) is not sent.
13
+
14
+ const http = require('node:http');
15
+
16
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1']);
17
+ const PORT_RE = /^[0-9]{1,5}$/;
18
+ const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
19
+ const KEEP_ALIVE_MS = 65000;
20
+ // A whole IPv4 literal: a prefix test would let a rebinding name like 127.evil.com through.
21
+ const LOOPBACK_V4_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
22
+ // Chrome rejects an IPv6 literal as a frame-ancestors source.
23
+ const FRAME_CSP = "frame-ancestors 'self' http://localhost:* http://127.0.0.1:*";
24
+
25
+ function isLoopbackAddress(host) {
26
+ if (!host) return false;
27
+ const h = String(host).toLowerCase().replace(/^\[(.*)\]$/, '$1');
28
+ return LOOPBACK_HOSTS.has(h) || LOOPBACK_V4_RE.test(h);
29
+ }
30
+
31
+ // Hand-parsed: new URL() accepts far more than a real client sends. Returns the lowercased
32
+ // hostname, or null when malformed. The port is not compared, because a busy port falls back to a
33
+ // random one.
34
+ function parseHostHeader(value) {
35
+ if (typeof value !== 'string') return null;
36
+ const raw = value.trim();
37
+ if (!raw || raw.length > 259) return null;
38
+ for (let i = 0; i < raw.length; i++) {
39
+ const code = raw.charCodeAt(i);
40
+ if (code <= 0x20 || code === 0x7f) return null;
41
+ }
42
+ if (raw.includes('@')) return null;
43
+
44
+ let host;
45
+ if (raw.startsWith('[')) {
46
+ const end = raw.indexOf(']');
47
+ if (end === -1) return null;
48
+ host = raw.slice(1, end);
49
+ const rest = raw.slice(end + 1);
50
+ if (rest && !(rest.startsWith(':') && validPort(rest.slice(1)))) return null;
51
+ } else {
52
+ const parts = raw.split(':');
53
+ if (parts.length > 2) return null;
54
+ host = parts[0];
55
+ if (parts.length === 2 && !validPort(parts[1])) return null;
56
+ }
57
+ if (host.endsWith('.')) host = host.slice(0, -1);
58
+ if (!host) return null;
59
+ return host.toLowerCase();
60
+ }
61
+
62
+ function parseList(value) {
63
+ return String(value || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
64
+ }
65
+
66
+ /**
67
+ * @param {{appName?: string, bindHost?: string, allowedHosts?: string}} [config]
68
+ */
69
+ function createNetGuard(config = {}) {
70
+ const appName = config.appName || 'This app';
71
+ const BIND_HOST = config.bindHost || '127.0.0.1';
72
+ const BIND_HOST_LC = BIND_HOST.toLowerCase();
73
+ const EXPOSED = !isLoopbackAddress(BIND_HOST);
74
+ const extraHosts = new Set(parseList(config.allowedHosts));
75
+ let actualPort = null;
76
+
77
+ function hostAllowed(host) {
78
+ if (!host) return false;
79
+ if (isLoopbackAddress(host) || extraHosts.has(host)) return true;
80
+ return EXPOSED && host === BIND_HOST_LC;
81
+ }
82
+
83
+ function hostGuard(req, res, next) {
84
+ const host = parseHostHeader(req.headers.host);
85
+ if (hostAllowed(host)) return next();
86
+ res.status(403).type('text/plain').send(
87
+ `403 Forbidden - unrecognized Host header: ${req.headers.host || '(none)'}\n\n` +
88
+ `${appName} only answers requests addressed to localhost. This protects you from DNS\n` +
89
+ `rebinding, where a website re-points its own hostname at 127.0.0.1 to read local data.\n\n` +
90
+ `To reach it from another machine, restart with:\n` +
91
+ ` --host 0.0.0.0 --allowed-hosts=${host || '<your-hostname>'}\n\n` +
92
+ `Only do that on a network you trust - there is no authentication.\n`,
93
+ );
94
+ }
95
+
96
+ // true = allow, false = block, null = no Origin header.
97
+ function originVerdict(origin) {
98
+ if (!origin || origin === 'null') return null;
99
+ let parsed;
100
+ try { parsed = new URL(origin); } catch { return false; }
101
+ if (!isLoopbackAddress(parsed.hostname)) return false;
102
+ // A different local port is a different app and has no business driving this one.
103
+ if (actualPort && parsed.port && Number(parsed.port) !== Number(actualPort)) return false;
104
+ return true;
105
+ }
106
+
107
+ function originGuard(req, res, next) {
108
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
109
+ const verdict = originVerdict(req.headers.origin);
110
+ if (verdict === true) return next();
111
+ if (verdict === false) {
112
+ return res.status(403).type('text/plain')
113
+ .send(`403 Forbidden - cross-origin ${req.method} from ${req.headers.origin} is not allowed.\n`);
114
+ }
115
+ // No Origin: a non-browser client (curl, the CLI). Trust it unless the browser says otherwise.
116
+ const site = req.headers['sec-fetch-site'];
117
+ if (site && site !== 'same-origin' && site !== 'none') {
118
+ return res.status(403).type('text/plain')
119
+ .send(`403 Forbidden - cross-site ${req.method} (Sec-Fetch-Site: ${site}) is not allowed.\n`);
120
+ }
121
+ return next();
122
+ }
123
+
124
+ function frameGuard(_req, res, next) {
125
+ res.setHeader('X-Content-Type-Options', 'nosniff');
126
+ res.setHeader('Referrer-Policy', 'no-referrer');
127
+ res.setHeader('Content-Security-Policy', FRAME_CSP);
128
+ next();
129
+ }
130
+
131
+ /**
132
+ * Bind BIND_HOST, then serve the other loopback family on the same port, so
133
+ * http://localhost:<port> works whichever family the resolver returns first. A family the host
134
+ * does not have is skipped. If another process holds the port on the other family, the primary
135
+ * closes and emits EADDRINUSE, because that process would answer localhost:<port> in our name.
136
+ * @returns {http.Server} the primary server; attach the 'error' handler to it.
137
+ */
138
+ function listenLoopback(app, port, onReady, opts = {}) {
139
+ const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
140
+ const makeServer = () => {
141
+ const s = http.createServer(serverOpts, app);
142
+ s.keepAliveTimeout = KEEP_ALIVE_MS;
143
+ return s;
144
+ };
145
+ const server = makeServer();
146
+ let secondary = null;
147
+ let randomTries = 0;
148
+
149
+ server.on('listening', () => {
150
+ const boundPort = server.address().port;
151
+ actualPort = boundPort;
152
+ const ready = () => { if (onReady) onReady(boundPort); };
153
+ if (EXPOSED) return ready();
154
+ const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
155
+ secondary = makeServer();
156
+ secondary.once('listening', ready);
157
+ secondary.on('error', (err) => {
158
+ if (err.code !== 'EADDRINUSE') return ready();
159
+ if (port === 0 && ++randomTries < 5) return server.close(() => server.listen(0, BIND_HOST));
160
+ server.close();
161
+ server.emit('error', err);
162
+ });
163
+ secondary.listen({ host: otherFamily, port: boundPort, ipv6Only: otherFamily === '::1' });
164
+ });
165
+ server.on('close', () => { if (secondary) secondary.close(() => {}); });
166
+
167
+ server.listen(port, BIND_HOST);
168
+ return server;
169
+ }
170
+
171
+ function exposureWarning() {
172
+ return EXPOSED ? `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.` : null;
173
+ }
174
+
175
+ return { BIND_HOST, EXPOSED, hostGuard, originGuard, frameGuard, listenLoopback, exposureWarning };
176
+ }
177
+
178
+ module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress, FRAME_CSP, KEEP_ALIVE_MS };
@@ -5,89 +5,214 @@ const path = require('node:path');
5
5
  const os = require('node:os');
6
6
  const readline = require('node:readline');
7
7
 
8
- const PI_DIR = process.env.PI_DIR || path.join(os.homedir(), '.pi');
9
- const SESSIONS_DIR = path.join(PI_DIR, 'agent', 'sessions');
8
+ const ENV_SESSION_DIR = 'PI_CODING_AGENT_SESSION_DIR';
10
9
 
11
- function getPiDir() { return PI_DIR; }
12
- function getSessionsDir() { return SESSIONS_DIR; }
10
+ // libuv aborts on a watched Windows path that holds an 8.3 short name (NIKIFO~1), because events
11
+ // come back with the long name. Resolve the deepest existing ancestor and re-append the rest.
12
+ function longPath(target) {
13
+ let cur = path.resolve(target);
14
+ const tail = [];
15
+ for (;;) {
16
+ try {
17
+ const real = fs.realpathSync.native(cur);
18
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
19
+ } catch {
20
+ const parent = path.dirname(cur);
21
+ if (parent === cur) return path.resolve(target);
22
+ tail.push(path.basename(cur));
23
+ cur = parent;
24
+ }
25
+ }
26
+ }
27
+
28
+ const pathKeys = new Map();
29
+ const PATH_KEYS_MAX = 20_000;
30
+
31
+ // Comparison key for a file path. pi records paths as it was given them, which may hold 8.3 short
32
+ // names (NIKIFO~1) while listings are long; a short name always has a `~`, so only those hit the disk.
33
+ function pathKey(p) {
34
+ if (!p) return '';
35
+ let key = pathKeys.get(p);
36
+ if (key !== undefined) return key;
37
+ key = p.includes('~') ? longPath(p) : path.resolve(p);
38
+ if (process.platform === 'win32') key = key.toLowerCase();
39
+ if (pathKeys.size >= PATH_KEYS_MAX) pathKeys.clear();
40
+ pathKeys.set(p, key);
41
+ return key;
42
+ }
43
+
44
+ // pi's normalizePath: Git Bash / Cygwin / WSL drive paths on Windows, then ~.
45
+ function normalizePiPath(p) {
46
+ let s = String(p).trim();
47
+ if (process.platform === 'win32' && s.startsWith('/') && !s.startsWith('//') && !s.includes('\\')) {
48
+ const m = s.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
49
+ if (m) s = `${m[1].toUpperCase()}:\\${(m[2] || '').replaceAll('/', '\\')}`;
50
+ }
51
+ if (s === '~') return os.homedir();
52
+ if (/^~[\\/]/.test(s)) return path.join(os.homedir(), s.slice(2));
53
+ return s;
54
+ }
55
+
56
+ // Same rule as pi's getAgentDir: PI_CODING_AGENT_DIR, else <PI_DIR>/agent with PI_DIR defaulting to ~/.pi.
57
+ function rawAgentDir(env) {
58
+ if (env.PI_CODING_AGENT_DIR) return normalizePiPath(env.PI_CODING_AGENT_DIR);
59
+ return path.join(env.PI_DIR || path.join(os.homedir(), '.pi'), 'agent');
60
+ }
61
+
62
+ function readSettingsSessionDir(agentDir) {
63
+ try {
64
+ const dir = JSON.parse(fs.readFileSync(path.join(agentDir, 'settings.json'), 'utf8'))?.sessionDir;
65
+ return typeof dir === 'string' && dir.trim() ? normalizePiPath(dir) : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // pi's precedence: --session-dir, then PI_CODING_AGENT_SESSION_DIR, then settings.sessionDir. A
72
+ // custom dir is flat (<dir>/<ts>_<id>.jsonl). The default nested dir stays in the list, because
73
+ // sessions written before a custom dir was set are still there. pi resolves a relative settings
74
+ // dir against the cwd of each pi run, which pi-cost does not know, so only an absolute one counts.
75
+ function resolveRoots({ env = process.env, sessionDir = null, cwd = process.cwd() } = {}) {
76
+ const agentDir = longPath(rawAgentDir(env));
77
+ const nested = { dir: longPath(path.join(agentDir, 'sessions')), flat: false };
78
+ const forced = sessionDir || env[ENV_SESSION_DIR];
79
+ const fromSettings = forced ? null : readSettingsSessionDir(agentDir);
80
+ const custom = forced ? path.resolve(cwd, normalizePiPath(forced))
81
+ : fromSettings && path.isAbsolute(fromSettings) ? fromSettings : null;
82
+ const roots = [];
83
+ if (custom) {
84
+ const dir = longPath(custom);
85
+ if (pathKey(dir) !== pathKey(nested.dir)) roots.push({ dir, flat: true });
86
+ }
87
+ roots.push(nested);
88
+ return { agentDir, roots };
89
+ }
90
+
91
+ let config = resolveRoots();
92
+
93
+ function configure(opts) {
94
+ config = resolveRoots(opts);
95
+ sessionCache.clear();
96
+ headerCache.clear();
97
+ }
98
+
99
+ function getPiDir() { return path.dirname(config.agentDir); }
100
+ function getAgentDir() { return config.agentDir; }
101
+ function getCostDir() { return path.join(config.agentDir, 'cost'); }
102
+ function getSessionsDir() { return config.roots[0].dir; }
103
+ function getSessionRoots() { return config.roots; }
104
+
105
+ function encodeProjectDir(cwd) {
106
+ return `--${String(cwd).replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
107
+ }
13
108
 
14
- // "--C--Users-nikiforovall-dev-pi--" -> "C:\Users\nikiforovall\dev\pi"
109
+ // Lossy: a dash inside a folder name reads back as a separator. Prefer the header cwd.
15
110
  function decodeProjectDir(name) {
16
111
  let s = name;
17
112
  if (s.startsWith('--')) s = s.slice(2);
18
113
  if (s.endsWith('--')) s = s.slice(0, -2);
19
114
  const m = s.match(/^([A-Za-z])--(.+)$/);
20
115
  if (m) return `${m[1]}:\\${m[2].replace(/-/g, '\\')}`;
21
- return s.replace(/-/g, path.sep);
116
+ return `/${s.replace(/-/g, '/')}`;
22
117
  }
23
118
 
24
- async function listSessionFiles() {
25
- let projDirs;
26
- try { projDirs = await fs.promises.readdir(SESSIONS_DIR, { withFileTypes: true }); }
119
+ const HEADER_MAX = 64 * 1024;
120
+ const headerCache = new Map();
121
+
122
+ async function readSessionHeader(file) {
123
+ if (headerCache.has(file)) return headerCache.get(file);
124
+ let header = null;
125
+ let fh;
126
+ try {
127
+ fh = await fs.promises.open(file, 'r');
128
+ const buf = Buffer.alloc(HEADER_MAX);
129
+ const { bytesRead } = await fh.read(buf, 0, HEADER_MAX, 0);
130
+ const nl = buf.subarray(0, bytesRead).indexOf(0x0a);
131
+ const obj = JSON.parse(buf.toString('utf8', 0, nl === -1 ? bytesRead : nl));
132
+ if (obj && obj.type === 'session' && obj.id) header = obj;
133
+ } catch {
134
+ header = null;
135
+ } finally {
136
+ if (fh) await fh.close().catch(() => {});
137
+ }
138
+ if (header) headerCache.set(file, header);
139
+ return header;
140
+ }
141
+
142
+ async function readdirSafe(dir) {
143
+ try { return await fs.promises.readdir(dir, { withFileTypes: true }); }
27
144
  catch { return []; }
28
- const projects = projDirs.filter((d) => d.isDirectory());
29
- const perProject = await Promise.all(projects.map(async (d) => {
30
- const projDir = d.name;
31
- const projPath = path.join(SESSIONS_DIR, projDir);
32
- let files;
33
- try { files = await fs.promises.readdir(projPath, { withFileTypes: true }); }
34
- catch { return []; }
35
- const jsonls = files.filter((f) => f.isFile() && f.name.endsWith('.jsonl'));
36
- const stats = await Promise.all(jsonls.map(async (f) => {
37
- const full = path.join(projPath, f.name);
38
- try {
39
- const s = await fs.promises.stat(full);
40
- return { file: full, projectDir: projDir, cwd: decodeProjectDir(projDir), mtime: s.mtime, mtimeMs: s.mtimeMs, size: s.size };
41
- } catch { return null; }
42
- }));
43
- return stats.filter(Boolean);
145
+ }
146
+
147
+ async function statJsonls(dir) {
148
+ const entries = await readdirSafe(dir);
149
+ const out = await Promise.all(entries.filter((f) => f.isFile() && f.name.endsWith('.jsonl')).map(async (f) => {
150
+ const file = path.join(dir, f.name);
151
+ try {
152
+ const s = await fs.promises.stat(file);
153
+ return { file, mtime: s.mtime, mtimeMs: s.mtimeMs, size: s.size };
154
+ } catch { return null; }
44
155
  }));
45
- return perProject.flat();
156
+ return out.filter(Boolean);
46
157
  }
47
158
 
48
- // Nested subagent JSONLs live under <projectDir>/<parentSessionId>/<runId>/run-N/session.jsonl.
49
- // Cost is already accounted for inline in the parent's `subagent` toolResult; we list
50
- // them separately so the UI can drill into a specific run.
51
- async function listNestedSubagentFiles() {
52
- let projDirs;
53
- try { projDirs = await fs.promises.readdir(SESSIONS_DIR, { withFileTypes: true }); }
54
- catch { return []; }
55
- const out = [];
56
- await Promise.all(projDirs.filter((d) => d.isDirectory()).map(async (proj) => {
57
- const projPath = path.join(SESSIONS_DIR, proj.name);
58
- let parentDirs;
59
- try { parentDirs = await fs.promises.readdir(projPath, { withFileTypes: true }); }
60
- catch { return; }
61
- await Promise.all(parentDirs.filter((d) => d.isDirectory()).map(async (parent) => {
62
- const parentPath = path.join(projPath, parent.name);
63
- let runDirs;
64
- try { runDirs = await fs.promises.readdir(parentPath, { withFileTypes: true }); }
65
- catch { return; }
66
- await Promise.all(runDirs.filter((d) => d.isDirectory()).map(async (run) => {
67
- const runPath = path.join(parentPath, run.name);
68
- let attempts;
69
- try { attempts = await fs.promises.readdir(runPath, { withFileTypes: true }); }
70
- catch { return; }
71
- await Promise.all(attempts.filter((d) => d.isDirectory()).map(async (attempt) => {
72
- const file = path.join(runPath, attempt.name, 'session.jsonl');
73
- try {
74
- const s = await fs.promises.stat(file);
75
- out.push({
76
- file,
77
- projectDir: proj.name,
78
- parentDirName: parent.name,
79
- runId: run.name,
80
- attempt: attempt.name,
81
- mtime: s.mtime, mtimeMs: s.mtimeMs, size: s.size,
82
- });
83
- } catch {}
84
- }));
85
- }));
159
+ // In a flat dir the project comes from the header cwd; in the nested dir from the folder name.
160
+ async function withProject(f, projectDirName) {
161
+ if (projectDirName) return { ...f, projectDir: projectDirName, cwd: decodeProjectDir(projectDirName) };
162
+ const header = await readSessionHeader(f.file);
163
+ if (!header || !header.cwd) return null;
164
+ return { ...f, projectDir: encodeProjectDir(header.cwd), cwd: header.cwd };
165
+ }
166
+
167
+ async function listSessionFiles() {
168
+ const perRoot = await Promise.all(config.roots.map(async (root) => {
169
+ if (root.flat) {
170
+ const files = await statJsonls(root.dir);
171
+ return Promise.all(files.map((f) => withProject(f, null)));
172
+ }
173
+ const projects = (await readdirSafe(root.dir)).filter((d) => d.isDirectory());
174
+ const perProject = await Promise.all(projects.map(async (d) => {
175
+ const files = await statJsonls(path.join(root.dir, d.name));
176
+ return Promise.all(files.map((f) => withProject(f, d.name)));
86
177
  }));
178
+ return perProject.flat();
87
179
  }));
180
+ return perRoot.flat().filter(Boolean);
181
+ }
182
+
183
+ async function subdirs(dir) {
184
+ return (await readdirSafe(dir)).filter((d) => d.isDirectory()).map((d) => path.join(dir, d.name));
185
+ }
186
+
187
+ // pi-subagents writes <dir of parent jsonl>/<parent basename>/<runId>/<attempt>/session.jsonl.
188
+ // Their cost is already in the parent's `subagent` toolResult; they are listed so a run can be opened.
189
+ async function nestedUnder(parentsDir, projectDirName) {
190
+ const out = [];
191
+ for (const parent of await subdirs(parentsDir)) {
192
+ for (const run of await subdirs(parent)) {
193
+ for (const attempt of await subdirs(run)) {
194
+ const file = path.join(attempt, 'session.jsonl');
195
+ try {
196
+ const s = await fs.promises.stat(file);
197
+ const base = { file, parentDirName: path.basename(parent), runId: path.basename(run), attempt: path.basename(attempt), mtime: s.mtime, mtimeMs: s.mtimeMs, size: s.size };
198
+ const withProj = await withProject(base, projectDirName);
199
+ if (withProj) out.push(withProj);
200
+ } catch {}
201
+ }
202
+ }
203
+ }
88
204
  return out;
89
205
  }
90
206
 
207
+ async function listNestedSubagentFiles() {
208
+ const perRoot = await Promise.all(config.roots.map(async (root) => {
209
+ if (root.flat) return nestedUnder(root.dir, null);
210
+ const projects = await subdirs(root.dir);
211
+ return (await Promise.all(projects.map((p) => nestedUnder(p, path.basename(p))))).flat();
212
+ }));
213
+ return perRoot.flat();
214
+ }
215
+
91
216
  async function readJsonlLines(file) {
92
217
  return new Promise((resolve, reject) => {
93
218
  const lines = [];
@@ -96,6 +221,7 @@ async function readJsonlLines(file) {
96
221
  rl.on('line', (l) => { if (l.trim()) lines.push(l); });
97
222
  rl.on('close', () => resolve(lines));
98
223
  rl.on('error', reject);
224
+ stream.on('error', reject);
99
225
  });
100
226
  }
101
227
 
@@ -171,7 +297,7 @@ function summarize(entries) {
171
297
  userCount++;
172
298
  if (!firstUserText) {
173
299
  const c = m.content;
174
- const txt = Array.isArray(c) ? c.find((x) => x && x.type === 'text')?.text : null;
300
+ const txt = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x && x.type === 'text')?.text : null;
175
301
  if (txt) firstUserText = txt;
176
302
  }
177
303
  } else if (m.role === 'assistant') {
@@ -230,7 +356,13 @@ function invalidateSessionCache(file) {
230
356
  else sessionCache.clear();
231
357
  }
232
358
 
233
- // Yields { ts, provider, model, usage, messageId } per assistant message with usage.
359
+ function toolNames(content) {
360
+ if (!Array.isArray(content)) return [];
361
+ return content.filter((b) => b && b.type === 'toolCall' && b.name).map((b) => b.name);
362
+ }
363
+
364
+ // Yields one usage row per assistant message and per compaction that recorded its summarizer usage.
365
+ // A compaction has no model of its own, so it takes the model in effect at that point.
234
366
  function* enumerateMessageUsages(entries) {
235
367
  let provider = null;
236
368
  let model = null;
@@ -240,9 +372,23 @@ function* enumerateMessageUsages(entries) {
240
372
  model = e.modelId || model;
241
373
  continue;
242
374
  }
375
+ if (e.type === 'compaction') {
376
+ if (!e.usage) continue;
377
+ yield {
378
+ ts: e.timestamp || null,
379
+ provider,
380
+ model,
381
+ usage: e.usage,
382
+ messageId: e.id || null,
383
+ compaction: { tokensBefore: e.tokensBefore || 0, fromHook: !!e.fromHook },
384
+ };
385
+ continue;
386
+ }
243
387
  if (e.type !== 'message') continue;
244
388
  const m = e.message || {};
245
389
  if (m.role !== 'assistant') continue;
390
+ if (m.provider) provider = m.provider;
391
+ if (m.model) model = m.model;
246
392
  const u = m.usage;
247
393
  if (!u) continue;
248
394
  yield {
@@ -251,10 +397,20 @@ function* enumerateMessageUsages(entries) {
251
397
  model: m.model || model,
252
398
  usage: u,
253
399
  messageId: e.id || null,
400
+ tools: toolNames(m.content),
401
+ durationMs: m.durationMs || null,
254
402
  };
255
403
  }
256
404
  }
257
405
 
406
+ // Compactions without recorded usage still matter to the timeline, so they are listed apart.
407
+ function* enumerateCompactions(entries) {
408
+ for (const e of entries) {
409
+ if (e.type !== 'compaction') continue;
410
+ yield { ts: e.timestamp || null, tokensBefore: e.tokensBefore || 0, fromHook: !!e.fromHook, hasUsage: !!e.usage, id: e.id || null };
411
+ }
412
+ }
413
+
258
414
  // Yields { ts, agent, task, model, usage, runId, jsonlPath } per subagent run.
259
415
  // pi records subagent invocations as `subagent` tool results in the parent's
260
416
  // JSONL; each `details.results[]` entry is one run with its own usage block.
@@ -282,9 +438,20 @@ function* enumerateSubagentRuns(entries) {
282
438
  }
283
439
 
284
440
  module.exports = {
441
+ ENV_SESSION_DIR,
442
+ resolveRoots,
443
+ configure,
444
+ longPath,
445
+ pathKey,
446
+ normalizePiPath,
285
447
  getPiDir,
448
+ getAgentDir,
449
+ getCostDir,
286
450
  getSessionsDir,
451
+ getSessionRoots,
452
+ encodeProjectDir,
287
453
  decodeProjectDir,
454
+ readSessionHeader,
288
455
  listSessionFiles,
289
456
  listNestedSubagentFiles,
290
457
  readSessionEntriesUncached,
@@ -292,6 +459,7 @@ module.exports = {
292
459
  getCachedSession,
293
460
  invalidateSessionCache,
294
461
  enumerateMessageUsages,
462
+ enumerateCompactions,
295
463
  enumerateSubagentRuns,
296
464
  slugFromFile,
297
465
  };
package/lib/pi-pricing.js CHANGED
@@ -50,9 +50,17 @@ let cachedPricing = null;
50
50
  let pricingFetchedAt = 0;
51
51
  let pricingSource = 'none';
52
52
 
53
+ function useOffline(now) {
54
+ cachedPricing = new Map(Object.entries(OFFLINE_PRICING));
55
+ pricingFetchedAt = now;
56
+ pricingSource = 'offline';
57
+ return cachedPricing;
58
+ }
59
+
53
60
  async function fetchPricing() {
54
61
  const now = Date.now();
55
62
  if (cachedPricing && (now - pricingFetchedAt) < PRICING_REFRESH_MS) return cachedPricing;
63
+ if (process.env.PI_COST_OFFLINE === '1') return useOffline(now);
56
64
  try {
57
65
  const resp = await fetch(LITELLM_PRICING_URL);
58
66
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -67,16 +75,10 @@ async function fetchPricing() {
67
75
  cachedPricing = pricing;
68
76
  pricingFetchedAt = now;
69
77
  pricingSource = 'litellm';
70
- console.log(`[pi-cost pricing] loaded ${pricing.size} models from LiteLLM`);
71
78
  return pricing;
72
79
  } catch (err) {
73
80
  console.warn('[pi-cost pricing] fetch failed, using offline fallback:', err.message);
74
- if (!cachedPricing) {
75
- cachedPricing = new Map(Object.entries(OFFLINE_PRICING));
76
- pricingFetchedAt = now;
77
- pricingSource = 'offline';
78
- }
79
- return cachedPricing;
81
+ return cachedPricing || useOffline(now);
80
82
  }
81
83
  }
82
84
 
@@ -101,8 +103,19 @@ function lookupExact(pricing, key) {
101
103
  return null;
102
104
  }
103
105
 
106
+ // The fuzzy step scans the whole LiteLLM map, so each (provider, model) resolves once per map.
107
+ const resolveMemo = new WeakMap();
108
+
104
109
  function resolveKey(pricing, provider, modelId) {
105
110
  if (!pricing || !modelId) return null;
111
+ let memo = resolveMemo.get(pricing);
112
+ if (!memo) resolveMemo.set(pricing, (memo = new Map()));
113
+ const memoKey = `${provider || ''}\0${modelId}`;
114
+ if (!memo.has(memoKey)) memo.set(memoKey, resolveKeyUncached(pricing, provider, modelId));
115
+ return memo.get(memoKey);
116
+ }
117
+
118
+ function resolveKeyUncached(pricing, provider, modelId) {
106
119
  const candidates = piIdNormalizations(modelId);
107
120
  const providerLower = provider ? String(provider).toLowerCase() : null;
108
121
  const isRouting = providerLower ? PI_ROUTING_PROVIDERS.has(providerLower) : false;
@@ -194,4 +207,18 @@ function estimate(pricing, provider, modelId, usage) {
194
207
  return { cost, source: 'pricing', modelKey: match.key };
195
208
  }
196
209
 
197
- module.exports = { fetchPricing, refresh, getMeta, estimate, resolveKey };
210
+ // What one cache-read token saved against fresh input. pi's own cost split wins over LiteLLM
211
+ // because it reflects the provider pi actually billed through.
212
+ function cacheReadSaving(pricing, provider, modelId, usage) {
213
+ const read = usage?.cacheRead || 0;
214
+ if (read <= 0) return 0;
215
+ const c = usage.cost;
216
+ if (c && usage.input > 0 && c.input > 0 && typeof c.cacheRead === 'number') {
217
+ return read * (c.input / usage.input - c.cacheRead / read);
218
+ }
219
+ const info = resolveKey(pricing, provider, modelId)?.info;
220
+ if (info?.input_cost_per_token == null || info?.cache_read_input_token_cost == null) return 0;
221
+ return read * (info.input_cost_per_token - info.cache_read_input_token_cost);
222
+ }
223
+
224
+ module.exports = { fetchPricing, refresh, getMeta, estimate, resolveKey, cacheReadSaving };