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/net-guard.js
ADDED
|
@@ -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 };
|
package/lib/pi-parsers-cost.js
CHANGED
|
@@ -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
|
|
9
|
-
const SESSIONS_DIR = path.join(PI_DIR, 'agent', 'sessions');
|
|
8
|
+
const ENV_SESSION_DIR = 'PI_CODING_AGENT_SESSION_DIR';
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
//
|
|
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,
|
|
116
|
+
return `/${s.replace(/-/g, '/')}`;
|
|
22
117
|
}
|
|
23
118
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
156
|
+
return out.filter(Boolean);
|
|
46
157
|
}
|
|
47
158
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|