ccviz 0.1.0

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.
@@ -0,0 +1,521 @@
1
+ // src/server/index.ts
2
+ import express from "express";
3
+ import path6 from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ // src/server/api/projects.ts
7
+ import { Router } from "express";
8
+ import fs from "fs/promises";
9
+ import path from "path";
10
+ function createProjectsRouter(claudeDir) {
11
+ const router = Router();
12
+ router.get("/", async (_req, res) => {
13
+ try {
14
+ const projectsDir = path.join(claudeDir, "projects");
15
+ const entries = await fs.readdir(projectsDir, { withFileTypes: true });
16
+ const folders = entries.filter((e) => e.isDirectory()).map((e) => e.name);
17
+ const projects = await Promise.all(
18
+ folders.map(async (encoded) => {
19
+ const decoded = decodePath(encoded);
20
+ const projectDir = path.join(projectsDir, encoded);
21
+ const files = await fs.readdir(projectDir);
22
+ const conversationCount = files.filter(
23
+ (f) => f.endsWith(".jsonl")
24
+ ).length;
25
+ return { encoded, decoded, conversationCount };
26
+ })
27
+ );
28
+ const tree = buildTree(projects);
29
+ res.json(tree);
30
+ } catch (err) {
31
+ res.status(500).json({ error: "Failed to read projects directory" });
32
+ }
33
+ });
34
+ return router;
35
+ }
36
+ function decodePath(encoded) {
37
+ return "/" + encoded.slice(1).replace(/-/g, "/");
38
+ }
39
+ function buildTree(projects) {
40
+ const root = { name: "", children: [] };
41
+ for (const project of projects) {
42
+ const parts = project.decoded.split("/").filter(Boolean);
43
+ let current = root;
44
+ for (let i = 0; i < parts.length; i++) {
45
+ const part = parts[i];
46
+ let child = current.children.find((c) => c.name === part);
47
+ if (!child) {
48
+ child = { name: part, children: [] };
49
+ current.children.push(child);
50
+ }
51
+ if (i === parts.length - 1) {
52
+ child.fullPath = project.decoded;
53
+ child.encoded = project.encoded;
54
+ child.conversationCount = project.conversationCount;
55
+ }
56
+ current = child;
57
+ }
58
+ }
59
+ return root.children;
60
+ }
61
+
62
+ // src/server/api/conversations.ts
63
+ import { Router as Router2 } from "express";
64
+ import fs2 from "fs/promises";
65
+ import path2 from "path";
66
+ import { createReadStream } from "fs";
67
+ import readline from "readline";
68
+ function createConversationsRouter(claudeDir) {
69
+ const router = Router2();
70
+ router.get("/:encodedPath/conversations", async (req, res) => {
71
+ try {
72
+ const projectDir = path2.join(
73
+ claudeDir,
74
+ "projects",
75
+ req.params.encodedPath
76
+ );
77
+ const files = await fs2.readdir(projectDir);
78
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
79
+ const conversations = await Promise.all(
80
+ jsonlFiles.map(async (file) => {
81
+ const filePath = path2.join(projectDir, file);
82
+ const sessionId = file.replace(".jsonl", "");
83
+ const stat = await fs2.stat(filePath);
84
+ const summary = await quickScan(filePath);
85
+ return {
86
+ sessionId,
87
+ filePath,
88
+ sizeBytes: stat.size,
89
+ lastModified: stat.mtime.toISOString(),
90
+ ...summary
91
+ };
92
+ })
93
+ );
94
+ conversations.sort(
95
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
96
+ );
97
+ res.json(conversations);
98
+ } catch (err) {
99
+ res.status(500).json({ error: "Failed to list conversations" });
100
+ }
101
+ });
102
+ return router;
103
+ }
104
+ async function quickScan(filePath) {
105
+ const summary = {
106
+ title: null,
107
+ agentName: null,
108
+ model: null,
109
+ startedAt: null,
110
+ lastTimestamp: null,
111
+ lineCount: 0
112
+ };
113
+ const stream = createReadStream(filePath, { encoding: "utf-8" });
114
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
115
+ for await (const line of rl) {
116
+ summary.lineCount++;
117
+ if (!line.trim()) continue;
118
+ try {
119
+ const msg = JSON.parse(line);
120
+ if (!summary.startedAt && msg.timestamp) {
121
+ summary.startedAt = msg.timestamp;
122
+ }
123
+ if (msg.timestamp) {
124
+ summary.lastTimestamp = msg.timestamp;
125
+ }
126
+ if (msg.type === "custom-title" && msg.title) {
127
+ summary.title = msg.title;
128
+ }
129
+ if (msg.type === "agent-name" && msg.agentName) {
130
+ summary.agentName = msg.agentName;
131
+ }
132
+ if (!summary.model && msg.type === "assistant" && msg.message?.model) {
133
+ summary.model = msg.message.model;
134
+ }
135
+ } catch {
136
+ }
137
+ }
138
+ return summary;
139
+ }
140
+
141
+ // src/server/api/conversation.ts
142
+ import { Router as Router3 } from "express";
143
+ import path5 from "path";
144
+ import fs4 from "fs/promises";
145
+
146
+ // src/server/parser/index.ts
147
+ import { createReadStream as createReadStream2 } from "fs";
148
+ import readline2 from "readline";
149
+ import path4 from "path";
150
+
151
+ // src/server/parser/subagents.ts
152
+ import fs3 from "fs/promises";
153
+ import path3 from "path";
154
+ async function parseSubagents(sessionDir) {
155
+ const subagentsDir = path3.join(sessionDir, "subagents");
156
+ try {
157
+ await fs3.access(subagentsDir);
158
+ } catch {
159
+ return [];
160
+ }
161
+ const files = await fs3.readdir(subagentsDir);
162
+ const agentFiles = files.filter(
163
+ (f) => f.startsWith("agent-") && f.endsWith(".jsonl")
164
+ );
165
+ const subagents = [];
166
+ for (const file of agentFiles) {
167
+ const agentId = file.replace(/^agent-/, "").replace(/\.jsonl$/, "");
168
+ const jsonlPath = path3.join(subagentsDir, file);
169
+ const metaPath = path3.join(subagentsDir, `agent-${agentId}.meta.json`);
170
+ let agentType = "unknown";
171
+ let description = "";
172
+ try {
173
+ const metaContent = await fs3.readFile(metaPath, "utf-8");
174
+ const meta = JSON.parse(metaContent);
175
+ agentType = meta.agentType ?? "unknown";
176
+ description = meta.description ?? "";
177
+ } catch {
178
+ }
179
+ const parsed = await parseConversation(jsonlPath, agentId);
180
+ subagents.push({
181
+ id: agentId,
182
+ agentType,
183
+ description,
184
+ turns: parsed.turns,
185
+ toolCalls: parsed.toolCalls,
186
+ tokenTimeline: parsed.tokenTimeline,
187
+ totals: {
188
+ inputTokens: parsed.totals.inputTokens,
189
+ outputTokens: parsed.totals.outputTokens,
190
+ toolCalls: parsed.totals.toolCalls,
191
+ turnCount: parsed.totals.turnCount
192
+ }
193
+ });
194
+ }
195
+ return subagents;
196
+ }
197
+
198
+ // src/server/parser/index.ts
199
+ async function parseConversation(filePath, sessionId) {
200
+ const stream = createReadStream2(filePath, { encoding: "utf-8" });
201
+ const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
202
+ let title = null;
203
+ let agentName = null;
204
+ let model = "";
205
+ let gitBranch = "";
206
+ let version = "";
207
+ let startedAt = "";
208
+ const turns = [];
209
+ const toolCalls = [];
210
+ const tokenTimeline = [];
211
+ const subagents = [];
212
+ const pendingToolCalls = /* @__PURE__ */ new Map();
213
+ let currentUserMsg = null;
214
+ let currentAssistantMsg = null;
215
+ let turnIndex = 0;
216
+ let turnDurationMs = null;
217
+ let cumInput = 0;
218
+ let cumOutput = 0;
219
+ for await (const line of rl) {
220
+ if (!line.trim()) continue;
221
+ let msg;
222
+ try {
223
+ msg = JSON.parse(line);
224
+ } catch {
225
+ continue;
226
+ }
227
+ if (!startedAt && msg.timestamp) {
228
+ startedAt = msg.timestamp;
229
+ }
230
+ switch (msg.type) {
231
+ case "custom-title":
232
+ title = msg.title ?? title;
233
+ break;
234
+ case "agent-name":
235
+ agentName = msg.agentName ?? agentName;
236
+ break;
237
+ case "summary": {
238
+ break;
239
+ }
240
+ case "user": {
241
+ if (currentAssistantMsg) {
242
+ finalizeTurn();
243
+ }
244
+ currentUserMsg = msg;
245
+ if (msg.message?.content) {
246
+ for (const block of msg.message.content) {
247
+ if (block.type === "tool_result" && block.tool_use_id) {
248
+ const pending = pendingToolCalls.get(block.tool_use_id);
249
+ if (pending) {
250
+ const resultContent = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map((c) => c.text ?? JSON.stringify(c)).join("\n") : JSON.stringify(block.content);
251
+ pending.result = {
252
+ content: resultContent,
253
+ sizeBytes: new TextEncoder().encode(resultContent).length,
254
+ persistedPath: null
255
+ };
256
+ pending.endTimestamp = msg.timestamp;
257
+ if (pending.startTimestamp && msg.timestamp) {
258
+ pending.durationMs = new Date(msg.timestamp).getTime() - new Date(pending.startTimestamp).getTime();
259
+ }
260
+ if (msg.mcpMeta) {
261
+ pending.isMcp = true;
262
+ }
263
+ const completed = pending;
264
+ toolCalls.push(completed);
265
+ pendingToolCalls.delete(block.tool_use_id);
266
+ }
267
+ }
268
+ }
269
+ }
270
+ if (msg.toolUseResult) {
271
+ const pending = pendingToolCalls.get(msg.toolUseResult.toolUseId);
272
+ if (pending) {
273
+ pending.result = {
274
+ content: msg.toolUseResult.content ?? "[persisted to disk]",
275
+ sizeBytes: msg.toolUseResult.sizeBytes ?? 0,
276
+ persistedPath: msg.toolUseResult.path ?? null
277
+ };
278
+ pending.endTimestamp = msg.timestamp;
279
+ if (pending.startTimestamp && msg.timestamp) {
280
+ pending.durationMs = new Date(msg.timestamp).getTime() - new Date(pending.startTimestamp).getTime();
281
+ }
282
+ const completed = pending;
283
+ toolCalls.push(completed);
284
+ pendingToolCalls.delete(msg.toolUseResult.toolUseId);
285
+ }
286
+ }
287
+ break;
288
+ }
289
+ case "assistant": {
290
+ currentAssistantMsg = msg;
291
+ if (msg.message?.model && !model) {
292
+ model = msg.message.model;
293
+ }
294
+ const usage = msg.message?.usage;
295
+ if (usage) {
296
+ const inputTokens = usage.input_tokens ?? 0;
297
+ const outputTokens = usage.output_tokens ?? 0;
298
+ const cacheCreation = usage.cache_creation_input_tokens ?? 0;
299
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
300
+ cumInput += inputTokens;
301
+ cumOutput += outputTokens;
302
+ tokenTimeline.push({
303
+ turnIndex,
304
+ timestamp: msg.timestamp,
305
+ inputTokens,
306
+ outputTokens,
307
+ cacheCreationTokens: cacheCreation,
308
+ cacheReadTokens: cacheRead,
309
+ cumulativeInputTokens: cumInput,
310
+ cumulativeOutputTokens: cumOutput
311
+ });
312
+ }
313
+ if (msg.message?.content) {
314
+ for (const block of msg.message.content) {
315
+ if (block.type === "tool_use") {
316
+ const toolName = block.name ?? "unknown";
317
+ const isMcp = toolName.startsWith("mcp__");
318
+ let mcpServer = null;
319
+ if (isMcp) {
320
+ const parts = toolName.split("__");
321
+ mcpServer = parts[1] ?? null;
322
+ }
323
+ pendingToolCalls.set(block.id, {
324
+ id: block.id,
325
+ name: toolName,
326
+ input: block.input ?? {},
327
+ startTimestamp: msg.timestamp,
328
+ turnIndex,
329
+ isMcp,
330
+ mcpServer
331
+ });
332
+ }
333
+ }
334
+ }
335
+ break;
336
+ }
337
+ case "system": {
338
+ if (msg.subtype === "turn_duration" && msg.durationMs != null) {
339
+ turnDurationMs = msg.durationMs;
340
+ }
341
+ if (msg.version) {
342
+ version = msg.version;
343
+ }
344
+ if (msg.gitBranch) {
345
+ gitBranch = msg.gitBranch;
346
+ }
347
+ break;
348
+ }
349
+ }
350
+ }
351
+ if (currentAssistantMsg) {
352
+ finalizeTurn();
353
+ }
354
+ function finalizeTurn() {
355
+ if (!currentAssistantMsg) return;
356
+ const userContent = extractTextContent(currentUserMsg?.message?.content);
357
+ const userToolResults = extractToolResults(currentUserMsg?.message?.content);
358
+ const assistantContent = extractTextContent(
359
+ currentAssistantMsg.message?.content
360
+ );
361
+ const assistantToolCalls = extractToolCallRefs(
362
+ currentAssistantMsg.message?.content
363
+ );
364
+ const usage = currentAssistantMsg.message?.usage ?? {};
365
+ turns.push({
366
+ index: turnIndex,
367
+ userMessage: {
368
+ timestamp: currentUserMsg?.timestamp ?? "",
369
+ content: userContent,
370
+ toolResults: userToolResults
371
+ },
372
+ assistantMessage: {
373
+ timestamp: currentAssistantMsg.timestamp ?? "",
374
+ content: assistantContent,
375
+ toolCalls: assistantToolCalls,
376
+ usage: {
377
+ inputTokens: usage.input_tokens ?? 0,
378
+ outputTokens: usage.output_tokens ?? 0,
379
+ cacheCreationTokens: usage.cache_creation_input_tokens ?? 0,
380
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0
381
+ }
382
+ },
383
+ durationMs: turnDurationMs
384
+ });
385
+ turnIndex++;
386
+ turnDurationMs = null;
387
+ currentUserMsg = null;
388
+ currentAssistantMsg = null;
389
+ }
390
+ const sessionDir = path4.join(
391
+ path4.dirname(filePath),
392
+ path4.basename(filePath, ".jsonl")
393
+ );
394
+ const parsedSubagents = await parseSubagents(sessionDir);
395
+ subagents.push(...parsedSubagents);
396
+ const totalDuration = startedAt && tokenTimeline.length > 0 ? new Date(tokenTimeline[tokenTimeline.length - 1].timestamp).getTime() - new Date(startedAt).getTime() : null;
397
+ const result = {
398
+ id: sessionId,
399
+ title,
400
+ agentName,
401
+ startedAt,
402
+ model,
403
+ gitBranch,
404
+ version,
405
+ turns,
406
+ toolCalls,
407
+ tokenTimeline,
408
+ subagents,
409
+ totals: {
410
+ inputTokens: cumInput,
411
+ outputTokens: cumOutput,
412
+ cacheCreationTokens: tokenTimeline.reduce(
413
+ (s, t) => s + t.cacheCreationTokens,
414
+ 0
415
+ ),
416
+ cacheReadTokens: tokenTimeline.reduce(
417
+ (s, t) => s + t.cacheReadTokens,
418
+ 0
419
+ ),
420
+ toolCalls: toolCalls.length,
421
+ durationMs: totalDuration,
422
+ turnCount: turns.length
423
+ }
424
+ };
425
+ return result;
426
+ }
427
+ function extractTextContent(content) {
428
+ if (!content) return "";
429
+ if (typeof content === "string") return content;
430
+ if (!Array.isArray(content)) return "";
431
+ return content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
432
+ }
433
+ function extractToolResults(content) {
434
+ if (!content || !Array.isArray(content)) return [];
435
+ return content.filter((b) => b.type === "tool_result").map((b) => ({
436
+ toolUseId: b.tool_use_id,
437
+ content: typeof b.content === "string" ? b.content : JSON.stringify(b.content)
438
+ }));
439
+ }
440
+ function extractToolCallRefs(content) {
441
+ if (!content || !Array.isArray(content)) return [];
442
+ return content.filter((b) => b.type === "tool_use").map((b) => ({
443
+ id: b.id,
444
+ name: b.name
445
+ }));
446
+ }
447
+
448
+ // src/server/api/conversation.ts
449
+ function createConversationRouter(claudeDir) {
450
+ const router = Router3();
451
+ const cache = /* @__PURE__ */ new Map();
452
+ const MAX_CACHE = 20;
453
+ router.get("/:projectPath/:sessionId", async (req, res) => {
454
+ try {
455
+ const filePath = path5.join(
456
+ claudeDir,
457
+ "projects",
458
+ req.params.projectPath,
459
+ `${req.params.sessionId}.jsonl`
460
+ );
461
+ const stat = await fs4.stat(filePath);
462
+ const cacheKey = filePath;
463
+ const cached = cache.get(cacheKey);
464
+ if (cached && cached.mtime === stat.mtimeMs) {
465
+ res.json(cached.data);
466
+ return;
467
+ }
468
+ const parsed = await parseConversation(filePath, req.params.sessionId);
469
+ if (cache.size >= MAX_CACHE) {
470
+ const oldest = cache.keys().next().value;
471
+ cache.delete(oldest);
472
+ }
473
+ cache.set(cacheKey, { mtime: stat.mtimeMs, data: parsed });
474
+ res.json(parsed);
475
+ } catch (err) {
476
+ res.status(500).json({ error: "Failed to parse conversation" });
477
+ }
478
+ });
479
+ router.get(
480
+ "/:projectPath/:sessionId/tool-result/:toolUseId",
481
+ async (req, res) => {
482
+ try {
483
+ const resultPath = path5.join(
484
+ claudeDir,
485
+ "projects",
486
+ req.params.projectPath,
487
+ req.params.sessionId,
488
+ "tool-results",
489
+ `${req.params.toolUseId}.txt`
490
+ );
491
+ const content = await fs4.readFile(resultPath, "utf-8");
492
+ res.type("text/plain").send(content);
493
+ } catch {
494
+ res.status(404).json({ error: "Tool result not found" });
495
+ }
496
+ }
497
+ );
498
+ return router;
499
+ }
500
+
501
+ // src/server/index.ts
502
+ var __dirname = path6.dirname(fileURLToPath(import.meta.url));
503
+ function createServer({ claudeDir, isDev }) {
504
+ const app = express();
505
+ app.use("/api/projects", createProjectsRouter(claudeDir));
506
+ app.use("/api/projects", createConversationsRouter(claudeDir));
507
+ app.use("/api/conversations", createConversationRouter(claudeDir));
508
+ if (!isDev) {
509
+ const uiDir = path6.join(__dirname, "ui");
510
+ app.use(express.static(uiDir));
511
+ app.get("/{*splat}", (_req, res) => {
512
+ res.sendFile(path6.join(uiDir, "index.html"));
513
+ });
514
+ }
515
+ return app;
516
+ }
517
+
518
+ export {
519
+ parseConversation,
520
+ createServer
521
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ createServer,
4
+ parseConversation
5
+ } from "./chunk-KVXQKWK4.js";
6
+
7
+ // src/cli.ts
8
+ import { Command } from "commander";
9
+ import open from "open";
10
+ import path from "path";
11
+ import fs from "fs/promises";
12
+ var program = new Command();
13
+ program.name("ccviz").description("Visualize Claude Code conversations").version("0.1.0").option("-p, --port <number>", "server port", "3333").option(
14
+ "-d, --claude-dir <path>",
15
+ "path to .claude directory",
16
+ `${process.env.HOME}/.claude`
17
+ ).option("--no-open", "don't open browser automatically").option("--dev", "development mode (proxy to Vite dev server)").option("--json <path>", "output parsed conversation as JSON (no server)").option(
18
+ "--conversation <project/session>",
19
+ "open directly to a specific conversation"
20
+ ).action(async (opts) => {
21
+ const claudeDir = opts.claudeDir;
22
+ if (opts.json) {
23
+ const jsonlPath = path.resolve(opts.json);
24
+ const sessionId = path.basename(jsonlPath, ".jsonl");
25
+ try {
26
+ await fs.access(jsonlPath);
27
+ } catch {
28
+ console.error(`File not found: ${jsonlPath}`);
29
+ process.exit(1);
30
+ }
31
+ const parsed = await parseConversation(jsonlPath, sessionId);
32
+ console.log(JSON.stringify(parsed, null, 2));
33
+ return;
34
+ }
35
+ const port = parseInt(opts.port, 10);
36
+ const isDev = opts.dev ?? false;
37
+ const app = createServer({ claudeDir, isDev });
38
+ app.listen(port, () => {
39
+ let url = `http://localhost:${port}`;
40
+ if (opts.conversation) {
41
+ url += `/conversation/${opts.conversation}`;
42
+ }
43
+ console.log(`ccviz running at ${url}`);
44
+ if (opts.open) {
45
+ open(url);
46
+ }
47
+ });
48
+ });
49
+ program.parse();
@@ -0,0 +1,6 @@
1
+ import {
2
+ createServer
3
+ } from "../chunk-KVXQKWK4.js";
4
+ export {
5
+ createServer
6
+ };