@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
@@ -0,0 +1,970 @@
1
+ import { Flags } from "@oclif/core";
2
+ import { DateTime } from "luxon";
3
+ import * as fs from "node:fs";
4
+ import * as http from "node:http";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { x } from "tinyexec";
9
+ import { BaseCommand } from "./base.js";
10
+
11
+ // Load HTML template from file (resolved relative to this module)
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const TEMPLATE_PATH = path.join(__dirname, "graph-template.html");
14
+
15
+ // Types for parsing Claude Code session JSONL files
16
+ interface ContentBlock {
17
+ type: string;
18
+ text?: string;
19
+ id?: string;
20
+ name?: string;
21
+ input?: Record<string, unknown>;
22
+ }
23
+
24
+ interface JournalEntry {
25
+ type: string;
26
+ sessionId: string;
27
+ timestamp: string;
28
+ message?: {
29
+ role: "user" | "assistant";
30
+ model?: string;
31
+ usage?: {
32
+ input_tokens?: number;
33
+ output_tokens?: number;
34
+ cache_read_input_tokens?: number;
35
+ cache_creation_input_tokens?: number;
36
+ };
37
+ content?: ContentBlock[] | string;
38
+ };
39
+ uuid?: string;
40
+ }
41
+
42
+ interface ToolData {
43
+ name: string;
44
+ detail?: string;
45
+ inputTokens: number;
46
+ outputTokens: number;
47
+ }
48
+
49
+ // Bar chart types for stacked bar visualization - aggregated by project
50
+ export interface ProjectBar {
51
+ project: string;
52
+ totalTokens: number;
53
+ }
54
+
55
+ export interface BarChartDay {
56
+ date: string; // YYYY-MM-DD format
57
+ projects: ProjectBar[];
58
+ }
59
+
60
+ export interface BarChartData {
61
+ days: BarChartDay[];
62
+ }
63
+
64
+ export interface SessionResult {
65
+ sessionId: string;
66
+ path: string;
67
+ date: string;
68
+ tokens: number;
69
+ project: string;
70
+ mtime: number;
71
+ }
72
+
73
+ /**
74
+ * Calculate cutoff timestamp for days filtering.
75
+ * Returns 0 if days <= 0 (no filtering).
76
+ */
77
+ export function calculateCutoffMs(days: number): number {
78
+ return days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
79
+ }
80
+
81
+ /**
82
+ * Filter items by mtime against a days cutoff.
83
+ * Returns all items if days <= 0.
84
+ */
85
+ export function filterByDays<T extends { mtime: number }>(items: T[], days: number): T[] {
86
+ const cutoff = calculateCutoffMs(days);
87
+ if (cutoff === 0) return items;
88
+ return items.filter((item) => item.mtime >= cutoff);
89
+ }
90
+
91
+ /**
92
+ * Parse JSONL file into JournalEntry array.
93
+ */
94
+ export function parseJsonl(filePath: string): JournalEntry[] {
95
+ const content = fs.readFileSync(filePath, "utf-8");
96
+ const entries: JournalEntry[] = [];
97
+
98
+ for (const line of content.split("\n")) {
99
+ if (!line.trim()) continue;
100
+ try {
101
+ entries.push(JSON.parse(line) as JournalEntry);
102
+ } catch {
103
+ // Skip invalid lines
104
+ }
105
+ }
106
+
107
+ return entries;
108
+ }
109
+
110
+ /**
111
+ * Analyze session entries to get token breakdown by model.
112
+ */
113
+ export function analyzeSession(entries: JournalEntry[]): {
114
+ inputTokens: number;
115
+ outputTokens: number;
116
+ opusTokens: number;
117
+ sonnetTokens: number;
118
+ haikuTokens: number;
119
+ cacheHitRate: number;
120
+ repeatedReads: number;
121
+ modelEfficiency: number;
122
+ } {
123
+ let inputTokens = 0;
124
+ let outputTokens = 0;
125
+ let opusTokens = 0;
126
+ let sonnetTokens = 0;
127
+ let haikuTokens = 0;
128
+ let cacheRead = 0;
129
+ let totalInput = 0;
130
+ const fileReadCounts = new Map<string, number>();
131
+
132
+ for (const entry of entries) {
133
+ // Count file reads for repeatedReads metric
134
+ if (entry.message?.content && Array.isArray(entry.message.content)) {
135
+ for (const block of entry.message.content) {
136
+ if (block.type === "tool_use" && block.name === "Read" && block.input) {
137
+ const filePath = (block.input as { file_path?: string }).file_path;
138
+ if (filePath) {
139
+ fileReadCounts.set(filePath, (fileReadCounts.get(filePath) || 0) + 1);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ if (!entry.message?.usage) continue;
146
+ const usage = entry.message.usage;
147
+ const model = entry.message.model || "";
148
+ const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
149
+
150
+ inputTokens += usage.input_tokens || 0;
151
+ outputTokens += usage.output_tokens || 0;
152
+ cacheRead += usage.cache_read_input_tokens || 0;
153
+ totalInput += usage.input_tokens || 0;
154
+
155
+ if (model.includes("opus")) opusTokens += tokens;
156
+ else if (model.includes("sonnet")) sonnetTokens += tokens;
157
+ else if (model.includes("haiku")) haikuTokens += tokens;
158
+ }
159
+
160
+ // Count files read more than once
161
+ let repeatedReads = 0;
162
+ for (const count of fileReadCounts.values()) {
163
+ if (count > 1) repeatedReads += count - 1;
164
+ }
165
+
166
+ const totalTokens = opusTokens + sonnetTokens + haikuTokens;
167
+
168
+ return {
169
+ inputTokens,
170
+ outputTokens,
171
+ opusTokens,
172
+ sonnetTokens,
173
+ haikuTokens,
174
+ cacheHitRate: totalInput > 0 ? cacheRead / totalInput : 0,
175
+ repeatedReads,
176
+ modelEfficiency: totalTokens > 0 ? opusTokens / totalTokens : 0,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Extract a meaningful label from session entries.
182
+ */
183
+ export function extractSessionLabel(entries: JournalEntry[], sessionId: string): string {
184
+ let firstUserText: string | undefined;
185
+ let firstAssistantText: string | undefined;
186
+ let gitBranch: string | undefined;
187
+ let slug: string | undefined;
188
+
189
+ for (const entry of entries) {
190
+ // Extract metadata from any entry
191
+ if (!gitBranch && (entry as any).gitBranch) {
192
+ gitBranch = (entry as any).gitBranch;
193
+ }
194
+ if (!slug && (entry as any).slug) {
195
+ slug = (entry as any).slug;
196
+ }
197
+
198
+ if (!entry.message) continue;
199
+
200
+ // Look for first user message with actual text (not UUID reference)
201
+ if (!firstUserText && entry.type === "user" && entry.message.role === "user") {
202
+ const content = entry.message.content;
203
+ if (typeof content === "string") {
204
+ // Check if it's a UUID (skip those) or actual text
205
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
206
+ content,
207
+ );
208
+ if (!isUuid && content.length > 0) {
209
+ firstUserText = content;
210
+ }
211
+ } else if (Array.isArray(content)) {
212
+ // Look for text blocks in array content
213
+ for (const block of content) {
214
+ if (block.type === "text" && block.text && block.text.length > 0) {
215
+ firstUserText = block.text;
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ // Look for first assistant text response
223
+ if (!firstAssistantText && entry.type === "assistant" && entry.message.role === "assistant") {
224
+ const content = entry.message.content;
225
+ if (Array.isArray(content)) {
226
+ for (const block of content) {
227
+ if (block.type === "text" && block.text && block.text.length > 0) {
228
+ firstAssistantText = block.text;
229
+ break;
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ // Stop early if we have user text
236
+ if (firstUserText) break;
237
+ }
238
+
239
+ // Priority: user text > assistant text > git branch > slug > short ID
240
+ let label = firstUserText || firstAssistantText || gitBranch || slug || sessionId.slice(0, 8);
241
+
242
+ // Clean up the label
243
+ label = label
244
+ .replace(/^\/\S+\s*/, "") // Remove /command prefixes
245
+ .replace(/<[^>]+>[^<]*<\/[^>]+>/g, "") // Remove XML-style tags with content
246
+ .replace(/<[^>]+>/g, "") // Remove remaining XML tags
247
+ .replace(/^\s*Caveat:.*$/m, "") // Remove caveat lines
248
+ .replace(/\n.*/g, "") // Take only first line
249
+ // eslint-disable-next-line no-control-regex
250
+ .replace(/[\x00-\x1F]+/g, " ") // Replace control characters with space
251
+ .trim();
252
+
253
+ // If still empty or too short, use fallback
254
+ if (label.length < 3) {
255
+ label = slug || sessionId.slice(0, 8);
256
+ }
257
+
258
+ // Truncate very long labels (will be smart-truncated in UI based on box size)
259
+ if (label.length > 80) {
260
+ label = label.slice(0, 77) + "...";
261
+ }
262
+
263
+ return label;
264
+ }
265
+
266
+ /**
267
+ * Build bar chart data structure from session results.
268
+ * Groups sessions by date and project folder, aggregating tokens per project per day.
269
+ */
270
+ export function buildBarChartData(
271
+ sessions: SessionResult[],
272
+ extractProjectName: (encoded: string) => string,
273
+ ): BarChartData {
274
+ if (sessions.length === 0) {
275
+ return { days: [] };
276
+ }
277
+
278
+ // Group sessions by date, then by project
279
+ const byDateProject = new Map<string, Map<string, number>>();
280
+
281
+ for (const session of sessions) {
282
+ const project = extractProjectName(session.project);
283
+
284
+ if (!byDateProject.has(session.date)) {
285
+ byDateProject.set(session.date, new Map());
286
+ }
287
+ const projectMap = byDateProject.get(session.date)!;
288
+ projectMap.set(project, (projectMap.get(project) || 0) + session.tokens);
289
+ }
290
+
291
+ // Build days array sorted chronologically (oldest first for x-axis)
292
+ const sortedDates = [...byDateProject.keys()].sort();
293
+ const days: BarChartDay[] = sortedDates.map((date) => {
294
+ const projectMap = byDateProject.get(date)!;
295
+ // Sort projects by total tokens descending
296
+ const projects: ProjectBar[] = [...projectMap.entries()]
297
+ .map(([project, totalTokens]) => ({ project, totalTokens }))
298
+ .sort((a, b) => b.totalTokens - a.totalTokens);
299
+ return { date, projects };
300
+ });
301
+
302
+ return { days };
303
+ }
304
+
305
+ interface TreemapNode {
306
+ name: string;
307
+ value?: number;
308
+ children?: TreemapNode[];
309
+ // Metadata for tooltips
310
+ sessionId?: string;
311
+ fullSessionId?: string;
312
+ filePath?: string;
313
+ startTime?: string;
314
+ model?: string;
315
+ inputTokens?: number;
316
+ outputTokens?: number;
317
+ ratio?: number;
318
+ date?: string;
319
+ project?: string;
320
+ // Waste metrics
321
+ repeatedReads?: number;
322
+ modelEfficiency?: number; // Opus tokens / total tokens
323
+ // Tool data
324
+ tools?: ToolData[];
325
+ toolName?: string; // For coloring by tool type
326
+ }
327
+
328
+ /**
329
+ * Generate interactive HTML treemap from session token data
330
+ */
331
+ export default class Graph extends BaseCommand {
332
+ static override description = "Generate interactive HTML treemap from session token data";
333
+
334
+ static override examples = [
335
+ {
336
+ description: "Generate treemap for all recent sessions",
337
+ command: "<%= config.bin %> <%= command.id %>",
338
+ },
339
+ {
340
+ description: "Generate treemap for a specific session",
341
+ command: "<%= config.bin %> <%= command.id %> --session abc123",
342
+ },
343
+ {
344
+ description: "Generate and auto-open in browser",
345
+ command: "<%= config.bin %> <%= command.id %> --open",
346
+ },
347
+ ];
348
+
349
+ static override flags = {
350
+ ...BaseCommand.baseFlags,
351
+ session: Flags.string({
352
+ char: "s",
353
+ description: "Session ID to analyze (shows all sessions if not provided)",
354
+ }),
355
+ open: Flags.boolean({
356
+ char: "o",
357
+ description: "Open treemap in browser after generating",
358
+ default: true,
359
+ allowNo: true,
360
+ }),
361
+ serve: Flags.boolean({
362
+ description: "Start local HTTP server to serve treemap (default: true)",
363
+ default: true,
364
+ allowNo: true,
365
+ }),
366
+ port: Flags.integer({
367
+ char: "p",
368
+ description: "Port for local server",
369
+ default: 8765,
370
+ }),
371
+ days: Flags.integer({
372
+ description: "Filter to sessions from last N days (0=no limit)",
373
+ default: 7,
374
+ }),
375
+ };
376
+
377
+ async run(): Promise<void> {
378
+ const { flags } = await this.parse(Graph);
379
+
380
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
381
+ if (!fs.existsSync(projectsDir)) {
382
+ this.error("No Claude projects directory found at ~/.claude/projects/");
383
+ }
384
+
385
+ const sessionId = flags.session;
386
+ let treemapData: TreemapNode;
387
+ let barChartData: BarChartData = { days: [] };
388
+
389
+ if (!sessionId) {
390
+ // All sessions mode
391
+ const sessions = this.findRecentSessions(projectsDir, 500, flags.days);
392
+ if (sessions.length === 0) {
393
+ this.error("No sessions found");
394
+ }
395
+
396
+ const daysMsg = flags.days > 0 ? ` (last ${flags.days} days)` : "";
397
+ this.log(`📊 Generating treemap for ${sessions.length} sessions${daysMsg}...`);
398
+ treemapData = this.buildAllSessionsTreemap(sessions);
399
+ barChartData = buildBarChartData(sessions, this.extractProjectName.bind(this));
400
+ } else {
401
+ // Single session mode
402
+ const sessionPath = this.findSessionPath(projectsDir, sessionId);
403
+ if (!sessionPath) {
404
+ this.error(`Session ${sessionId} not found`);
405
+ }
406
+
407
+ this.log(`📊 Generating treemap for session ${sessionId}...`);
408
+ const entries = this.parseJsonl(sessionPath);
409
+ treemapData = this.buildSessionTreemap(sessionId, entries);
410
+ // Bar chart not meaningful for single session, leave empty
411
+ }
412
+
413
+ // Generate HTML
414
+ const html = this.generateTreemapHtml(treemapData, barChartData);
415
+
416
+ // Write output file
417
+ const reportsDir = path.join(os.homedir(), ".claude", "reports");
418
+ if (!fs.existsSync(reportsDir)) {
419
+ fs.mkdirSync(reportsDir, { recursive: true });
420
+ }
421
+
422
+ const timestamp = DateTime.now().toFormat("yyyy-MM-dd'T'HH-mmZZZ");
423
+ const daysLabel = flags.days > 0 ? `${flags.days}d` : "all";
424
+ const filename = sessionId
425
+ ? `treemap-${sessionId.slice(0, 8)}-${timestamp}.html`
426
+ : `treemap-${daysLabel}-${timestamp}.html`;
427
+ const outputPath = path.join(reportsDir, filename);
428
+
429
+ fs.writeFileSync(outputPath, html);
430
+ this.log(`✓ Saved to ${outputPath}`);
431
+
432
+ if (flags.serve) {
433
+ // Start local HTTP server
434
+ const server = http.createServer((req, res) => {
435
+ // Serve the generated HTML file
436
+ if (req.url === "/" || req.url === `/${filename}`) {
437
+ res.writeHead(200, { "Content-Type": "text/html" });
438
+ res.end(html);
439
+ } else {
440
+ res.writeHead(404);
441
+ res.end("Not found");
442
+ }
443
+ });
444
+
445
+ // Try to start server, fallback to next port if in use
446
+ const startPort = flags.port;
447
+ const maxAttempts = 10;
448
+
449
+ const tryPort = (port: number): Promise<number> => {
450
+ return new Promise((resolve, reject) => {
451
+ const onError = (err: NodeJS.ErrnoException) => {
452
+ server.removeListener("listening", onListening);
453
+ if (err.code === "EADDRINUSE" && port < startPort + maxAttempts - 1) {
454
+ resolve(tryPort(port + 1));
455
+ } else {
456
+ reject(err);
457
+ }
458
+ };
459
+
460
+ const onListening = () => {
461
+ server.removeListener("error", onError);
462
+ resolve(port);
463
+ };
464
+
465
+ server.once("error", onError);
466
+ server.once("listening", onListening);
467
+ server.listen(port);
468
+ });
469
+ };
470
+
471
+ const tryListen = (): Promise<number> => tryPort(startPort);
472
+
473
+ const actualPort = await tryListen();
474
+ const url = `http://localhost:${actualPort}/`;
475
+ if (actualPort !== startPort) {
476
+ this.log(`\n⚠️ Port ${startPort} in use, using ${actualPort}`);
477
+ }
478
+ this.log(`🌐 Server running at ${url}`);
479
+ this.log(" Press Ctrl+C to stop\n");
480
+
481
+ if (flags.open) {
482
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
483
+ x(openCmd, [url]);
484
+ }
485
+
486
+ // Keep server running until Ctrl+C
487
+ await new Promise<void>((resolve) => {
488
+ process.on("SIGINT", () => {
489
+ this.log("\n👋 Stopping server...");
490
+ server.close();
491
+ resolve();
492
+ });
493
+ });
494
+ } else if (flags.open) {
495
+ this.log("\n📈 Opening treemap...");
496
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
497
+ await x(openCmd, [outputPath]);
498
+ }
499
+ }
500
+
501
+ private generateTreemapHtml(data: TreemapNode, barChartData: BarChartData): string {
502
+ const width = 1200;
503
+ const height = 800;
504
+
505
+ // Read template from file and replace placeholders
506
+ // Use function replacement to avoid special $& $' $` patterns in data being interpreted
507
+ const template = fs.readFileSync(TEMPLATE_PATH, "utf-8");
508
+ return template
509
+ .replace(/\{\{WIDTH\}\}/g, String(width))
510
+ .replace(/\{\{HEIGHT\}\}/g, String(height))
511
+ .replace(/\{\{DATA\}\}/g, () => JSON.stringify(data))
512
+ .replace(/\{\{BAR_CHART_DATA\}\}/g, () => JSON.stringify(barChartData));
513
+ }
514
+
515
+ private findRecentSessions(projectsDir: string, limit: number, days: number): SessionResult[] {
516
+ const sessions: Array<{
517
+ sessionId: string;
518
+ path: string;
519
+ date: string;
520
+ tokens: number;
521
+ project: string;
522
+ mtime: number;
523
+ }> = [];
524
+
525
+ const cutoffMs = calculateCutoffMs(days);
526
+
527
+ const projectDirs = fs.readdirSync(projectsDir);
528
+ for (const project of projectDirs) {
529
+ const projectPath = path.join(projectsDir, project);
530
+ if (!fs.statSync(projectPath).isDirectory()) continue;
531
+
532
+ const files = fs.readdirSync(projectPath).filter((f) => f.endsWith(".jsonl"));
533
+ for (const file of files) {
534
+ const filePath = path.join(projectPath, file);
535
+ const stat = fs.statSync(filePath);
536
+
537
+ // Filter by days if cutoff is set
538
+ if (cutoffMs > 0 && stat.mtimeMs < cutoffMs) continue;
539
+
540
+ const sessionId = file.replace(".jsonl", "");
541
+
542
+ // Quick token count from file
543
+ const tokens = this.quickTokenCount(filePath);
544
+
545
+ sessions.push({
546
+ sessionId,
547
+ path: filePath,
548
+ date: stat.mtime.toLocaleDateString("en-CA"), // YYYY-MM-DD in local timezone
549
+ tokens,
550
+ project,
551
+ mtime: stat.mtimeMs,
552
+ });
553
+ }
554
+ }
555
+
556
+ // Sort by modification time, most recent first
557
+ sessions.sort((a, b) => b.mtime - a.mtime);
558
+ return sessions.slice(0, limit);
559
+ }
560
+
561
+ private quickTokenCount(filePath: string): number {
562
+ try {
563
+ const content = fs.readFileSync(filePath, "utf-8");
564
+ let total = 0;
565
+ for (const line of content.split("\n")) {
566
+ if (!line.trim()) continue;
567
+ try {
568
+ const entry = JSON.parse(line) as JournalEntry;
569
+ if (entry.message?.usage) {
570
+ total +=
571
+ (entry.message.usage.input_tokens || 0) + (entry.message.usage.output_tokens || 0);
572
+ }
573
+ } catch {
574
+ // Skip invalid lines
575
+ }
576
+ }
577
+ return total;
578
+ } catch {
579
+ return 0;
580
+ }
581
+ }
582
+
583
+ private findSessionPath(projectsDir: string, sessionId: string): string | undefined {
584
+ const projectDirs = fs.readdirSync(projectsDir);
585
+ for (const project of projectDirs) {
586
+ const projectPath = path.join(projectsDir, project);
587
+ if (!fs.statSync(projectPath).isDirectory()) continue;
588
+
589
+ const jsonlPath = path.join(projectPath, `${sessionId}.jsonl`);
590
+ if (fs.existsSync(jsonlPath)) {
591
+ return jsonlPath;
592
+ }
593
+ }
594
+ return undefined;
595
+ }
596
+
597
+ private parseJsonl(filePath: string): JournalEntry[] {
598
+ return parseJsonl(filePath);
599
+ }
600
+
601
+ private buildSessionTreemap(sessionId: string, entries: JournalEntry[]): TreemapNode {
602
+ return {
603
+ name: `Session ${sessionId.slice(0, 8)}`,
604
+ children: this.buildTurnNodes(sessionId, entries),
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Build turn-level nodes from session entries.
610
+ * Used by both single-session and all-sessions views.
611
+ */
612
+ private buildTurnNodes(
613
+ sessionId: string,
614
+ entries: JournalEntry[],
615
+ filePath?: string,
616
+ ): TreemapNode[] {
617
+ const children: TreemapNode[] = [];
618
+ let turnNumber = 0;
619
+
620
+ for (const entry of entries) {
621
+ if (entry.type !== "user" && entry.type !== "assistant") continue;
622
+ if (!entry.message) continue;
623
+
624
+ const role = entry.message.role;
625
+ const usage = entry.message.usage;
626
+ const model = entry.message.model;
627
+
628
+ if (role === "user") {
629
+ turnNumber++;
630
+ }
631
+
632
+ if (!usage) continue;
633
+
634
+ const inputTokens = usage.input_tokens || 0;
635
+ const outputTokens = usage.output_tokens || 0;
636
+ const totalTokens = inputTokens + outputTokens;
637
+
638
+ if (totalTokens === 0) continue;
639
+
640
+ const ratio = outputTokens > 0 ? inputTokens / outputTokens : inputTokens > 0 ? 999 : 0;
641
+
642
+ // Extract individual tool calls from content blocks
643
+ const tools = this.extractToolData(entry.message.content, inputTokens, outputTokens);
644
+
645
+ // Create individual tool children nodes
646
+ const toolChildren: TreemapNode[] = tools.map((tool) => ({
647
+ name: tool.detail ? `${tool.name}: ${tool.detail}` : tool.name,
648
+ value: tool.inputTokens + tool.outputTokens,
649
+ inputTokens: tool.inputTokens,
650
+ outputTokens: tool.outputTokens,
651
+ ratio: tool.outputTokens > 0 ? tool.inputTokens / tool.outputTokens : 0,
652
+ toolName: tool.name,
653
+ }));
654
+
655
+ // Format turn name based on tools used
656
+ let turnName: string;
657
+ let primaryToolName: string | undefined;
658
+ if (role === "user") {
659
+ turnName = `Turn ${turnNumber}: User`;
660
+ } else if (tools.length === 1) {
661
+ // Single tool: show tool name and detail
662
+ const t = tools[0];
663
+ turnName = t.detail ? `${t.name}: ${t.detail}` : t.name;
664
+ primaryToolName = t.name;
665
+ } else if (tools.length > 1) {
666
+ // Multiple tools: list unique tool names, primary is most common
667
+ const uniqueNames = [...new Set(tools.map((t) => t.name))];
668
+ turnName = uniqueNames.slice(0, 3).join(", ") + (uniqueNames.length > 3 ? "..." : "");
669
+ primaryToolName = tools[0].name; // Use first tool as primary
670
+ } else {
671
+ turnName = `Turn ${turnNumber}: Response`;
672
+ primaryToolName = "Response";
673
+ }
674
+ children.push({
675
+ name: turnName,
676
+ value: toolChildren.length > 0 ? undefined : totalTokens, // Let children sum if present
677
+ children: toolChildren.length > 0 ? toolChildren : undefined,
678
+ sessionId: sessionId.slice(0, 8),
679
+ fullSessionId: sessionId,
680
+ filePath,
681
+ toolName: primaryToolName,
682
+ model: this.getModelName(model),
683
+ inputTokens,
684
+ outputTokens,
685
+ ratio,
686
+ tools: tools.length > 0 ? tools : undefined,
687
+ });
688
+ }
689
+
690
+ return children;
691
+ }
692
+
693
+ /**
694
+ * Extract individual tool calls from message content blocks.
695
+ * Returns each tool call with its detail (file path, command, etc.).
696
+ */
697
+ private extractToolData(
698
+ content: ContentBlock[] | string | undefined,
699
+ turnInputTokens: number,
700
+ turnOutputTokens: number,
701
+ ): ToolData[] {
702
+ if (!content || typeof content === "string") return [];
703
+
704
+ // Collect individual tool_use blocks
705
+ const toolBlocks: Array<{ name: string; detail?: string }> = [];
706
+ for (const block of content) {
707
+ if (block.type === "tool_use" && block.name) {
708
+ const detail = this.extractToolDetail(block.name, block.input);
709
+ toolBlocks.push({ name: block.name, detail });
710
+ }
711
+ }
712
+
713
+ if (toolBlocks.length === 0) return [];
714
+
715
+ // Distribute tokens proportionally across individual calls
716
+ const tokensPerCall = {
717
+ input: Math.round(turnInputTokens / toolBlocks.length),
718
+ output: Math.round(turnOutputTokens / toolBlocks.length),
719
+ };
720
+
721
+ return toolBlocks.map((tool) => ({
722
+ name: tool.name,
723
+ detail: tool.detail,
724
+ inputTokens: tokensPerCall.input,
725
+ outputTokens: tokensPerCall.output,
726
+ }));
727
+ }
728
+
729
+ /**
730
+ * Extract a meaningful detail string from tool input.
731
+ */
732
+ private extractToolDetail(toolName: string, input?: Record<string, unknown>): string | undefined {
733
+ if (!input) return undefined;
734
+
735
+ switch (toolName) {
736
+ case "Read":
737
+ return this.truncateDetail(input.file_path as string);
738
+ case "Write":
739
+ case "Edit":
740
+ return this.truncateDetail(input.file_path as string);
741
+ case "Bash":
742
+ return this.truncateDetail(input.command as string, 50);
743
+ case "Glob":
744
+ return this.truncateDetail(input.pattern as string, 50);
745
+ case "Grep":
746
+ return this.truncateDetail(input.pattern as string, 50);
747
+ case "Task":
748
+ return this.truncateDetail(input.description as string, 50);
749
+ case "WebFetch":
750
+ return this.truncateDetail(input.url as string, 40);
751
+ default:
752
+ return undefined;
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Sanitize string by replacing control characters (newlines, tabs, etc.) with spaces.
758
+ */
759
+ private sanitizeString(str: string): string {
760
+ // Replace all control characters (ASCII 0-31) with space, collapse multiple spaces
761
+ // eslint-disable-next-line no-control-regex
762
+ return str.replace(/[\x00-\x1F]+/g, " ").trim();
763
+ }
764
+
765
+ /**
766
+ * Truncate a string and extract just the filename for paths.
767
+ */
768
+ private truncateDetail(str: string | undefined, maxLen = 30): string | undefined {
769
+ if (!str) return undefined;
770
+ // Sanitize control characters first
771
+ const sanitized = this.sanitizeString(str);
772
+ // For file paths, show just the filename
773
+ if (sanitized.includes("/")) {
774
+ const parts = sanitized.split("/");
775
+ const filename = parts[parts.length - 1];
776
+ return filename.length > maxLen ? filename.slice(0, maxLen - 3) + "..." : filename;
777
+ }
778
+ return sanitized.length > maxLen ? sanitized.slice(0, maxLen - 3) + "..." : sanitized;
779
+ }
780
+
781
+ /**
782
+ * Aggregate tool usage across all entries in a session.
783
+ * Returns combined tool data for session-level tooltips (aggregated by name).
784
+ */
785
+ private aggregateSessionTools(entries: JournalEntry[]): ToolData[] {
786
+ const toolAgg = new Map<string, { count: number; inputTokens: number; outputTokens: number }>();
787
+
788
+ for (const entry of entries) {
789
+ if (!entry.message?.content || typeof entry.message.content === "string") continue;
790
+ if (!entry.message.usage) continue;
791
+
792
+ const inputTokens = entry.message.usage.input_tokens || 0;
793
+ const outputTokens = entry.message.usage.output_tokens || 0;
794
+ const turnTools = this.extractToolData(entry.message.content, inputTokens, outputTokens);
795
+
796
+ for (const tool of turnTools) {
797
+ const existing = toolAgg.get(tool.name);
798
+ if (existing) {
799
+ existing.count += 1;
800
+ existing.inputTokens += tool.inputTokens;
801
+ existing.outputTokens += tool.outputTokens;
802
+ } else {
803
+ toolAgg.set(tool.name, {
804
+ count: 1,
805
+ inputTokens: tool.inputTokens,
806
+ outputTokens: tool.outputTokens,
807
+ });
808
+ }
809
+ }
810
+ }
811
+
812
+ // Convert to array and sort by token usage
813
+ const tools: ToolData[] = [...toolAgg.entries()].map(([name, data]) => ({
814
+ name,
815
+ detail: `${data.count}x`,
816
+ inputTokens: data.inputTokens,
817
+ outputTokens: data.outputTokens,
818
+ }));
819
+ tools.sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens));
820
+
821
+ return tools;
822
+ }
823
+
824
+ private buildAllSessionsTreemap(
825
+ sessions: Array<{
826
+ sessionId: string;
827
+ path: string;
828
+ date: string;
829
+ tokens: number;
830
+ project: string;
831
+ }>,
832
+ ): TreemapNode {
833
+ // Group sessions by project, then by date
834
+ const byProject = new Map<string, typeof sessions>();
835
+ for (const session of sessions) {
836
+ const projectName = this.extractProjectName(session.project);
837
+ if (!byProject.has(projectName)) {
838
+ byProject.set(projectName, []);
839
+ }
840
+ byProject.get(projectName)!.push(session);
841
+ }
842
+
843
+ // Sort projects by total tokens
844
+ const projectTotals = [...byProject.entries()].map(([name, sess]) => ({
845
+ name,
846
+ sessions: sess,
847
+ total: sess.reduce((sum, s) => sum + s.tokens, 0),
848
+ }));
849
+ projectTotals.sort((a, b) => b.total - a.total);
850
+
851
+ const projectChildren: TreemapNode[] = [];
852
+
853
+ for (const { name: projectName, sessions: projectSessions } of projectTotals) {
854
+ // Group by date within project
855
+ const byDate = new Map<string, typeof sessions>();
856
+ for (const session of projectSessions) {
857
+ if (!byDate.has(session.date)) {
858
+ byDate.set(session.date, []);
859
+ }
860
+ byDate.get(session.date)!.push(session);
861
+ }
862
+
863
+ // Sort dates (most recent first)
864
+ const sortedDates = [...byDate.keys()].sort().reverse();
865
+
866
+ const dateChildren: TreemapNode[] = [];
867
+
868
+ for (const date of sortedDates) {
869
+ const dateSessions = byDate.get(date)!;
870
+
871
+ const sessionChildren: TreemapNode[] = [];
872
+
873
+ for (const session of dateSessions) {
874
+ const entries = this.parseJsonl(session.path);
875
+ const analysis = this.analyzeSession(entries);
876
+ const label = this.extractSessionLabel(entries, session.sessionId);
877
+ const tools = this.aggregateSessionTools(entries);
878
+ const startTime = entries[0]?.timestamp
879
+ ? new Date(entries[0].timestamp).toLocaleTimeString()
880
+ : undefined;
881
+
882
+ // Build turn-level children for drill-down
883
+ const turnChildren = this.buildTurnNodes(session.sessionId, entries, session.path);
884
+
885
+ sessionChildren.push({
886
+ name: label,
887
+ // If we have turn children, let them sum; otherwise use session total
888
+ value: turnChildren.length > 0 ? undefined : session.tokens,
889
+ children: turnChildren.length > 0 ? turnChildren : undefined,
890
+ sessionId: session.sessionId.slice(0, 8),
891
+ fullSessionId: session.sessionId,
892
+ filePath: session.path,
893
+ startTime,
894
+ model: this.getPrimaryModel(analysis),
895
+ inputTokens: analysis.inputTokens,
896
+ outputTokens: analysis.outputTokens,
897
+ ratio: analysis.outputTokens > 0 ? analysis.inputTokens / analysis.outputTokens : 0,
898
+ date: session.date,
899
+ project: projectName,
900
+ repeatedReads: analysis.repeatedReads,
901
+ modelEfficiency: analysis.modelEfficiency,
902
+ tools: tools.length > 0 ? tools : undefined,
903
+ });
904
+ }
905
+
906
+ dateChildren.push({
907
+ name: date,
908
+ children: sessionChildren,
909
+ date,
910
+ });
911
+ }
912
+
913
+ projectChildren.push({
914
+ name: projectName,
915
+ children: dateChildren,
916
+ project: projectName,
917
+ });
918
+ }
919
+
920
+ return {
921
+ name: "All Sessions",
922
+ children: projectChildren,
923
+ };
924
+ }
925
+
926
+ private extractProjectName(encodedProject: string): string {
927
+ // Directory names encode paths: -home-ctowles-code-p-towles-tool
928
+ const parts = encodedProject.split("-").filter(Boolean);
929
+ const pathMarkers = new Set(["code", "projects", "src", "p", "repos", "git", "workspace"]);
930
+
931
+ // Find LAST index of a path marker
932
+ let lastMarkerIdx = -1;
933
+ for (let i = 0; i < parts.length; i++) {
934
+ if (pathMarkers.has(parts[i].toLowerCase())) {
935
+ lastMarkerIdx = i;
936
+ }
937
+ }
938
+
939
+ // Take everything after the last marker
940
+ const projectParts = lastMarkerIdx >= 0 ? parts.slice(lastMarkerIdx + 1) : parts.slice(-2);
941
+
942
+ if (projectParts.length === 0) {
943
+ return parts[parts.length - 1] || encodedProject.slice(0, 20);
944
+ }
945
+ return projectParts.join("-");
946
+ }
947
+
948
+ private extractSessionLabel(entries: JournalEntry[], sessionId: string): string {
949
+ return extractSessionLabel(entries, sessionId);
950
+ }
951
+
952
+ private analyzeSession(entries: JournalEntry[]): ReturnType<typeof analyzeSession> {
953
+ return analyzeSession(entries);
954
+ }
955
+
956
+ private getPrimaryModel(analysis: ReturnType<typeof this.analyzeSession>): string {
957
+ const { opusTokens, sonnetTokens, haikuTokens } = analysis;
958
+ if (opusTokens >= sonnetTokens && opusTokens >= haikuTokens) return "Opus";
959
+ if (sonnetTokens >= haikuTokens) return "Sonnet";
960
+ return "Haiku";
961
+ }
962
+
963
+ private getModelName(model?: string): string {
964
+ if (!model) return "unknown";
965
+ if (model.includes("opus")) return "Opus";
966
+ if (model.includes("sonnet")) return "Sonnet";
967
+ if (model.includes("haiku")) return "Haiku";
968
+ return model.split("-")[0] || "unknown";
969
+ }
970
+ }