@towles/tool 0.0.62 → 0.0.63

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 (83) hide show
  1. package/package.json +50 -57
  2. package/src/commands/agentboard.ts +176 -0
  3. package/src/commands/{auto-claude.ts → auto-claude/index.ts} +18 -28
  4. package/src/commands/auto-claude/list.ts +114 -0
  5. package/src/commands/auto-claude/retry.test.ts +138 -0
  6. package/src/commands/auto-claude/retry.ts +139 -0
  7. package/src/commands/auto-claude/status.test.ts +147 -0
  8. package/src/commands/auto-claude/status.ts +123 -0
  9. package/src/commands/base.ts +7 -2
  10. package/src/commands/config.ts +5 -7
  11. package/src/commands/doctor.ts +111 -12
  12. package/src/commands/gh/branch.ts +4 -4
  13. package/src/commands/gh/pr.ts +1 -0
  14. package/src/commands/graph/index.ts +169 -0
  15. package/src/commands/graph.test.ts +1 -1
  16. package/src/commands/install.ts +40 -68
  17. package/src/commands/journal/daily-notes.ts +3 -3
  18. package/src/commands/journal/meeting.ts +3 -3
  19. package/src/commands/journal/note.ts +3 -3
  20. package/src/lib/auto-claude/claude-cli.ts +183 -0
  21. package/src/lib/auto-claude/config.test.ts +6 -8
  22. package/src/lib/auto-claude/config.ts +3 -4
  23. package/src/lib/auto-claude/index.ts +2 -3
  24. package/src/lib/auto-claude/labels.test.ts +85 -0
  25. package/src/lib/auto-claude/labels.ts +42 -0
  26. package/src/lib/auto-claude/pipeline-execution.test.ts +129 -33
  27. package/src/lib/auto-claude/pipeline.test.ts +2 -2
  28. package/src/lib/auto-claude/pipeline.ts +120 -36
  29. package/src/lib/auto-claude/prompt-templates/01_plan.prompt.md +68 -0
  30. package/src/lib/auto-claude/prompt-templates/{05_implement.prompt.md → 02_implement.prompt.md} +3 -2
  31. package/src/lib/auto-claude/prompt-templates/03_simplify.prompt.md +52 -0
  32. package/src/lib/auto-claude/prompt-templates/{06_review.prompt.md → 04_review.prompt.md} +29 -6
  33. package/src/lib/auto-claude/prompt-templates/index.test.ts +9 -42
  34. package/src/lib/auto-claude/prompt-templates/index.ts +13 -28
  35. package/src/lib/auto-claude/run-claude.test.ts +48 -68
  36. package/src/lib/auto-claude/shell.ts +6 -0
  37. package/src/lib/auto-claude/steps/create-pr.ts +89 -25
  38. package/src/lib/auto-claude/steps/fetch-issues.ts +4 -1
  39. package/src/lib/auto-claude/steps/implement.ts +9 -16
  40. package/src/lib/auto-claude/steps/simple-steps.ts +34 -0
  41. package/src/lib/auto-claude/steps/steps.test.ts +68 -63
  42. package/src/lib/auto-claude/templates.test.ts +91 -0
  43. package/src/lib/auto-claude/templates.ts +34 -0
  44. package/src/lib/auto-claude/test-helpers.ts +2 -1
  45. package/src/lib/auto-claude/utils-execution.test.ts +9 -57
  46. package/src/lib/auto-claude/utils.test.ts +5 -9
  47. package/src/lib/auto-claude/utils.ts +27 -253
  48. package/src/lib/graph/analyzer.test.ts +451 -0
  49. package/src/lib/graph/analyzer.ts +165 -0
  50. package/src/lib/graph/index.ts +24 -0
  51. package/src/lib/graph/labels.ts +87 -0
  52. package/src/lib/graph/parser.test.ts +150 -0
  53. package/src/lib/graph/parser.ts +65 -0
  54. package/src/lib/graph/render.ts +25 -0
  55. package/src/lib/graph/server.ts +70 -0
  56. package/src/lib/graph/sessions.ts +104 -0
  57. package/src/lib/graph/tools.ts +90 -0
  58. package/src/lib/graph/treemap.ts +211 -0
  59. package/src/lib/graph/types.ts +80 -0
  60. package/src/lib/install/claude-settings.ts +64 -0
  61. package/src/lib/journal/editor.ts +33 -0
  62. package/src/lib/journal/fs.ts +13 -0
  63. package/src/lib/journal/index.ts +11 -0
  64. package/src/lib/journal/paths.ts +106 -0
  65. package/src/lib/journal/{utils.ts → templates.ts} +3 -151
  66. package/src/utils/fs.ts +19 -0
  67. package/src/utils/git/exec.ts +18 -0
  68. package/src/utils/git/gh-cli-wrapper.test.ts +47 -8
  69. package/src/utils/git/gh-cli-wrapper.ts +31 -19
  70. package/src/utils/render.ts +3 -1
  71. package/src/commands/graph.ts +0 -970
  72. package/src/lib/auto-claude/prompt-templates/01_research.prompt.md +0 -21
  73. package/src/lib/auto-claude/prompt-templates/02_plan.prompt.md +0 -27
  74. package/src/lib/auto-claude/prompt-templates/03_plan-annotations.prompt.md +0 -15
  75. package/src/lib/auto-claude/prompt-templates/04_plan-implementation.prompt.md +0 -35
  76. package/src/lib/auto-claude/prompt-templates/07_refresh.prompt.md +0 -30
  77. package/src/lib/auto-claude/steps/plan-annotations.ts +0 -54
  78. package/src/lib/auto-claude/steps/plan-implementation.ts +0 -14
  79. package/src/lib/auto-claude/steps/plan.ts +0 -14
  80. package/src/lib/auto-claude/steps/refresh.ts +0 -114
  81. package/src/lib/auto-claude/steps/remove-label.ts +0 -22
  82. package/src/lib/auto-claude/steps/research.ts +0 -21
  83. package/src/lib/auto-claude/steps/review.ts +0 -14
