graphtrack 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fahim Islam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # Graphtrack
2
+
3
+ Visualize your **Claude Code** token usage as an interactive graph.
4
+
5
+ Instead of tables and bars, Graphtrack turns a session into a navigable network —
6
+ prompts, tool calls, and their token weight rendered as nodes and edges. Node size
7
+ is token share, color is tool type, and edges trace how your tokens actually flowed.
8
+ Find out at a glance which tool is eating your budget, or trace a single request
9
+ from prompt to result.
10
+
11
+ ```
12
+ ~/.claude/projects/<project>/<session>.jsonl → parse → graph → serve on localhost
13
+ ```
14
+
15
+ ## Why local?
16
+
17
+ This is a **local-first, open-source tool**. Your logs are parsed on your machine and
18
+ served to your own browser at `localhost`. Nothing is uploaded, stored, or shared.
19
+ Your usage data never leaves your computer.
20
+
21
+ ## Getting started
22
+
23
+ ```bash
24
+ # from the repo root
25
+ npm install
26
+ npm run build
27
+ npm start # starts a localhost server
28
+ ```
29
+
30
+ Then open the printed URL, pick a session (or point Graphtrack at
31
+ `~/.claude/projects`), and explore the graph.
32
+
33
+ ### Preview (no server needed)
34
+
35
+ A standalone page is generated from a synthetic sample session so you can judge
36
+ the visualization without any setup:
37
+
38
+ ```bash
39
+ npm run build
40
+ node scripts/build-spike.mjs
41
+ # open public/index.html in a browser
42
+ ```
43
+
44
+ ## Roadmap
45
+
46
+ - **Phase 0** — validate the graph metaphor on realistic data ✓ (spike shipped)
47
+ - **Phase 1** — JSONL parser + typed model + per-tool aggregation
48
+ - **Phase 2** — localhost web app: vis.js graph + table view
49
+ - **Phase 3** — multi-session overview, empty state, error handling
50
+ - **Phase 4** — install polish, docs, CI
51
+
52
+ ## Project layout
53
+
54
+ ```
55
+ src/
56
+ types.ts shared data model (Session/Turn/ToolCall/Usage)
57
+ parser.ts JSONL → typed Session
58
+ graph.ts Session → vis.js nodes/edges (aggregate + chronological)
59
+ scripts/
60
+ build-spike.mjs regenerates public/graph-data.js from a sample session
61
+ public/
62
+ index.html the app page (hand-authored)
63
+ graph-data.js generated graph data
64
+ test/
65
+ fixtures/ sample-session generator
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Graphtrack CLI — run `graphtrack` anywhere to start the local dashboard.
4
+ *
5
+ * graphtrack → http://localhost:9090 (reads ~/.claude/projects)
6
+ * graphtrack --dir <path> → read logs from a custom folder
7
+ * graphtrack --port <n> → use a different port (default 9090)
8
+ * graphtrack --help → this help
9
+ */
10
+ import { startServer, DEFAULT_LOGS_DIR, DEFAULT_PORT } from "./server.js";
11
+ function printHelp() {
12
+ console.log(`
13
+ Graphtrack — visualize Claude Code token usage as a graph.
14
+
15
+ Usage:
16
+ graphtrack [options]
17
+
18
+ Options:
19
+ --dir, -d <path> read session logs from <path> (default: ${DEFAULT_LOGS_DIR})
20
+ --port, -p <n> serve on port <n> (default: ${DEFAULT_PORT})
21
+ --help, -h show this help
22
+
23
+ Everything runs locally. Your logs never leave your machine.
24
+ `);
25
+ }
26
+ function parseArgs(argv) {
27
+ const opts = { logsDir: DEFAULT_LOGS_DIR };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a === "--dir" || a === "-d") {
31
+ opts.logsDir = argv[++i] ?? DEFAULT_LOGS_DIR;
32
+ }
33
+ else if (a === "--port" || a === "-p") {
34
+ opts.port = Number(argv[++i]);
35
+ }
36
+ else if (a === "--help" || a === "-h") {
37
+ printHelp();
38
+ process.exit(0);
39
+ }
40
+ }
41
+ return opts;
42
+ }
43
+ startServer(parseArgs(process.argv.slice(2)));
44
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;GAOG;AACH,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE1E,SAAS,SAAS;IAChB,OAAO,CAAC,GAAG,CAAC;;;;;;;+DAOiD,gBAAgB;mDAC5B,YAAY;;;;CAI9D,CAAC,CAAC;AACH,CAAC;AAOD,SAAS,SAAS,CAAC,IAAc;IAC/B,MAAM,IAAI,GAAe,EAAE,OAAO,EAAE,gBAAgB,EAAE,CAAC;IACvD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,IAAI,CAAC,KAAK,OAAO,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAChC,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,gBAAgB,CAAC;QAC/C,CAAC;aAAM,IAAI,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YACxC,IAAI,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAChC,CAAC;aAAM,IAAI,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YACxC,SAAS,EAAE,CAAC;YACZ,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,WAAW,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Graph builder: transforms the parsed Session into vis.js node/edge arrays.
3
+ *
4
+ * Two views are provided so Phase 0 can compare which reads better:
5
+ * - aggregate: a "hub & tool" graph — session center connected to tool nodes
6
+ * sized by cumulative token share, colored by tool type.
7
+ * - chronological: the turn-by-turn chain — user prompts and tool calls in
8
+ * the order they happened.
9
+ *
10
+ * vis.js nodes use `value` (scales node size) and `color.background`.
11
+ */
12
+ import type { Session, ToolAggregate } from "./types.js";
13
+ /**
14
+ * Claude Code logs report usage per assistant message, not per tool call.
15
+ * To size tool nodes fairly, spread each turn's total tokens onto its tool
16
+ * calls proportional to call count. This is a heuristic — call.totalTokens is
17
+ * approximate per-call, exact in aggregate. Callers must invoke it after parse.
18
+ */
19
+ export declare function spreadTokens(session: Session): void;
20
+ export interface VisNode {
21
+ id: string;
22
+ label: string;
23
+ value?: number;
24
+ color?: {
25
+ background?: string;
26
+ border?: string;
27
+ };
28
+ group?: string;
29
+ title?: string;
30
+ data?: Record<string, unknown>;
31
+ }
32
+ export interface VisEdge {
33
+ from: string;
34
+ to: string;
35
+ value?: number;
36
+ title?: string;
37
+ arrows?: string;
38
+ }
39
+ export declare function toolColor(name: string): string;
40
+ /** Hub-and-tool aggregate graph for one session. */
41
+ export declare function buildAggregateGraph(session: Session): {
42
+ nodes: VisNode[];
43
+ edges: VisEdge[];
44
+ };
45
+ /** Turn-by-turn chronological graph: session → prompts → their tool calls. */
46
+ export declare function buildChronologicalGraph(session: Session): {
47
+ nodes: VisNode[];
48
+ edges: VisEdge[];
49
+ };
50
+ export type { ToolAggregate };
package/dist/graph.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Claude Code logs report usage per assistant message, not per tool call.
3
+ * To size tool nodes fairly, spread each turn's total tokens onto its tool
4
+ * calls proportional to call count. This is a heuristic — call.totalTokens is
5
+ * approximate per-call, exact in aggregate. Callers must invoke it after parse.
6
+ */
7
+ export function spreadTokens(session) {
8
+ for (const turn of session.turns) {
9
+ if (turn.toolCalls.length && turn.totalTokens > 0) {
10
+ const share = turn.totalTokens / turn.toolCalls.length;
11
+ for (const call of turn.toolCalls) {
12
+ call.totalTokens = Math.round(share);
13
+ }
14
+ }
15
+ }
16
+ }
17
+ /** Consistent color per tool name, tuned for a dark canvas. */
18
+ const TOOL_COLORS = {
19
+ Bash: "#faff69",
20
+ Read: "#3b82f6",
21
+ Edit: "#22c55e",
22
+ Grep: "#ef4444",
23
+ Glob: "#a78bfa",
24
+ WebSearch: "#f59e0b",
25
+ Task: "#22d3ee",
26
+ Write: "#34d399",
27
+ default: "#888888",
28
+ };
29
+ export function toolColor(name) {
30
+ return TOOL_COLORS[name] ?? TOOL_COLORS.default;
31
+ }
32
+ /** Normalize raw token counts into a vis.js-friendly size (sqrt scale). */
33
+ function scale(value) {
34
+ return Math.sqrt(value) / 10;
35
+ }
36
+ /** Hub-and-tool aggregate graph for one session. */
37
+ export function buildAggregateGraph(session) {
38
+ const nodes = [];
39
+ const edges = [];
40
+ const rootId = "session";
41
+ nodes.push({
42
+ id: rootId,
43
+ label: `${session.project}\n${session.turns.length} turns · ${fmt(session.totalTokens)} tok`,
44
+ value: 6,
45
+ color: { background: "#faff69", border: "#e6eb52" },
46
+ group: "session",
47
+ title: "This session",
48
+ });
49
+ // Group tool calls across the whole session (reuse aggregation).
50
+ const byTool = new Map();
51
+ for (const turn of session.turns) {
52
+ for (const call of turn.toolCalls) {
53
+ const entry = byTool.get(call.name) ?? { calls: 0, tokens: 0 };
54
+ entry.calls += 1;
55
+ entry.tokens += call.totalTokens || 1;
56
+ byTool.set(call.name, entry);
57
+ }
58
+ }
59
+ const maxTokens = Math.max(1, ...[...byTool.values()].map((v) => v.tokens));
60
+ for (const [name, agg] of byTool) {
61
+ const id = `tool:${name}`;
62
+ nodes.push({
63
+ id,
64
+ label: `${name}\n${agg.calls}× · ${fmt(agg.tokens)}`,
65
+ value: 1 + 5 * (agg.tokens / maxTokens),
66
+ color: { background: toolColor(name), border: "#ffffff" },
67
+ group: "tool",
68
+ title: `${name}: ${agg.calls} calls, ${fmt(agg.tokens)} tokens`,
69
+ });
70
+ edges.push({ from: rootId, to: id, value: 2 + 4 * (agg.tokens / maxTokens), title: `${name}: ${fmt(agg.tokens)}` });
71
+ }
72
+ return { nodes, edges };
73
+ }
74
+ /** Turn-by-turn chronological graph: session → prompts → their tool calls. */
75
+ export function buildChronologicalGraph(session) {
76
+ const nodes = [];
77
+ const edges = [];
78
+ nodes.push({
79
+ id: "session",
80
+ label: session.project,
81
+ value: 6,
82
+ color: { background: "#faff69", border: "#e6eb52" },
83
+ group: "session",
84
+ });
85
+ let prevTurnId = "session";
86
+ for (const turn of session.turns) {
87
+ const turnId = `turn:${turn.index}`;
88
+ nodes.push({
89
+ id: turnId,
90
+ label: truncate(turn.prompt, 22),
91
+ value: 2.5,
92
+ color: { background: "#121212", border: "#3a3a3a" },
93
+ group: "turn",
94
+ title: `Turn ${turn.index + 1}: ${turn.prompt}`,
95
+ });
96
+ edges.push({ from: prevTurnId, to: turnId, value: 1, arrows: "to" });
97
+ // Tool calls inside this turn.
98
+ for (const call of turn.toolCalls) {
99
+ const callId = `${turnId}:${call.id}`;
100
+ nodes.push({
101
+ id: callId,
102
+ label: call.name,
103
+ value: 1.2,
104
+ color: { background: toolColor(call.name), border: "#ffffff" },
105
+ group: "tool",
106
+ title: `${call.name} ${call.summary}`,
107
+ });
108
+ edges.push({ from: turnId, to: callId, value: 1, arrows: "to" });
109
+ }
110
+ prevTurnId = turnId;
111
+ }
112
+ return { nodes, edges };
113
+ }
114
+ function fmt(n) {
115
+ if (n >= 1_000_000)
116
+ return `${(n / 1_000_000).toFixed(1)}M`;
117
+ if (n >= 1_000)
118
+ return `${(n / 1_000).toFixed(1)}k`;
119
+ return String(n);
120
+ }
121
+ function truncate(s, n) {
122
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
123
+ }
124
+ //# sourceMappingURL=graph.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph.js","sourceRoot":"","sources":["../src/graph.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import type { Session, ToolAggregate } from "./types.js";
2
+ /** Extracts tool calls from a session's JSONL and groups them into turns. */
3
+ export declare function parseSession(filePath: string, project: string): Session;
4
+ /** Aggregate tool calls across a session (the collapse-by-tool model). */
5
+ export declare function aggregateTools(session: Session): ToolAggregate[];
package/dist/parser.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Parser: reads Claude Code JSONL log files and produces the typed Session model.
3
+ *
4
+ * Handles a single session file. Returns a Session, or throws on a completely
5
+ * unreadable file (callers decide how to surface partial failures).
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ /** Parse one line of JSONL into a drop of structure we care about. */
9
+ function parseLine(raw) {
10
+ const line = raw.trim();
11
+ if (!line)
12
+ return null;
13
+ let obj;
14
+ try {
15
+ obj = JSON.parse(line);
16
+ }
17
+ catch {
18
+ return null; // malformed line — skip, don't crash
19
+ }
20
+ return obj?.message ?? null;
21
+ }
22
+ function contentSummary(content) {
23
+ if (!content)
24
+ return "";
25
+ return content
26
+ .map((b) => {
27
+ if (b.type === "text")
28
+ return b.text ?? "";
29
+ if (b.type === "tool_use")
30
+ return "";
31
+ return "";
32
+ })
33
+ .filter(Boolean)
34
+ .join(" ");
35
+ }
36
+ function tokensOf(usage) {
37
+ if (!usage)
38
+ return 0;
39
+ return ((usage.input_tokens ?? 0) +
40
+ (usage.output_tokens ?? 0) +
41
+ (usage.cache_creation_input_tokens ?? 0) +
42
+ (usage.cache_read_input_tokens ?? 0));
43
+ }
44
+ /** Extracts tool calls from a session's JSONL and groups them into turns. */
45
+ export function parseSession(filePath, project) {
46
+ const raw = readFileSync(filePath, "utf8");
47
+ const lines = raw.split("\n");
48
+ const session = {
49
+ id: filePath.split(/[\\/]/).pop()?.replace(/\.jsonl$/, "") ?? "unknown",
50
+ project,
51
+ model: "unknown",
52
+ startedAt: "",
53
+ turns: [],
54
+ totalTokens: 0,
55
+ totalInput: 0,
56
+ totalOutput: 0,
57
+ totalCacheRead: 0,
58
+ totalCacheWrite: 0,
59
+ };
60
+ // We walk the stream and slice it into turns. A turn starts at a user message
61
+ // that contains a text prompt (i.e. a real request, not a tool_result).
62
+ let current = null;
63
+ for (const line of lines) {
64
+ const msg = parseLine(line);
65
+ if (!msg)
66
+ continue;
67
+ const text = contentSummary(msg.content);
68
+ // Determine message kind
69
+ const hasText = Boolean(text.trim());
70
+ const content = msg.content ?? [];
71
+ // A new turn begins when the user speaks with text (not a tool_result).
72
+ if (msg.role === "user" && hasText && content.every((b) => b.type !== "tool_result")) {
73
+ if (current)
74
+ session.turns.push(current);
75
+ current = { prompt: text, index: session.turns.length, toolCalls: [], totalTokens: 0 };
76
+ continue;
77
+ }
78
+ if (!current)
79
+ continue;
80
+ // Capture tool_use blocks.
81
+ for (const block of content) {
82
+ if (block.type === "tool_use") {
83
+ const input = block.input ?? {};
84
+ const summary = Object.entries(input)
85
+ .map(([k, v]) => `${k}=${String(v)}`)
86
+ .slice(0, 3)
87
+ .join(" ");
88
+ const call = {
89
+ id: block.id ?? `tool_${current.index}_${current.toolCalls.length}`,
90
+ name: block.name ?? "unknown",
91
+ summary,
92
+ totalTokens: 0,
93
+ };
94
+ current.toolCalls.push(call);
95
+ }
96
+ }
97
+ // Attribute usage to the current turn (assistant text replies carry usage).
98
+ if (msg.usage) {
99
+ const usage = msg.usage;
100
+ session.totalInput += usage.input_tokens ?? 0;
101
+ session.totalOutput += usage.output_tokens ?? 0;
102
+ session.totalCacheRead += usage.cache_read_input_tokens ?? 0;
103
+ session.totalCacheWrite += usage.cache_creation_input_tokens ?? 0;
104
+ current.totalTokens += tokensOf(usage);
105
+ if (msg.model && session.model === "unknown")
106
+ session.model = msg.model;
107
+ }
108
+ }
109
+ if (current)
110
+ session.turns.push(current);
111
+ session.startedAt = session.turns[0] ? String(session.turns[0]?.prompt.length) : "";
112
+ // Derive a pseudo start time from the file's first activity is omitted here;
113
+ // the fixture/deployer can supply timestamps via a richer parser later.
114
+ session.totalTokens = session.turns.reduce((a, t) => a + t.totalTokens, 0);
115
+ return session;
116
+ }
117
+ /** Aggregate tool calls across a session (the collapse-by-tool model). */
118
+ export function aggregateTools(session) {
119
+ const map = new Map();
120
+ for (const turn of session.turns) {
121
+ for (const call of turn.toolCalls) {
122
+ const agg = map.get(call.name) ?? {
123
+ name: call.name,
124
+ calls: 0,
125
+ totalTokens: 0,
126
+ inputTokens: 0,
127
+ outputTokens: 0,
128
+ cacheRead: 0,
129
+ cacheWrite: 0,
130
+ };
131
+ agg.calls += 1;
132
+ agg.totalTokens += call.totalTokens;
133
+ const u = call.usage;
134
+ if (u) {
135
+ agg.inputTokens += u.input_tokens ?? 0;
136
+ agg.outputTokens += u.output_tokens ?? 0;
137
+ agg.cacheRead += u.cache_read_input_tokens ?? 0;
138
+ agg.cacheWrite += u.cache_creation_input_tokens ?? 0;
139
+ }
140
+ map.set(call.name, agg);
141
+ }
142
+ }
143
+ return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
144
+ }
145
+ //# sourceMappingURL=parser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.js","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAgBvC,sEAAsE;AACtE,SAAS,SAAS,CAAC,GAAW;IAC5B,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACxB,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,IAAI,GAAuI,CAAC;IAC5I,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC,CAAC,qCAAqC;IACpD,CAAC;IACD,OAAO,GAAG,EAAE,OAAO,IAAI,IAAI,CAAC;AAC9B,CAAC;AAED,SAAS,cAAc,CAAC,OAAiC;IACvD,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IACxB,OAAO,OAAO;SACX,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACT,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM;YAAE,OAAO,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;QAC3C,IAAI,CAAC,CAAC,IAAI,KAAK,UAAU;YAAE,OAAO,EAAE,CAAC;QACrC,OAAO,EAAE,CAAC;IACZ,CAAC,CAAC;SACD,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAC;AACf,CAAC;AAED,SAAS,QAAQ,CAAC,KAAwB;IACxC,IAAI,CAAC,KAAK;QAAE,OAAO,CAAC,CAAC;IACrB,OAAO,CACL,CAAC,KAAK,CAAC,YAAY,IAAI,CAAC,CAAC;QACzB,CAAC,KAAK,CAAC,aAAa,IAAI,CAAC,CAAC;QAC1B,CAAC,KAAK,CAAC,2BAA2B,IAAI,CAAC,CAAC;QACxC,CAAC,KAAK,CAAC,uBAAuB,IAAI,CAAC,CAAC,CACrC,CAAC;AACJ,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,OAAe;IAC5D,MAAM,GAAG,GAAG,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC3C,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAE9B,MAAM,OAAO,GAAY;QACvB,EAAE,EAAE,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,IAAI,SAAS;QACvE,OAAO;QACP,KAAK,EAAE,SAAS;QAChB,SAAS,EAAE,EAAE;QACb,KAAK,EAAE,EAAE;QACT,WAAW,EAAE,CAAC;QACd,UAAU,EAAE,CAAC;QACb,WAAW,EAAE,CAAC;QACd,cAAc,EAAE,CAAC;QACjB,eAAe,EAAE,CAAC;KACnB,CAAC;IAEF,8EAA8E;IAC9E,wEAAwE;IACxE,IAAI,OAAO,GAAgB,IAAI,CAAC;IAEhC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,CAAC,GAAG;YAAE,SAAS;QAEnB,MAAM,IAAI,GAAG,cAAc,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAEzC,yBAAyB;QACzB,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QACrC,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;QAElC,wEAAwE;QACxE,IAAI,GAAG,CAAC,IAAI,KAAK,MAAM,IAAI,OAAO,IAAI,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,aAAa,CAAC,EAAE,CAAC;YACrF,IAAI,OAAO;gBAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACzC,OAAO,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC;YACvF,SAAS;QACX,CAAC;QAED,IAAI,CAAC,OAAO;YAAE,SAAS;QAEvB,2BAA2B;QAC3B,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC5B,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;gBAC9B,MAAM,KAAK,GAAI,KAAK,CAAC,KAAiC,IAAI,EAAE,CAAC;gBAC7D,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;qBAClC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;qBACpC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;qBACX,IAAI,CAAC,GAAG,CAAC,CAAC;gBACb,MAAM,IAAI,GAAa;oBACrB,EAAE,EAAE,KAAK,CAAC,EAAE,IAAI,QAAQ,OAAO,CAAC,KAAK,IAAI,OAAO,CAAC,SAAS,CAAC,MAAM,EAAE;oBACnE,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,SAAS;oBAC7B,OAAO;oBACP,WAAW,EAAE,CAAC;iBACf,CAAC;gBACF,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC/B,CAAC;QACH,CAAC;QAED,4EAA4E;QAC5E,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;YACd,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC;YACxB,OAAO,CAAC,UAAU,IAAI,KAAK,CAAC,YAAY,IAAI,CAAC,CAAC;YAC9C,OAAO,CAAC,WAAW,IAAI,KAAK,CAAC,aAAa,IAAI,CAAC,CAAC;YAChD,OAAO,CAAC,cAAc,IAAI,KAAK,CAAC,uBAAuB,IAAI,CAAC,CAAC;YAC7D,OAAO,CAAC,eAAe,IAAI,KAAK,CAAC,2BAA2B,IAAI,CAAC,CAAC;YAClE,OAAO,CAAC,WAAW,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC;YACvC,IAAI,GAAG,CAAC,KAAK,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;gBAAE,OAAO,CAAC,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC;QAC1E,CAAC;IACH,CAAC;IACD,IAAI,OAAO;QAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAEzC,OAAO,CAAC,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACpF,6EAA6E;IAC7E,wEAAwE;IAExE,OAAO,CAAC,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAC3E,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,cAAc,CAAC,OAAgB;IAC7C,MAAM,GAAG,GAAG,IAAI,GAAG,EAAyB,CAAC;IAC7C,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACjC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YAClC,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI;gBAChC,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,KAAK,EAAE,CAAC;gBACR,WAAW,EAAE,CAAC;gBACd,WAAW,EAAE,CAAC;gBACd,YAAY,EAAE,CAAC;gBACf,SAAS,EAAE,CAAC;gBACZ,UAAU,EAAE,CAAC;aACd,CAAC;YACF,GAAG,CAAC,KAAK,IAAI,CAAC,CAAC;YACf,GAAG,CAAC,WAAW,IAAI,IAAI,CAAC,WAAW,CAAC;YACpC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC;YACrB,IAAI,CAAC,EAAE,CAAC;gBACN,GAAG,CAAC,WAAW,IAAI,CAAC,CAAC,YAAY,IAAI,CAAC,CAAC;gBACvC,GAAG,CAAC,YAAY,IAAI,CAAC,CAAC,aAAa,IAAI,CAAC,CAAC;gBACzC,GAAG,CAAC,SAAS,IAAI,CAAC,CAAC,uBAAuB,IAAI,CAAC,CAAC;gBAChD,GAAG,CAAC,UAAU,IAAI,CAAC,CAAC,2BAA2B,IAAI,CAAC,CAAC;YACvD,CAAC;YACD,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAC1B,CAAC;IACH,CAAC;IACD,OAAO,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC;AACzE,CAAC"}
@@ -0,0 +1,11 @@
1
+ import type { Server } from "node:http";
2
+ export declare const DEFAULT_LOGS_DIR: string;
3
+ declare const DEFAULT_PORT = 9090;
4
+ export interface StartOptions {
5
+ logsDir?: string;
6
+ port?: number;
7
+ }
8
+ /** Recursively collect *.jsonl session files under logsDir. */
9
+ export declare function findSessionFiles(logsDir: string): string[];
10
+ export declare function startServer(opts?: StartOptions): Server;
11
+ export { DEFAULT_PORT };
package/dist/server.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Graphtrack localhost server.
3
+ *
4
+ * Serves the static app (public/) and exposes the user's Claude Code session
5
+ * logs over a small read-only API. Everything runs locally — nothing is sent
6
+ * anywhere.
7
+ *
8
+ * GET / → public/index.html
9
+ * GET /api/sessions → lightweight list of all sessions found
10
+ * GET /api/session/:slug/:id → full graph data for one session
11
+ */
12
+ import express from "express";
13
+ import { readdirSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join, relative, dirname, basename } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { parseSession, aggregateTools } from "./parser.js";
18
+ import { buildAggregateGraph, buildChronologicalGraph, toolColor, spreadTokens } from "./graph.js";
19
+ export const DEFAULT_LOGS_DIR = join(homedir(), ".claude", "projects");
20
+ const DEFAULT_PORT = 9090;
21
+ /**
22
+ * public/ lives at the package root, not the caller's cwd — resolve via this
23
+ * module. The compiled file is at <root>/dist/server.js, so one level up.
24
+ */
25
+ const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "public");
26
+ /** Recursively collect *.jsonl session files under logsDir. */
27
+ export function findSessionFiles(logsDir) {
28
+ const out = [];
29
+ const walk = (dir) => {
30
+ let entries;
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ for (const e of entries) {
38
+ const p = join(dir, e.name);
39
+ if (e.isDirectory())
40
+ walk(p);
41
+ else if (e.isFile() && e.name.endsWith(".jsonl"))
42
+ out.push(p);
43
+ }
44
+ };
45
+ walk(logsDir);
46
+ return out.sort();
47
+ }
48
+ /** Derive the project slug from a file path relative to the logs root. */
49
+ function projectOf(logsDir, file) {
50
+ const rel = relative(logsDir, dirname(file));
51
+ return rel ? basename(rel) : "?";
52
+ }
53
+ export function startServer(opts = {}) {
54
+ const logsDir = opts.logsDir ?? DEFAULT_LOGS_DIR;
55
+ const port = opts.port ?? DEFAULT_PORT;
56
+ const app = express();
57
+ // Lightweight session listing.
58
+ app.get("/api/sessions", (_req, res) => {
59
+ const sessions = findSessionFiles(logsDir).map((file) => {
60
+ const id = basename(file).replace(/\.jsonl$/, "");
61
+ return { id, project: projectOf(logsDir, file), file };
62
+ });
63
+ res.json({ sessions, logsDir });
64
+ });
65
+ // Full graph data for one session.
66
+ app.get("/api/session/:slug/:id", (req, res) => {
67
+ const { slug, id } = req.params;
68
+ const dir = join(logsDir, slug);
69
+ const file = join(dir, `${id}.jsonl`);
70
+ let session;
71
+ try {
72
+ session = parseSession(file, slug);
73
+ }
74
+ catch (err) {
75
+ res.status(404).json({ error: String(err) });
76
+ return;
77
+ }
78
+ spreadTokens(session);
79
+ const aggregates = aggregateTools(session).map((a) => ({
80
+ name: a.name,
81
+ calls: a.calls,
82
+ totalTokens: a.totalTokens,
83
+ color: toolColor(a.name),
84
+ }));
85
+ res.json({
86
+ session: {
87
+ id: session.id,
88
+ project: session.project,
89
+ model: session.model,
90
+ turns: session.turns.length,
91
+ toolCalls: session.turns.reduce((a, t) => a + t.toolCalls.length, 0),
92
+ totalTokens: session.totalTokens,
93
+ totalInput: session.totalInput,
94
+ totalOutput: session.totalOutput,
95
+ totalCacheRead: session.totalCacheRead,
96
+ totalCacheWrite: session.totalCacheWrite,
97
+ },
98
+ aggregate: buildAggregateGraph(session),
99
+ chronological: buildChronologicalGraph(session),
100
+ toolAggregates: aggregates,
101
+ });
102
+ });
103
+ // Static app (resolved relative to the installed package, not the cwd).
104
+ app.use(express.static(PUBLIC_DIR));
105
+ return app.listen(port, () => {
106
+ console.log(` Graphtrack → http://localhost:${port}`);
107
+ console.log(` Reading logs from: ${logsDir}`);
108
+ });
109
+ }
110
+ export { DEFAULT_PORT };
111
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Graphtrack data model — the typed representation of Claude Code session logs.
3
+ * These types are the single source of truth shared by the parser and the UI.
4
+ */
5
+ /** Raw token-usage numbers as surfaced in Claude Code JSONL usage blocks. */
6
+ export interface Usage {
7
+ input_tokens: number;
8
+ output_tokens: number;
9
+ cache_creation_input_tokens: number;
10
+ cache_read_input_tokens: number;
11
+ }
12
+ /** A single tool invocation within a turn. */
13
+ export interface ToolCall {
14
+ /** Stable id from the log (e.g. toolu_...) if present, else synthetic. */
15
+ id: string;
16
+ /** Tool name: "Bash", "Read", "Edit", "WebSearch", ... */
17
+ name: string;
18
+ /** Humanized args/summary, used for node labels. */
19
+ summary: string;
20
+ /** Cumulative tokens attributable to this call (input+output+cache). */
21
+ totalTokens: number;
22
+ usage?: Usage;
23
+ }
24
+ /** One exchange: the user's prompt + any assistant reply and tool calls. */
25
+ export interface Turn {
26
+ /** The user prompt text (the thing that was asked). */
27
+ prompt: string;
28
+ /** Sequential index within the session. */
29
+ index: number;
30
+ /** Tool calls fired in service of this prompt. */
31
+ toolCalls: ToolCall[];
32
+ /** Total tokens across this turn (prompt + outputs + tools + cache). */
33
+ totalTokens: number;
34
+ }
35
+ /** A single parsed Claude Code session file. */
36
+ export interface Session {
37
+ id: string;
38
+ /** Project slug directory the session lives under. */
39
+ project: string;
40
+ model: string;
41
+ /** ISO timestamp of first activity. */
42
+ startedAt: string;
43
+ turns: Turn[];
44
+ totalTokens: number;
45
+ totalInput: number;
46
+ totalOutput: number;
47
+ totalCacheRead: number;
48
+ totalCacheWrite: number;
49
+ }
50
+ /**
51
+ * Aggregated tool usage across a session (or across sessions).
52
+ * This is the collapse-by-tool model that powers the top level of the graph.
53
+ */
54
+ export interface ToolAggregate {
55
+ name: string;
56
+ calls: number;
57
+ totalTokens: number;
58
+ inputTokens: number;
59
+ outputTokens: number;
60
+ cacheRead: number;
61
+ cacheWrite: number;
62
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Graphtrack data model — the typed representation of Claude Code session logs.
3
+ * These types are the single source of truth shared by the parser and the UI.
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "graphtrack",
3
+ "version": "0.1.0",
4
+ "description": "Visualize Claude Code token usage as an interactive graph. Local-first, open source, your logs never leave your machine.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "graphtrack": "dist/cli.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/cli.js",
13
+ "dev": "tsc --watch",
14
+ "test": "npm run build && node --experimental-strip-types --test test/*.test.ts",
15
+ "prepublishOnly": "npm run build",
16
+ "pack:check": "npm pack --dry-run"
17
+ },
18
+ "files": ["dist", "public/index.html"],
19
+ "keywords": [
20
+ "claude-code",
21
+ "token",
22
+ "usage",
23
+ "analytics",
24
+ "graph",
25
+ "visualization"
26
+ ],
27
+ "dependencies": {
28
+ "express": "^4.19.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/express": "^4.17.21",
32
+ "@types/node": "^22.5.0",
33
+ "typescript": "^5.5.0"
34
+ }
35
+ }
@@ -0,0 +1,202 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="description" content="Graphtrack — visualize Claude Code token usage as an interactive graph." />
7
+ <title>Graphtrack</title>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.9/standalone/umd/vis-network.min.js"></script>
9
+ <style>
10
+ :root {
11
+ --canvas:#0a0a0a; --card:#1a1a1a; --yellow:#faff69; --body:#cccccc; --muted:#888888;
12
+ }
13
+ * { box-sizing: border-box; }
14
+ html, body { height:100%; }
15
+ body { margin:0; font-family: Inter, system-ui, sans-serif; background:var(--canvas); color:var(--body); display:flex; flex-direction:column; }
16
+ header { padding:12px 28px; border-bottom:1px solid #2a2a2a; display:flex; align-items:center; gap:18px; flex: 0 0 auto; flex-wrap:wrap; }
17
+ header h1 { font-size:18px; margin:0; color:#fff; letter-spacing:-0.5px; font-weight:700; }
18
+ header .dot { color:var(--yellow); margin-right:2px; }
19
+ header .sub { font-size:12px; color:var(--muted); }
20
+ select {
21
+ background:var(--card); color:var(--body); border:1px solid #3a3a3a;
22
+ padding:6px 10px; border-radius:6px; font-size:13px; max-width:320px;
23
+ }
24
+ select:disabled { opacity:0.5; }
25
+ .toggle { margin-left:auto; display:flex; gap:8px; }
26
+ .toggle button {
27
+ background:var(--card); color:var(--body); border:1px solid #3a3a3a;
28
+ padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px;
29
+ }
30
+ .toggle button:hover { border-color:#5a5a5a; }
31
+ .toggle button.active { background:var(--yellow); color:#0a0a0a; border-color:var(--yellow); font-weight:600; }
32
+ #stats { flex: 0 0 auto; padding:12px 28px; display:flex; gap:28px; flex-wrap:wrap; font-size:13px; border-bottom:1px solid #2a2a2a; }
33
+ #stats .stat { display:flex; flex-direction:column; gap:2px; }
34
+ #stats .stat .label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
35
+ #stats .stat .value { color:#fff; font-weight:600; font-size:14px; }
36
+ #graph {
37
+ flex: 1 1 auto; min-height:0; width:100%;
38
+ background-color: var(--canvas);
39
+ background-image:
40
+ linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
41
+ linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
42
+ background-size: 42px 42px;
43
+ position: relative;
44
+ }
45
+ #graph::after {
46
+ content:"";
47
+ position: absolute; inset: 0; pointer-events: none;
48
+ background: radial-gradient(ellipse at center, rgba(255,250,105,0.04) 0%, transparent 55%);
49
+ }
50
+ #graph canvas { position: relative; z-index: 2; }
51
+ #graph .empty {
52
+ position: absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
53
+ gap:8px; z-index:2; text-align:center; padding:24px;
54
+ }
55
+ #graph .empty h2 { color:#fff; margin:0; font-size:20px; }
56
+ #graph .empty p { color:var(--muted); margin:0; font-size:13px; max-width:460px; line-height:1.5; }
57
+ #graph .empty code { background:var(--card); padding:2px 6px; border-radius:4px; color:var(--yellow); }
58
+ #spinner { display:none; position:absolute; inset:0; z-index:3; align-items:center; justify-content:center; }
59
+ #spinner.on { display:flex; }
60
+ #spinner span { color:var(--yellow); border:2px solid #3a3a3a; border-top-color:var(--yellow); border-radius:50%; width:34px; height:34px; animation:spin 0.8s linear infinite; }
61
+ @keyframes spin { to { transform: rotate(360deg); } }
62
+ #glow {
63
+ position: fixed; inset: 0; pointer-events: none; z-index: 9999;
64
+ background: radial-gradient(circle 200px at var(--gx, -9999px) var(--gy, -9999px),
65
+ rgba(255,250,105,0.16), rgba(255,250,105,0.05) 45%, transparent 72%);
66
+ opacity: 0;
67
+ }
68
+ #glow.on { opacity: 1; }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <header>
73
+ <h1><span class="dot">▪</span>Graphtrack</h1>
74
+ <span class="sub">Claude Code token usage, as a graph</span>
75
+ <select id="sessionSelect" disabled title="Select a session"><option>Loading sessions…</option></select>
76
+ <div class="toggle">
77
+ <button id="btnAgg" class="active">Aggregate</button>
78
+ <button id="btnChain">Chronological</button>
79
+ </div>
80
+ </header>
81
+ <div id="stats">
82
+ <div class="stat"><span class="label">Model</span><span class="value" id="sModel">–</span></div>
83
+ <div class="stat"><span class="label">Turns</span><span class="value" id="sTurns">–</span></div>
84
+ <div class="stat"><span class="label">Tool calls</span><span class="value" id="sCalls">–</span></div>
85
+ <div class="stat"><span class="label">Total</span><span class="value" id="sTotal">–</span></div>
86
+ <div class="stat"><span class="label">Input</span><span class="value" id="sIn">–</span></div>
87
+ <div class="stat"><span class="label">Output</span><span class="value" id="sOut">–</span></div>
88
+ <div class="stat"><span class="label">Cache read</span><span class="value" id="sRead">–</span></div>
89
+ <div class="stat"><span class="label">Cache write</span><span class="value" id="sWrite">–</span></div>
90
+ </div>
91
+ <div id="graph"><div id="spinner"><span></span></div></div>
92
+ <div id="glow"></div>
93
+ <script>
94
+ const $ = (id) => document.getElementById(id);
95
+ const fmt = (n) => n == null ? "–" : n >= 1e6 ? (n/1e6).toFixed(1)+"M" : n >= 1e3 ? (n/1e3).toFixed(1)+"k" : n;
96
+
97
+ const sessionSelect = $("sessionSelect");
98
+ const spinner = $("spinner");
99
+ const container = $("graph");
100
+ let current = null; // { session, aggregate, chronological, toolAggregates }
101
+ let view = "aggregate";
102
+ let network = null;
103
+
104
+ const glow = document.getElementById("glow");
105
+ const root = document.documentElement;
106
+ container.addEventListener("mousemove", (e) => {
107
+ root.style.setProperty("--gx", e.clientX + "px");
108
+ root.style.setProperty("--gy", e.clientY + "px");
109
+ glow.classList.add("on");
110
+ });
111
+ container.addEventListener("mouseleave", () => glow.classList.remove("on"));
112
+
113
+ const options = {
114
+ physics: { enabled: true, solver: "forceAtlas2Based", stabilization: { iterations: 200 } },
115
+ interaction: { hover: true, tooltipDelay: 200 },
116
+ nodes: { shape: "dot", font: { color: "#cccccc", size: 13 }, widthConstraint: { maximum: 140 } },
117
+ edges: { color: { color: "#3a3a3a", highlight: "#faff69" }, smooth: { enabled: true, type: "dynamic" } },
118
+ };
119
+
120
+ function render() {
121
+ if (!current) return;
122
+ const data = view === "aggregate" ? current.aggregate : current.chronological;
123
+ if (network) network.destroy();
124
+ network = new vis.Network(container, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options);
125
+ }
126
+
127
+ function showEmpty(title, body) {
128
+ if (network) network.destroy();
129
+ network = null;
130
+ container.innerHTML = '<div class="empty">' + (title ? "<h2></h2><p></p>" : "") + '</div><div id="spinner"><span></span></div>';
131
+ if (title) { container.querySelector("h2").textContent = title; container.querySelector("p").textContent = body; }
132
+ // reattach glow/spinner refs are fixed; spinner is a child now
133
+ spinner.classList.remove("on");
134
+ }
135
+
136
+ function fillStats(s) {
137
+ $("sModel").textContent = s.model;
138
+ $("sTurns").textContent = s.turns;
139
+ $("sCalls").textContent = s.toolCalls;
140
+ $("sTotal").textContent = fmt(s.totalTokens);
141
+ $("sIn").textContent = fmt(s.totalInput);
142
+ $("sOut").textContent = fmt(s.totalOutput);
143
+ $("sRead").textContent = fmt(s.totalCacheRead);
144
+ $("sWrite").textContent = fmt(s.totalCacheWrite);
145
+ }
146
+
147
+ async function loadSession(slug, id) {
148
+ spinner.classList.add("on");
149
+ try {
150
+ const r = await fetch("/api/session/" + encodeURIComponent(slug) + "/" + encodeURIComponent(id));
151
+ if (!r.ok) throw new Error("no data");
152
+ current = await r.json();
153
+ fillStats(current.session);
154
+ spinner.classList.remove("on");
155
+ render();
156
+ } catch (e) {
157
+ spinner.classList.remove("on");
158
+ showEmpty("Could not load session", "The server couldn't parse this session file. It may be malformed or the API returned no data.");
159
+ }
160
+ }
161
+
162
+ async function loadSessions() {
163
+ spinner.classList.add("on");
164
+ try {
165
+ const r = await fetch("/api/sessions");
166
+ const data = await r.json();
167
+ spinner.classList.remove("on");
168
+ if (!data.sessions || data.sessions.length === 0) {
169
+ showEmpty("No Claude Code sessions found",
170
+ "Graphtrack reads session logs from " + data.logsDir + ". Run Claude Code a few times, then restart with: graphtrack --dir <path> if your logs live elsewhere.");
171
+ return;
172
+ }
173
+ sessionSelect.disabled = false;
174
+ sessionSelect.innerHTML = "";
175
+ data.sessions.forEach((s) => {
176
+ const opt = document.createElement("option");
177
+ opt.value = s.id;
178
+ opt.dataset.slug = s.project;
179
+ opt.textContent = s.project + " · " + s.id;
180
+ sessionSelect.appendChild(opt);
181
+ });
182
+ const first = data.sessions[0];
183
+ loadSession(first.project, first.id);
184
+ } catch (e) {
185
+ spinner.classList.remove("on");
186
+ showEmpty("Can't reach the Graphtrack server",
187
+ "Start it with: graphtrack — this page must be served from http://localhost:9090.");
188
+ }
189
+ }
190
+
191
+ sessionSelect.addEventListener("change", () => {
192
+ const opt = sessionSelect.selectedOptions[0];
193
+ if (opt) loadSession(opt.dataset.slug, opt.value);
194
+ });
195
+ $("btnAgg").addEventListener("click", () => { view = "aggregate"; setActive("btnAgg"); render(); });
196
+ $("btnChain").addEventListener("click", () => { view = "chronological"; setActive("btnChain"); render(); });
197
+ function setActive(id) { $("btnAgg").classList.toggle("active", id==="btnAgg"); $("btnChain").classList.toggle("active", id==="btnChain"); }
198
+
199
+ loadSessions();
200
+ </script>
201
+ </body>
202
+ </html>