@towles/tool 0.0.41 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +67 -109
  2. package/package.json +51 -41
  3. package/src/commands/base.ts +3 -18
  4. package/src/commands/config.ts +9 -8
  5. package/src/commands/doctor.ts +4 -1
  6. package/src/commands/gh/branch-clean.ts +10 -4
  7. package/src/commands/gh/branch.ts +6 -3
  8. package/src/commands/gh/pr.ts +10 -3
  9. package/src/commands/graph-template.html +1214 -0
  10. package/src/commands/graph.test.ts +176 -0
  11. package/src/commands/graph.ts +970 -0
  12. package/src/commands/install.ts +8 -2
  13. package/src/commands/journal/daily-notes.ts +9 -5
  14. package/src/commands/journal/meeting.ts +12 -6
  15. package/src/commands/journal/note.ts +12 -6
  16. package/src/commands/ralph/plan/add.ts +75 -0
  17. package/src/commands/ralph/plan/done.ts +82 -0
  18. package/src/commands/ralph/{task → plan}/list.test.ts +5 -5
  19. package/src/commands/ralph/{task → plan}/list.ts +28 -39
  20. package/src/commands/ralph/plan/remove.ts +71 -0
  21. package/src/commands/ralph/run.test.ts +521 -0
  22. package/src/commands/ralph/run.ts +126 -189
  23. package/src/commands/ralph/show.ts +88 -0
  24. package/src/config/settings.ts +8 -27
  25. package/src/{commands/ralph/lib → lib/ralph}/execution.ts +4 -14
  26. package/src/lib/ralph/formatter.ts +238 -0
  27. package/src/{commands/ralph/lib → lib/ralph}/state.ts +17 -42
  28. package/src/utils/date-utils.test.ts +2 -1
  29. package/src/utils/date-utils.ts +2 -2
  30. package/LICENSE.md +0 -20
  31. package/src/commands/index.ts +0 -55
  32. package/src/commands/observe/graph.test.ts +0 -89
  33. package/src/commands/observe/graph.ts +0 -1640
  34. package/src/commands/observe/report.ts +0 -166
  35. package/src/commands/observe/session.ts +0 -385
  36. package/src/commands/observe/setup.ts +0 -180
  37. package/src/commands/observe/status.ts +0 -146
  38. package/src/commands/ralph/lib/formatter.ts +0 -298
  39. package/src/commands/ralph/lib/marker.ts +0 -108
  40. package/src/commands/ralph/marker/create.ts +0 -23
  41. package/src/commands/ralph/plan.ts +0 -73
  42. package/src/commands/ralph/progress.ts +0 -44
  43. package/src/commands/ralph/ralph.test.ts +0 -673
  44. package/src/commands/ralph/task/add.ts +0 -105
  45. package/src/commands/ralph/task/done.ts +0 -73
  46. package/src/commands/ralph/task/remove.ts +0 -62
  47. package/src/config/context.ts +0 -7
  48. package/src/constants.ts +0 -3
  49. package/src/utils/anthropic/types.ts +0 -158
  50. package/src/utils/exec.ts +0 -8
  51. package/src/utils/git/git.ts +0 -25
  52. /package/src/{commands → lib}/journal/utils.ts +0 -0
  53. /package/src/{commands/ralph/lib → lib/ralph}/index.ts +0 -0
@@ -1,1640 +0,0 @@
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
- }