@ygncode/pi-insights 1.0.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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/assets/index.js +46 -0
- package/dist/favicon.svg +20 -0
- package/dist/index.html +14 -0
- package/index.ts +163 -0
- package/lib/analytics.ts +254 -0
- package/lib/parser.ts +185 -0
- package/lib/rage.ts +52 -0
- package/lib/types.ts +160 -0
- package/package.json +66 -0
package/dist/favicon.svg
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
|
3
|
+
<path fill="#000" fill-rule="evenodd" d="
|
|
4
|
+
M165.29 165.29
|
|
5
|
+
H517.36
|
|
6
|
+
V400
|
|
7
|
+
H400
|
|
8
|
+
V517.36
|
|
9
|
+
H282.65
|
|
10
|
+
V634.72
|
|
11
|
+
H165.29
|
|
12
|
+
Z
|
|
13
|
+
M282.65 282.65
|
|
14
|
+
V400
|
|
15
|
+
H400
|
|
16
|
+
V282.65
|
|
17
|
+
Z
|
|
18
|
+
"/>
|
|
19
|
+
<path fill="#000" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
|
20
|
+
</svg>
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Pi Insights</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
|
8
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
|
9
|
+
<script type="module" crossorigin src="./assets/index.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/index.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Insights Extension
|
|
3
|
+
* Generates beautiful analytics reports for pi sessions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { exec } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { promises as fs } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { parseSessionFile } from "./lib/parser.js";
|
|
14
|
+
import { computeAnalytics } from "./lib/analytics.js";
|
|
15
|
+
import type { Analytics } from "./lib/types.js";
|
|
16
|
+
|
|
17
|
+
const execAsync = promisify(exec);
|
|
18
|
+
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
// ── Report Generator ──────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
async function ensureBuilt(extensionDir: string): Promise<void> {
|
|
23
|
+
const distDir = path.join(extensionDir, "dist");
|
|
24
|
+
const indexHtml = path.join(distDir, "index.html");
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await fs.access(indexHtml);
|
|
28
|
+
return;
|
|
29
|
+
} catch {
|
|
30
|
+
// Need to build
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const nodeModules = path.join(extensionDir, "node_modules");
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(nodeModules);
|
|
36
|
+
} catch {
|
|
37
|
+
await execAsync("npm install", { cwd: extensionDir });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await execAsync("npm run build", { cwd: extensionDir });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function generateReport(
|
|
44
|
+
extensionDir: string,
|
|
45
|
+
analytics: Analytics,
|
|
46
|
+
reportDir: string
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const distDir = path.join(extensionDir, "dist");
|
|
49
|
+
const reportPath = path.join(reportDir, "pi-insights.html");
|
|
50
|
+
|
|
51
|
+
const indexPath = path.join(distDir, "index.html");
|
|
52
|
+
let html = await fs.readFile(indexPath, "utf8");
|
|
53
|
+
|
|
54
|
+
// Inline the favicon as a data URI so it works from file://
|
|
55
|
+
const faviconPath = path.join(distDir, "favicon.svg");
|
|
56
|
+
try {
|
|
57
|
+
const faviconSvg = await fs.readFile(faviconPath, "utf8");
|
|
58
|
+
const faviconDataUri = "data:image/svg+xml;base64," + Buffer.from(faviconSvg).toString("base64");
|
|
59
|
+
html = html.replace(/href="\.\/favicon\.svg"/, `href="${faviconDataUri}"`);
|
|
60
|
+
} catch { /* no favicon, skip */ }
|
|
61
|
+
|
|
62
|
+
// Inline the JS bundle so the file works from file:// without CORS issues
|
|
63
|
+
const jsMatch = html.match(/<script[^>]*src="\.\/assets\/([^"]*)\.js"[^>]*><\/script>/);
|
|
64
|
+
if (jsMatch) {
|
|
65
|
+
const jsFile = path.join(distDir, "assets", jsMatch[1] + ".js");
|
|
66
|
+
try {
|
|
67
|
+
const js = await fs.readFile(jsFile, "utf8");
|
|
68
|
+
// Escape </ so the HTML parser doesn't exit the script block early
|
|
69
|
+
const safeJs = js.replace(/<\//g, "<\\/");
|
|
70
|
+
html = html.replace(jsMatch[0], "");
|
|
71
|
+
html = html.replace(
|
|
72
|
+
"<div id=\"root\"></div>",
|
|
73
|
+
() => `<div id="root"></div>\n <script>\n${safeJs}\n </script>`
|
|
74
|
+
);
|
|
75
|
+
} catch { /* keep original if not found */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Escape </ in the JSON data for the same reason
|
|
79
|
+
const safeData = JSON.stringify(analytics).replace(/<\//g, "<\\/");
|
|
80
|
+
html = html.replace("<head>", () => `<head>\n <script>window.__ANALYTICS_DATA__ = ${safeData};</script>`);
|
|
81
|
+
|
|
82
|
+
await fs.writeFile(reportPath, html, "utf8");
|
|
83
|
+
return reportPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Main Extension ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export default function (pi: ExtensionAPI) {
|
|
89
|
+
pi.registerCommand("insights", {
|
|
90
|
+
description: "Generate insights report for pi sessions",
|
|
91
|
+
getArgumentCompletions: () => null,
|
|
92
|
+
handler: async (_args, ctx) => {
|
|
93
|
+
const reportDir = path.join(os.homedir(), ".pi", "agent", "insights-reports");
|
|
94
|
+
await fs.mkdir(reportDir, { recursive: true });
|
|
95
|
+
|
|
96
|
+
ctx.ui.notify("📊 Building UI (first run may take a minute)...", "info");
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await ensureBuilt(extensionDir);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
ctx.ui.notify("❌ Build failed: " + (err as Error).message, "error");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ctx.ui.notify("📁 Scanning sessions...", "info");
|
|
106
|
+
|
|
107
|
+
const sessionsDir = path.join(os.homedir(), ".pi", "agent", "sessions");
|
|
108
|
+
const sessionFiles: string[] = [];
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
const subEntries = await fs.readdir(path.join(sessionsDir, entry.name));
|
|
115
|
+
for (const sub of subEntries) {
|
|
116
|
+
if (sub.endsWith(".jsonl")) {
|
|
117
|
+
sessionFiles.push(path.join(sessionsDir, entry.name, sub));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
ctx.ui.notify("❌ Could not find sessions directory", "error");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ctx.ui.notify(`📁 Found ${sessionFiles.length} session files`, "info");
|
|
128
|
+
|
|
129
|
+
const sessions = [];
|
|
130
|
+
let parsed = 0;
|
|
131
|
+
for (const file of sessionFiles) {
|
|
132
|
+
const sess = await parseSessionFile(file);
|
|
133
|
+
if (sess) sessions.push(sess);
|
|
134
|
+
parsed++;
|
|
135
|
+
if (parsed % 50 === 0) {
|
|
136
|
+
ctx.ui.setStatus("insights", `Parsed ${parsed}/${sessionFiles.length}...`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (sessions.length === 0) {
|
|
141
|
+
ctx.ui.notify("❌ No valid sessions found", "error");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ctx.ui.notify(`✅ Parsed ${sessions.length} sessions, generating report...`, "info");
|
|
146
|
+
|
|
147
|
+
const analytics = computeAnalytics(sessions);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const reportPath = await generateReport(extensionDir, analytics, reportDir);
|
|
151
|
+
ctx.ui.notify(`🎉 Report ready!`, "info");
|
|
152
|
+
ctx.ui.setStatus("insights", undefined);
|
|
153
|
+
try {
|
|
154
|
+
await execAsync(`open "${reportPath}"`);
|
|
155
|
+
} catch {
|
|
156
|
+
ctx.ui.notify("Open manually: " + reportPath, "info");
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
ctx.ui.notify("❌ Report generation failed: " + (err as Error).message, "error");
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
package/lib/analytics.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ParsedSession,
|
|
3
|
+
Analytics,
|
|
4
|
+
DailyStats,
|
|
5
|
+
ProjectStats,
|
|
6
|
+
ModelStats,
|
|
7
|
+
RageStats,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
export function computeAnalytics(sessions: ParsedSession[]): Analytics {
|
|
11
|
+
if (sessions.length === 0) {
|
|
12
|
+
return emptyAnalytics();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sorted = [...sessions].sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
|
16
|
+
const totalSessions = sorted.length;
|
|
17
|
+
const totalMessages = sorted.reduce((s, sess) => s + sess.messageCount, 0);
|
|
18
|
+
const totalTokens = sorted.reduce((s, sess) => s + sess.tokenUsage.total, 0);
|
|
19
|
+
const totalCost = sorted.reduce((s, sess) => s + sess.cost.total, 0);
|
|
20
|
+
const totalDuration = sorted.reduce((s, sess) => s + sess.duration, 0);
|
|
21
|
+
|
|
22
|
+
const startDate = sorted[0].startTime;
|
|
23
|
+
const endDate = sorted[sorted.length - 1].endTime;
|
|
24
|
+
|
|
25
|
+
// Daily stats
|
|
26
|
+
const dailyMap = new Map<string, DailyStats>();
|
|
27
|
+
for (const sess of sorted) {
|
|
28
|
+
const date = sess.startTime.toISOString().split("T")[0];
|
|
29
|
+
const existing = dailyMap.get(date);
|
|
30
|
+
if (existing) {
|
|
31
|
+
existing.sessions++;
|
|
32
|
+
existing.messages += sess.messageCount;
|
|
33
|
+
existing.tokens += sess.tokenUsage.total;
|
|
34
|
+
existing.cost += sess.cost.total;
|
|
35
|
+
} else {
|
|
36
|
+
dailyMap.set(date, {
|
|
37
|
+
date,
|
|
38
|
+
sessions: 1,
|
|
39
|
+
messages: sess.messageCount,
|
|
40
|
+
tokens: sess.tokenUsage.total,
|
|
41
|
+
cost: sess.cost.total,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const dailyStats = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
46
|
+
|
|
47
|
+
// Project stats
|
|
48
|
+
const projectMap = new Map<string, ProjectStats>();
|
|
49
|
+
for (const sess of sorted) {
|
|
50
|
+
const existing = projectMap.get(sess.projectName);
|
|
51
|
+
if (existing) {
|
|
52
|
+
existing.sessions++;
|
|
53
|
+
existing.messages += sess.messageCount;
|
|
54
|
+
existing.tokens += sess.tokenUsage.total;
|
|
55
|
+
existing.cost += sess.cost.total;
|
|
56
|
+
existing.duration += sess.duration;
|
|
57
|
+
} else {
|
|
58
|
+
projectMap.set(sess.projectName, {
|
|
59
|
+
name: sess.projectName,
|
|
60
|
+
sessions: 1,
|
|
61
|
+
messages: sess.messageCount,
|
|
62
|
+
tokens: sess.tokenUsage.total,
|
|
63
|
+
cost: sess.cost.total,
|
|
64
|
+
duration: sess.duration,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const projectStats = Array.from(projectMap.values()).sort((a, b) => b.messages - a.messages);
|
|
69
|
+
|
|
70
|
+
// Model stats
|
|
71
|
+
const modelMap = new Map<string, ModelStats>();
|
|
72
|
+
let modelSwitchCount = 0;
|
|
73
|
+
for (const sess of sorted) {
|
|
74
|
+
const sessModels = Object.keys(sess.models);
|
|
75
|
+
if (sessModels.length > 1) modelSwitchCount++;
|
|
76
|
+
|
|
77
|
+
for (const [name, stats] of Object.entries(sess.models)) {
|
|
78
|
+
const existing = modelMap.get(name);
|
|
79
|
+
if (existing) {
|
|
80
|
+
existing.count += stats.count;
|
|
81
|
+
existing.tokens += stats.tokens;
|
|
82
|
+
existing.cost += stats.cost;
|
|
83
|
+
} else {
|
|
84
|
+
modelMap.set(name, { name, count: stats.count, tokens: stats.tokens, cost: stats.cost, avgDuration: 0 });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Only include models that actually generated tokens (filters model_change-only entries)
|
|
89
|
+
const modelStats = Array.from(modelMap.values())
|
|
90
|
+
.filter(m => m.tokens > 0)
|
|
91
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
92
|
+
|
|
93
|
+
// Tool usage
|
|
94
|
+
const toolMap = new Map<string, number>();
|
|
95
|
+
for (const sess of sorted) {
|
|
96
|
+
for (const [tool, count] of Object.entries(sess.toolUsage)) {
|
|
97
|
+
toolMap.set(tool, (toolMap.get(tool) ?? 0) + count);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const topTools = Array.from(toolMap.entries())
|
|
101
|
+
.map(([name, count]) => ({ name, count }))
|
|
102
|
+
.sort((a, b) => b.count - a.count)
|
|
103
|
+
.slice(0, 10);
|
|
104
|
+
|
|
105
|
+
// Thinking levels
|
|
106
|
+
const thinkingMap = new Map<string, number>();
|
|
107
|
+
for (const sess of sorted) {
|
|
108
|
+
for (const [level, count] of Object.entries(sess.thinkingLevels)) {
|
|
109
|
+
thinkingMap.set(level, (thinkingMap.get(level) ?? 0) + count);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const thinkingLevelDistribution = Array.from(thinkingMap.entries())
|
|
113
|
+
.map(([name, count]) => ({ name, count }))
|
|
114
|
+
.sort((a, b) => b.count - a.count);
|
|
115
|
+
|
|
116
|
+
// Stop reasons
|
|
117
|
+
const stopMap = new Map<string, number>();
|
|
118
|
+
for (const sess of sorted) {
|
|
119
|
+
for (const [reason, count] of Object.entries(sess.stopReasons)) {
|
|
120
|
+
stopMap.set(reason, (stopMap.get(reason) ?? 0) + count);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const stopReasonDistribution = Array.from(stopMap.entries())
|
|
124
|
+
.map(([name, count]) => ({ name, count }))
|
|
125
|
+
.sort((a, b) => b.count - a.count);
|
|
126
|
+
|
|
127
|
+
// Hourly distribution (session start hours)
|
|
128
|
+
const hourlyMap = new Map<number, number>();
|
|
129
|
+
for (let i = 0; i < 24; i++) hourlyMap.set(i, 0);
|
|
130
|
+
for (const sess of sorted) {
|
|
131
|
+
const hour = sess.startTime.getHours();
|
|
132
|
+
hourlyMap.set(hour, (hourlyMap.get(hour) ?? 0) + 1);
|
|
133
|
+
}
|
|
134
|
+
const hourlyDistribution = Array.from(hourlyMap.entries())
|
|
135
|
+
.map(([hour, count]) => ({ hour, count }))
|
|
136
|
+
.sort((a, b) => a.hour - b.hour);
|
|
137
|
+
|
|
138
|
+
// Rage stats
|
|
139
|
+
const rageByModel = new Map<string, number>();
|
|
140
|
+
const rageByHour = new Map<number, number>();
|
|
141
|
+
const rageByProject = new Map<string, number>();
|
|
142
|
+
const rageByWord = new Map<string, { group: string; count: number }>();
|
|
143
|
+
let rageTotalHits = 0;
|
|
144
|
+
let rageMsgsWithSwears = 0;
|
|
145
|
+
|
|
146
|
+
for (const sess of sorted) {
|
|
147
|
+
const seenMsgKeys = new Set<string>();
|
|
148
|
+
for (const hit of sess.rageHits) {
|
|
149
|
+
rageTotalHits++;
|
|
150
|
+
const msgKey = `${sess.id}-${hit.msgIndex}`;
|
|
151
|
+
if (!seenMsgKeys.has(msgKey)) {
|
|
152
|
+
rageMsgsWithSwears++;
|
|
153
|
+
seenMsgKeys.add(msgKey);
|
|
154
|
+
}
|
|
155
|
+
rageByModel.set(hit.model, (rageByModel.get(hit.model) ?? 0) + 1);
|
|
156
|
+
if (hit.hour >= 0) rageByHour.set(hit.hour, (rageByHour.get(hit.hour) ?? 0) + 1);
|
|
157
|
+
rageByProject.set(sess.projectName, (rageByProject.get(sess.projectName) ?? 0) + 1);
|
|
158
|
+
const existing = rageByWord.get(hit.word);
|
|
159
|
+
if (existing) { existing.count++; }
|
|
160
|
+
else { rageByWord.set(hit.word, { group: hit.group, count: 1 }); }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const rageStats: RageStats = {
|
|
165
|
+
total: rageTotalHits,
|
|
166
|
+
messagesWithSwears: rageMsgsWithSwears,
|
|
167
|
+
byModel: Array.from(rageByModel.entries())
|
|
168
|
+
.map(([name, count]) => ({ name, count }))
|
|
169
|
+
.sort((a, b) => b.count - a.count),
|
|
170
|
+
byHour: Array.from({ length: 24 }, (_, h) => ({ hour: h, count: rageByHour.get(h) ?? 0 })),
|
|
171
|
+
byProject: Array.from(rageByProject.entries())
|
|
172
|
+
.map(([name, count]) => ({ name, count }))
|
|
173
|
+
.sort((a, b) => b.count - a.count),
|
|
174
|
+
topWords: Array.from(rageByWord.entries())
|
|
175
|
+
.map(([word, { group, count }]) => ({ word, group, count }))
|
|
176
|
+
.sort((a, b) => b.count - a.count)
|
|
177
|
+
.slice(0, 20),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
totalSessions,
|
|
182
|
+
totalMessages,
|
|
183
|
+
totalTokens,
|
|
184
|
+
totalCost,
|
|
185
|
+
totalDuration,
|
|
186
|
+
avgSessionDuration: Math.round(totalDuration / totalSessions),
|
|
187
|
+
avgMessagesPerSession: Math.round(totalMessages / totalSessions),
|
|
188
|
+
dateRange: {
|
|
189
|
+
start: startDate.toISOString().split("T")[0],
|
|
190
|
+
end: endDate.toISOString().split("T")[0],
|
|
191
|
+
},
|
|
192
|
+
dailyStats,
|
|
193
|
+
projectStats,
|
|
194
|
+
modelStats,
|
|
195
|
+
topTools,
|
|
196
|
+
thinkingLevelDistribution,
|
|
197
|
+
stopReasonDistribution,
|
|
198
|
+
hourlyDistribution,
|
|
199
|
+
modelSwitchCount,
|
|
200
|
+
rageStats,
|
|
201
|
+
sessions: sorted.map(s => ({
|
|
202
|
+
id: s.id,
|
|
203
|
+
cwd: s.cwd,
|
|
204
|
+
projectName: s.projectName,
|
|
205
|
+
startTime: s.startTime.toISOString(),
|
|
206
|
+
endTime: s.endTime.toISOString(),
|
|
207
|
+
duration: s.duration,
|
|
208
|
+
messageCount: s.messageCount,
|
|
209
|
+
userMessageCount: s.userMessageCount,
|
|
210
|
+
assistantMessageCount: s.assistantMessageCount,
|
|
211
|
+
toolCallCount: s.toolCallCount,
|
|
212
|
+
tokenUsage: s.tokenUsage,
|
|
213
|
+
cost: s.cost,
|
|
214
|
+
models: s.models,
|
|
215
|
+
providers: s.providers,
|
|
216
|
+
thinkingLevels: s.thinkingLevels,
|
|
217
|
+
toolUsage: s.toolUsage,
|
|
218
|
+
stopReasons: s.stopReasons,
|
|
219
|
+
toolCallErrors: s.toolCallErrors,
|
|
220
|
+
hasError: s.hasError,
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function emptyAnalytics(): Analytics {
|
|
226
|
+
const emptyRage: RageStats = {
|
|
227
|
+
total: 0,
|
|
228
|
+
messagesWithSwears: 0,
|
|
229
|
+
byModel: [],
|
|
230
|
+
byHour: Array.from({ length: 24 }, (_, h) => ({ hour: h, count: 0 })),
|
|
231
|
+
byProject: [],
|
|
232
|
+
topWords: [],
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
totalSessions: 0,
|
|
236
|
+
totalMessages: 0,
|
|
237
|
+
totalTokens: 0,
|
|
238
|
+
totalCost: 0,
|
|
239
|
+
totalDuration: 0,
|
|
240
|
+
avgSessionDuration: 0,
|
|
241
|
+
avgMessagesPerSession: 0,
|
|
242
|
+
dateRange: { start: "", end: "" },
|
|
243
|
+
dailyStats: [],
|
|
244
|
+
projectStats: [],
|
|
245
|
+
modelStats: [],
|
|
246
|
+
topTools: [],
|
|
247
|
+
thinkingLevelDistribution: [],
|
|
248
|
+
stopReasonDistribution: [],
|
|
249
|
+
hourlyDistribution: Array.from({ length: 24 }, (_, h) => ({ hour: h, count: 0 })),
|
|
250
|
+
modelSwitchCount: 0,
|
|
251
|
+
rageStats: emptyRage,
|
|
252
|
+
sessions: [],
|
|
253
|
+
};
|
|
254
|
+
}
|
package/lib/parser.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { ParsedSession, SessionEvent, SessionMessage } from "./types.js";
|
|
5
|
+
import { detectRage } from "./rage.js";
|
|
6
|
+
|
|
7
|
+
export async function parseSessionFile(filePath: string): Promise<ParsedSession | null> {
|
|
8
|
+
try {
|
|
9
|
+
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
10
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
11
|
+
|
|
12
|
+
let sessionId = "";
|
|
13
|
+
let cwd = "";
|
|
14
|
+
let startTime: Date | null = null;
|
|
15
|
+
let endTime: Date | null = null;
|
|
16
|
+
let userMessages = 0;
|
|
17
|
+
let assistantMessages = 0;
|
|
18
|
+
let toolCallCount = 0;
|
|
19
|
+
let toolCallErrors = 0;
|
|
20
|
+
let totalInput = 0;
|
|
21
|
+
let totalOutput = 0;
|
|
22
|
+
let totalCacheRead = 0;
|
|
23
|
+
let totalCacheWrite = 0;
|
|
24
|
+
let totalTokens = 0;
|
|
25
|
+
let totalCost = 0;
|
|
26
|
+
let costInput = 0;
|
|
27
|
+
let costOutput = 0;
|
|
28
|
+
let costCacheRead = 0;
|
|
29
|
+
let costCacheWrite = 0;
|
|
30
|
+
const models: Record<string, { count: number; tokens: number; cost: number }> = {};
|
|
31
|
+
const providers: Record<string, number> = {};
|
|
32
|
+
const thinkingLevels: Record<string, number> = {};
|
|
33
|
+
const toolUsage: Record<string, number> = {};
|
|
34
|
+
const stopReasons: Record<string, number> = {};
|
|
35
|
+
let currentModel = "unknown";
|
|
36
|
+
const rageHits: ParsedSession["rageHits"] = [];
|
|
37
|
+
|
|
38
|
+
for await (const line of rl) {
|
|
39
|
+
if (!line.trim()) continue;
|
|
40
|
+
try {
|
|
41
|
+
const event = JSON.parse(line) as SessionEvent;
|
|
42
|
+
|
|
43
|
+
if (!startTime && event.timestamp) startTime = new Date(event.timestamp);
|
|
44
|
+
if (event.timestamp) endTime = new Date(event.timestamp);
|
|
45
|
+
|
|
46
|
+
if (event.type === "session" || event.type === "session_info") {
|
|
47
|
+
const data = event as unknown as { id?: string; cwd?: string };
|
|
48
|
+
if (data.id) sessionId = data.id;
|
|
49
|
+
if (data.cwd) cwd = data.cwd;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (event.type === "model_change") {
|
|
53
|
+
const data = event as unknown as { modelId?: string; provider?: string };
|
|
54
|
+
if (data.modelId) {
|
|
55
|
+
models[data.modelId] = models[data.modelId] ?? { count: 0, tokens: 0, cost: 0 };
|
|
56
|
+
models[data.modelId].count++;
|
|
57
|
+
currentModel = data.modelId;
|
|
58
|
+
}
|
|
59
|
+
if (data.provider) {
|
|
60
|
+
providers[data.provider] = (providers[data.provider] ?? 0) + 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (event.type === "thinking_level_change") {
|
|
65
|
+
const data = event as unknown as { thinkingLevel?: string };
|
|
66
|
+
if (data.thinkingLevel) {
|
|
67
|
+
thinkingLevels[data.thinkingLevel] = (thinkingLevels[data.thinkingLevel] ?? 0) + 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (event.type === "message") {
|
|
72
|
+
const msg = (event as unknown as { message: SessionMessage }).message;
|
|
73
|
+
|
|
74
|
+
if (msg.role === "user") {
|
|
75
|
+
userMessages++;
|
|
76
|
+
// Collect rage hits with the message index for accurate per-message dedup
|
|
77
|
+
const textParts: string[] = [];
|
|
78
|
+
if (msg.content) {
|
|
79
|
+
for (const item of msg.content) {
|
|
80
|
+
if (item.type === "text" && item.text) textParts.push(item.text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const text = textParts.join(" ");
|
|
84
|
+
if (text) {
|
|
85
|
+
const hour = event.timestamp ? new Date(event.timestamp).getHours() : -1;
|
|
86
|
+
for (const hit of detectRage(text)) {
|
|
87
|
+
rageHits.push({ ...hit, hour, model: currentModel, msgIndex: userMessages });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} else if (msg.role === "assistant") {
|
|
91
|
+
assistantMessages++;
|
|
92
|
+
|
|
93
|
+
if (msg.usage) {
|
|
94
|
+
const input = msg.usage.input ?? 0;
|
|
95
|
+
const output = msg.usage.output ?? 0;
|
|
96
|
+
const cacheRead = msg.usage.cacheRead ?? 0;
|
|
97
|
+
const cacheWrite = msg.usage.cacheWrite ?? 0;
|
|
98
|
+
const tokens = msg.usage.totalTokens ?? (input + output + cacheRead);
|
|
99
|
+
const cost = msg.usage.cost?.total ?? 0;
|
|
100
|
+
|
|
101
|
+
totalInput += input;
|
|
102
|
+
totalOutput += output;
|
|
103
|
+
totalCacheRead += cacheRead;
|
|
104
|
+
totalCacheWrite += cacheWrite;
|
|
105
|
+
totalTokens += tokens;
|
|
106
|
+
totalCost += cost;
|
|
107
|
+
costInput += msg.usage.cost?.input ?? 0;
|
|
108
|
+
costOutput += msg.usage.cost?.output ?? 0;
|
|
109
|
+
costCacheRead += msg.usage.cost?.cacheRead ?? 0;
|
|
110
|
+
costCacheWrite += msg.usage.cost?.cacheWrite ?? 0;
|
|
111
|
+
|
|
112
|
+
if (msg.model) {
|
|
113
|
+
models[msg.model] = models[msg.model] ?? { count: 0, tokens: 0, cost: 0 };
|
|
114
|
+
models[msg.model].tokens += tokens;
|
|
115
|
+
models[msg.model].cost += cost;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (msg.stopReason) {
|
|
120
|
+
stopReasons[msg.stopReason] = (stopReasons[msg.stopReason] ?? 0) + 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (msg.content) {
|
|
125
|
+
for (const item of msg.content) {
|
|
126
|
+
if (item.type === "toolCall" && item.name) {
|
|
127
|
+
toolCallCount++;
|
|
128
|
+
toolUsage[item.name] = (toolUsage[item.name] ?? 0) + 1;
|
|
129
|
+
}
|
|
130
|
+
if (item.type === "toolResult" && item.isError) {
|
|
131
|
+
toolCallErrors++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (msg.toolCalls) {
|
|
137
|
+
toolCallCount += msg.toolCalls.length;
|
|
138
|
+
for (const tc of msg.toolCalls) {
|
|
139
|
+
if (tc.name) toolUsage[tc.name] = (toolUsage[tc.name] ?? 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (msg.toolResults) {
|
|
144
|
+
for (const tr of msg.toolResults) {
|
|
145
|
+
if (tr.isError) toolCallErrors++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Skip malformed lines
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!startTime) return null;
|
|
155
|
+
if (!endTime) endTime = startTime;
|
|
156
|
+
|
|
157
|
+
const duration = Math.max(1, Math.round((endTime.getTime() - startTime.getTime()) / 60000));
|
|
158
|
+
const projectName = cwd ? path.basename(cwd) : "unknown";
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
id: sessionId || path.basename(filePath, ".jsonl"),
|
|
162
|
+
cwd,
|
|
163
|
+
projectName,
|
|
164
|
+
startTime,
|
|
165
|
+
endTime,
|
|
166
|
+
duration,
|
|
167
|
+
messageCount: userMessages + assistantMessages,
|
|
168
|
+
userMessageCount: userMessages,
|
|
169
|
+
assistantMessageCount: assistantMessages,
|
|
170
|
+
toolCallCount,
|
|
171
|
+
tokenUsage: { input: totalInput, output: totalOutput, cacheRead: totalCacheRead, cacheWrite: totalCacheWrite, total: totalTokens },
|
|
172
|
+
cost: { input: costInput, output: costOutput, cacheRead: costCacheRead, cacheWrite: costCacheWrite, total: totalCost },
|
|
173
|
+
models,
|
|
174
|
+
providers,
|
|
175
|
+
thinkingLevels,
|
|
176
|
+
toolUsage,
|
|
177
|
+
stopReasons,
|
|
178
|
+
toolCallErrors,
|
|
179
|
+
hasError: toolCallErrors > 0,
|
|
180
|
+
rageHits,
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/lib/rage.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface RageWordEntry {
|
|
2
|
+
word: string;
|
|
3
|
+
group: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface RageMatch {
|
|
7
|
+
word: string;
|
|
8
|
+
group: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const RAGE_WORDLIST: RageWordEntry[] = [
|
|
12
|
+
{ word: "fuck", group: "fuck" }, { word: "fucking", group: "fuck" },
|
|
13
|
+
{ word: "fucked", group: "fuck" }, { word: "fucker", group: "fuck" },
|
|
14
|
+
{ word: "motherfucker", group: "fuck" }, { word: "clusterfuck", group: "fuck" },
|
|
15
|
+
{ word: "fuckup", group: "fuck" }, { word: "bullshit", group: "shit" },
|
|
16
|
+
{ word: "shit", group: "shit" }, { word: "shitty", group: "shit" },
|
|
17
|
+
{ word: "shithead", group: "shit" }, { word: "shithole", group: "shit" },
|
|
18
|
+
{ word: "asshole", group: "ass" }, { word: "jackass", group: "ass" },
|
|
19
|
+
{ word: "dumbass", group: "ass" }, { word: "ass", group: "ass" },
|
|
20
|
+
{ word: "goddamn", group: "damn" }, { word: "goddammit", group: "damn" },
|
|
21
|
+
{ word: "dammit", group: "damn" }, { word: "damn", group: "damn" },
|
|
22
|
+
{ word: "bitches", group: "bitch" }, { word: "bitch", group: "bitch" },
|
|
23
|
+
{ word: "bastard", group: "bastard" },
|
|
24
|
+
{ word: "pissed", group: "piss" }, { word: "piss", group: "piss" },
|
|
25
|
+
{ word: "dickhead", group: "dick" }, { word: "dick", group: "dick" },
|
|
26
|
+
{ word: "crappy", group: "crap" }, { word: "crap", group: "crap" },
|
|
27
|
+
{ word: "hell", group: "hell" },
|
|
28
|
+
{ word: "wtf", group: "wtf" }, { word: "stfu", group: "stfu" },
|
|
29
|
+
{ word: "cunt", group: "cunt" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const PATTERN = new RegExp(
|
|
33
|
+
`\\b(${RAGE_WORDLIST.map(w => w.word).join("|")})\\b`,
|
|
34
|
+
"gi"
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const WORD_MAP = new Map<string, string>(
|
|
38
|
+
RAGE_WORDLIST.map(w => [w.word.toLowerCase(), w.group])
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export function detectRage(text: string): RageMatch[] {
|
|
42
|
+
const hits: RageMatch[] = [];
|
|
43
|
+
for (const m of text.toLowerCase().matchAll(PATTERN)) {
|
|
44
|
+
const group = WORD_MAP.get(m[0]);
|
|
45
|
+
if (group) hits.push({ word: m[0], group });
|
|
46
|
+
}
|
|
47
|
+
return hits;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function wordCount(): number {
|
|
51
|
+
return RAGE_WORDLIST.length;
|
|
52
|
+
}
|