@@ -0,0 +1,87 @@
1
+ import type { JournalEntry } from "./types.js";
2
+
3
+ /**
4
+ * Extract a meaningful label from session entries.
5
+ */
6
+ export function extractSessionLabel(entries: JournalEntry[], sessionId: string): string {
7
+ let firstUserText: string | undefined;
8
+ let firstAssistantText: string | undefined;
9
+ let gitBranch: string | undefined;
10
+ let slug: string | undefined;
11
+
12
+ for (const entry of entries) {
13
+ // Extract metadata from any entry
14
+ if (!gitBranch && (entry as any).gitBranch) {
15
+ gitBranch = (entry as any).gitBranch;
16
+ }
17
+ if (!slug && (entry as any).slug) {
18
+ slug = (entry as any).slug;
19
+ }
20
+
21
+ if (!entry.message) continue;
22
+
23
+ // Look for first user message with actual text (not UUID reference)
24
+ if (!firstUserText && entry.type === "user" && entry.message.role === "user") {
25
+ const content = entry.message.content;
26
+ if (typeof content === "string") {
27
+ // Check if it's a UUID (skip those) or actual text
28
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
29
+ content,
30
+ );
31
+ if (!isUuid && content.length > 0) {
32
+ firstUserText = content;
33
+ }
34
+ } else if (Array.isArray(content)) {
35
+ // Look for text blocks in array content
36
+ for (const block of content) {
37
+ if (block.type === "text" && block.text && block.text.length > 0) {
38
+ firstUserText = block.text;
39
+ break;
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ // Look for first assistant text response
46
+ if (!firstAssistantText && entry.type === "assistant" && entry.message.role === "assistant") {
47
+ const content = entry.message.content;
48
+ if (Array.isArray(content)) {
49
+ for (const block of content) {
50
+ if (block.type === "text" && block.text && block.text.length > 0) {
51
+ firstAssistantText = block.text;
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // Stop early if we have user text
59
+ if (firstUserText) break;
60
+ }
61
+
62
+ // Priority: user text > assistant text > git branch > slug > short ID
63
+ let label = firstUserText || firstAssistantText || gitBranch || slug || sessionId.slice(0, 8);
64
+
65
+ // Clean up the label
66
+ label = label
67
+ .replace(/^\/\S+\s*/, "") // Remove /command prefixes
68
+ .replace(/<[^>]+>[^<]*<\/[^>]+>/g, "") // Remove XML-style tags with content
69
+ .replace(/<[^>]+>/g, "") // Remove remaining XML tags
70
+ .replace(/^\s*Caveat:.*$/m, "") // Remove caveat lines
71
+ .replace(/\n.*/g, "") // Take only first line
72
+ // eslint-disable-next-line no-control-regex
73
+ .replace(/[\x00-\x1F]+/g, " ") // Replace control characters with space
74
+ .trim();
75
+
76
+ // If still empty or too short, use fallback
77
+ if (label.length < 3) {
78
+ label = slug || sessionId.slice(0, 8);
79
+ }
80
+
81
+ // Truncate very long labels (will be smart-truncated in UI based on box size)
82
+ if (label.length > 80) {
83
+ label = label.slice(0, 77) + "...";
84
+ }
85
+
86
+ return label;
87
+ }
@@ -0,0 +1,150 @@
1
+ import * as fs from "node:fs";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { calculateCutoffMs, filterByDays, parseJsonl, quickTokenCount } from "./parser";
5
+
6
+ vi.mock("node:fs", () => ({
7
+ readFileSync: vi.fn(),
8
+ }));
9
+
10
+ const mockedReadFileSync = vi.mocked(fs.readFileSync);
11
+
12
+ // ── Pure functions (no mocking needed) ──
13
+
14
+ describe("calculateCutoffMs", () => {
15
+ it("returns 0 for days <= 0", () => {
16
+ expect(calculateCutoffMs(0)).toBe(0);
17
+ expect(calculateCutoffMs(-1)).toBe(0);
18
+ });
19
+
20
+ it("returns a timestamp in the past for positive days", () => {
21
+ const now = Date.now();
22
+ const cutoff = calculateCutoffMs(7);
23
+ const expectedApprox = now - 7 * 24 * 60 * 60 * 1000;
24
+ // Allow 100ms tolerance for execution time
25
+ expect(Math.abs(cutoff - expectedApprox)).toBeLessThan(100);
26
+ });
27
+
28
+ it("returns larger cutoff for more days", () => {
29
+ const cutoff7 = calculateCutoffMs(7);
30
+ const cutoff30 = calculateCutoffMs(30);
31
+ expect(cutoff30).toBeLessThan(cutoff7); // Further in the past
32
+ });
33
+ });
34
+
35
+ describe("filterByDays", () => {
36
+ const now = Date.now();
37
+ const items = [
38
+ { mtime: now - 1 * 24 * 60 * 60 * 1000, name: "1-day-ago" },
39
+ { mtime: now - 5 * 24 * 60 * 60 * 1000, name: "5-days-ago" },
40
+ { mtime: now - 10 * 24 * 60 * 60 * 1000, name: "10-days-ago" },
41
+ { mtime: now - 20 * 24 * 60 * 60 * 1000, name: "20-days-ago" },
42
+ ];
43
+
44
+ it("returns all items when days <= 0", () => {
45
+ expect(filterByDays(items, 0)).toEqual(items);
46
+ expect(filterByDays(items, -5)).toEqual(items);
47
+ });
48
+
49
+ it("filters items older than cutoff", () => {
50
+ const result = filterByDays(items, 7);
51
+ expect(result).toHaveLength(2);
52
+ expect(result.map((i) => i.name)).toEqual(["1-day-ago", "5-days-ago"]);
53
+ });
54
+
55
+ it("returns empty array when all items are too old", () => {
56
+ const result = filterByDays(items, 0.001); // ~86ms
57
+ expect(result).toHaveLength(0);
58
+ });
59
+
60
+ it("returns all items when cutoff is very large", () => {
61
+ const result = filterByDays(items, 365);
62
+ expect(result).toHaveLength(4);
63
+ });
64
+
65
+ it("handles empty array", () => {
66
+ expect(filterByDays([], 7)).toEqual([]);
67
+ });
68
+ });
69
+
70
+ // ── parseJsonl and quickTokenCount (require fs mock) ──
71
+
72
+ describe("parseJsonl", () => {
73
+ it("parses valid JSONL lines", () => {
74
+ mockedReadFileSync.mockReturnValue(
75
+ '{"type":"user","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}\n{"type":"assistant","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}\n',
76
+ );
77
+ const entries = parseJsonl("/fake/path.jsonl");
78
+ expect(entries).toHaveLength(2);
79
+ expect(entries[0].type).toBe("user");
80
+ expect(entries[1].type).toBe("assistant");
81
+ });
82
+
83
+ it("skips empty lines", () => {
84
+ mockedReadFileSync.mockReturnValue(
85
+ '{"type":"user","sessionId":"s1","timestamp":"t"}\n\n\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
86
+ );
87
+ const entries = parseJsonl("/fake/path.jsonl");
88
+ expect(entries).toHaveLength(2);
89
+ });
90
+
91
+ it("skips invalid JSON lines", () => {
92
+ mockedReadFileSync.mockReturnValue(
93
+ '{"type":"user","sessionId":"s1","timestamp":"t"}\nnot-json\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
94
+ );
95
+ const entries = parseJsonl("/fake/path.jsonl");
96
+ expect(entries).toHaveLength(2);
97
+ });
98
+
99
+ it("returns empty array for empty file", () => {
100
+ mockedReadFileSync.mockReturnValue("");
101
+ const entries = parseJsonl("/fake/path.jsonl");
102
+ expect(entries).toHaveLength(0);
103
+ });
104
+ });
105
+
106
+ describe("quickTokenCount", () => {
107
+ it("sums input and output tokens from entries with usage", () => {
108
+ const lines = [
109
+ JSON.stringify({
110
+ message: { usage: { input_tokens: 100, output_tokens: 50 } },
111
+ }),
112
+ JSON.stringify({
113
+ message: { usage: { input_tokens: 200, output_tokens: 75 } },
114
+ }),
115
+ ].join("\n");
116
+ mockedReadFileSync.mockReturnValue(lines);
117
+ expect(quickTokenCount("/fake/path.jsonl")).toBe(425);
118
+ });
119
+
120
+ it("skips entries without usage", () => {
121
+ const lines = [
122
+ JSON.stringify({ message: { content: "text" } }),
123
+ JSON.stringify({ message: { usage: { input_tokens: 100, output_tokens: 50 } } }),
124
+ ].join("\n");
125
+ mockedReadFileSync.mockReturnValue(lines);
126
+ expect(quickTokenCount("/fake/path.jsonl")).toBe(150);
127
+ });
128
+
129
+ it("returns 0 for unreadable files", () => {
130
+ mockedReadFileSync.mockImplementation(() => {
131
+ throw new Error("ENOENT");
132
+ });
133
+ expect(quickTokenCount("/missing/file.jsonl")).toBe(0);
134
+ });
135
+
136
+ it("handles entries with partial usage (only input_tokens)", () => {
137
+ const lines = JSON.stringify({
138
+ message: { usage: { input_tokens: 100 } },
139
+ });
140
+ mockedReadFileSync.mockReturnValue(lines);
141
+ expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
142
+ });
143
+
144
+ it("skips invalid JSON lines gracefully", () => {
145
+ mockedReadFileSync.mockReturnValue(
146
+ '{"message":{"usage":{"input_tokens":50,"output_tokens":50}}}\nbadline\n',
147
+ );
148
+ expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
149
+ });
150
+ });
@@ -0,0 +1,65 @@
1
+ import * as fs from "node:fs";
2
+ import type { JournalEntry } from "./types.js";
3
+
4
+ /**
5
+ * Calculate cutoff timestamp for days filtering.
6
+ * Returns 0 if days <= 0 (no filtering).
7
+ */
8
+ export function calculateCutoffMs(days: number): number {
9
+ return days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
10
+ }
11
+
12
+ /**
13
+ * Filter items by mtime against a days cutoff.
14
+ * Returns all items if days <= 0.
15
+ */
16
+ export function filterByDays<T extends { mtime: number }>(items: T[], days: number): T[] {
17
+ const cutoff = calculateCutoffMs(days);
18
+ if (cutoff === 0) return items;
19
+ return items.filter((item) => item.mtime >= cutoff);
20
+ }
21
+
22
+ /**
23
+ * Parse JSONL file into JournalEntry array.
24
+ */
25
+ export function parseJsonl(filePath: string): JournalEntry[] {
26
+ const content = fs.readFileSync(filePath, "utf-8");
27
+ const entries: JournalEntry[] = [];
28
+
29
+ for (const line of content.split("\n")) {
30
+ if (!line.trim()) continue;
31
+ try {
32
+ entries.push(JSON.parse(line) as JournalEntry);
33
+ } catch {
34
+ // Skip invalid lines
35
+ }
36
+ }
37
+
38
+ return entries;
39
+ }
40
+
41
+ /**
42
+ * Quick token count from a JSONL file without full parsing.
43
+ */
44
+ export function quickTokenCount(filePath: string): number {
45
+ try {
46
+ const content = fs.readFileSync(filePath, "utf-8");
47
+ let total = 0;
48
+ for (const line of content.split("\n")) {
49
+ if (!line.trim()) continue;
50
+ try {
51
+ const entry = JSON.parse(line) as JournalEntry;
52
+ if (entry.message?.usage) {
53
+ total +=
54
+ (entry.message.usage.input_tokens || 0) + (entry.message.usage.output_tokens || 0);
55
+ }
56
+ } catch {
57
+ // Skip invalid lines
58
+ }
59
+ }
60
+ return total;
61
+ } catch {
62
+ // File unreadable or missing — treat token count as 0
63
+ return 0;
64
+ }
65
+ }
@@ -0,0 +1,25 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { BarChartData, TreemapNode } from "./types.js";
5
+
6
+ // Load HTML template from file (resolved relative to this module)
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const TEMPLATE_PATH = path.join(__dirname, "..", "graph-template.html");
9
+
10
+ /**
11
+ * Generate HTML from treemap data and bar chart data using the template.
12
+ */
13
+ export function generateTreemapHtml(data: TreemapNode, barChartData: BarChartData): string {
14
+ const width = 1200;
15
+ const height = 800;
16
+
17
+ // Read template from file and replace placeholders
18
+ // Use function replacement to avoid special $& $' $` patterns in data being interpreted
19
+ const template = fs.readFileSync(TEMPLATE_PATH, "utf-8");
20
+ return template
21
+ .replace(/\{\{WIDTH\}\}/g, String(width))
22
+ .replace(/\{\{HEIGHT\}\}/g, String(height))
23
+ .replace(/\{\{DATA\}\}/g, () => JSON.stringify(data))
24
+ .replace(/\{\{BAR_CHART_DATA\}\}/g, () => JSON.stringify(barChartData));
25
+ }
@@ -0,0 +1,70 @@
1
+ import * as http from "node:http";
2
+ import { x } from "tinyexec";
3
+
4
+ /**
5
+ * Start a local HTTP server to serve the generated HTML.
6
+ * Tries successive ports if the initial port is in use.
7
+ * Returns the actual port used.
8
+ */
9
+ export async function startServer(
10
+ html: string,
11
+ filename: string,
12
+ startPort: number,
13
+ ): Promise<{ server: http.Server; port: number }> {
14
+ const server = http.createServer((req, res) => {
15
+ if (req.url === "/" || req.url === `/${filename}`) {
16
+ res.writeHead(200, { "Content-Type": "text/html" });
17
+ res.end(html);
18
+ } else {
19
+ res.writeHead(404);
20
+ res.end("Not found");
21
+ }
22
+ });
23
+
24
+ const maxAttempts = 10;
25
+
26
+ const tryPort = (port: number): Promise<number> => {
27
+ return new Promise((resolve, reject) => {
28
+ const onError = (err: NodeJS.ErrnoException) => {
29
+ server.removeListener("listening", onListening);
30
+ if (err.code === "EADDRINUSE" && port < startPort + maxAttempts - 1) {
31
+ resolve(tryPort(port + 1));
32
+ } else {
33
+ reject(err);
34
+ }
35
+ };
36
+
37
+ const onListening = () => {
38
+ server.removeListener("error", onError);
39
+ resolve(port);
40
+ };
41
+
42
+ server.once("error", onError);
43
+ server.once("listening", onListening);
44
+ server.listen(port);
45
+ });
46
+ };
47
+
48
+ const port = await tryPort(startPort);
49
+ return { server, port };
50
+ }
51
+
52
+ /**
53
+ * Open a URL in the default browser.
54
+ */
55
+ export function openInBrowser(url: string): void {
56
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
57
+ x(openCmd, [url]);
58
+ }
59
+
60
+ /**
61
+ * Wait for SIGINT and then close the server.
62
+ */
63
+ export function waitForShutdown(server: http.Server): Promise<void> {
64
+ return new Promise<void>((resolve) => {
65
+ process.on("SIGINT", () => {
66
+ server.close();
67
+ resolve();
68
+ });
69
+ });
70
+ }
@@ -0,0 +1,104 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { extractProjectName } from "./analyzer.js";
4
+ import { calculateCutoffMs, quickTokenCount } from "./parser.js";
5
+ import type { BarChartData, BarChartDay, ProjectBar, SessionResult } from "./types.js";
6
+
7
+ /**
8
+ * Find recent sessions from the projects directory.
9
+ */
10
+ export function findRecentSessions(
11
+ projectsDir: string,
12
+ limit: number,
13
+ days: number,
14
+ ): SessionResult[] {
15
+ const sessions: SessionResult[] = [];
16
+
17
+ const cutoffMs = calculateCutoffMs(days);
18
+
19
+ const projectDirs = fs.readdirSync(projectsDir);
20
+ for (const project of projectDirs) {
21
+ const projectPath = path.join(projectsDir, project);
22
+ if (!fs.statSync(projectPath).isDirectory()) continue;
23
+
24
+ const files = fs.readdirSync(projectPath).filter((f) => f.endsWith(".jsonl"));
25
+ for (const file of files) {
26
+ const filePath = path.join(projectPath, file);
27
+ const stat = fs.statSync(filePath);
28
+
29
+ // Filter by days if cutoff is set
30
+ if (cutoffMs > 0 && stat.mtimeMs < cutoffMs) continue;
31
+
32
+ const sessionId = file.replace(".jsonl", "");
33
+
34
+ // Quick token count from file
35
+ const tokens = quickTokenCount(filePath);
36
+
37
+ sessions.push({
38
+ sessionId,
39
+ path: filePath,
40
+ date: stat.mtime.toLocaleDateString("en-CA"), // YYYY-MM-DD in local timezone
41
+ tokens,
42
+ project,
43
+ mtime: stat.mtimeMs,
44
+ });
45
+ }
46
+ }
47
+
48
+ // Sort by modification time, most recent first
49
+ sessions.sort((a, b) => b.mtime - a.mtime);
50
+ return sessions.slice(0, limit);
51
+ }
52
+
53
+ /**
54
+ * Find the file path for a specific session ID.
55
+ */
56
+ export function findSessionPath(projectsDir: string, sessionId: string): string | undefined {
57
+ const projectDirs = fs.readdirSync(projectsDir);
58
+ for (const project of projectDirs) {
59
+ const projectPath = path.join(projectsDir, project);
60
+ if (!fs.statSync(projectPath).isDirectory()) continue;
61
+
62
+ const jsonlPath = path.join(projectPath, `${sessionId}.jsonl`);
63
+ if (fs.existsSync(jsonlPath)) {
64
+ return jsonlPath;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Build bar chart data structure from session results.
72
+ * Groups sessions by date and project folder, aggregating tokens per project per day.
73
+ */
74
+ export function buildBarChartData(sessions: SessionResult[]): BarChartData {
75
+ if (sessions.length === 0) {
76
+ return { days: [] };
77
+ }
78
+
79
+ // Group sessions by date, then by project
80
+ const byDateProject = new Map<string, Map<string, number>>();
81
+
82
+ for (const session of sessions) {
83
+ const project = extractProjectName(session.project);
84
+
85
+ if (!byDateProject.has(session.date)) {
86
+ byDateProject.set(session.date, new Map());
87
+ }
88
+ const projectMap = byDateProject.get(session.date)!;
89
+ projectMap.set(project, (projectMap.get(project) || 0) + session.tokens);
90
+ }
91
+
92
+ // Build days array sorted chronologically (oldest first for x-axis)
93
+ const sortedDates = [...byDateProject.keys()].sort();
94
+ const days: BarChartDay[] = sortedDates.map((date) => {
95
+ const projectMap = byDateProject.get(date)!;
96
+ // Sort projects by total tokens descending
97
+ const projects: ProjectBar[] = [...projectMap.entries()]
98
+ .map(([project, totalTokens]) => ({ project, totalTokens }))
99
+ .sort((a, b) => b.totalTokens - a.totalTokens);
100
+ return { date, projects };
101
+ });
102
+
103
+ return { days };
104
+ }
@@ -0,0 +1,90 @@
1
+ import type { ContentBlock, ToolData } from "./types.js";
2
+
3
+ /**
4
+ * Sanitize string by replacing control characters (newlines, tabs, etc.) with spaces.
5
+ */
6
+ export function sanitizeString(str: string): string {
7
+ // Replace all control characters (ASCII 0-31) with space, collapse multiple spaces
8
+ // eslint-disable-next-line no-control-regex
9
+ return str.replace(/[\x00-\x1F]+/g, " ").trim();
10
+ }
11
+
12
+ /**
13
+ * Truncate a string and extract just the filename for paths.
14
+ */
15
+ export function truncateDetail(str: string | undefined, maxLen = 30): string | undefined {
16
+ if (!str) return undefined;
17
+ // Sanitize control characters first
18
+ const sanitized = sanitizeString(str);
19
+ // For file paths, show just the filename
20
+ if (sanitized.includes("/")) {
21
+ const parts = sanitized.split("/");
22
+ const filename = parts[parts.length - 1];
23
+ return filename.length > maxLen ? filename.slice(0, maxLen - 3) + "..." : filename;
24
+ }
25
+ return sanitized.length > maxLen ? sanitized.slice(0, maxLen - 3) + "..." : sanitized;
26
+ }
27
+
28
+ /**
29
+ * Extract a meaningful detail string from tool input.
30
+ */
31
+ export function extractToolDetail(
32
+ toolName: string,
33
+ input?: Record<string, unknown>,
34
+ ): string | undefined {
35
+ if (!input) return undefined;
36
+
37
+ switch (toolName) {
38
+ case "Read":
39
+ case "Write":
40
+ case "Edit":
41
+ return truncateDetail(input.file_path as string);
42
+ case "Bash":
43
+ return truncateDetail(input.command as string, 50);
44
+ case "Glob":
45
+ case "Grep":
46
+ return truncateDetail(input.pattern as string, 50);
47
+ case "Task":
48
+ return truncateDetail(input.description as string, 50);
49
+ case "WebFetch":
50
+ return truncateDetail(input.url as string, 40);
51
+ default:
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Extract individual tool calls from message content blocks.
58
+ * Returns each tool call with its detail (file path, command, etc.).
59
+ */
60
+ export function extractToolData(
61
+ content: ContentBlock[] | string | undefined,
62
+ turnInputTokens: number,
63
+ turnOutputTokens: number,
64
+ ): ToolData[] {
65
+ if (!content || typeof content === "string") return [];
66
+
67
+ // Collect individual tool_use blocks
68
+ const toolBlocks: Array<{ name: string; detail?: string }> = [];
69
+ for (const block of content) {
70
+ if (block.type === "tool_use" && block.name) {
71
+ const detail = extractToolDetail(block.name, block.input);
72
+ toolBlocks.push({ name: block.name, detail });
73
+ }
74
+ }
75
+
76
+ if (toolBlocks.length === 0) return [];
77
+
78
+ // Distribute tokens proportionally across individual calls
79
+ const tokensPerCall = {
80
+ input: Math.round(turnInputTokens / toolBlocks.length),
81
+ output: Math.round(turnOutputTokens / toolBlocks.length),
82
+ };
83
+
84
+ return toolBlocks.map((tool) => ({
85
+ name: tool.name,
86
+ detail: tool.detail,
87
+ inputTokens: tokensPerCall.input,
88
+ outputTokens: tokensPerCall.output,
89
+ }));
90
+ }