@towles/tool 0.0.62 → 0.0.64
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,451 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ContentBlock, JournalEntry } from "./types";
|
|
3
|
+
import {
|
|
4
|
+
aggregateSessionTools,
|
|
5
|
+
analyzeSession,
|
|
6
|
+
extractProjectName,
|
|
7
|
+
getModelName,
|
|
8
|
+
getPrimaryModel,
|
|
9
|
+
} from "./analyzer";
|
|
10
|
+
import { extractSessionLabel } from "./labels";
|
|
11
|
+
import { extractToolData, extractToolDetail, sanitizeString, truncateDetail } from "./tools";
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──
|
|
14
|
+
|
|
15
|
+
function makeEntry(overrides: Partial<JournalEntry> = {}): JournalEntry {
|
|
16
|
+
return {
|
|
17
|
+
type: "assistant",
|
|
18
|
+
sessionId: "test-session",
|
|
19
|
+
timestamp: "2025-01-01T00:00:00Z",
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeAssistantEntry(
|
|
25
|
+
model: string,
|
|
26
|
+
inputTokens: number,
|
|
27
|
+
outputTokens: number,
|
|
28
|
+
content?: ContentBlock[],
|
|
29
|
+
extra?: Partial<JournalEntry["message"]>,
|
|
30
|
+
): JournalEntry {
|
|
31
|
+
return makeEntry({
|
|
32
|
+
type: "assistant",
|
|
33
|
+
message: {
|
|
34
|
+
role: "assistant",
|
|
35
|
+
model,
|
|
36
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
37
|
+
content: content ?? [{ type: "text", text: "response" }],
|
|
38
|
+
...extra,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── analyzeSession ──
|
|
44
|
+
|
|
45
|
+
describe("analyzeSession", () => {
|
|
46
|
+
it("returns zeros for empty entries", () => {
|
|
47
|
+
const result = analyzeSession([]);
|
|
48
|
+
expect(result.inputTokens).toBe(0);
|
|
49
|
+
expect(result.outputTokens).toBe(0);
|
|
50
|
+
expect(result.opusTokens).toBe(0);
|
|
51
|
+
expect(result.sonnetTokens).toBe(0);
|
|
52
|
+
expect(result.haikuTokens).toBe(0);
|
|
53
|
+
expect(result.cacheHitRate).toBe(0);
|
|
54
|
+
expect(result.repeatedReads).toBe(0);
|
|
55
|
+
expect(result.modelEfficiency).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("counts tokens by model", () => {
|
|
59
|
+
const entries = [
|
|
60
|
+
makeAssistantEntry("claude-opus-4", 100, 50),
|
|
61
|
+
makeAssistantEntry("claude-sonnet-4", 200, 100),
|
|
62
|
+
makeAssistantEntry("claude-haiku-3", 50, 25),
|
|
63
|
+
];
|
|
64
|
+
const result = analyzeSession(entries);
|
|
65
|
+
expect(result.inputTokens).toBe(350);
|
|
66
|
+
expect(result.outputTokens).toBe(175);
|
|
67
|
+
expect(result.opusTokens).toBe(150);
|
|
68
|
+
expect(result.sonnetTokens).toBe(300);
|
|
69
|
+
expect(result.haikuTokens).toBe(75);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("calculates cache hit rate", () => {
|
|
73
|
+
const entries = [
|
|
74
|
+
makeEntry({
|
|
75
|
+
type: "assistant",
|
|
76
|
+
message: {
|
|
77
|
+
role: "assistant",
|
|
78
|
+
model: "claude-opus-4",
|
|
79
|
+
usage: { input_tokens: 1000, output_tokens: 0, cache_read_input_tokens: 800 },
|
|
80
|
+
content: [{ type: "text", text: "hi" }],
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
];
|
|
84
|
+
const result = analyzeSession(entries);
|
|
85
|
+
expect(result.cacheHitRate).toBe(0.8);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("counts repeated file reads", () => {
|
|
89
|
+
const content: ContentBlock[] = [
|
|
90
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
|
|
91
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
|
|
92
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/b.ts" } },
|
|
93
|
+
];
|
|
94
|
+
const entries = [makeAssistantEntry("claude-opus-4", 100, 50, content)];
|
|
95
|
+
const result = analyzeSession(entries);
|
|
96
|
+
expect(result.repeatedReads).toBe(1); // /a.ts read twice => 1 repeated
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("calculates model efficiency as opus fraction", () => {
|
|
100
|
+
const entries = [
|
|
101
|
+
makeAssistantEntry("claude-opus-4", 400, 100), // 500 opus
|
|
102
|
+
makeAssistantEntry("claude-sonnet-4", 400, 100), // 500 sonnet
|
|
103
|
+
];
|
|
104
|
+
const result = analyzeSession(entries);
|
|
105
|
+
expect(result.modelEfficiency).toBe(0.5);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("skips entries without usage", () => {
|
|
109
|
+
const entries = [makeEntry({ message: { role: "assistant", content: "text" } })];
|
|
110
|
+
const result = analyzeSession(entries);
|
|
111
|
+
expect(result.inputTokens).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── extractSessionLabel ──
|
|
116
|
+
|
|
117
|
+
describe("extractSessionLabel", () => {
|
|
118
|
+
it("uses first user text message", () => {
|
|
119
|
+
const entries = [
|
|
120
|
+
makeEntry({
|
|
121
|
+
type: "user",
|
|
122
|
+
message: { role: "user", content: "Fix the bug in parser" },
|
|
123
|
+
}),
|
|
124
|
+
];
|
|
125
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("Fix the bug in parser");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("skips UUID-only user messages", () => {
|
|
129
|
+
const entries = [
|
|
130
|
+
makeEntry({
|
|
131
|
+
type: "user",
|
|
132
|
+
message: { role: "user", content: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" },
|
|
133
|
+
}),
|
|
134
|
+
makeEntry({
|
|
135
|
+
type: "user",
|
|
136
|
+
message: { role: "user", content: "Real message" },
|
|
137
|
+
}),
|
|
138
|
+
];
|
|
139
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("Real message");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("extracts text from array content blocks", () => {
|
|
143
|
+
const entries = [
|
|
144
|
+
makeEntry({
|
|
145
|
+
type: "user",
|
|
146
|
+
message: {
|
|
147
|
+
role: "user",
|
|
148
|
+
content: [{ type: "text", text: "Array content message" }],
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
];
|
|
152
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("Array content message");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("falls back to assistant text", () => {
|
|
156
|
+
const entries = [
|
|
157
|
+
makeEntry({
|
|
158
|
+
type: "assistant",
|
|
159
|
+
message: {
|
|
160
|
+
role: "assistant",
|
|
161
|
+
content: [{ type: "text", text: "I'll help you with that" }],
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
];
|
|
165
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("I'll help you with that");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to gitBranch", () => {
|
|
169
|
+
const entries = [makeEntry({ type: "user" }) as any];
|
|
170
|
+
(entries[0] as any).gitBranch = "feat/new-feature";
|
|
171
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("feat/new-feature");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("falls back to short session ID", () => {
|
|
175
|
+
expect(extractSessionLabel([], "abc12345-long-id")).toBe("abc12345");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("removes /command prefixes", () => {
|
|
179
|
+
const entries = [
|
|
180
|
+
makeEntry({
|
|
181
|
+
type: "user",
|
|
182
|
+
message: { role: "user", content: "/review Fix the parser" },
|
|
183
|
+
}),
|
|
184
|
+
];
|
|
185
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("Fix the parser");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("removes XML tags", () => {
|
|
189
|
+
const entries = [
|
|
190
|
+
makeEntry({
|
|
191
|
+
type: "user",
|
|
192
|
+
message: { role: "user", content: "<tag>content</tag> Real text" },
|
|
193
|
+
}),
|
|
194
|
+
];
|
|
195
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("Real text");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("truncates labels longer than 80 chars", () => {
|
|
199
|
+
const longText = "A".repeat(100);
|
|
200
|
+
const entries = [
|
|
201
|
+
makeEntry({
|
|
202
|
+
type: "user",
|
|
203
|
+
message: { role: "user", content: longText },
|
|
204
|
+
}),
|
|
205
|
+
];
|
|
206
|
+
const label = extractSessionLabel(entries, "abc12345");
|
|
207
|
+
expect(label.length).toBe(80);
|
|
208
|
+
expect(label.endsWith("...")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("uses slug fallback for short labels after cleanup", () => {
|
|
212
|
+
const entries = [makeEntry() as any];
|
|
213
|
+
(entries[0] as any).slug = "my-slug";
|
|
214
|
+
expect(extractSessionLabel(entries, "abc12345")).toBe("my-slug");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── sanitizeString ──
|
|
219
|
+
|
|
220
|
+
describe("sanitizeString", () => {
|
|
221
|
+
it("replaces control characters with space", () => {
|
|
222
|
+
expect(sanitizeString("hello\nworld\ttab")).toBe("hello world tab");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("trims result", () => {
|
|
226
|
+
expect(sanitizeString(" hello ")).toBe("hello");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("collapses multiple control chars", () => {
|
|
230
|
+
expect(sanitizeString("a\n\n\nb")).toBe("a b");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("handles empty string", () => {
|
|
234
|
+
expect(sanitizeString("")).toBe("");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── truncateDetail ──
|
|
239
|
+
|
|
240
|
+
describe("truncateDetail", () => {
|
|
241
|
+
it("returns undefined for undefined input", () => {
|
|
242
|
+
expect(truncateDetail(undefined)).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("returns short strings unchanged", () => {
|
|
246
|
+
expect(truncateDetail("hello")).toBe("hello");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("truncates long strings with ellipsis", () => {
|
|
250
|
+
const long = "A".repeat(40);
|
|
251
|
+
const result = truncateDetail(long, 30);
|
|
252
|
+
expect(result?.length).toBe(30);
|
|
253
|
+
expect(result?.endsWith("...")).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("extracts filename from paths", () => {
|
|
257
|
+
expect(truncateDetail("/home/user/project/file.ts")).toBe("file.ts");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("truncates long filenames", () => {
|
|
261
|
+
const longFile = "/path/" + "A".repeat(40) + ".ts";
|
|
262
|
+
const result = truncateDetail(longFile, 30);
|
|
263
|
+
expect(result?.length).toBe(30);
|
|
264
|
+
expect(result?.endsWith("...")).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── extractToolDetail ──
|
|
269
|
+
|
|
270
|
+
describe("extractToolDetail", () => {
|
|
271
|
+
it("returns undefined when no input", () => {
|
|
272
|
+
expect(extractToolDetail("Read")).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("extracts file_path for Read", () => {
|
|
276
|
+
expect(extractToolDetail("Read", { file_path: "/src/index.ts" })).toBe("index.ts");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("extracts file_path for Write", () => {
|
|
280
|
+
expect(extractToolDetail("Write", { file_path: "/src/utils.ts" })).toBe("utils.ts");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("extracts file_path for Edit", () => {
|
|
284
|
+
expect(extractToolDetail("Edit", { file_path: "/src/edit.ts" })).toBe("edit.ts");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("extracts command for Bash", () => {
|
|
288
|
+
expect(extractToolDetail("Bash", { command: "pnpm test" })).toBe("pnpm test");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("extracts pattern for Glob", () => {
|
|
292
|
+
// Pattern contains "/" so truncateDetail extracts the last segment
|
|
293
|
+
expect(extractToolDetail("Glob", { pattern: "**/*.ts" })).toBe("*.ts");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("extracts pattern for Grep", () => {
|
|
297
|
+
expect(extractToolDetail("Grep", { pattern: "TODO" })).toBe("TODO");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("returns undefined for unknown tools", () => {
|
|
301
|
+
expect(extractToolDetail("CustomTool", { foo: "bar" })).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── extractToolData ──
|
|
306
|
+
|
|
307
|
+
describe("extractToolData", () => {
|
|
308
|
+
it("returns empty for undefined content", () => {
|
|
309
|
+
expect(extractToolData(undefined, 100, 50)).toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("returns empty for string content", () => {
|
|
313
|
+
expect(extractToolData("text", 100, 50)).toEqual([]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("returns empty when no tool_use blocks", () => {
|
|
317
|
+
const content: ContentBlock[] = [{ type: "text", text: "hello" }];
|
|
318
|
+
expect(extractToolData(content, 100, 50)).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("extracts tool calls and distributes tokens", () => {
|
|
322
|
+
const content: ContentBlock[] = [
|
|
323
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
|
|
324
|
+
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
|
|
325
|
+
];
|
|
326
|
+
const result = extractToolData(content, 200, 100);
|
|
327
|
+
expect(result).toHaveLength(2);
|
|
328
|
+
expect(result[0].name).toBe("Read");
|
|
329
|
+
expect(result[0].inputTokens).toBe(100);
|
|
330
|
+
expect(result[0].outputTokens).toBe(50);
|
|
331
|
+
expect(result[1].name).toBe("Bash");
|
|
332
|
+
expect(result[1].inputTokens).toBe(100);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ── aggregateSessionTools ──
|
|
337
|
+
|
|
338
|
+
describe("aggregateSessionTools", () => {
|
|
339
|
+
it("returns empty for no entries", () => {
|
|
340
|
+
expect(aggregateSessionTools([])).toEqual([]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("aggregates tools across entries", () => {
|
|
344
|
+
const entries = [
|
|
345
|
+
makeAssistantEntry("claude-opus-4", 100, 50, [
|
|
346
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
|
|
347
|
+
]),
|
|
348
|
+
makeAssistantEntry("claude-opus-4", 200, 100, [
|
|
349
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/b.ts" } },
|
|
350
|
+
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
|
|
351
|
+
]),
|
|
352
|
+
];
|
|
353
|
+
const result = aggregateSessionTools(entries);
|
|
354
|
+
const readTool = result.find((t) => t.name === "Read");
|
|
355
|
+
expect(readTool).toBeDefined();
|
|
356
|
+
expect(readTool?.detail).toBe("2x");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("sorts by total token usage descending", () => {
|
|
360
|
+
const entries = [
|
|
361
|
+
makeAssistantEntry("claude-opus-4", 100, 50, [
|
|
362
|
+
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
|
|
363
|
+
]),
|
|
364
|
+
makeAssistantEntry("claude-opus-4", 1000, 500, [
|
|
365
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
|
|
366
|
+
]),
|
|
367
|
+
];
|
|
368
|
+
const result = aggregateSessionTools(entries);
|
|
369
|
+
expect(result[0].name).toBe("Read");
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ── getPrimaryModel ──
|
|
374
|
+
|
|
375
|
+
describe("getPrimaryModel", () => {
|
|
376
|
+
it("returns Opus when opus has most tokens", () => {
|
|
377
|
+
expect(getPrimaryModel({ opusTokens: 100, sonnetTokens: 50, haikuTokens: 10 })).toBe("Opus");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("returns Sonnet when sonnet dominates", () => {
|
|
381
|
+
expect(getPrimaryModel({ opusTokens: 10, sonnetTokens: 500, haikuTokens: 10 })).toBe("Sonnet");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("returns Haiku when haiku dominates", () => {
|
|
385
|
+
expect(getPrimaryModel({ opusTokens: 0, sonnetTokens: 0, haikuTokens: 100 })).toBe("Haiku");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("returns Opus on tie between opus and sonnet", () => {
|
|
389
|
+
expect(getPrimaryModel({ opusTokens: 100, sonnetTokens: 100, haikuTokens: 0 })).toBe("Opus");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("returns Opus when all zero", () => {
|
|
393
|
+
expect(getPrimaryModel({ opusTokens: 0, sonnetTokens: 0, haikuTokens: 0 })).toBe("Opus");
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── getModelName ──
|
|
398
|
+
|
|
399
|
+
describe("getModelName", () => {
|
|
400
|
+
it("returns 'unknown' for undefined", () => {
|
|
401
|
+
expect(getModelName()).toBe("unknown");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("returns Opus for opus models", () => {
|
|
405
|
+
expect(getModelName("claude-opus-4-20250514")).toBe("Opus");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("returns Sonnet for sonnet models", () => {
|
|
409
|
+
expect(getModelName("claude-sonnet-4-20250514")).toBe("Sonnet");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("returns Haiku for haiku models", () => {
|
|
413
|
+
expect(getModelName("claude-3-haiku")).toBe("Haiku");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("returns first segment for unknown models", () => {
|
|
417
|
+
expect(getModelName("gpt-4-turbo")).toBe("gpt");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("returns 'unknown' for empty string", () => {
|
|
421
|
+
expect(getModelName("")).toBe("unknown");
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// ── extractProjectName ──
|
|
426
|
+
|
|
427
|
+
describe("extractProjectName", () => {
|
|
428
|
+
it("extracts project name after path markers", () => {
|
|
429
|
+
expect(extractProjectName("-home-ctowles-code-p-towles-tool")).toBe("towles-tool");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("handles 'projects' marker", () => {
|
|
433
|
+
expect(extractProjectName("-home-user-projects-my-app")).toBe("my-app");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("handles 'src' marker", () => {
|
|
437
|
+
expect(extractProjectName("-home-user-src-cool-lib")).toBe("cool-lib");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("uses last two parts when no marker found", () => {
|
|
441
|
+
expect(extractProjectName("-foo-bar-baz")).toBe("bar-baz");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("handles single segment after marker", () => {
|
|
445
|
+
expect(extractProjectName("-home-user-code-myproject")).toBe("myproject");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("uses last marker when multiple exist", () => {
|
|
449
|
+
expect(extractProjectName("-home-code-old-src-new-project")).toBe("new-project");
|
|
450
|
+
});
|
|
451
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { JournalEntry, ToolData } from "./types.js";
|
|
2
|
+
import { extractToolData } from "./tools.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Analyze session entries to get token breakdown by model.
|
|
6
|
+
*/
|
|
7
|
+
export function analyzeSession(entries: JournalEntry[]): {
|
|
8
|
+
inputTokens: number;
|
|
9
|
+
outputTokens: number;
|
|
10
|
+
opusTokens: number;
|
|
11
|
+
sonnetTokens: number;
|
|
12
|
+
haikuTokens: number;
|
|
13
|
+
cacheHitRate: number;
|
|
14
|
+
repeatedReads: number;
|
|
15
|
+
modelEfficiency: number;
|
|
16
|
+
} {
|
|
17
|
+
let inputTokens = 0;
|
|
18
|
+
let outputTokens = 0;
|
|
19
|
+
let opusTokens = 0;
|
|
20
|
+
let sonnetTokens = 0;
|
|
21
|
+
let haikuTokens = 0;
|
|
22
|
+
let cacheRead = 0;
|
|
23
|
+
let totalInput = 0;
|
|
24
|
+
const fileReadCounts = new Map<string, number>();
|
|
25
|
+
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
// Count file reads for repeatedReads metric
|
|
28
|
+
if (entry.message?.content && Array.isArray(entry.message.content)) {
|
|
29
|
+
for (const block of entry.message.content) {
|
|
30
|
+
if (block.type === "tool_use" && block.name === "Read" && block.input) {
|
|
31
|
+
const filePath = (block.input as { file_path?: string }).file_path;
|
|
32
|
+
if (filePath) {
|
|
33
|
+
fileReadCounts.set(filePath, (fileReadCounts.get(filePath) || 0) + 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!entry.message?.usage) continue;
|
|
40
|
+
const usage = entry.message.usage;
|
|
41
|
+
const model = entry.message.model || "";
|
|
42
|
+
const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
|
|
43
|
+
|
|
44
|
+
inputTokens += usage.input_tokens || 0;
|
|
45
|
+
outputTokens += usage.output_tokens || 0;
|
|
46
|
+
cacheRead += usage.cache_read_input_tokens || 0;
|
|
47
|
+
totalInput += usage.input_tokens || 0;
|
|
48
|
+
|
|
49
|
+
if (model.includes("opus")) opusTokens += tokens;
|
|
50
|
+
else if (model.includes("sonnet")) sonnetTokens += tokens;
|
|
51
|
+
else if (model.includes("haiku")) haikuTokens += tokens;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Count files read more than once
|
|
55
|
+
let repeatedReads = 0;
|
|
56
|
+
for (const count of fileReadCounts.values()) {
|
|
57
|
+
if (count > 1) repeatedReads += count - 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const totalTokens = opusTokens + sonnetTokens + haikuTokens;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
inputTokens,
|
|
64
|
+
outputTokens,
|
|
65
|
+
opusTokens,
|
|
66
|
+
sonnetTokens,
|
|
67
|
+
haikuTokens,
|
|
68
|
+
cacheHitRate: totalInput > 0 ? cacheRead / totalInput : 0,
|
|
69
|
+
repeatedReads,
|
|
70
|
+
modelEfficiency: totalTokens > 0 ? opusTokens / totalTokens : 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Aggregate tool usage across all entries in a session.
|
|
76
|
+
* Returns combined tool data for session-level tooltips (aggregated by name).
|
|
77
|
+
*/
|
|
78
|
+
export function aggregateSessionTools(entries: JournalEntry[]): ToolData[] {
|
|
79
|
+
const toolAgg = new Map<string, { count: number; inputTokens: number; outputTokens: number }>();
|
|
80
|
+
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (!entry.message?.content || typeof entry.message.content === "string") continue;
|
|
83
|
+
if (!entry.message.usage) continue;
|
|
84
|
+
|
|
85
|
+
const inputTokens = entry.message.usage.input_tokens || 0;
|
|
86
|
+
const outputTokens = entry.message.usage.output_tokens || 0;
|
|
87
|
+
const turnTools = extractToolData(entry.message.content, inputTokens, outputTokens);
|
|
88
|
+
|
|
89
|
+
for (const tool of turnTools) {
|
|
90
|
+
const existing = toolAgg.get(tool.name);
|
|
91
|
+
if (existing) {
|
|
92
|
+
existing.count += 1;
|
|
93
|
+
existing.inputTokens += tool.inputTokens;
|
|
94
|
+
existing.outputTokens += tool.outputTokens;
|
|
95
|
+
} else {
|
|
96
|
+
toolAgg.set(tool.name, {
|
|
97
|
+
count: 1,
|
|
98
|
+
inputTokens: tool.inputTokens,
|
|
99
|
+
outputTokens: tool.outputTokens,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Convert to array and sort by token usage
|
|
106
|
+
const tools: ToolData[] = [...toolAgg.entries()].map(([name, data]) => ({
|
|
107
|
+
name,
|
|
108
|
+
detail: `${data.count}x`,
|
|
109
|
+
inputTokens: data.inputTokens,
|
|
110
|
+
outputTokens: data.outputTokens,
|
|
111
|
+
}));
|
|
112
|
+
tools.sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens));
|
|
113
|
+
|
|
114
|
+
return tools;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get the primary model name from analysis results.
|
|
119
|
+
*/
|
|
120
|
+
export function getPrimaryModel(analysis: {
|
|
121
|
+
opusTokens: number;
|
|
122
|
+
sonnetTokens: number;
|
|
123
|
+
haikuTokens: number;
|
|
124
|
+
}): string {
|
|
125
|
+
const { opusTokens, sonnetTokens, haikuTokens } = analysis;
|
|
126
|
+
if (opusTokens >= sonnetTokens && opusTokens >= haikuTokens) return "Opus";
|
|
127
|
+
if (sonnetTokens >= haikuTokens) return "Sonnet";
|
|
128
|
+
return "Haiku";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get a short model name from the full model string.
|
|
133
|
+
*/
|
|
134
|
+
export function getModelName(model?: string): string {
|
|
135
|
+
if (!model) return "unknown";
|
|
136
|
+
if (model.includes("opus")) return "Opus";
|
|
137
|
+
if (model.includes("sonnet")) return "Sonnet";
|
|
138
|
+
if (model.includes("haiku")) return "Haiku";
|
|
139
|
+
return model.split("-")[0] || "unknown";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extract project name from encoded directory name.
|
|
144
|
+
*/
|
|
145
|
+
export function extractProjectName(encodedProject: string): string {
|
|
146
|
+
// Directory names encode paths: -home-ctowles-code-p-towles-tool
|
|
147
|
+
const parts = encodedProject.split("-").filter(Boolean);
|
|
148
|
+
const pathMarkers = new Set(["code", "projects", "src", "p", "repos", "git", "workspace"]);
|
|
149
|
+
|
|
150
|
+
// Find LAST index of a path marker
|
|
151
|
+
let lastMarkerIdx = -1;
|
|
152
|
+
for (let i = 0; i < parts.length; i++) {
|
|
153
|
+
if (pathMarkers.has(parts[i].toLowerCase())) {
|
|
154
|
+
lastMarkerIdx = i;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Take everything after the last marker
|
|
159
|
+
const projectParts = lastMarkerIdx >= 0 ? parts.slice(lastMarkerIdx + 1) : parts.slice(-2);
|
|
160
|
+
|
|
161
|
+
if (projectParts.length === 0) {
|
|
162
|
+
return parts[parts.length - 1] || encodedProject.slice(0, 20);
|
|
163
|
+
}
|
|
164
|
+
return projectParts.join("-");
|
|
165
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export {
|
|
2
|
+
analyzeSession,
|
|
3
|
+
aggregateSessionTools,
|
|
4
|
+
getPrimaryModel,
|
|
5
|
+
getModelName,
|
|
6
|
+
extractProjectName,
|
|
7
|
+
} from "./analyzer.js";
|
|
8
|
+
export { extractSessionLabel } from "./labels.js";
|
|
9
|
+
export { calculateCutoffMs, filterByDays, parseJsonl, quickTokenCount } from "./parser.js";
|
|
10
|
+
export { generateTreemapHtml } from "./render.js";
|
|
11
|
+
export { openInBrowser, startServer, waitForShutdown } from "./server.js";
|
|
12
|
+
export { findRecentSessions, findSessionPath, buildBarChartData } from "./sessions.js";
|
|
13
|
+
export { sanitizeString, truncateDetail, extractToolDetail, extractToolData } from "./tools.js";
|
|
14
|
+
export { buildTurnNodes, buildSessionTreemap, buildAllSessionsTreemap } from "./treemap.js";
|
|
15
|
+
export type {
|
|
16
|
+
BarChartData,
|
|
17
|
+
BarChartDay,
|
|
18
|
+
ContentBlock,
|
|
19
|
+
JournalEntry,
|
|
20
|
+
ProjectBar,
|
|
21
|
+
SessionResult,
|
|
22
|
+
ToolData,
|
|
23
|
+
TreemapNode,
|
|
24
|
+
} from "./types.js";
|