@towles/tool 0.0.18 → 0.0.41
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/LICENSE.md +9 -10
- package/README.md +121 -78
- package/bin/run.ts +5 -0
- package/package.json +63 -53
- package/patches/prompts.patch +34 -0
- package/src/commands/base.ts +42 -0
- package/src/commands/config.test.ts +15 -0
- package/src/commands/config.ts +43 -0
- package/src/commands/doctor.ts +133 -0
- package/src/commands/gh/branch-clean.ts +110 -0
- package/src/commands/gh/branch.test.ts +124 -0
- package/src/commands/gh/branch.ts +132 -0
- package/src/commands/gh/pr.ts +168 -0
- package/src/commands/index.ts +55 -0
- package/src/commands/install.ts +148 -0
- package/src/commands/journal/daily-notes.ts +66 -0
- package/src/commands/journal/meeting.ts +83 -0
- package/src/commands/journal/note.ts +83 -0
- package/src/commands/journal/utils.ts +399 -0
- package/src/commands/observe/graph.test.ts +89 -0
- package/src/commands/observe/graph.ts +1640 -0
- package/src/commands/observe/report.ts +166 -0
- package/src/commands/observe/session.ts +385 -0
- package/src/commands/observe/setup.ts +180 -0
- package/src/commands/observe/status.ts +146 -0
- package/src/commands/ralph/lib/execution.ts +302 -0
- package/src/commands/ralph/lib/formatter.ts +298 -0
- package/src/commands/ralph/lib/index.ts +4 -0
- package/src/commands/ralph/lib/marker.ts +108 -0
- package/src/commands/ralph/lib/state.ts +191 -0
- package/src/commands/ralph/marker/create.ts +23 -0
- package/src/commands/ralph/plan.ts +73 -0
- package/src/commands/ralph/progress.ts +44 -0
- package/src/commands/ralph/ralph.test.ts +673 -0
- package/src/commands/ralph/run.ts +408 -0
- package/src/commands/ralph/task/add.ts +105 -0
- package/src/commands/ralph/task/done.ts +73 -0
- package/src/commands/ralph/task/list.test.ts +48 -0
- package/src/commands/ralph/task/list.ts +110 -0
- package/src/commands/ralph/task/remove.ts +62 -0
- package/src/config/context.ts +7 -0
- package/src/config/settings.ts +155 -0
- package/src/constants.ts +3 -0
- package/src/types/journal.ts +16 -0
- package/src/utils/anthropic/types.ts +158 -0
- package/src/utils/date-utils.test.ts +96 -0
- package/src/utils/date-utils.ts +54 -0
- package/src/utils/exec.ts +8 -0
- package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
- package/src/utils/git/gh-cli-wrapper.ts +54 -0
- package/src/utils/git/git-wrapper.test.ts +26 -0
- package/src/utils/git/git-wrapper.ts +15 -0
- package/src/utils/git/git.ts +25 -0
- package/src/utils/render.test.ts +71 -0
- package/src/utils/render.ts +34 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -794
|
@@ -0,0 +1,1640 @@
|
|
|
1
|
+
import { Flags } from "@oclif/core";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as http from "node:http";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { x } from "tinyexec";
|
|
7
|
+
import { BaseCommand } from "../base.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calculate cutoff timestamp for days filtering.
|
|
11
|
+
* Returns 0 if days <= 0 (no filtering).
|
|
12
|
+
*/
|
|
13
|
+
export function calculateCutoffMs(days: number): number {
|
|
14
|
+
return days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Filter items by mtime against a days cutoff.
|
|
19
|
+
* Returns all items if days <= 0.
|
|
20
|
+
*/
|
|
21
|
+
export function filterByDays<T extends { mtime: number }>(items: T[], days: number): T[] {
|
|
22
|
+
const cutoff = calculateCutoffMs(days);
|
|
23
|
+
if (cutoff === 0) return items;
|
|
24
|
+
return items.filter((item) => item.mtime >= cutoff);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ContentBlock {
|
|
28
|
+
type: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
id?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
input?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface JournalEntry {
|
|
36
|
+
type: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
message?: {
|
|
40
|
+
role: "user" | "assistant";
|
|
41
|
+
model?: string;
|
|
42
|
+
usage?: {
|
|
43
|
+
input_tokens?: number;
|
|
44
|
+
output_tokens?: number;
|
|
45
|
+
cache_read_input_tokens?: number;
|
|
46
|
+
cache_creation_input_tokens?: number;
|
|
47
|
+
};
|
|
48
|
+
content?: ContentBlock[] | string;
|
|
49
|
+
};
|
|
50
|
+
uuid?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ToolData {
|
|
54
|
+
name: string;
|
|
55
|
+
detail?: string; // file path, command, etc.
|
|
56
|
+
inputTokens: number;
|
|
57
|
+
outputTokens: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TreemapNode {
|
|
61
|
+
name: string;
|
|
62
|
+
value?: number;
|
|
63
|
+
children?: TreemapNode[];
|
|
64
|
+
// Metadata for tooltips
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
fullSessionId?: string;
|
|
67
|
+
filePath?: string;
|
|
68
|
+
startTime?: string;
|
|
69
|
+
model?: string;
|
|
70
|
+
inputTokens?: number;
|
|
71
|
+
outputTokens?: number;
|
|
72
|
+
ratio?: number;
|
|
73
|
+
date?: string;
|
|
74
|
+
project?: string;
|
|
75
|
+
// Waste metrics
|
|
76
|
+
repeatedReads?: number;
|
|
77
|
+
modelEfficiency?: number; // Opus tokens / total tokens
|
|
78
|
+
// Tool data
|
|
79
|
+
tools?: ToolData[];
|
|
80
|
+
toolName?: string; // For coloring by tool type
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generate interactive HTML treemap from session token data
|
|
85
|
+
*/
|
|
86
|
+
export default class ObserveGraph extends BaseCommand {
|
|
87
|
+
static override description = "Generate interactive HTML treemap from session token data";
|
|
88
|
+
|
|
89
|
+
static override examples = [
|
|
90
|
+
"<%= config.bin %> <%= command.id %>",
|
|
91
|
+
"<%= config.bin %> <%= command.id %> --session abc123",
|
|
92
|
+
"<%= config.bin %> <%= command.id %> --open",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
static override flags = {
|
|
96
|
+
...BaseCommand.baseFlags,
|
|
97
|
+
session: Flags.string({
|
|
98
|
+
char: "s",
|
|
99
|
+
description: "Session ID to analyze (shows all sessions if not provided)",
|
|
100
|
+
}),
|
|
101
|
+
open: Flags.boolean({
|
|
102
|
+
char: "o",
|
|
103
|
+
description: "Open treemap in browser after generating",
|
|
104
|
+
default: true,
|
|
105
|
+
allowNo: true,
|
|
106
|
+
}),
|
|
107
|
+
serve: Flags.boolean({
|
|
108
|
+
description: "Start local HTTP server to serve treemap (default: true)",
|
|
109
|
+
default: true,
|
|
110
|
+
allowNo: true,
|
|
111
|
+
}),
|
|
112
|
+
port: Flags.integer({
|
|
113
|
+
char: "p",
|
|
114
|
+
description: "Port for local server",
|
|
115
|
+
default: 8765,
|
|
116
|
+
}),
|
|
117
|
+
days: Flags.integer({
|
|
118
|
+
description: "Filter to sessions from last N days (0=no limit)",
|
|
119
|
+
default: 7,
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
async run(): Promise<void> {
|
|
124
|
+
const { flags } = await this.parse(ObserveGraph);
|
|
125
|
+
|
|
126
|
+
const projectsDir = path.join(os.homedir(), ".claude", "projects");
|
|
127
|
+
if (!fs.existsSync(projectsDir)) {
|
|
128
|
+
this.error("No Claude projects directory found at ~/.claude/projects/");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sessionId = flags.session;
|
|
132
|
+
let treemapData: TreemapNode;
|
|
133
|
+
|
|
134
|
+
if (!sessionId) {
|
|
135
|
+
// All sessions mode
|
|
136
|
+
const sessions = this.findRecentSessions(projectsDir, 500, flags.days);
|
|
137
|
+
if (sessions.length === 0) {
|
|
138
|
+
this.error("No sessions found");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const daysMsg = flags.days > 0 ? ` (last ${flags.days} days)` : "";
|
|
142
|
+
this.log(`📊 Generating treemap for ${sessions.length} sessions${daysMsg}...`);
|
|
143
|
+
treemapData = this.buildAllSessionsTreemap(sessions);
|
|
144
|
+
} else {
|
|
145
|
+
// Single session mode
|
|
146
|
+
const sessionPath = this.findSessionPath(projectsDir, sessionId);
|
|
147
|
+
if (!sessionPath) {
|
|
148
|
+
this.error(`Session ${sessionId} not found`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.log(`📊 Generating treemap for session ${sessionId}...`);
|
|
152
|
+
const entries = this.parseJsonl(sessionPath);
|
|
153
|
+
treemapData = this.buildSessionTreemap(sessionId, entries);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Generate HTML
|
|
157
|
+
const html = this.generateTreemapHtml(treemapData);
|
|
158
|
+
|
|
159
|
+
// Write output file
|
|
160
|
+
const reportsDir = path.join(os.homedir(), ".claude", "reports");
|
|
161
|
+
if (!fs.existsSync(reportsDir)) {
|
|
162
|
+
fs.mkdirSync(reportsDir, { recursive: true });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const now = new Date();
|
|
166
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
167
|
+
const tzOffset = -now.getTimezoneOffset();
|
|
168
|
+
const tzSign = tzOffset >= 0 ? "+" : "-";
|
|
169
|
+
const tzHours = pad(Math.floor(Math.abs(tzOffset) / 60));
|
|
170
|
+
const tzMins = pad(Math.abs(tzOffset) % 60);
|
|
171
|
+
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}${tzSign}${tzHours}${tzMins}`;
|
|
172
|
+
const daysLabel = flags.days > 0 ? `${flags.days}d` : "all";
|
|
173
|
+
const filename = sessionId
|
|
174
|
+
? `treemap-${sessionId.slice(0, 8)}-${timestamp}.html`
|
|
175
|
+
: `treemap-${daysLabel}-${timestamp}.html`;
|
|
176
|
+
const outputPath = path.join(reportsDir, filename);
|
|
177
|
+
|
|
178
|
+
fs.writeFileSync(outputPath, html);
|
|
179
|
+
this.log(`✓ Saved to ${outputPath}`);
|
|
180
|
+
|
|
181
|
+
if (flags.serve) {
|
|
182
|
+
// Start local HTTP server
|
|
183
|
+
const server = http.createServer((req, res) => {
|
|
184
|
+
// Serve the generated HTML file
|
|
185
|
+
if (req.url === "/" || req.url === `/${filename}`) {
|
|
186
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
187
|
+
res.end(html);
|
|
188
|
+
} else {
|
|
189
|
+
res.writeHead(404);
|
|
190
|
+
res.end("Not found");
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Try to start server, fallback to next port if in use
|
|
195
|
+
const startPort = flags.port;
|
|
196
|
+
const maxAttempts = 10;
|
|
197
|
+
|
|
198
|
+
const tryPort = (port: number): Promise<number> => {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
201
|
+
server.removeListener("listening", onListening);
|
|
202
|
+
if (err.code === "EADDRINUSE" && port < startPort + maxAttempts - 1) {
|
|
203
|
+
resolve(tryPort(port + 1));
|
|
204
|
+
} else {
|
|
205
|
+
reject(err);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const onListening = () => {
|
|
210
|
+
server.removeListener("error", onError);
|
|
211
|
+
resolve(port);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
server.once("error", onError);
|
|
215
|
+
server.once("listening", onListening);
|
|
216
|
+
server.listen(port);
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const tryListen = (): Promise<number> => tryPort(startPort);
|
|
221
|
+
|
|
222
|
+
const actualPort = await tryListen();
|
|
223
|
+
const url = `http://localhost:${actualPort}/`;
|
|
224
|
+
if (actualPort !== startPort) {
|
|
225
|
+
this.log(`\n⚠️ Port ${startPort} in use, using ${actualPort}`);
|
|
226
|
+
}
|
|
227
|
+
this.log(`🌐 Server running at ${url}`);
|
|
228
|
+
this.log(" Press Ctrl+C to stop\n");
|
|
229
|
+
|
|
230
|
+
if (flags.open) {
|
|
231
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
232
|
+
x(openCmd, [url]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Keep server running until Ctrl+C
|
|
236
|
+
await new Promise<void>((resolve) => {
|
|
237
|
+
process.on("SIGINT", () => {
|
|
238
|
+
this.log("\n👋 Stopping server...");
|
|
239
|
+
server.close();
|
|
240
|
+
resolve();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
} else if (flags.open) {
|
|
244
|
+
this.log("\n📈 Opening treemap...");
|
|
245
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
246
|
+
await x(openCmd, [outputPath]);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private generateTreemapHtml(data: TreemapNode): string {
|
|
251
|
+
const width = 1200;
|
|
252
|
+
const height = 800;
|
|
253
|
+
|
|
254
|
+
return `<!DOCTYPE html>
|
|
255
|
+
<html lang="en">
|
|
256
|
+
<head>
|
|
257
|
+
<meta charset="UTF-8">
|
|
258
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
259
|
+
<title>Claude Token Treemap</title>
|
|
260
|
+
<style>
|
|
261
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
262
|
+
body {
|
|
263
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
264
|
+
background: #1a1a2e;
|
|
265
|
+
color: #eee;
|
|
266
|
+
min-height: 100vh;
|
|
267
|
+
padding: 20px;
|
|
268
|
+
}
|
|
269
|
+
h1 {
|
|
270
|
+
font-size: 1.5rem;
|
|
271
|
+
margin-bottom: 15px;
|
|
272
|
+
color: #fff;
|
|
273
|
+
}
|
|
274
|
+
.container {
|
|
275
|
+
max-width: 1540px;
|
|
276
|
+
margin: 0 auto;
|
|
277
|
+
}
|
|
278
|
+
.main-content {
|
|
279
|
+
display: flex;
|
|
280
|
+
gap: 20px;
|
|
281
|
+
}
|
|
282
|
+
.treemap-wrapper {
|
|
283
|
+
flex: 1;
|
|
284
|
+
}
|
|
285
|
+
.detail-panel {
|
|
286
|
+
width: 280px;
|
|
287
|
+
flex-shrink: 0;
|
|
288
|
+
background: #16213e;
|
|
289
|
+
border-radius: 8px;
|
|
290
|
+
padding: 16px;
|
|
291
|
+
height: fit-content;
|
|
292
|
+
max-height: 800px;
|
|
293
|
+
overflow-y: auto;
|
|
294
|
+
}
|
|
295
|
+
.detail-panel.empty {
|
|
296
|
+
color: #666;
|
|
297
|
+
font-size: 0.9rem;
|
|
298
|
+
text-align: center;
|
|
299
|
+
padding: 40px 16px;
|
|
300
|
+
}
|
|
301
|
+
.detail-title {
|
|
302
|
+
font-weight: 600;
|
|
303
|
+
font-size: 1rem;
|
|
304
|
+
margin-bottom: 12px;
|
|
305
|
+
color: #fff;
|
|
306
|
+
word-break: break-word;
|
|
307
|
+
}
|
|
308
|
+
.detail-row {
|
|
309
|
+
display: flex;
|
|
310
|
+
justify-content: space-between;
|
|
311
|
+
gap: 12px;
|
|
312
|
+
margin: 6px 0;
|
|
313
|
+
font-size: 0.85rem;
|
|
314
|
+
}
|
|
315
|
+
.detail-label { color: #888; }
|
|
316
|
+
.detail-value { font-weight: 500; color: #ccc; }
|
|
317
|
+
.detail-actions {
|
|
318
|
+
margin-top: 16px;
|
|
319
|
+
padding-top: 12px;
|
|
320
|
+
border-top: 1px solid #333;
|
|
321
|
+
display: flex;
|
|
322
|
+
flex-direction: column;
|
|
323
|
+
gap: 8px;
|
|
324
|
+
}
|
|
325
|
+
.detail-btn {
|
|
326
|
+
background: #2a2a4a;
|
|
327
|
+
border: 1px solid #444;
|
|
328
|
+
color: #6b9fff;
|
|
329
|
+
padding: 8px 12px;
|
|
330
|
+
border-radius: 4px;
|
|
331
|
+
cursor: pointer;
|
|
332
|
+
font-size: 0.85rem;
|
|
333
|
+
text-align: left;
|
|
334
|
+
}
|
|
335
|
+
.detail-btn:hover {
|
|
336
|
+
background: #3a3a5a;
|
|
337
|
+
border-color: #666;
|
|
338
|
+
}
|
|
339
|
+
.legend {
|
|
340
|
+
display: flex;
|
|
341
|
+
gap: 20px;
|
|
342
|
+
margin-bottom: 15px;
|
|
343
|
+
font-size: 0.85rem;
|
|
344
|
+
}
|
|
345
|
+
.legend-item {
|
|
346
|
+
display: flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
gap: 6px;
|
|
349
|
+
}
|
|
350
|
+
.legend-color {
|
|
351
|
+
width: 20px;
|
|
352
|
+
height: 14px;
|
|
353
|
+
border-radius: 3px;
|
|
354
|
+
}
|
|
355
|
+
#treemap {
|
|
356
|
+
position: relative;
|
|
357
|
+
width: ${width}px;
|
|
358
|
+
height: ${height}px;
|
|
359
|
+
background: #16213e;
|
|
360
|
+
border-radius: 8px;
|
|
361
|
+
overflow: hidden;
|
|
362
|
+
}
|
|
363
|
+
.node {
|
|
364
|
+
position: absolute;
|
|
365
|
+
overflow: hidden;
|
|
366
|
+
border-radius: 3px;
|
|
367
|
+
transition: opacity 0.15s;
|
|
368
|
+
cursor: pointer;
|
|
369
|
+
}
|
|
370
|
+
.node:hover {
|
|
371
|
+
opacity: 0.85;
|
|
372
|
+
}
|
|
373
|
+
.node-label {
|
|
374
|
+
font-size: 11px;
|
|
375
|
+
padding: 2px 4px;
|
|
376
|
+
white-space: nowrap;
|
|
377
|
+
overflow: hidden;
|
|
378
|
+
text-overflow: ellipsis;
|
|
379
|
+
color: #fff;
|
|
380
|
+
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
|
381
|
+
}
|
|
382
|
+
.node-group {
|
|
383
|
+
background: rgba(255,255,255,0.05);
|
|
384
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
385
|
+
}
|
|
386
|
+
.node-group .node-label {
|
|
387
|
+
font-weight: 600;
|
|
388
|
+
font-size: 12px;
|
|
389
|
+
color: rgba(255,255,255,0.9);
|
|
390
|
+
}
|
|
391
|
+
.tooltip {
|
|
392
|
+
position: fixed;
|
|
393
|
+
background: #2a2a4a;
|
|
394
|
+
border: 1px solid #444;
|
|
395
|
+
border-radius: 6px;
|
|
396
|
+
padding: 12px;
|
|
397
|
+
font-size: 0.85rem;
|
|
398
|
+
pointer-events: none;
|
|
399
|
+
z-index: 1000;
|
|
400
|
+
max-width: 300px;
|
|
401
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
402
|
+
display: none;
|
|
403
|
+
}
|
|
404
|
+
.tooltip-title {
|
|
405
|
+
font-weight: 600;
|
|
406
|
+
margin-bottom: 8px;
|
|
407
|
+
color: #fff;
|
|
408
|
+
}
|
|
409
|
+
.tooltip-row {
|
|
410
|
+
display: flex;
|
|
411
|
+
justify-content: space-between;
|
|
412
|
+
gap: 20px;
|
|
413
|
+
margin: 4px 0;
|
|
414
|
+
color: #ccc;
|
|
415
|
+
}
|
|
416
|
+
.tooltip-label { color: #888; }
|
|
417
|
+
.tooltip-value { font-weight: 500; }
|
|
418
|
+
.ratio-good { color: #4ade80; }
|
|
419
|
+
.ratio-moderate { color: #fbbf24; }
|
|
420
|
+
.ratio-high { color: #f87171; }
|
|
421
|
+
.tooltip-link {
|
|
422
|
+
color: #6b9fff;
|
|
423
|
+
cursor: pointer;
|
|
424
|
+
text-decoration: none;
|
|
425
|
+
}
|
|
426
|
+
.tooltip-link:hover {
|
|
427
|
+
text-decoration: underline;
|
|
428
|
+
}
|
|
429
|
+
.tooltip-actions {
|
|
430
|
+
margin-top: 10px;
|
|
431
|
+
padding-top: 8px;
|
|
432
|
+
border-top: 1px solid #444;
|
|
433
|
+
display: flex;
|
|
434
|
+
gap: 12px;
|
|
435
|
+
font-size: 0.8rem;
|
|
436
|
+
}
|
|
437
|
+
.tool-table {
|
|
438
|
+
margin-top: 8px;
|
|
439
|
+
width: 100%;
|
|
440
|
+
border-collapse: collapse;
|
|
441
|
+
font-size: 0.8rem;
|
|
442
|
+
}
|
|
443
|
+
.tool-table th {
|
|
444
|
+
text-align: left;
|
|
445
|
+
color: #888;
|
|
446
|
+
font-weight: 500;
|
|
447
|
+
padding: 3px 6px 3px 0;
|
|
448
|
+
border-bottom: 1px solid #444;
|
|
449
|
+
}
|
|
450
|
+
.tool-table td {
|
|
451
|
+
padding: 3px 6px 3px 0;
|
|
452
|
+
color: #ccc;
|
|
453
|
+
}
|
|
454
|
+
.tool-table td:last-child,
|
|
455
|
+
.tool-table th:last-child {
|
|
456
|
+
text-align: right;
|
|
457
|
+
padding-right: 0;
|
|
458
|
+
}
|
|
459
|
+
.tool-table-header {
|
|
460
|
+
color: #888;
|
|
461
|
+
font-size: 0.75rem;
|
|
462
|
+
margin-top: 10px;
|
|
463
|
+
margin-bottom: 4px;
|
|
464
|
+
}
|
|
465
|
+
.stats {
|
|
466
|
+
margin-top: 15px;
|
|
467
|
+
font-size: 0.85rem;
|
|
468
|
+
color: #888;
|
|
469
|
+
}
|
|
470
|
+
.breadcrumb {
|
|
471
|
+
margin-bottom: 10px;
|
|
472
|
+
font-size: 0.9rem;
|
|
473
|
+
color: #aaa;
|
|
474
|
+
}
|
|
475
|
+
.crumb {
|
|
476
|
+
color: #6b9fff;
|
|
477
|
+
}
|
|
478
|
+
.crumb:hover:not(.current) {
|
|
479
|
+
text-decoration: underline;
|
|
480
|
+
}
|
|
481
|
+
.crumb.current {
|
|
482
|
+
color: #fff;
|
|
483
|
+
cursor: default;
|
|
484
|
+
}
|
|
485
|
+
.crumb-sep {
|
|
486
|
+
color: #666;
|
|
487
|
+
margin: 0 4px;
|
|
488
|
+
}
|
|
489
|
+
.controls {
|
|
490
|
+
display: flex;
|
|
491
|
+
align-items: center;
|
|
492
|
+
gap: 30px;
|
|
493
|
+
margin-bottom: 15px;
|
|
494
|
+
flex-wrap: wrap;
|
|
495
|
+
}
|
|
496
|
+
.tile-selector, .min-tokens {
|
|
497
|
+
display: flex;
|
|
498
|
+
align-items: center;
|
|
499
|
+
gap: 8px;
|
|
500
|
+
}
|
|
501
|
+
.tile-selector label, .min-tokens label {
|
|
502
|
+
color: #888;
|
|
503
|
+
font-size: 0.85rem;
|
|
504
|
+
}
|
|
505
|
+
.tile-selector select, .min-tokens select {
|
|
506
|
+
background: #2a2a4a;
|
|
507
|
+
color: #fff;
|
|
508
|
+
border: 1px solid #444;
|
|
509
|
+
border-radius: 4px;
|
|
510
|
+
padding: 4px 8px;
|
|
511
|
+
font-size: 0.85rem;
|
|
512
|
+
cursor: pointer;
|
|
513
|
+
}
|
|
514
|
+
.tile-selector select:hover, .min-tokens select:hover {
|
|
515
|
+
border-color: #666;
|
|
516
|
+
}
|
|
517
|
+
</style>
|
|
518
|
+
<script src="https://cdn.jsdelivr.net/npm/d3-hierarchy@3"></script>
|
|
519
|
+
</head>
|
|
520
|
+
<body>
|
|
521
|
+
<div class="container">
|
|
522
|
+
<h1>Claude Token Usage Treemap</h1>
|
|
523
|
+
|
|
524
|
+
<div class="controls">
|
|
525
|
+
<div class="legend">
|
|
526
|
+
<div class="legend-item"><div class="legend-color" style="background: #4ade80;"></div><span>Read</span></div>
|
|
527
|
+
<div class="legend-item"><div class="legend-color" style="background: #f87171;"></div><span>Write</span></div>
|
|
528
|
+
<div class="legend-item"><div class="legend-color" style="background: #fb923c;"></div><span>Edit</span></div>
|
|
529
|
+
<div class="legend-item"><div class="legend-color" style="background: #a78bfa;"></div><span>Bash</span></div>
|
|
530
|
+
<div class="legend-item"><div class="legend-color" style="background: #38bdf8;"></div><span>Glob</span></div>
|
|
531
|
+
<div class="legend-item"><div class="legend-color" style="background: #22d3ee;"></div><span>Grep</span></div>
|
|
532
|
+
<div class="legend-item"><div class="legend-color" style="background: #facc15;"></div><span>Task</span></div>
|
|
533
|
+
<div class="legend-item"><div class="legend-color" style="background: #60a5fa;"></div><span>MCP</span></div>
|
|
534
|
+
</div>
|
|
535
|
+
<div class="tile-selector">
|
|
536
|
+
<label for="tileMethod">Layout:</label>
|
|
537
|
+
<select id="tileMethod">
|
|
538
|
+
<option value="squarify" selected>Squarify (readable)</option>
|
|
539
|
+
<option value="binary">Binary (balanced)</option>
|
|
540
|
+
<option value="sliceDice">Slice & Dice (alternating)</option>
|
|
541
|
+
</select>
|
|
542
|
+
</div>
|
|
543
|
+
<div class="min-tokens">
|
|
544
|
+
<label for="minTokens">Min tokens:</label>
|
|
545
|
+
<select id="minTokens">
|
|
546
|
+
<option value="0">All</option>
|
|
547
|
+
<option value="100">100+</option>
|
|
548
|
+
<option value="500">500+</option>
|
|
549
|
+
<option value="1000" selected>1K+</option>
|
|
550
|
+
<option value="5000">5K+</option>
|
|
551
|
+
<option value="10000">10K+</option>
|
|
552
|
+
</select>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
|
|
556
|
+
<div class="main-content">
|
|
557
|
+
<div class="treemap-wrapper">
|
|
558
|
+
<div id="treemap"></div>
|
|
559
|
+
<div class="tooltip" id="tooltip"></div>
|
|
560
|
+
<div class="breadcrumb" id="breadcrumb"></div>
|
|
561
|
+
<div class="stats" id="stats"></div>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="detail-panel empty" id="detailPanel">
|
|
564
|
+
Click a tile to see details
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<script>
|
|
570
|
+
const treeData = ${JSON.stringify(data)};
|
|
571
|
+
const container = document.getElementById('treemap');
|
|
572
|
+
const tooltip = document.getElementById('tooltip');
|
|
573
|
+
const stats = document.getElementById('stats');
|
|
574
|
+
const breadcrumb = document.getElementById('breadcrumb');
|
|
575
|
+
const detailPanel = document.getElementById('detailPanel');
|
|
576
|
+
const WIDTH = ${width};
|
|
577
|
+
const HEIGHT = ${height};
|
|
578
|
+
|
|
579
|
+
// Controls
|
|
580
|
+
const tileMethodSelect = document.getElementById('tileMethod');
|
|
581
|
+
const minTokensSelect = document.getElementById('minTokens');
|
|
582
|
+
|
|
583
|
+
// Navigation stack for zoom
|
|
584
|
+
let navStack = [treeData];
|
|
585
|
+
|
|
586
|
+
function getCurrentNode() {
|
|
587
|
+
return navStack[navStack.length - 1];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function zoomTo(node) {
|
|
591
|
+
if (node.children && node.children.length > 0) {
|
|
592
|
+
navStack.push(node);
|
|
593
|
+
render();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function zoomOut(index) {
|
|
598
|
+
navStack = navStack.slice(0, index + 1);
|
|
599
|
+
render();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Filter nodes below minTokens threshold
|
|
603
|
+
function filterByMinTokens(node, minTokens) {
|
|
604
|
+
if (minTokens <= 0) return node;
|
|
605
|
+
|
|
606
|
+
function sumValue(n) {
|
|
607
|
+
if (n.value !== undefined && n.value > 0) return n.value;
|
|
608
|
+
if (!n.children) return 0;
|
|
609
|
+
return n.children.reduce((sum, c) => sum + sumValue(c), 0);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function filterNode(n) {
|
|
613
|
+
const val = sumValue(n);
|
|
614
|
+
if (val < minTokens && !n.children) return null;
|
|
615
|
+
|
|
616
|
+
if (!n.children) return n;
|
|
617
|
+
|
|
618
|
+
const filteredChildren = n.children
|
|
619
|
+
.map(filterNode)
|
|
620
|
+
.filter(c => c !== null);
|
|
621
|
+
|
|
622
|
+
if (filteredChildren.length === 0 && val < minTokens) return null;
|
|
623
|
+
|
|
624
|
+
return { ...n, children: filteredChildren.length > 0 ? filteredChildren : undefined };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return filterNode(node) || node;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Get d3 tile method
|
|
631
|
+
function getTileMethod() {
|
|
632
|
+
const method = tileMethodSelect.value;
|
|
633
|
+
switch (method) {
|
|
634
|
+
case 'binary': return d3.treemapBinary;
|
|
635
|
+
case 'sliceDice': return d3.treemapSliceDice;
|
|
636
|
+
default: return d3.treemapSquarify;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function computeLayout(data) {
|
|
641
|
+
const minTokens = parseInt(minTokensSelect.value) || 0;
|
|
642
|
+
const filteredData = filterByMinTokens(data, minTokens);
|
|
643
|
+
|
|
644
|
+
// Use d3-hierarchy for layout
|
|
645
|
+
const root = d3.hierarchy(filteredData)
|
|
646
|
+
.sum(d => d.value || 0)
|
|
647
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
648
|
+
|
|
649
|
+
const layout = d3.treemap()
|
|
650
|
+
.size([WIDTH, HEIGHT])
|
|
651
|
+
.paddingOuter(3)
|
|
652
|
+
.paddingTop(19)
|
|
653
|
+
.paddingInner(1)
|
|
654
|
+
.tile(getTileMethod());
|
|
655
|
+
|
|
656
|
+
layout(root);
|
|
657
|
+
|
|
658
|
+
// Convert to rect array
|
|
659
|
+
return root.descendants().map(d => ({
|
|
660
|
+
x: d.x0,
|
|
661
|
+
y: d.y0,
|
|
662
|
+
width: d.x1 - d.x0,
|
|
663
|
+
height: d.y1 - d.y0,
|
|
664
|
+
depth: d.depth,
|
|
665
|
+
name: d.data.name,
|
|
666
|
+
value: d.value || 0,
|
|
667
|
+
hasChildren: !!d.children?.length,
|
|
668
|
+
sessionId: d.data.sessionId,
|
|
669
|
+
fullSessionId: d.data.fullSessionId,
|
|
670
|
+
filePath: d.data.filePath,
|
|
671
|
+
startTime: d.data.startTime,
|
|
672
|
+
model: d.data.model,
|
|
673
|
+
inputTokens: d.data.inputTokens,
|
|
674
|
+
outputTokens: d.data.outputTokens,
|
|
675
|
+
ratio: d.data.ratio,
|
|
676
|
+
date: d.data.date,
|
|
677
|
+
project: d.data.project,
|
|
678
|
+
repeatedReads: d.data.repeatedReads,
|
|
679
|
+
modelEfficiency: d.data.modelEfficiency,
|
|
680
|
+
tools: d.data.tools,
|
|
681
|
+
toolName: d.data.toolName,
|
|
682
|
+
nodeRef: d.data
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Re-render on control changes
|
|
687
|
+
tileMethodSelect.addEventListener('change', render);
|
|
688
|
+
minTokensSelect.addEventListener('change', render);
|
|
689
|
+
|
|
690
|
+
function render() {
|
|
691
|
+
// Clear container using replaceChildren (safe)
|
|
692
|
+
container.replaceChildren();
|
|
693
|
+
const currentNode = getCurrentNode();
|
|
694
|
+
const rects = computeLayout(currentNode);
|
|
695
|
+
|
|
696
|
+
// Update breadcrumb using DOM methods (safe)
|
|
697
|
+
breadcrumb.replaceChildren();
|
|
698
|
+
navStack.forEach((node, i) => {
|
|
699
|
+
const crumb = document.createElement('span');
|
|
700
|
+
crumb.className = 'crumb' + (i === navStack.length - 1 ? ' current' : '');
|
|
701
|
+
crumb.textContent = node.name;
|
|
702
|
+
if (i < navStack.length - 1) {
|
|
703
|
+
crumb.style.cursor = 'pointer';
|
|
704
|
+
crumb.onclick = () => zoomOut(i);
|
|
705
|
+
}
|
|
706
|
+
breadcrumb.appendChild(crumb);
|
|
707
|
+
if (i < navStack.length - 1) {
|
|
708
|
+
const sep = document.createElement('span');
|
|
709
|
+
sep.className = 'crumb-sep';
|
|
710
|
+
sep.textContent = ' > ';
|
|
711
|
+
breadcrumb.appendChild(sep);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Calculate totals for current view
|
|
716
|
+
let totalTokens = 0, totalInput = 0, totalOutput = 0;
|
|
717
|
+
rects.forEach(r => {
|
|
718
|
+
if (!r.hasChildren && r.depth > 0) {
|
|
719
|
+
totalTokens += r.value || 0;
|
|
720
|
+
totalInput += r.inputTokens || 0;
|
|
721
|
+
totalOutput += r.outputTokens || 0;
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const overallRatio = totalOutput > 0 ? (totalInput / totalOutput).toFixed(1) : 'N/A';
|
|
726
|
+
stats.textContent = 'Total: ' + formatTokens(totalTokens) + ' tokens | Input: ' + formatTokens(totalInput) + ' | Output: ' + formatTokens(totalOutput) + ' | Ratio: ' + overallRatio + ':1';
|
|
727
|
+
|
|
728
|
+
// Render nodes
|
|
729
|
+
rects.forEach((r) => {
|
|
730
|
+
if (r.width < 1 || r.height < 1) return;
|
|
731
|
+
|
|
732
|
+
const node = document.createElement('div');
|
|
733
|
+
node.className = 'node' + (r.hasChildren ? ' node-group' : '');
|
|
734
|
+
node.style.left = r.x + 'px';
|
|
735
|
+
node.style.top = r.y + 'px';
|
|
736
|
+
node.style.width = r.width + 'px';
|
|
737
|
+
node.style.height = r.height + 'px';
|
|
738
|
+
|
|
739
|
+
if (r.toolName && r.depth > 0) {
|
|
740
|
+
node.style.background = getColor(r.toolName);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (r.width > 30 && r.height > 15) {
|
|
744
|
+
const label = document.createElement('div');
|
|
745
|
+
label.className = 'node-label';
|
|
746
|
+
label.textContent = r.name + (r.value > 0 && !r.hasChildren ? ' (' + formatTokens(r.value) + ')' : '');
|
|
747
|
+
node.appendChild(label);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Click to zoom and/or show detail
|
|
751
|
+
node.addEventListener('click', (e) => {
|
|
752
|
+
e.stopPropagation();
|
|
753
|
+
showDetail(r);
|
|
754
|
+
if (r.hasChildren && r.nodeRef) {
|
|
755
|
+
zoomTo(r.nodeRef);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
node.addEventListener('mouseenter', (e) => showTooltip(e, r));
|
|
760
|
+
node.addEventListener('mousemove', (e) => moveTooltip(e));
|
|
761
|
+
node.addEventListener('mouseleave', hideTooltip);
|
|
762
|
+
|
|
763
|
+
container.appendChild(node);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const toolColors = {
|
|
768
|
+
Read: '#4ade80', // green
|
|
769
|
+
Write: '#f87171', // red
|
|
770
|
+
Edit: '#fb923c', // orange
|
|
771
|
+
MultiEdit: '#f97316', // darker orange
|
|
772
|
+
Bash: '#a78bfa', // purple
|
|
773
|
+
Glob: '#38bdf8', // sky blue
|
|
774
|
+
Grep: '#22d3ee', // cyan
|
|
775
|
+
Task: '#facc15', // yellow
|
|
776
|
+
WebFetch: '#2dd4bf', // teal
|
|
777
|
+
WebSearch: '#14b8a6', // darker teal
|
|
778
|
+
TodoWrite: '#e879f9', // pink
|
|
779
|
+
LSP: '#818cf8', // indigo
|
|
780
|
+
Response: '#cbd5e1', // light gray for text-only responses
|
|
781
|
+
AskUserQuestion: '#f472b6', // pink
|
|
782
|
+
default: '#94a3b8' // gray
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
function getColor(toolName) {
|
|
786
|
+
if (!toolName) return '#4a5568';
|
|
787
|
+
// Check for MCP tools (mcp__*)
|
|
788
|
+
if (toolName.startsWith('mcp__')) return '#60a5fa'; // blue for MCP
|
|
789
|
+
return toolColors[toolName] || toolColors.default;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function getRatioClass(ratio) {
|
|
793
|
+
if (ratio === undefined || ratio === null) return '';
|
|
794
|
+
if (ratio < 2) return 'ratio-good';
|
|
795
|
+
if (ratio < 5) return 'ratio-moderate';
|
|
796
|
+
return 'ratio-high';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function formatTokens(n) {
|
|
800
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
801
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
802
|
+
return n.toString();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function showTooltip(e, r) {
|
|
806
|
+
// Build tooltip using DOM methods (safe from XSS)
|
|
807
|
+
tooltip.replaceChildren();
|
|
808
|
+
|
|
809
|
+
const title = document.createElement('div');
|
|
810
|
+
title.className = 'tooltip-title';
|
|
811
|
+
title.textContent = r.name;
|
|
812
|
+
tooltip.appendChild(title);
|
|
813
|
+
|
|
814
|
+
function addRow(label, value, extraClass) {
|
|
815
|
+
const row = document.createElement('div');
|
|
816
|
+
row.className = 'tooltip-row';
|
|
817
|
+
const labelEl = document.createElement('span');
|
|
818
|
+
labelEl.className = 'tooltip-label';
|
|
819
|
+
labelEl.textContent = label;
|
|
820
|
+
const valueEl = document.createElement('span');
|
|
821
|
+
valueEl.className = 'tooltip-value' + (extraClass ? ' ' + extraClass : '');
|
|
822
|
+
valueEl.textContent = value;
|
|
823
|
+
row.appendChild(labelEl);
|
|
824
|
+
row.appendChild(valueEl);
|
|
825
|
+
tooltip.appendChild(row);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function addLinkRow(label, text, onClick, title) {
|
|
829
|
+
const row = document.createElement('div');
|
|
830
|
+
row.className = 'tooltip-row';
|
|
831
|
+
const labelEl = document.createElement('span');
|
|
832
|
+
labelEl.className = 'tooltip-label';
|
|
833
|
+
labelEl.textContent = label;
|
|
834
|
+
const link = document.createElement('span');
|
|
835
|
+
link.className = 'tooltip-link';
|
|
836
|
+
link.textContent = text;
|
|
837
|
+
if (title) link.title = title;
|
|
838
|
+
link.onclick = (e) => { e.stopPropagation(); onClick(); };
|
|
839
|
+
row.appendChild(labelEl);
|
|
840
|
+
row.appendChild(link);
|
|
841
|
+
tooltip.appendChild(row);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (r.sessionId) {
|
|
845
|
+
addLinkRow('Session:', r.sessionId, () => {
|
|
846
|
+
navigator.clipboard.writeText(r.fullSessionId || r.sessionId);
|
|
847
|
+
}, 'Click to copy full session ID');
|
|
848
|
+
}
|
|
849
|
+
if (r.date) addRow('Date:', r.date);
|
|
850
|
+
if (r.startTime) addRow('Started:', r.startTime);
|
|
851
|
+
if (r.model) addRow('Model:', r.model);
|
|
852
|
+
if (r.value > 0) addRow('Total tokens:', formatTokens(r.value));
|
|
853
|
+
if (r.inputTokens !== undefined) addRow('Input:', formatTokens(r.inputTokens));
|
|
854
|
+
if (r.outputTokens !== undefined) addRow('Output:', formatTokens(r.outputTokens));
|
|
855
|
+
if (r.ratio !== undefined && r.ratio !== null) {
|
|
856
|
+
addRow('Ratio (in:out):', r.ratio.toFixed(1) + ':1', getRatioClass(r.ratio));
|
|
857
|
+
}
|
|
858
|
+
if (r.repeatedReads !== undefined && r.repeatedReads > 0) {
|
|
859
|
+
addRow('Repeated reads:', r.repeatedReads.toString());
|
|
860
|
+
}
|
|
861
|
+
if (r.modelEfficiency !== undefined && r.modelEfficiency > 0) {
|
|
862
|
+
addRow('Opus usage:', (r.modelEfficiency * 100).toFixed(0) + '%');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Tool breakdown table
|
|
866
|
+
if (r.tools && r.tools.length > 0) {
|
|
867
|
+
const header = document.createElement('div');
|
|
868
|
+
header.className = 'tool-table-header';
|
|
869
|
+
header.textContent = 'Tool Usage';
|
|
870
|
+
tooltip.appendChild(header);
|
|
871
|
+
|
|
872
|
+
const table = document.createElement('table');
|
|
873
|
+
table.className = 'tool-table';
|
|
874
|
+
|
|
875
|
+
const thead = document.createElement('thead');
|
|
876
|
+
const headerRow = document.createElement('tr');
|
|
877
|
+
['Tool', 'Detail', 'Tokens'].forEach(text => {
|
|
878
|
+
const th = document.createElement('th');
|
|
879
|
+
th.textContent = text;
|
|
880
|
+
headerRow.appendChild(th);
|
|
881
|
+
});
|
|
882
|
+
thead.appendChild(headerRow);
|
|
883
|
+
table.appendChild(thead);
|
|
884
|
+
|
|
885
|
+
const tbody = document.createElement('tbody');
|
|
886
|
+
r.tools.forEach(tool => {
|
|
887
|
+
const tr = document.createElement('tr');
|
|
888
|
+
const tdName = document.createElement('td');
|
|
889
|
+
tdName.textContent = tool.name;
|
|
890
|
+
const tdDetail = document.createElement('td');
|
|
891
|
+
tdDetail.textContent = tool.detail || '';
|
|
892
|
+
const tdTokens = document.createElement('td');
|
|
893
|
+
tdTokens.textContent = formatTokens(tool.inputTokens + tool.outputTokens);
|
|
894
|
+
tr.appendChild(tdName);
|
|
895
|
+
tr.appendChild(tdDetail);
|
|
896
|
+
tr.appendChild(tdTokens);
|
|
897
|
+
tbody.appendChild(tr);
|
|
898
|
+
});
|
|
899
|
+
table.appendChild(tbody);
|
|
900
|
+
tooltip.appendChild(table);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Actions section with links
|
|
904
|
+
if (r.fullSessionId || r.filePath) {
|
|
905
|
+
const actions = document.createElement('div');
|
|
906
|
+
actions.className = 'tooltip-actions';
|
|
907
|
+
|
|
908
|
+
if (r.filePath) {
|
|
909
|
+
const fileLink = document.createElement('span');
|
|
910
|
+
fileLink.className = 'tooltip-link';
|
|
911
|
+
fileLink.textContent = '📄 Copy path';
|
|
912
|
+
fileLink.title = r.filePath;
|
|
913
|
+
fileLink.onclick = (e) => {
|
|
914
|
+
e.stopPropagation();
|
|
915
|
+
navigator.clipboard.writeText(r.filePath);
|
|
916
|
+
};
|
|
917
|
+
actions.appendChild(fileLink);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (r.fullSessionId) {
|
|
921
|
+
const transcriptLink = document.createElement('span');
|
|
922
|
+
transcriptLink.className = 'tooltip-link';
|
|
923
|
+
transcriptLink.textContent = '📜 View transcript';
|
|
924
|
+
transcriptLink.title = 'Copy command to view with claude-code-transcripts';
|
|
925
|
+
transcriptLink.onclick = (e) => {
|
|
926
|
+
e.stopPropagation();
|
|
927
|
+
navigator.clipboard.writeText('uvx claude-code-transcripts ' + r.fullSessionId);
|
|
928
|
+
};
|
|
929
|
+
actions.appendChild(transcriptLink);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
tooltip.appendChild(actions);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
tooltip.style.display = 'block';
|
|
936
|
+
moveTooltip(e);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function moveTooltip(e) {
|
|
940
|
+
const x = e.clientX + 15;
|
|
941
|
+
const y = e.clientY + 15;
|
|
942
|
+
const rect = tooltip.getBoundingClientRect();
|
|
943
|
+
|
|
944
|
+
tooltip.style.left = (x + rect.width > window.innerWidth ? e.clientX - rect.width - 15 : x) + 'px';
|
|
945
|
+
tooltip.style.top = (y + rect.height > window.innerHeight ? e.clientY - rect.height - 15 : y) + 'px';
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function hideTooltip() {
|
|
949
|
+
tooltip.style.display = 'none';
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function showDetail(r) {
|
|
953
|
+
detailPanel.className = 'detail-panel';
|
|
954
|
+
detailPanel.replaceChildren();
|
|
955
|
+
|
|
956
|
+
const title = document.createElement('div');
|
|
957
|
+
title.className = 'detail-title';
|
|
958
|
+
title.textContent = r.name;
|
|
959
|
+
detailPanel.appendChild(title);
|
|
960
|
+
|
|
961
|
+
function addDetailRow(label, value, extraClass) {
|
|
962
|
+
const row = document.createElement('div');
|
|
963
|
+
row.className = 'detail-row';
|
|
964
|
+
const labelEl = document.createElement('span');
|
|
965
|
+
labelEl.className = 'detail-label';
|
|
966
|
+
labelEl.textContent = label;
|
|
967
|
+
const valueEl = document.createElement('span');
|
|
968
|
+
valueEl.className = 'detail-value' + (extraClass ? ' ' + extraClass : '');
|
|
969
|
+
valueEl.textContent = value;
|
|
970
|
+
row.appendChild(labelEl);
|
|
971
|
+
row.appendChild(valueEl);
|
|
972
|
+
detailPanel.appendChild(row);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (r.sessionId) addDetailRow('Session:', r.fullSessionId || r.sessionId);
|
|
976
|
+
if (r.date) addDetailRow('Date:', r.date);
|
|
977
|
+
if (r.startTime) addDetailRow('Started:', r.startTime);
|
|
978
|
+
if (r.model) addDetailRow('Model:', r.model);
|
|
979
|
+
if (r.value > 0) addDetailRow('Total tokens:', formatTokens(r.value));
|
|
980
|
+
if (r.inputTokens !== undefined) addDetailRow('Input:', formatTokens(r.inputTokens));
|
|
981
|
+
if (r.outputTokens !== undefined) addDetailRow('Output:', formatTokens(r.outputTokens));
|
|
982
|
+
if (r.ratio !== undefined && r.ratio !== null) {
|
|
983
|
+
addDetailRow('Ratio (in:out):', r.ratio.toFixed(1) + ':1', getRatioClass(r.ratio));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Action buttons
|
|
987
|
+
if (r.fullSessionId || r.filePath) {
|
|
988
|
+
const actions = document.createElement('div');
|
|
989
|
+
actions.className = 'detail-actions';
|
|
990
|
+
|
|
991
|
+
if (r.filePath) {
|
|
992
|
+
const fileBtn = document.createElement('button');
|
|
993
|
+
fileBtn.className = 'detail-btn';
|
|
994
|
+
fileBtn.textContent = '📄 Copy file path';
|
|
995
|
+
fileBtn.onclick = () => {
|
|
996
|
+
navigator.clipboard.writeText(r.filePath);
|
|
997
|
+
fileBtn.textContent = '✓ Copied!';
|
|
998
|
+
setTimeout(() => fileBtn.textContent = '📄 Copy file path', 1500);
|
|
999
|
+
};
|
|
1000
|
+
actions.appendChild(fileBtn);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (r.fullSessionId) {
|
|
1004
|
+
const copyIdBtn = document.createElement('button');
|
|
1005
|
+
copyIdBtn.className = 'detail-btn';
|
|
1006
|
+
copyIdBtn.textContent = '🔗 Copy session ID';
|
|
1007
|
+
copyIdBtn.onclick = () => {
|
|
1008
|
+
navigator.clipboard.writeText(r.fullSessionId);
|
|
1009
|
+
copyIdBtn.textContent = '✓ Copied!';
|
|
1010
|
+
setTimeout(() => copyIdBtn.textContent = '🔗 Copy session ID', 1500);
|
|
1011
|
+
};
|
|
1012
|
+
actions.appendChild(copyIdBtn);
|
|
1013
|
+
|
|
1014
|
+
const transcriptBtn = document.createElement('button');
|
|
1015
|
+
transcriptBtn.className = 'detail-btn';
|
|
1016
|
+
transcriptBtn.textContent = '📜 View transcript';
|
|
1017
|
+
transcriptBtn.title = 'Copy command - use start time above when prompted';
|
|
1018
|
+
transcriptBtn.onclick = () => {
|
|
1019
|
+
navigator.clipboard.writeText('uvx claude-code-transcripts ' + r.fullSessionId);
|
|
1020
|
+
transcriptBtn.textContent = '✓ Copied!';
|
|
1021
|
+
setTimeout(() => transcriptBtn.textContent = '📜 View transcript', 1500);
|
|
1022
|
+
};
|
|
1023
|
+
actions.appendChild(transcriptBtn);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
detailPanel.appendChild(actions);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
render();
|
|
1031
|
+
</script>
|
|
1032
|
+
</body>
|
|
1033
|
+
</html>`;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private findRecentSessions(
|
|
1037
|
+
projectsDir: string,
|
|
1038
|
+
limit: number,
|
|
1039
|
+
days: number,
|
|
1040
|
+
): Array<{ sessionId: string; path: string; date: string; tokens: number; project: string }> {
|
|
1041
|
+
const sessions: Array<{
|
|
1042
|
+
sessionId: string;
|
|
1043
|
+
path: string;
|
|
1044
|
+
date: string;
|
|
1045
|
+
tokens: number;
|
|
1046
|
+
project: string;
|
|
1047
|
+
mtime: number;
|
|
1048
|
+
}> = [];
|
|
1049
|
+
|
|
1050
|
+
const cutoffMs = calculateCutoffMs(days);
|
|
1051
|
+
|
|
1052
|
+
const projectDirs = fs.readdirSync(projectsDir);
|
|
1053
|
+
for (const project of projectDirs) {
|
|
1054
|
+
const projectPath = path.join(projectsDir, project);
|
|
1055
|
+
if (!fs.statSync(projectPath).isDirectory()) continue;
|
|
1056
|
+
|
|
1057
|
+
const files = fs.readdirSync(projectPath).filter((f) => f.endsWith(".jsonl"));
|
|
1058
|
+
for (const file of files) {
|
|
1059
|
+
const filePath = path.join(projectPath, file);
|
|
1060
|
+
const stat = fs.statSync(filePath);
|
|
1061
|
+
|
|
1062
|
+
// Filter by days if cutoff is set
|
|
1063
|
+
if (cutoffMs > 0 && stat.mtimeMs < cutoffMs) continue;
|
|
1064
|
+
|
|
1065
|
+
const sessionId = file.replace(".jsonl", "");
|
|
1066
|
+
|
|
1067
|
+
// Quick token count from file
|
|
1068
|
+
const tokens = this.quickTokenCount(filePath);
|
|
1069
|
+
|
|
1070
|
+
sessions.push({
|
|
1071
|
+
sessionId,
|
|
1072
|
+
path: filePath,
|
|
1073
|
+
date: stat.mtime.toISOString().split("T")[0],
|
|
1074
|
+
tokens,
|
|
1075
|
+
project,
|
|
1076
|
+
mtime: stat.mtimeMs,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Sort by modification time, most recent first
|
|
1082
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
1083
|
+
return sessions.slice(0, limit);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
private quickTokenCount(filePath: string): number {
|
|
1087
|
+
try {
|
|
1088
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
1089
|
+
let total = 0;
|
|
1090
|
+
for (const line of content.split("\n")) {
|
|
1091
|
+
if (!line.trim()) continue;
|
|
1092
|
+
try {
|
|
1093
|
+
const entry = JSON.parse(line) as JournalEntry;
|
|
1094
|
+
if (entry.message?.usage) {
|
|
1095
|
+
total +=
|
|
1096
|
+
(entry.message.usage.input_tokens || 0) + (entry.message.usage.output_tokens || 0);
|
|
1097
|
+
}
|
|
1098
|
+
} catch {
|
|
1099
|
+
// Skip invalid lines
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return total;
|
|
1103
|
+
} catch {
|
|
1104
|
+
return 0;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private findSessionPath(projectsDir: string, sessionId: string): string | undefined {
|
|
1109
|
+
const projectDirs = fs.readdirSync(projectsDir);
|
|
1110
|
+
for (const project of projectDirs) {
|
|
1111
|
+
const projectPath = path.join(projectsDir, project);
|
|
1112
|
+
if (!fs.statSync(projectPath).isDirectory()) continue;
|
|
1113
|
+
|
|
1114
|
+
const jsonlPath = path.join(projectPath, `${sessionId}.jsonl`);
|
|
1115
|
+
if (fs.existsSync(jsonlPath)) {
|
|
1116
|
+
return jsonlPath;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return undefined;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
private parseJsonl(filePath: string): JournalEntry[] {
|
|
1123
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
1124
|
+
const entries: JournalEntry[] = [];
|
|
1125
|
+
|
|
1126
|
+
for (const line of content.split("\n")) {
|
|
1127
|
+
if (!line.trim()) continue;
|
|
1128
|
+
try {
|
|
1129
|
+
entries.push(JSON.parse(line) as JournalEntry);
|
|
1130
|
+
} catch {
|
|
1131
|
+
// Skip invalid lines
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
return entries;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private buildSessionTreemap(sessionId: string, entries: JournalEntry[]): TreemapNode {
|
|
1139
|
+
return {
|
|
1140
|
+
name: `Session ${sessionId.slice(0, 8)}`,
|
|
1141
|
+
children: this.buildTurnNodes(sessionId, entries),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Build turn-level nodes from session entries.
|
|
1147
|
+
* Used by both single-session and all-sessions views.
|
|
1148
|
+
*/
|
|
1149
|
+
private buildTurnNodes(
|
|
1150
|
+
sessionId: string,
|
|
1151
|
+
entries: JournalEntry[],
|
|
1152
|
+
filePath?: string,
|
|
1153
|
+
): TreemapNode[] {
|
|
1154
|
+
const children: TreemapNode[] = [];
|
|
1155
|
+
let turnNumber = 0;
|
|
1156
|
+
|
|
1157
|
+
for (const entry of entries) {
|
|
1158
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
1159
|
+
if (!entry.message) continue;
|
|
1160
|
+
|
|
1161
|
+
const role = entry.message.role;
|
|
1162
|
+
const usage = entry.message.usage;
|
|
1163
|
+
const model = entry.message.model;
|
|
1164
|
+
|
|
1165
|
+
if (role === "user") {
|
|
1166
|
+
turnNumber++;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (!usage) continue;
|
|
1170
|
+
|
|
1171
|
+
const inputTokens = usage.input_tokens || 0;
|
|
1172
|
+
const outputTokens = usage.output_tokens || 0;
|
|
1173
|
+
const totalTokens = inputTokens + outputTokens;
|
|
1174
|
+
|
|
1175
|
+
if (totalTokens === 0) continue;
|
|
1176
|
+
|
|
1177
|
+
const ratio = outputTokens > 0 ? inputTokens / outputTokens : inputTokens > 0 ? 999 : 0;
|
|
1178
|
+
|
|
1179
|
+
// Extract individual tool calls from content blocks
|
|
1180
|
+
const tools = this.extractToolData(entry.message.content, inputTokens, outputTokens);
|
|
1181
|
+
|
|
1182
|
+
// Create individual tool children nodes
|
|
1183
|
+
const toolChildren: TreemapNode[] = tools.map((tool) => ({
|
|
1184
|
+
name: tool.detail ? `${tool.name}: ${tool.detail}` : tool.name,
|
|
1185
|
+
value: tool.inputTokens + tool.outputTokens,
|
|
1186
|
+
inputTokens: tool.inputTokens,
|
|
1187
|
+
outputTokens: tool.outputTokens,
|
|
1188
|
+
ratio: tool.outputTokens > 0 ? tool.inputTokens / tool.outputTokens : 0,
|
|
1189
|
+
toolName: tool.name,
|
|
1190
|
+
}));
|
|
1191
|
+
|
|
1192
|
+
// Format turn name based on tools used
|
|
1193
|
+
let turnName: string;
|
|
1194
|
+
let primaryToolName: string | undefined;
|
|
1195
|
+
if (role === "user") {
|
|
1196
|
+
turnName = `Turn ${turnNumber}: User`;
|
|
1197
|
+
} else if (tools.length === 1) {
|
|
1198
|
+
// Single tool: show tool name and detail
|
|
1199
|
+
const t = tools[0];
|
|
1200
|
+
turnName = t.detail ? `${t.name}: ${t.detail}` : t.name;
|
|
1201
|
+
primaryToolName = t.name;
|
|
1202
|
+
} else if (tools.length > 1) {
|
|
1203
|
+
// Multiple tools: list unique tool names, primary is most common
|
|
1204
|
+
const uniqueNames = [...new Set(tools.map((t) => t.name))];
|
|
1205
|
+
turnName = uniqueNames.slice(0, 3).join(", ") + (uniqueNames.length > 3 ? "..." : "");
|
|
1206
|
+
primaryToolName = tools[0].name; // Use first tool as primary
|
|
1207
|
+
} else {
|
|
1208
|
+
turnName = `Turn ${turnNumber}: Response`;
|
|
1209
|
+
primaryToolName = "Response";
|
|
1210
|
+
}
|
|
1211
|
+
children.push({
|
|
1212
|
+
name: turnName,
|
|
1213
|
+
value: toolChildren.length > 0 ? undefined : totalTokens, // Let children sum if present
|
|
1214
|
+
children: toolChildren.length > 0 ? toolChildren : undefined,
|
|
1215
|
+
sessionId: sessionId.slice(0, 8),
|
|
1216
|
+
fullSessionId: sessionId,
|
|
1217
|
+
filePath,
|
|
1218
|
+
toolName: primaryToolName,
|
|
1219
|
+
model: this.getModelName(model),
|
|
1220
|
+
inputTokens,
|
|
1221
|
+
outputTokens,
|
|
1222
|
+
ratio,
|
|
1223
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return children;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Extract individual tool calls from message content blocks.
|
|
1232
|
+
* Returns each tool call with its detail (file path, command, etc.).
|
|
1233
|
+
*/
|
|
1234
|
+
private extractToolData(
|
|
1235
|
+
content: ContentBlock[] | string | undefined,
|
|
1236
|
+
turnInputTokens: number,
|
|
1237
|
+
turnOutputTokens: number,
|
|
1238
|
+
): ToolData[] {
|
|
1239
|
+
if (!content || typeof content === "string") return [];
|
|
1240
|
+
|
|
1241
|
+
// Collect individual tool_use blocks
|
|
1242
|
+
const toolBlocks: Array<{ name: string; detail?: string }> = [];
|
|
1243
|
+
for (const block of content) {
|
|
1244
|
+
if (block.type === "tool_use" && block.name) {
|
|
1245
|
+
const detail = this.extractToolDetail(block.name, block.input);
|
|
1246
|
+
toolBlocks.push({ name: block.name, detail });
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (toolBlocks.length === 0) return [];
|
|
1251
|
+
|
|
1252
|
+
// Distribute tokens proportionally across individual calls
|
|
1253
|
+
const tokensPerCall = {
|
|
1254
|
+
input: Math.round(turnInputTokens / toolBlocks.length),
|
|
1255
|
+
output: Math.round(turnOutputTokens / toolBlocks.length),
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
return toolBlocks.map((tool) => ({
|
|
1259
|
+
name: tool.name,
|
|
1260
|
+
detail: tool.detail,
|
|
1261
|
+
inputTokens: tokensPerCall.input,
|
|
1262
|
+
outputTokens: tokensPerCall.output,
|
|
1263
|
+
}));
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Extract a meaningful detail string from tool input.
|
|
1268
|
+
*/
|
|
1269
|
+
private extractToolDetail(toolName: string, input?: Record<string, unknown>): string | undefined {
|
|
1270
|
+
if (!input) return undefined;
|
|
1271
|
+
|
|
1272
|
+
switch (toolName) {
|
|
1273
|
+
case "Read":
|
|
1274
|
+
return this.truncateDetail(input.file_path as string);
|
|
1275
|
+
case "Write":
|
|
1276
|
+
case "Edit":
|
|
1277
|
+
return this.truncateDetail(input.file_path as string);
|
|
1278
|
+
case "Bash":
|
|
1279
|
+
return this.truncateDetail(input.command as string, 50);
|
|
1280
|
+
case "Glob":
|
|
1281
|
+
return input.pattern as string;
|
|
1282
|
+
case "Grep":
|
|
1283
|
+
return input.pattern as string;
|
|
1284
|
+
case "Task":
|
|
1285
|
+
return input.description as string;
|
|
1286
|
+
case "WebFetch":
|
|
1287
|
+
return this.truncateDetail(input.url as string, 40);
|
|
1288
|
+
default:
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Truncate a string and extract just the filename for paths.
|
|
1295
|
+
*/
|
|
1296
|
+
private truncateDetail(str: string | undefined, maxLen = 30): string | undefined {
|
|
1297
|
+
if (!str) return undefined;
|
|
1298
|
+
// For file paths, show just the filename
|
|
1299
|
+
if (str.includes("/")) {
|
|
1300
|
+
const parts = str.split("/");
|
|
1301
|
+
const filename = parts[parts.length - 1];
|
|
1302
|
+
return filename.length > maxLen ? filename.slice(0, maxLen - 3) + "..." : filename;
|
|
1303
|
+
}
|
|
1304
|
+
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Aggregate tool usage across all entries in a session.
|
|
1309
|
+
* Returns combined tool data for session-level tooltips (aggregated by name).
|
|
1310
|
+
*/
|
|
1311
|
+
private aggregateSessionTools(entries: JournalEntry[]): ToolData[] {
|
|
1312
|
+
const toolAgg = new Map<string, { count: number; inputTokens: number; outputTokens: number }>();
|
|
1313
|
+
|
|
1314
|
+
for (const entry of entries) {
|
|
1315
|
+
if (!entry.message?.content || typeof entry.message.content === "string") continue;
|
|
1316
|
+
if (!entry.message.usage) continue;
|
|
1317
|
+
|
|
1318
|
+
const inputTokens = entry.message.usage.input_tokens || 0;
|
|
1319
|
+
const outputTokens = entry.message.usage.output_tokens || 0;
|
|
1320
|
+
const turnTools = this.extractToolData(entry.message.content, inputTokens, outputTokens);
|
|
1321
|
+
|
|
1322
|
+
for (const tool of turnTools) {
|
|
1323
|
+
const existing = toolAgg.get(tool.name);
|
|
1324
|
+
if (existing) {
|
|
1325
|
+
existing.count += 1;
|
|
1326
|
+
existing.inputTokens += tool.inputTokens;
|
|
1327
|
+
existing.outputTokens += tool.outputTokens;
|
|
1328
|
+
} else {
|
|
1329
|
+
toolAgg.set(tool.name, {
|
|
1330
|
+
count: 1,
|
|
1331
|
+
inputTokens: tool.inputTokens,
|
|
1332
|
+
outputTokens: tool.outputTokens,
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Convert to array and sort by token usage
|
|
1339
|
+
const tools: ToolData[] = [...toolAgg.entries()].map(([name, data]) => ({
|
|
1340
|
+
name,
|
|
1341
|
+
detail: `${data.count}x`,
|
|
1342
|
+
inputTokens: data.inputTokens,
|
|
1343
|
+
outputTokens: data.outputTokens,
|
|
1344
|
+
}));
|
|
1345
|
+
tools.sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens));
|
|
1346
|
+
|
|
1347
|
+
return tools;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
private buildAllSessionsTreemap(
|
|
1351
|
+
sessions: Array<{
|
|
1352
|
+
sessionId: string;
|
|
1353
|
+
path: string;
|
|
1354
|
+
date: string;
|
|
1355
|
+
tokens: number;
|
|
1356
|
+
project: string;
|
|
1357
|
+
}>,
|
|
1358
|
+
): TreemapNode {
|
|
1359
|
+
// Group sessions by project, then by date
|
|
1360
|
+
const byProject = new Map<string, typeof sessions>();
|
|
1361
|
+
for (const session of sessions) {
|
|
1362
|
+
const projectName = this.extractProjectName(session.project);
|
|
1363
|
+
if (!byProject.has(projectName)) {
|
|
1364
|
+
byProject.set(projectName, []);
|
|
1365
|
+
}
|
|
1366
|
+
byProject.get(projectName)!.push(session);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Sort projects by total tokens
|
|
1370
|
+
const projectTotals = [...byProject.entries()].map(([name, sess]) => ({
|
|
1371
|
+
name,
|
|
1372
|
+
sessions: sess,
|
|
1373
|
+
total: sess.reduce((sum, s) => sum + s.tokens, 0),
|
|
1374
|
+
}));
|
|
1375
|
+
projectTotals.sort((a, b) => b.total - a.total);
|
|
1376
|
+
|
|
1377
|
+
const projectChildren: TreemapNode[] = [];
|
|
1378
|
+
|
|
1379
|
+
for (const { name: projectName, sessions: projectSessions } of projectTotals) {
|
|
1380
|
+
// Group by date within project
|
|
1381
|
+
const byDate = new Map<string, typeof sessions>();
|
|
1382
|
+
for (const session of projectSessions) {
|
|
1383
|
+
if (!byDate.has(session.date)) {
|
|
1384
|
+
byDate.set(session.date, []);
|
|
1385
|
+
}
|
|
1386
|
+
byDate.get(session.date)!.push(session);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Sort dates (most recent first)
|
|
1390
|
+
const sortedDates = [...byDate.keys()].sort().reverse();
|
|
1391
|
+
|
|
1392
|
+
const dateChildren: TreemapNode[] = [];
|
|
1393
|
+
|
|
1394
|
+
for (const date of sortedDates) {
|
|
1395
|
+
const dateSessions = byDate.get(date)!;
|
|
1396
|
+
|
|
1397
|
+
const sessionChildren: TreemapNode[] = [];
|
|
1398
|
+
|
|
1399
|
+
for (const session of dateSessions) {
|
|
1400
|
+
const entries = this.parseJsonl(session.path);
|
|
1401
|
+
const analysis = this.analyzeSession(entries);
|
|
1402
|
+
const label = this.extractSessionLabel(entries, session.sessionId);
|
|
1403
|
+
const tools = this.aggregateSessionTools(entries);
|
|
1404
|
+
const startTime = entries[0]?.timestamp
|
|
1405
|
+
? new Date(entries[0].timestamp).toLocaleTimeString()
|
|
1406
|
+
: undefined;
|
|
1407
|
+
|
|
1408
|
+
// Build turn-level children for drill-down
|
|
1409
|
+
const turnChildren = this.buildTurnNodes(session.sessionId, entries, session.path);
|
|
1410
|
+
|
|
1411
|
+
sessionChildren.push({
|
|
1412
|
+
name: label,
|
|
1413
|
+
// If we have turn children, let them sum; otherwise use session total
|
|
1414
|
+
value: turnChildren.length > 0 ? undefined : session.tokens,
|
|
1415
|
+
children: turnChildren.length > 0 ? turnChildren : undefined,
|
|
1416
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
1417
|
+
fullSessionId: session.sessionId,
|
|
1418
|
+
filePath: session.path,
|
|
1419
|
+
startTime,
|
|
1420
|
+
model: this.getPrimaryModel(analysis),
|
|
1421
|
+
inputTokens: analysis.inputTokens,
|
|
1422
|
+
outputTokens: analysis.outputTokens,
|
|
1423
|
+
ratio: analysis.outputTokens > 0 ? analysis.inputTokens / analysis.outputTokens : 0,
|
|
1424
|
+
date: session.date,
|
|
1425
|
+
project: projectName,
|
|
1426
|
+
repeatedReads: analysis.repeatedReads,
|
|
1427
|
+
modelEfficiency: analysis.modelEfficiency,
|
|
1428
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
dateChildren.push({
|
|
1433
|
+
name: date,
|
|
1434
|
+
children: sessionChildren,
|
|
1435
|
+
date,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
projectChildren.push({
|
|
1440
|
+
name: projectName,
|
|
1441
|
+
children: dateChildren,
|
|
1442
|
+
project: projectName,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return {
|
|
1447
|
+
name: "All Sessions",
|
|
1448
|
+
children: projectChildren,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
private extractProjectName(encodedProject: string): string {
|
|
1453
|
+
// Directory names encode paths: -home-ctowles-code-p-towles-tool
|
|
1454
|
+
const parts = encodedProject.split("-").filter(Boolean);
|
|
1455
|
+
const pathMarkers = new Set(["code", "projects", "src", "p", "repos", "git", "workspace"]);
|
|
1456
|
+
|
|
1457
|
+
// Find LAST index of a path marker
|
|
1458
|
+
let lastMarkerIdx = -1;
|
|
1459
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1460
|
+
if (pathMarkers.has(parts[i].toLowerCase())) {
|
|
1461
|
+
lastMarkerIdx = i;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Take everything after the last marker
|
|
1466
|
+
const projectParts = lastMarkerIdx >= 0 ? parts.slice(lastMarkerIdx + 1) : parts.slice(-2);
|
|
1467
|
+
|
|
1468
|
+
if (projectParts.length === 0) {
|
|
1469
|
+
return parts[parts.length - 1] || encodedProject.slice(0, 20);
|
|
1470
|
+
}
|
|
1471
|
+
return projectParts.join("-");
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Extract a meaningful label from session entries.
|
|
1476
|
+
* Priority: first user text > first assistant response > git branch > slug > short ID
|
|
1477
|
+
*/
|
|
1478
|
+
private extractSessionLabel(entries: JournalEntry[], sessionId: string): string {
|
|
1479
|
+
let firstUserText: string | undefined;
|
|
1480
|
+
let firstAssistantText: string | undefined;
|
|
1481
|
+
let gitBranch: string | undefined;
|
|
1482
|
+
let slug: string | undefined;
|
|
1483
|
+
|
|
1484
|
+
for (const entry of entries) {
|
|
1485
|
+
// Extract metadata from any entry
|
|
1486
|
+
if (!gitBranch && (entry as any).gitBranch) {
|
|
1487
|
+
gitBranch = (entry as any).gitBranch;
|
|
1488
|
+
}
|
|
1489
|
+
if (!slug && (entry as any).slug) {
|
|
1490
|
+
slug = (entry as any).slug;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (!entry.message) continue;
|
|
1494
|
+
|
|
1495
|
+
// Look for first user message with actual text (not UUID reference)
|
|
1496
|
+
if (!firstUserText && entry.type === "user" && entry.message.role === "user") {
|
|
1497
|
+
const content = entry.message.content;
|
|
1498
|
+
if (typeof content === "string") {
|
|
1499
|
+
// Check if it's a UUID (skip those) or actual text
|
|
1500
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
1501
|
+
content,
|
|
1502
|
+
);
|
|
1503
|
+
if (!isUuid && content.length > 0) {
|
|
1504
|
+
firstUserText = content;
|
|
1505
|
+
}
|
|
1506
|
+
} else if (Array.isArray(content)) {
|
|
1507
|
+
// Look for text blocks in array content
|
|
1508
|
+
for (const block of content) {
|
|
1509
|
+
if (block.type === "text" && block.text && block.text.length > 0) {
|
|
1510
|
+
firstUserText = block.text;
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Look for first assistant text response
|
|
1518
|
+
if (!firstAssistantText && entry.type === "assistant" && entry.message.role === "assistant") {
|
|
1519
|
+
const content = entry.message.content;
|
|
1520
|
+
if (Array.isArray(content)) {
|
|
1521
|
+
for (const block of content) {
|
|
1522
|
+
if (block.type === "text" && block.text && block.text.length > 0) {
|
|
1523
|
+
firstAssistantText = block.text;
|
|
1524
|
+
break;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Stop early if we have user text
|
|
1531
|
+
if (firstUserText) break;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Priority: user text > assistant text > git branch > slug > short ID
|
|
1535
|
+
let label = firstUserText || firstAssistantText || gitBranch || slug || sessionId.slice(0, 8);
|
|
1536
|
+
|
|
1537
|
+
// Clean up the label
|
|
1538
|
+
label = label
|
|
1539
|
+
.replace(/^\/\S+\s*/, "") // Remove /command prefixes
|
|
1540
|
+
.replace(/<[^>]+>[^<]*<\/[^>]+>/g, "") // Remove XML-style tags with content
|
|
1541
|
+
.replace(/<[^>]+>/g, "") // Remove remaining XML tags
|
|
1542
|
+
.replace(/^\s*Caveat:.*$/m, "") // Remove caveat lines
|
|
1543
|
+
.replace(/\n.*/g, "") // Take only first line
|
|
1544
|
+
.trim();
|
|
1545
|
+
|
|
1546
|
+
// If still empty or too short, use fallback
|
|
1547
|
+
if (label.length < 3) {
|
|
1548
|
+
label = slug || sessionId.slice(0, 8);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Truncate very long labels (will be smart-truncated in UI based on box size)
|
|
1552
|
+
if (label.length > 80) {
|
|
1553
|
+
label = label.slice(0, 77) + "...";
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return label;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
private analyzeSession(entries: JournalEntry[]): {
|
|
1560
|
+
inputTokens: number;
|
|
1561
|
+
outputTokens: number;
|
|
1562
|
+
opusTokens: number;
|
|
1563
|
+
sonnetTokens: number;
|
|
1564
|
+
haikuTokens: number;
|
|
1565
|
+
cacheHitRate: number;
|
|
1566
|
+
repeatedReads: number;
|
|
1567
|
+
modelEfficiency: number;
|
|
1568
|
+
} {
|
|
1569
|
+
let inputTokens = 0;
|
|
1570
|
+
let outputTokens = 0;
|
|
1571
|
+
let opusTokens = 0;
|
|
1572
|
+
let sonnetTokens = 0;
|
|
1573
|
+
let haikuTokens = 0;
|
|
1574
|
+
let cacheRead = 0;
|
|
1575
|
+
let totalInput = 0;
|
|
1576
|
+
const fileReadCounts = new Map<string, number>();
|
|
1577
|
+
|
|
1578
|
+
for (const entry of entries) {
|
|
1579
|
+
// Count file reads for repeatedReads metric
|
|
1580
|
+
if (entry.message?.content && Array.isArray(entry.message.content)) {
|
|
1581
|
+
for (const block of entry.message.content) {
|
|
1582
|
+
if (block.type === "tool_use" && block.name === "Read" && block.input) {
|
|
1583
|
+
const filePath = (block.input as { file_path?: string }).file_path;
|
|
1584
|
+
if (filePath) {
|
|
1585
|
+
fileReadCounts.set(filePath, (fileReadCounts.get(filePath) || 0) + 1);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
if (!entry.message?.usage) continue;
|
|
1592
|
+
const usage = entry.message.usage;
|
|
1593
|
+
const model = entry.message.model || "";
|
|
1594
|
+
const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
|
|
1595
|
+
|
|
1596
|
+
inputTokens += usage.input_tokens || 0;
|
|
1597
|
+
outputTokens += usage.output_tokens || 0;
|
|
1598
|
+
cacheRead += usage.cache_read_input_tokens || 0;
|
|
1599
|
+
totalInput += usage.input_tokens || 0;
|
|
1600
|
+
|
|
1601
|
+
if (model.includes("opus")) opusTokens += tokens;
|
|
1602
|
+
else if (model.includes("sonnet")) sonnetTokens += tokens;
|
|
1603
|
+
else if (model.includes("haiku")) haikuTokens += tokens;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Count files read more than once
|
|
1607
|
+
let repeatedReads = 0;
|
|
1608
|
+
for (const count of fileReadCounts.values()) {
|
|
1609
|
+
if (count > 1) repeatedReads += count - 1;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const totalTokens = opusTokens + sonnetTokens + haikuTokens;
|
|
1613
|
+
|
|
1614
|
+
return {
|
|
1615
|
+
inputTokens,
|
|
1616
|
+
outputTokens,
|
|
1617
|
+
opusTokens,
|
|
1618
|
+
sonnetTokens,
|
|
1619
|
+
haikuTokens,
|
|
1620
|
+
cacheHitRate: totalInput > 0 ? cacheRead / totalInput : 0,
|
|
1621
|
+
repeatedReads,
|
|
1622
|
+
modelEfficiency: totalTokens > 0 ? opusTokens / totalTokens : 0,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
private getPrimaryModel(analysis: ReturnType<typeof this.analyzeSession>): string {
|
|
1627
|
+
const { opusTokens, sonnetTokens, haikuTokens } = analysis;
|
|
1628
|
+
if (opusTokens >= sonnetTokens && opusTokens >= haikuTokens) return "Opus";
|
|
1629
|
+
if (sonnetTokens >= haikuTokens) return "Sonnet";
|
|
1630
|
+
return "Haiku";
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
private getModelName(model?: string): string {
|
|
1634
|
+
if (!model) return "unknown";
|
|
1635
|
+
if (model.includes("opus")) return "Opus";
|
|
1636
|
+
if (model.includes("sonnet")) return "Sonnet";
|
|
1637
|
+
if (model.includes("haiku")) return "Haiku";
|
|
1638
|
+
return model.split("-")[0] || "unknown";
|
|
1639
|
+
}
|
|
1640
|
+
}
|