@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.
- package/package.json +50 -57
- package/src/commands/agentboard.ts +176 -0
- package/src/commands/{auto-claude.ts → auto-claude/index.ts} +18 -28
- package/src/commands/auto-claude/list.ts +114 -0
- package/src/commands/auto-claude/retry.test.ts +138 -0
- package/src/commands/auto-claude/retry.ts +139 -0
- package/src/commands/auto-claude/status.test.ts +147 -0
- package/src/commands/auto-claude/status.ts +123 -0
- package/src/commands/base.ts +7 -2
- package/src/commands/config.ts +5 -7
- package/src/commands/doctor.ts +111 -12
- package/src/commands/gh/branch.ts +4 -4
- package/src/commands/gh/pr.ts +1 -0
- package/src/commands/graph/index.ts +169 -0
- package/src/commands/graph.test.ts +1 -1
- package/src/commands/install.ts +40 -68
- package/src/commands/journal/daily-notes.ts +3 -3
- package/src/commands/journal/meeting.ts +3 -3
- package/src/commands/journal/note.ts +3 -3
- package/src/lib/auto-claude/claude-cli.ts +183 -0
- package/src/lib/auto-claude/config.test.ts +6 -8
- package/src/lib/auto-claude/config.ts +3 -4
- package/src/lib/auto-claude/index.ts +2 -3
- package/src/lib/auto-claude/labels.test.ts +85 -0
- package/src/lib/auto-claude/labels.ts +42 -0
- package/src/lib/auto-claude/pipeline-execution.test.ts +129 -33
- package/src/lib/auto-claude/pipeline.test.ts +2 -2
- package/src/lib/auto-claude/pipeline.ts +120 -36
- package/src/lib/auto-claude/prompt-templates/01_plan.prompt.md +68 -0
- package/src/lib/auto-claude/prompt-templates/{05_implement.prompt.md → 02_implement.prompt.md} +3 -2
- package/src/lib/auto-claude/prompt-templates/03_simplify.prompt.md +52 -0
- package/src/lib/auto-claude/prompt-templates/{06_review.prompt.md → 04_review.prompt.md} +29 -6
- package/src/lib/auto-claude/prompt-templates/index.test.ts +9 -42
- package/src/lib/auto-claude/prompt-templates/index.ts +13 -28
- package/src/lib/auto-claude/run-claude.test.ts +48 -68
- package/src/lib/auto-claude/shell.ts +6 -0
- package/src/lib/auto-claude/steps/create-pr.ts +89 -25
- package/src/lib/auto-claude/steps/fetch-issues.ts +4 -1
- package/src/lib/auto-claude/steps/implement.ts +9 -16
- package/src/lib/auto-claude/steps/simple-steps.ts +34 -0
- package/src/lib/auto-claude/steps/steps.test.ts +68 -63
- package/src/lib/auto-claude/templates.test.ts +91 -0
- package/src/lib/auto-claude/templates.ts +34 -0
- package/src/lib/auto-claude/test-helpers.ts +2 -1
- package/src/lib/auto-claude/utils-execution.test.ts +9 -57
- package/src/lib/auto-claude/utils.test.ts +5 -9
- package/src/lib/auto-claude/utils.ts +27 -253
- package/src/lib/graph/analyzer.test.ts +451 -0
- package/src/lib/graph/analyzer.ts +165 -0
- package/src/lib/graph/index.ts +24 -0
- package/src/lib/graph/labels.ts +87 -0
- package/src/lib/graph/parser.test.ts +150 -0
- package/src/lib/graph/parser.ts +65 -0
- package/src/lib/graph/render.ts +25 -0
- package/src/lib/graph/server.ts +70 -0
- package/src/lib/graph/sessions.ts +104 -0
- package/src/lib/graph/tools.ts +90 -0
- package/src/lib/graph/treemap.ts +211 -0
- package/src/lib/graph/types.ts +80 -0
- package/src/lib/install/claude-settings.ts +64 -0
- package/src/lib/journal/editor.ts +33 -0
- package/src/lib/journal/fs.ts +13 -0
- package/src/lib/journal/index.ts +11 -0
- package/src/lib/journal/paths.ts +106 -0
- package/src/lib/journal/{utils.ts → templates.ts} +3 -151
- package/src/utils/fs.ts +19 -0
- package/src/utils/git/exec.ts +18 -0
- package/src/utils/git/gh-cli-wrapper.test.ts +47 -8
- package/src/utils/git/gh-cli-wrapper.ts +31 -19
- package/src/utils/render.ts +3 -1
- package/src/commands/graph.ts +0 -970
- package/src/lib/auto-claude/prompt-templates/01_research.prompt.md +0 -21
- package/src/lib/auto-claude/prompt-templates/02_plan.prompt.md +0 -27
- package/src/lib/auto-claude/prompt-templates/03_plan-annotations.prompt.md +0 -15
- package/src/lib/auto-claude/prompt-templates/04_plan-implementation.prompt.md +0 -35
- package/src/lib/auto-claude/prompt-templates/07_refresh.prompt.md +0 -30
- package/src/lib/auto-claude/steps/plan-annotations.ts +0 -54
- package/src/lib/auto-claude/steps/plan-implementation.ts +0 -14
- package/src/lib/auto-claude/steps/plan.ts +0 -14
- package/src/lib/auto-claude/steps/refresh.ts +0 -114
- package/src/lib/auto-claude/steps/remove-label.ts +0 -22
- package/src/lib/auto-claude/steps/research.ts +0 -21
- 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
|
+
}
|