pi-cost 0.1.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/README.md +46 -0
- package/extensions/cost.ts +181 -0
- package/extensions/package.json +1 -0
- package/extensions/tsconfig.json +16 -0
- package/lib/pi-parsers-cost.js +297 -0
- package/lib/pi-pricing.js +197 -0
- package/package.json +61 -0
- package/public/app.js +1290 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +7 -0
- package/public/index.html +109 -0
- package/public/manifest.json +26 -0
- package/public/style.css +420 -0
- package/public/sw.js +60 -0
- package/server.js +673 -0
- package/themes/classic-dark.json +21 -0
- package/themes/classic-light.json +21 -0
- package/themes/pi-dark.json +21 -0
- package/themes/pi-light.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# pi-cost
|
|
2
|
+
|
|
3
|
+
Cost dashboard for the [pi coding agent](https://pi.dev) — overview → project → session → message, with actual and estimated spend.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/user-attachments/assets/f9383f3f-4379-4ca6-b920-badb35f5eea8)
|
|
6
|
+
|
|
7
|
+
## Documentation
|
|
8
|
+
|
|
9
|
+
**[→ See the documentation](https://nikiforovall.github.io/pi-cost/)**
|
|
10
|
+
|
|
11
|
+
User guide, cost-source model, keyboard shortcuts, and theme authoring all live there.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pi install npm:pi-cost
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then use `/cost start | stop | restart | status | open` from inside pi.
|
|
20
|
+
|
|
21
|
+
Or run standalone:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx pi-cost
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open `http://localhost:5461` — `npm run dev` auto-opens the browser.
|
|
28
|
+
|
|
29
|
+
### From source
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
git clone https://github.com/NikiforovAll/pi-cost
|
|
33
|
+
cd pi-cost
|
|
34
|
+
npm install
|
|
35
|
+
npm start # http://localhost:5461
|
|
36
|
+
pi --add ./extensions # register as a pi extension
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Screenshots
|
|
40
|
+
|
|
41
|
+
| View | Preview |
|
|
42
|
+
| --------------- | -------------------------------------------- |
|
|
43
|
+
| Overview |  |
|
|
44
|
+
| Project |  |
|
|
45
|
+
| Session |  |
|
|
46
|
+
| Session (light) |  |
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join as joinPath, resolve as resolvePath } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
const extDir = fileURLToPath(new URL('.', import.meta.url));
|
|
10
|
+
|
|
11
|
+
let child: ChildProcess | null = null;
|
|
12
|
+
let lastStderr = "";
|
|
13
|
+
const port = 5461;
|
|
14
|
+
|
|
15
|
+
const COST_DIR = joinPath(homedir(), ".pi", "agent", "cost");
|
|
16
|
+
const SETTINGS_PATH = joinPath(COST_DIR, "settings.json");
|
|
17
|
+
const DEFAULT_THEME_DIR = joinPath(COST_DIR, "themes");
|
|
18
|
+
|
|
19
|
+
type CostSettings = {
|
|
20
|
+
themes?: { light?: string; dark?: string; dir?: string };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function readSettings(): CostSettings {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, "utf8")) as CostSettings;
|
|
26
|
+
} catch (e: any) {
|
|
27
|
+
if (e?.code !== "ENOENT") console.warn(`pi-cost: cannot read ${SETTINGS_PATH}: ${e.message}`);
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildServerEnv(): NodeJS.ProcessEnv {
|
|
33
|
+
const settings = readSettings();
|
|
34
|
+
const t = settings.themes ?? {};
|
|
35
|
+
const env: NodeJS.ProcessEnv = { ...process.env, PORT: String(port) };
|
|
36
|
+
env.COST_THEME_DIR = process.env.COST_THEME_DIR ?? t.dir ?? DEFAULT_THEME_DIR;
|
|
37
|
+
if (process.env.COST_LIGHT_THEME ?? t.light) {
|
|
38
|
+
env.COST_LIGHT_THEME = process.env.COST_LIGHT_THEME ?? t.light;
|
|
39
|
+
}
|
|
40
|
+
if (process.env.COST_DARK_THEME ?? t.dark) {
|
|
41
|
+
env.COST_DARK_THEME = process.env.COST_DARK_THEME ?? t.dark;
|
|
42
|
+
}
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SUBCOMMANDS = ["start", "stop", "restart", "status", "open"] as const;
|
|
47
|
+
type Sub = (typeof SUBCOMMANDS)[number];
|
|
48
|
+
|
|
49
|
+
function probePort(p: number, timeoutMs = 250): Promise<boolean> {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const sock = createConnection({ port: p, host: "127.0.0.1" });
|
|
52
|
+
const done = (ok: boolean) => { sock.destroy(); resolve(ok); };
|
|
53
|
+
sock.setTimeout(timeoutMs);
|
|
54
|
+
sock.once("connect", () => done(true));
|
|
55
|
+
sock.once("timeout", () => done(false));
|
|
56
|
+
sock.once("error", () => done(false));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function waitForPort(p: number, totalMs = 5000): Promise<boolean> {
|
|
61
|
+
const start = Date.now();
|
|
62
|
+
while (Date.now() - start < totalMs) {
|
|
63
|
+
if (await probePort(p)) return true;
|
|
64
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function splitArgs(args: string): string[] {
|
|
70
|
+
return args.trim().split(/\s+/).filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findPidsOnPort(p: number): number[] {
|
|
74
|
+
if (process.platform === "win32") {
|
|
75
|
+
const r = spawnSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf8" });
|
|
76
|
+
if (r.status !== 0) return [];
|
|
77
|
+
const pids = new Set<number>();
|
|
78
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
79
|
+
const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
|
|
80
|
+
if (m && Number(m[1]) === p) pids.add(Number(m[2]));
|
|
81
|
+
}
|
|
82
|
+
return [...pids];
|
|
83
|
+
}
|
|
84
|
+
const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
85
|
+
if (r.status !== 0) return [];
|
|
86
|
+
return r.stdout.split(/\s+/).map((s) => Number(s)).filter((n) => Number.isFinite(n) && n > 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function killPid(pid: number): void {
|
|
90
|
+
if (process.platform === "win32") {
|
|
91
|
+
spawnSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "ignore" });
|
|
92
|
+
} else {
|
|
93
|
+
try { process.kill(pid, "SIGKILL"); } catch {}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function costExtension(pi: ExtensionAPI) {
|
|
98
|
+
const serverPath = resolvePath(extDir, "..", "server.js");
|
|
99
|
+
const url = `http://localhost:${port}`;
|
|
100
|
+
|
|
101
|
+
type Notify = (m: string, l?: "info" | "error") => void;
|
|
102
|
+
|
|
103
|
+
async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
104
|
+
if (await probePort(port)) {
|
|
105
|
+
notify(`pi-cost already listening on ${url} — run /cost open to launch it`);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
lastStderr = "";
|
|
109
|
+
child = spawn(process.execPath, [serverPath], {
|
|
110
|
+
env: buildServerEnv(),
|
|
111
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
112
|
+
detached: true,
|
|
113
|
+
windowsHide: true,
|
|
114
|
+
});
|
|
115
|
+
child.stderr?.on("data", (b) => { lastStderr += b.toString(); });
|
|
116
|
+
child.on("exit", () => { child = null; });
|
|
117
|
+
|
|
118
|
+
if (!(await waitForPort(port))) {
|
|
119
|
+
notify(`pi-cost failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`, "error");
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
child.stderr?.removeAllListeners("data");
|
|
123
|
+
child.stderr?.resume();
|
|
124
|
+
child.unref();
|
|
125
|
+
if (!opts.silentSuccess) notify(`pi-cost started → ${url} — run /cost open to launch it`);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function stopServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
130
|
+
if (child) child.kill("SIGINT");
|
|
131
|
+
child = null;
|
|
132
|
+
|
|
133
|
+
if (await probePort(port)) {
|
|
134
|
+
const pids = findPidsOnPort(port);
|
|
135
|
+
for (const pid of pids) killPid(pid);
|
|
136
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
137
|
+
if (await probePort(port)) {
|
|
138
|
+
notify(`port ${port} still in use after killing pids ${pids.join(",") || "?"}`, "error");
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (!opts.silentSuccess) {
|
|
142
|
+
notify(`pi-cost stopped (killed orphan pid${pids.length > 1 ? "s" : ""} ${pids.join(",")})`);
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (!opts.silentSuccess) notify("pi-cost stopped");
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pi.registerCommand("cost", {
|
|
151
|
+
description: "pi-cost dashboard: start | stop | restart | status | open",
|
|
152
|
+
getArgumentCompletions: (prefix) =>
|
|
153
|
+
SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),
|
|
154
|
+
handler: async (args, ctx) => {
|
|
155
|
+
const tokens = splitArgs(args);
|
|
156
|
+
const sub = (tokens[0] || "start") as Sub;
|
|
157
|
+
const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
|
|
158
|
+
|
|
159
|
+
if (sub === "start") { await startServer(notify); return; }
|
|
160
|
+
if (sub === "stop") { await stopServer(notify); return; }
|
|
161
|
+
if (sub === "restart") {
|
|
162
|
+
if (!(await stopServer(notify, { silentSuccess: true }))) return;
|
|
163
|
+
if (!(await startServer(notify, { silentSuccess: true }))) return;
|
|
164
|
+
notify(`pi-cost restarted → ${url}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (sub === "status") {
|
|
168
|
+
const up = await probePort(port);
|
|
169
|
+
const owned = child ? ` (pid ${child.pid})` : " (external)";
|
|
170
|
+
notify(up ? `running on ${url}${owned}` : "not running");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (sub === "open") {
|
|
174
|
+
const { default: open } = await import("open");
|
|
175
|
+
await open(url);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
notify(`Usage: /cost ${SUBCOMMANDS.join("|")}`, "error");
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"lib": ["es2024"],
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["./**/*.ts"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const readline = require('node:readline');
|
|
7
|
+
|
|
8
|
+
const PI_DIR = process.env.PI_DIR || path.join(os.homedir(), '.pi');
|
|
9
|
+
const SESSIONS_DIR = path.join(PI_DIR, 'agent', 'sessions');
|
|
10
|
+
|
|
11
|
+
function getPiDir() { return PI_DIR; }
|
|
12
|
+
function getSessionsDir() { return SESSIONS_DIR; }
|
|
13
|
+
|
|
14
|
+
// "--C--Users-nikiforovall-dev-pi--" -> "C:\Users\nikiforovall\dev\pi"
|
|
15
|
+
function decodeProjectDir(name) {
|
|
16
|
+
let s = name;
|
|
17
|
+
if (s.startsWith('--')) s = s.slice(2);
|
|
18
|
+
if (s.endsWith('--')) s = s.slice(0, -2);
|
|
19
|
+
const m = s.match(/^([A-Za-z])--(.+)$/);
|
|
20
|
+
if (m) return `${m[1]}:\\${m[2].replace(/-/g, '\\')}`;
|
|
21
|
+
return s.replace(/-/g, path.sep);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function listSessionFiles() {
|
|
25
|
+
let projDirs;
|
|
26
|
+
try { projDirs = await fs.promises.readdir(SESSIONS_DIR, { withFileTypes: true }); }
|
|
27
|
+
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);
|
|
44
|
+
}));
|
|
45
|
+
return perProject.flat();
|
|
46
|
+
}
|
|
47
|
+
|
|
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
|
+
}));
|
|
86
|
+
}));
|
|
87
|
+
}));
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readJsonlLines(file) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const lines = [];
|
|
94
|
+
const stream = fs.createReadStream(file, { encoding: 'utf8' });
|
|
95
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
96
|
+
rl.on('line', (l) => { if (l.trim()) lines.push(l); });
|
|
97
|
+
rl.on('close', () => resolve(lines));
|
|
98
|
+
rl.on('error', reject);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseLine(line) {
|
|
103
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readSessionEntriesUncached(file) {
|
|
107
|
+
const lines = await readJsonlLines(file);
|
|
108
|
+
const entries = [];
|
|
109
|
+
for (const l of lines) {
|
|
110
|
+
const e = parseLine(l);
|
|
111
|
+
if (e) entries.push(e);
|
|
112
|
+
}
|
|
113
|
+
return entries;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SESSION_CACHE_MAX = 200;
|
|
117
|
+
const sessionCache = new Map();
|
|
118
|
+
|
|
119
|
+
function cachePut(file, entry) {
|
|
120
|
+
if (sessionCache.has(file)) sessionCache.delete(file);
|
|
121
|
+
sessionCache.set(file, entry);
|
|
122
|
+
while (sessionCache.size > SESSION_CACHE_MAX) {
|
|
123
|
+
sessionCache.delete(sessionCache.keys().next().value);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function basenameWithoutExt(file) { return path.basename(file, '.jsonl'); }
|
|
128
|
+
|
|
129
|
+
function slugFromFile(file) {
|
|
130
|
+
const base = basenameWithoutExt(file);
|
|
131
|
+
const idx = base.indexOf('_');
|
|
132
|
+
return idx >= 0 ? base.slice(idx + 1) : base;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Aggregate per-session totals. Mirrors pi-kanban's summarize() but trimmed to
|
|
136
|
+
// the fields we need for cost reporting.
|
|
137
|
+
function summarize(entries) {
|
|
138
|
+
let sessionEntry = null;
|
|
139
|
+
let lastTimestamp = null;
|
|
140
|
+
let firstTimestamp = null;
|
|
141
|
+
let totalCost = 0;
|
|
142
|
+
let totalInput = 0;
|
|
143
|
+
let totalOutput = 0;
|
|
144
|
+
let totalCacheRead = 0;
|
|
145
|
+
let totalCacheWrite = 0;
|
|
146
|
+
let userCount = 0;
|
|
147
|
+
let assistantCount = 0;
|
|
148
|
+
let messageCount = 0;
|
|
149
|
+
let provider = null;
|
|
150
|
+
let model = null;
|
|
151
|
+
let customTitle = null;
|
|
152
|
+
let firstUserText = null;
|
|
153
|
+
let parentSession = null;
|
|
154
|
+
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
if (e.type === 'session') {
|
|
157
|
+
sessionEntry = e;
|
|
158
|
+
if (e.parentSession) parentSession = e.parentSession;
|
|
159
|
+
if (!firstTimestamp) firstTimestamp = e.timestamp || null;
|
|
160
|
+
} else if (e.type === 'session_info') {
|
|
161
|
+
if (typeof e.name === 'string' && e.name.trim()) customTitle = e.name.trim();
|
|
162
|
+
} else if (e.type === 'model_change') {
|
|
163
|
+
provider = e.provider || provider;
|
|
164
|
+
model = e.modelId || model;
|
|
165
|
+
} else if (e.type === 'message') {
|
|
166
|
+
messageCount++;
|
|
167
|
+
if (!firstTimestamp) firstTimestamp = e.timestamp || null;
|
|
168
|
+
lastTimestamp = e.timestamp || lastTimestamp;
|
|
169
|
+
const m = e.message || {};
|
|
170
|
+
if (m.role === 'user') {
|
|
171
|
+
userCount++;
|
|
172
|
+
if (!firstUserText) {
|
|
173
|
+
const c = m.content;
|
|
174
|
+
const txt = Array.isArray(c) ? c.find((x) => x && x.type === 'text')?.text : null;
|
|
175
|
+
if (txt) firstUserText = txt;
|
|
176
|
+
}
|
|
177
|
+
} else if (m.role === 'assistant') {
|
|
178
|
+
assistantCount++;
|
|
179
|
+
const u = m.usage;
|
|
180
|
+
if (u) {
|
|
181
|
+
totalInput += u.input || 0;
|
|
182
|
+
totalOutput += u.output || 0;
|
|
183
|
+
totalCacheRead += u.cacheRead || 0;
|
|
184
|
+
totalCacheWrite += u.cacheWrite || 0;
|
|
185
|
+
totalCost += (u.cost && u.cost.total) || 0;
|
|
186
|
+
}
|
|
187
|
+
if (m.provider) provider = m.provider;
|
|
188
|
+
if (m.model) model = m.model;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
sessionEntry,
|
|
195
|
+
firstTimestamp,
|
|
196
|
+
lastTimestamp,
|
|
197
|
+
totalCost,
|
|
198
|
+
totalInput,
|
|
199
|
+
totalOutput,
|
|
200
|
+
totalCacheRead,
|
|
201
|
+
totalCacheWrite,
|
|
202
|
+
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
|
|
203
|
+
userCount,
|
|
204
|
+
assistantCount,
|
|
205
|
+
messageCount,
|
|
206
|
+
provider,
|
|
207
|
+
model,
|
|
208
|
+
customTitle,
|
|
209
|
+
firstUserText,
|
|
210
|
+
parentSession,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function getCachedSession(file, knownStat = null) {
|
|
215
|
+
const stat = knownStat || await fs.promises.stat(file);
|
|
216
|
+
const cached = sessionCache.get(file);
|
|
217
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
218
|
+
cachePut(file, cached);
|
|
219
|
+
return cached;
|
|
220
|
+
}
|
|
221
|
+
const entries = await readSessionEntriesUncached(file);
|
|
222
|
+
const summary = summarize(entries);
|
|
223
|
+
const entry = { mtimeMs: stat.mtimeMs, size: stat.size, entries, summary };
|
|
224
|
+
cachePut(file, entry);
|
|
225
|
+
return entry;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function invalidateSessionCache(file) {
|
|
229
|
+
if (file) sessionCache.delete(file);
|
|
230
|
+
else sessionCache.clear();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Yields { ts, provider, model, usage, messageId } per assistant message with usage.
|
|
234
|
+
function* enumerateMessageUsages(entries) {
|
|
235
|
+
let provider = null;
|
|
236
|
+
let model = null;
|
|
237
|
+
for (const e of entries) {
|
|
238
|
+
if (e.type === 'model_change') {
|
|
239
|
+
provider = e.provider || provider;
|
|
240
|
+
model = e.modelId || model;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (e.type !== 'message') continue;
|
|
244
|
+
const m = e.message || {};
|
|
245
|
+
if (m.role !== 'assistant') continue;
|
|
246
|
+
const u = m.usage;
|
|
247
|
+
if (!u) continue;
|
|
248
|
+
yield {
|
|
249
|
+
ts: e.timestamp || null,
|
|
250
|
+
provider: m.provider || provider,
|
|
251
|
+
model: m.model || model,
|
|
252
|
+
usage: u,
|
|
253
|
+
messageId: e.id || null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Yields { ts, agent, task, model, usage, runId, jsonlPath } per subagent run.
|
|
259
|
+
// pi records subagent invocations as `subagent` tool results in the parent's
|
|
260
|
+
// JSONL; each `details.results[]` entry is one run with its own usage block.
|
|
261
|
+
function* enumerateSubagentRuns(entries) {
|
|
262
|
+
for (const e of entries) {
|
|
263
|
+
if (e.type !== 'message') continue;
|
|
264
|
+
const m = e.message;
|
|
265
|
+
if (!m || m.role !== 'toolResult' || m.toolName !== 'subagent') continue;
|
|
266
|
+
const results = m.details && Array.isArray(m.details.results) ? m.details.results : [];
|
|
267
|
+
for (const r of results) {
|
|
268
|
+
if (!r || !r.usage) continue;
|
|
269
|
+
yield {
|
|
270
|
+
ts: e.timestamp || m.timestamp || null,
|
|
271
|
+
agent: r.agent || null,
|
|
272
|
+
task: r.task || null,
|
|
273
|
+
model: r.model || null,
|
|
274
|
+
usage: r.usage,
|
|
275
|
+
runId: m.details?.runId || null,
|
|
276
|
+
jsonlPath: r.sessionFile || r.artifactPaths?.jsonlPath || null,
|
|
277
|
+
turns: r.usage.turns || null,
|
|
278
|
+
exitCode: r.exitCode ?? null,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
getPiDir,
|
|
286
|
+
getSessionsDir,
|
|
287
|
+
decodeProjectDir,
|
|
288
|
+
listSessionFiles,
|
|
289
|
+
listNestedSubagentFiles,
|
|
290
|
+
readSessionEntriesUncached,
|
|
291
|
+
summarize,
|
|
292
|
+
getCachedSession,
|
|
293
|
+
invalidateSessionCache,
|
|
294
|
+
enumerateMessageUsages,
|
|
295
|
+
enumerateSubagentRuns,
|
|
296
|
+
slugFromFile,
|
|
297
|
+
};
|