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