@cryptiklemur/lattice 1.5.0 → 1.6.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.
@@ -1,4 +1,25 @@
1
+ import { Component } from "react";
1
2
  import { useAnalytics } from "../../hooks/useAnalytics";
3
+
4
+ class ChartErrorBoundary extends Component<{ children: React.ReactNode; name: string }, { error: Error | null }> {
5
+ constructor(props: { children: React.ReactNode; name: string }) {
6
+ super(props);
7
+ this.state = { error: null };
8
+ }
9
+ static getDerivedStateFromError(error: Error) {
10
+ return { error: error };
11
+ }
12
+ render() {
13
+ if (this.state.error) {
14
+ return (
15
+ <div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
16
+ Chart error: {this.props.name}
17
+ </div>
18
+ );
19
+ }
20
+ return this.props.children;
21
+ }
22
+ }
2
23
  import { PeriodSelector } from "./PeriodSelector";
3
24
  import { ChartCard } from "./ChartCard";
4
25
  import { CostAreaChart } from "./charts/CostAreaChart";
@@ -6,6 +27,11 @@ import { CumulativeCostChart } from "./charts/CumulativeCostChart";
6
27
  import { CostDonutChart } from "./charts/CostDonutChart";
7
28
  import { CostDistributionChart } from "./charts/CostDistributionChart";
8
29
  import { SessionBubbleChart } from "./charts/SessionBubbleChart";
30
+ import { TokenFlowChart } from "./charts/TokenFlowChart";
31
+ import { CacheEfficiencyChart } from "./charts/CacheEfficiencyChart";
32
+ import { ResponseTimeScatter } from "./charts/ResponseTimeScatter";
33
+ import { ContextUtilizationChart } from "./charts/ContextUtilizationChart";
34
+ import { TokenSankeyChart } from "./charts/TokenSankeyChart";
9
35
 
10
36
  export function AnalyticsView() {
11
37
  var analytics = useAnalytics();
@@ -49,6 +75,38 @@ export function AnalyticsView() {
49
75
  <SessionBubbleChart data={analytics.data.sessionBubbles} />
50
76
  </ChartCard>
51
77
  </div>
78
+
79
+ <ChartCard title="Token Flow">
80
+ <ChartErrorBoundary name="TokenFlow">
81
+ <TokenFlowChart data={analytics.data.tokensOverTime} />
82
+ </ChartErrorBoundary>
83
+ </ChartCard>
84
+
85
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
86
+ <ChartCard title="Cache Efficiency">
87
+ <ChartErrorBoundary name="CacheEfficiency">
88
+ <CacheEfficiencyChart data={analytics.data.cacheHitRateOverTime} />
89
+ </ChartErrorBoundary>
90
+ </ChartCard>
91
+ <ChartCard title="Response Time vs Tokens">
92
+ <ChartErrorBoundary name="ResponseTime">
93
+ <ResponseTimeScatter data={analytics.data.responseTimeData} />
94
+ </ChartErrorBoundary>
95
+ </ChartCard>
96
+ </div>
97
+
98
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
99
+ <ChartCard title="Context Window Usage">
100
+ <ChartErrorBoundary name="ContextUtilization">
101
+ <ContextUtilizationChart data={analytics.data.contextUtilization} />
102
+ </ChartErrorBoundary>
103
+ </ChartCard>
104
+ <ChartCard title="Token Flow (Sankey)">
105
+ <ChartErrorBoundary name="Sankey">
106
+ <TokenSankeyChart data={analytics.data.tokenFlowSankey} />
107
+ </ChartErrorBoundary>
108
+ </ChartCard>
109
+ </div>
52
110
  </div>
53
111
  )}
54
112
 
@@ -0,0 +1,60 @@
1
+ import {
2
+ AreaChart,
3
+ Area,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from "recharts";
10
+
11
+ var TICK_STYLE = {
12
+ fontSize: 10,
13
+ fontFamily: "var(--font-mono)",
14
+ fill: "oklch(0.9 0.02 280 / 0.3)",
15
+ };
16
+
17
+ var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
18
+
19
+ interface CacheEfficiencyDatum {
20
+ date: string;
21
+ rate: number;
22
+ }
23
+
24
+ interface CacheEfficiencyChartProps {
25
+ data: CacheEfficiencyDatum[];
26
+ }
27
+
28
+ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ value: number }>; label?: string }) {
29
+ if (!active || !payload || payload.length === 0) return null;
30
+ return (
31
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
32
+ <p className="text-[10px] font-mono text-base-content/50 mb-1">{label}</p>
33
+ <p className="text-[11px] font-mono text-base-content">{(payload[0].value * 100).toFixed(1)}%</p>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export function CacheEfficiencyChart({ data }: CacheEfficiencyChartProps) {
39
+ var displayData = data.map(function (d) {
40
+ return { date: d.date, rate: d.rate * 100 };
41
+ });
42
+
43
+ return (
44
+ <ResponsiveContainer width="100%" height={200}>
45
+ <AreaChart data={displayData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
46
+ <defs>
47
+ <linearGradient id="cacheEffGrad" x1="0" y1="0" x2="0" y2="1">
48
+ <stop offset="5%" stopColor="#22c55e" stopOpacity={0.4} />
49
+ <stop offset="95%" stopColor="#22c55e" stopOpacity={0.02} />
50
+ </linearGradient>
51
+ </defs>
52
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
53
+ <XAxis dataKey="date" tick={TICK_STYLE} axisLine={false} tickLine={false} />
54
+ <YAxis domain={[0, 100]} tick={TICK_STYLE} axisLine={false} tickLine={false} tickFormatter={function (v) { return v + "%"; }} />
55
+ <Tooltip content={<CustomTooltip />} />
56
+ <Area type="monotone" dataKey="rate" stroke="#22c55e" fill="url(#cacheEffGrad)" strokeWidth={1.5} />
57
+ </AreaChart>
58
+ </ResponsiveContainer>
59
+ );
60
+ }
@@ -0,0 +1,110 @@
1
+ import {
2
+ LineChart,
3
+ Line,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from "recharts";
10
+
11
+ var TICK_STYLE = {
12
+ fontSize: 10,
13
+ fontFamily: "var(--font-mono)",
14
+ fill: "oklch(0.9 0.02 280 / 0.3)",
15
+ };
16
+
17
+ var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
18
+
19
+ var LINE_COLORS = [
20
+ "oklch(55% 0.25 280)",
21
+ "#a855f7",
22
+ "#22c55e",
23
+ "#f59e0b",
24
+ "oklch(65% 0.2 240)",
25
+ ];
26
+
27
+ interface ContextUtilDatum {
28
+ messageIndex: number;
29
+ contextPercent: number;
30
+ sessionId: string;
31
+ title: string;
32
+ }
33
+
34
+ interface ContextUtilizationChartProps {
35
+ data: ContextUtilDatum[];
36
+ }
37
+
38
+ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }> }) {
39
+ if (!active || !payload || payload.length === 0) return null;
40
+ return (
41
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
42
+ {payload.map(function (entry) {
43
+ return (
44
+ <div key={entry.name} className="flex items-center gap-2 text-[11px] font-mono">
45
+ <span className="inline-block w-2 h-2 rounded-full" style={{ background: entry.color }} />
46
+ <span className="text-base-content/60 truncate max-w-[120px]">{entry.name}</span>
47
+ <span className="text-base-content ml-auto pl-4">{entry.value.toFixed(1)}%</span>
48
+ </div>
49
+ );
50
+ })}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ export function ContextUtilizationChart({ data }: ContextUtilizationChartProps) {
56
+ var sessionMap = new Map<string, { title: string; points: Array<{ messageIndex: number; contextPercent: number }> }>();
57
+ for (var i = 0; i < data.length; i++) {
58
+ var d = data[i];
59
+ var entry = sessionMap.get(d.sessionId);
60
+ if (!entry) {
61
+ entry = { title: d.title, points: [] };
62
+ sessionMap.set(d.sessionId, entry);
63
+ }
64
+ entry.points.push({ messageIndex: d.messageIndex, contextPercent: d.contextPercent });
65
+ }
66
+
67
+ var sessions = Array.from(sessionMap.entries()).slice(0, 5);
68
+
69
+ var maxIndex = 0;
70
+ sessions.forEach(function (s) {
71
+ s[1].points.forEach(function (p) {
72
+ if (p.messageIndex > maxIndex) maxIndex = p.messageIndex;
73
+ });
74
+ });
75
+
76
+ var merged: Array<Record<string, number>> = [];
77
+ for (var mi = 0; mi <= maxIndex; mi++) {
78
+ var row: Record<string, number> = { messageIndex: mi };
79
+ sessions.forEach(function (s) {
80
+ var point = s[1].points.find(function (p) { return p.messageIndex === mi; });
81
+ if (point) row[s[0]] = point.contextPercent;
82
+ });
83
+ merged.push(row);
84
+ }
85
+
86
+ return (
87
+ <ResponsiveContainer width="100%" height={200}>
88
+ <LineChart data={merged} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
89
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
90
+ <XAxis dataKey="messageIndex" tick={TICK_STYLE} axisLine={false} tickLine={false} />
91
+ <YAxis domain={[0, 100]} tick={TICK_STYLE} axisLine={false} tickLine={false} tickFormatter={function (v) { return v + "%"; }} />
92
+ <Tooltip content={<CustomTooltip />} />
93
+ {sessions.map(function (s, idx) {
94
+ return (
95
+ <Line
96
+ key={s[0]}
97
+ type="monotone"
98
+ dataKey={s[0]}
99
+ name={s[1].title.slice(0, 30)}
100
+ stroke={LINE_COLORS[idx % LINE_COLORS.length]}
101
+ strokeWidth={1.5}
102
+ dot={false}
103
+ connectNulls
104
+ />
105
+ );
106
+ })}
107
+ </LineChart>
108
+ </ResponsiveContainer>
109
+ );
110
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ ScatterChart,
3
+ Scatter,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from "recharts";
10
+
11
+ var TICK_STYLE = {
12
+ fontSize: 10,
13
+ fontFamily: "var(--font-mono)",
14
+ fill: "oklch(0.9 0.02 280 / 0.3)",
15
+ };
16
+
17
+ var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
18
+
19
+ var MODEL_COLORS: Record<string, string> = {
20
+ opus: "#a855f7",
21
+ sonnet: "oklch(55% 0.25 280)",
22
+ haiku: "#22c55e",
23
+ other: "#f59e0b",
24
+ };
25
+
26
+ interface ResponseTimeDatum {
27
+ tokens: number;
28
+ duration: number;
29
+ model: string;
30
+ sessionId: string;
31
+ }
32
+
33
+ interface ResponseTimeScatterProps {
34
+ data: ResponseTimeDatum[];
35
+ }
36
+
37
+ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { tokens: number; durationSec: number; model: string } }> }) {
38
+ if (!active || !payload || payload.length === 0) return null;
39
+ var d = payload[0].payload;
40
+ return (
41
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
42
+ <div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
43
+ <p><span className="text-base-content/40">tokens </span>{d.tokens.toLocaleString()}</p>
44
+ <p><span className="text-base-content/40">duration </span>{d.durationSec.toFixed(1)}s</p>
45
+ <p><span className="text-base-content/40">model </span>{d.model}</p>
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ export function ResponseTimeScatter({ data }: ResponseTimeScatterProps) {
52
+ var models = Array.from(new Set(data.map(function (d) { return d.model; })));
53
+
54
+ var byModel = models.map(function (model) {
55
+ return {
56
+ model: model,
57
+ color: MODEL_COLORS[model] || "#f59e0b",
58
+ points: data
59
+ .filter(function (d) { return d.model === model; })
60
+ .map(function (d) { return { tokens: d.tokens, durationSec: d.duration / 1000, model: d.model }; }),
61
+ };
62
+ });
63
+
64
+ return (
65
+ <ResponsiveContainer width="100%" height={200}>
66
+ <ScatterChart margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
67
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} />
68
+ <XAxis
69
+ dataKey="tokens"
70
+ type="number"
71
+ tick={TICK_STYLE}
72
+ axisLine={false}
73
+ tickLine={false}
74
+ tickFormatter={function (v) { return v >= 1000 ? (v / 1000).toFixed(0) + "k" : String(v); }}
75
+ name="tokens"
76
+ />
77
+ <YAxis
78
+ dataKey="durationSec"
79
+ type="number"
80
+ tick={TICK_STYLE}
81
+ axisLine={false}
82
+ tickLine={false}
83
+ tickFormatter={function (v) { return v.toFixed(0) + "s"; }}
84
+ name="duration"
85
+ />
86
+ <Tooltip content={<CustomTooltip />} cursor={{ strokeDasharray: "3 3", stroke: GRID_COLOR }} />
87
+ {byModel.map(function (group) {
88
+ return (
89
+ <Scatter
90
+ key={group.model}
91
+ name={group.model}
92
+ data={group.points}
93
+ fill={group.color}
94
+ fillOpacity={0.7}
95
+ />
96
+ );
97
+ })}
98
+ </ScatterChart>
99
+ </ResponsiveContainer>
100
+ );
101
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ AreaChart,
3
+ Area,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from "recharts";
10
+
11
+ var TICK_STYLE = {
12
+ fontSize: 10,
13
+ fontFamily: "var(--font-mono)",
14
+ fill: "oklch(0.9 0.02 280 / 0.3)",
15
+ };
16
+
17
+ var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
18
+
19
+ interface TokenFlowDatum {
20
+ date: string;
21
+ input: number;
22
+ output: number;
23
+ cacheRead: number;
24
+ }
25
+
26
+ interface TokenFlowChartProps {
27
+ data: TokenFlowDatum[];
28
+ }
29
+
30
+ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) {
31
+ if (!active || !payload || payload.length === 0) return null;
32
+ return (
33
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
34
+ <p className="text-[10px] font-mono text-base-content/50 mb-1">{label}</p>
35
+ {payload.map(function (entry) {
36
+ return (
37
+ <div key={entry.name} className="flex items-center gap-2 text-[11px] font-mono">
38
+ <span className="inline-block w-2 h-2 rounded-full" style={{ background: entry.color }} />
39
+ <span className="text-base-content/60 capitalize">{entry.name}</span>
40
+ <span className="text-base-content ml-auto pl-4">{entry.value.toLocaleString()}</span>
41
+ </div>
42
+ );
43
+ })}
44
+ </div>
45
+ );
46
+ }
47
+
48
+ function formatTokens(v: number): string {
49
+ if (v >= 1000000) return (v / 1000000).toFixed(1) + "M";
50
+ if (v >= 1000) return (v / 1000).toFixed(0) + "k";
51
+ return String(v);
52
+ }
53
+
54
+ export function TokenFlowChart({ data }: TokenFlowChartProps) {
55
+ return (
56
+ <ResponsiveContainer width="100%" height={200}>
57
+ <AreaChart data={data} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
58
+ <defs>
59
+ <linearGradient id="inputGrad" x1="0" y1="0" x2="0" y2="1">
60
+ <stop offset="5%" stopColor="oklch(55% 0.25 280)" stopOpacity={0.4} />
61
+ <stop offset="95%" stopColor="oklch(55% 0.25 280)" stopOpacity={0.05} />
62
+ </linearGradient>
63
+ <linearGradient id="outputGrad" x1="0" y1="0" x2="0" y2="1">
64
+ <stop offset="5%" stopColor="#22c55e" stopOpacity={0.4} />
65
+ <stop offset="95%" stopColor="#22c55e" stopOpacity={0.05} />
66
+ </linearGradient>
67
+ <linearGradient id="cacheReadGrad" x1="0" y1="0" x2="0" y2="1">
68
+ <stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4} />
69
+ <stop offset="95%" stopColor="#f59e0b" stopOpacity={0.05} />
70
+ </linearGradient>
71
+ </defs>
72
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
73
+ <XAxis dataKey="date" tick={TICK_STYLE} axisLine={false} tickLine={false} />
74
+ <YAxis tick={TICK_STYLE} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
75
+ <Tooltip content={<CustomTooltip />} />
76
+ <Area type="monotone" dataKey="input" stackId="1" stroke="oklch(55% 0.25 280)" fill="url(#inputGrad)" strokeWidth={1.5} />
77
+ <Area type="monotone" dataKey="output" stackId="1" stroke="#22c55e" fill="url(#outputGrad)" strokeWidth={1.5} />
78
+ <Area type="monotone" dataKey="cacheRead" stackId="1" stroke="#f59e0b" fill="url(#cacheReadGrad)" strokeWidth={1.5} />
79
+ </AreaChart>
80
+ </ResponsiveContainer>
81
+ );
82
+ }
@@ -0,0 +1,89 @@
1
+ import { Sankey, Tooltip, ResponsiveContainer } from "recharts";
2
+
3
+ var NODE_COLORS: Record<string, string> = {
4
+ "Input Tokens": "oklch(55% 0.25 280)",
5
+ "Cache Read": "#f59e0b",
6
+ "Cache Creation": "oklch(65% 0.2 240)",
7
+ "Opus": "#a855f7",
8
+ "Sonnet": "oklch(55% 0.25 280)",
9
+ "Haiku": "#22c55e",
10
+ "Other": "#f59e0b",
11
+ "Output Tokens": "#22c55e",
12
+ };
13
+
14
+ interface SankeyData {
15
+ nodes: Array<{ name: string }>;
16
+ links: Array<{ source: number; target: number; value: number }>;
17
+ }
18
+
19
+ interface TokenSankeyChartProps {
20
+ data: SankeyData;
21
+ }
22
+
23
+ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { source?: { name: string }; target?: { name: string }; value?: number; name?: string } }> }) {
24
+ if (!active || !payload || payload.length === 0) return null;
25
+ var d = payload[0].payload;
26
+ if (d.source && d.target) {
27
+ return (
28
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
29
+ <p className="text-[11px] font-mono text-base-content">
30
+ {d.source.name} → {d.target.name}: {(d.value || 0).toLocaleString()}
31
+ </p>
32
+ </div>
33
+ );
34
+ }
35
+ if (d.name) {
36
+ return (
37
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
38
+ <p className="text-[11px] font-mono text-base-content">{d.name}</p>
39
+ </div>
40
+ );
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function SankeyNode({ x, y, width, height, index, payload }: { x: number; y: number; width: number; height: number; index: number; payload: { name: string } }) {
46
+ var color = NODE_COLORS[payload.name] || "oklch(0.5 0.1 280)";
47
+ return (
48
+ <g>
49
+ <rect x={x} y={y} width={width} height={height} fill={color} fillOpacity={0.85} rx={2} />
50
+ {height > 14 && (
51
+ <text
52
+ x={x + width + 6}
53
+ y={y + height / 2}
54
+ dy={4}
55
+ fill="oklch(0.9 0.02 280 / 0.5)"
56
+ fontSize={9}
57
+ fontFamily="var(--font-mono)"
58
+ >
59
+ {payload.name}
60
+ </text>
61
+ )}
62
+ </g>
63
+ );
64
+ }
65
+
66
+ export function TokenSankeyChart({ data }: TokenSankeyChartProps) {
67
+ if (!data.links || data.links.length === 0) {
68
+ return (
69
+ <div className="flex items-center justify-center h-[250px] text-base-content/30 font-mono text-[12px]">
70
+ No token flow data
71
+ </div>
72
+ );
73
+ }
74
+
75
+ return (
76
+ <ResponsiveContainer width="100%" height={250}>
77
+ <Sankey
78
+ data={data}
79
+ node={<SankeyNode x={0} y={0} width={0} height={0} index={0} payload={{ name: "" }} />}
80
+ link={{ stroke: "oklch(0.9 0.02 280 / 0.1)" }}
81
+ margin={{ top: 10, right: 100, left: 10, bottom: 10 }}
82
+ nodeWidth={12}
83
+ nodePadding={14}
84
+ >
85
+ <Tooltip content={<CustomTooltip />} />
86
+ </Sankey>
87
+ </ResponsiveContainer>
88
+ );
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",
@@ -5,6 +5,18 @@ import type { AnalyticsPayload, AnalyticsPeriod, AnalyticsScope } from "@lattice
5
5
  import { estimateCost, projectPathToHash } from "../project/session";
6
6
  import { loadConfig } from "../config";
7
7
 
8
+ interface ResponseTimeDatum {
9
+ tokens: number;
10
+ duration: number;
11
+ model: string;
12
+ }
13
+
14
+ interface ContextMessage {
15
+ messageIndex: number;
16
+ inputTokens: number;
17
+ model: string;
18
+ }
19
+
8
20
  interface SessionData {
9
21
  id: string;
10
22
  title: string;
@@ -18,6 +30,8 @@ interface SessionData {
18
30
  tools: Map<string, number>;
19
31
  startTime: number;
20
32
  endTime: number;
33
+ responseTimePoints: ResponseTimeDatum[];
34
+ contextMessages: ContextMessage[];
21
35
  }
22
36
 
23
37
  interface CacheEntry {
@@ -83,8 +97,13 @@ function parseSessionFile(filePath: string, sessionId: string, projectSlug: stri
83
97
  tools: new Map(),
84
98
  startTime: 0,
85
99
  endTime: 0,
100
+ responseTimePoints: [],
101
+ contextMessages: [],
86
102
  };
87
103
 
104
+ var lastUserTimestamp = 0;
105
+ var assistantIndex = 0;
106
+
88
107
  for (var i = 0; i < lines.length; i++) {
89
108
  var line = lines[i].trim();
90
109
  if (!line) continue;
@@ -136,6 +155,16 @@ function parseSessionFile(filePath: string, sessionId: string, projectSlug: stri
136
155
  } else {
137
156
  data.models.set(bucket, { cost: cost, tokens: inTok + outTok });
138
157
  }
158
+
159
+ if (outTok > 0 && timestamp > 0 && lastUserTimestamp > 0) {
160
+ var dur = timestamp - lastUserTimestamp;
161
+ if (dur > 0 && dur < 600000) {
162
+ data.responseTimePoints.push({ tokens: outTok, duration: dur, model: bucket });
163
+ }
164
+ }
165
+
166
+ data.contextMessages.push({ messageIndex: assistantIndex, inputTokens: inTok + cacheRead + cacheCreation, model: bucket });
167
+ assistantIndex++;
139
168
  }
140
169
 
141
170
  if (!data.title && message.content) {
@@ -144,6 +173,7 @@ function parseSessionFile(filePath: string, sessionId: string, projectSlug: stri
144
173
  }
145
174
  }
146
175
  } else if (parsed.type === "user") {
176
+ if (timestamp > 0) lastUserTimestamp = timestamp;
147
177
  var userMsg = parsed.message as Record<string, unknown> | undefined;
148
178
  if (!userMsg || !Array.isArray(userMsg.content)) continue;
149
179
 
@@ -408,6 +438,88 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
408
438
  });
409
439
  toolUsage.sort(function (a, b) { return b.count - a.count; });
410
440
 
441
+ var responseTimeData: AnalyticsPayload["responseTimeData"] = [];
442
+ for (var rti = 0; rti < filtered.length; rti++) {
443
+ var rtSess = filtered[rti];
444
+ for (var rtj = 0; rtj < rtSess.responseTimePoints.length; rtj++) {
445
+ var rtp = rtSess.responseTimePoints[rtj];
446
+ responseTimeData.push({ tokens: rtp.tokens, duration: rtp.duration, model: rtp.model, sessionId: rtSess.id });
447
+ }
448
+ if (responseTimeData.length >= 200) break;
449
+ }
450
+ if (responseTimeData.length > 200) responseTimeData.length = 200;
451
+
452
+ var contextWindowSizes: Record<string, number> = { opus: 200000, sonnet: 200000, haiku: 200000, other: 200000 };
453
+ var contextUtilization: AnalyticsPayload["contextUtilization"] = [];
454
+ var recentSessions = sorted.slice(0, 5);
455
+ for (var cui = 0; cui < recentSessions.length; cui++) {
456
+ var cuSess = recentSessions[cui];
457
+ var runningTokens = 0;
458
+ var primaryModel = "other";
459
+ var maxModelTokens = 0;
460
+ cuSess.models.forEach(function (val, key) {
461
+ if (val.tokens > maxModelTokens) {
462
+ maxModelTokens = val.tokens;
463
+ primaryModel = key;
464
+ }
465
+ });
466
+ var windowSize = contextWindowSizes[primaryModel] || 200000;
467
+ for (var cmj = 0; cmj < cuSess.contextMessages.length; cmj++) {
468
+ var cm = cuSess.contextMessages[cmj];
469
+ runningTokens += cm.inputTokens;
470
+ contextUtilization.push({
471
+ messageIndex: cm.messageIndex,
472
+ contextPercent: Math.min((runningTokens / windowSize) * 100, 100),
473
+ sessionId: cuSess.id,
474
+ title: cuSess.title,
475
+ });
476
+ }
477
+ }
478
+
479
+ var sankeyNodes = [
480
+ { name: "Input Tokens" },
481
+ { name: "Cache Read" },
482
+ { name: "Cache Creation" },
483
+ { name: "Opus" },
484
+ { name: "Sonnet" },
485
+ { name: "Haiku" },
486
+ { name: "Other" },
487
+ { name: "Output Tokens" },
488
+ ];
489
+ var modelNodeMap: Record<string, number> = { opus: 3, sonnet: 4, haiku: 5, other: 6 };
490
+ var sankeyLinks: Array<{ source: number; target: number; value: number }> = [];
491
+ var modelInputTotals = new Map<string, number>();
492
+ var modelCacheTotals = new Map<string, number>();
493
+ var modelCacheCreationTotals = new Map<string, number>();
494
+ var modelOutputTotals = new Map<string, number>();
495
+
496
+ for (var ski = 0; ski < filtered.length; ski++) {
497
+ var skSess = filtered[ski];
498
+ var skTotal = skSess.inputTokens + skSess.cacheReadTokens + skSess.cacheCreationTokens;
499
+ if (skTotal === 0) continue;
500
+ skSess.models.forEach(function (val, key) {
501
+ var proportion = val.tokens / (skTotal + skSess.outputTokens || 1);
502
+ modelInputTotals.set(key, (modelInputTotals.get(key) || 0) + skSess.inputTokens * proportion);
503
+ modelCacheTotals.set(key, (modelCacheTotals.get(key) || 0) + skSess.cacheReadTokens * proportion);
504
+ modelCacheCreationTotals.set(key, (modelCacheCreationTotals.get(key) || 0) + skSess.cacheCreationTokens * proportion);
505
+ modelOutputTotals.set(key, (modelOutputTotals.get(key) || 0) + skSess.outputTokens * proportion);
506
+ });
507
+ }
508
+
509
+ ["opus", "sonnet", "haiku", "other"].forEach(function (model) {
510
+ var nodeIdx = modelNodeMap[model];
511
+ var inputVal = Math.round(modelInputTotals.get(model) || 0);
512
+ var cacheVal = Math.round(modelCacheTotals.get(model) || 0);
513
+ var cacheCreationVal = Math.round(modelCacheCreationTotals.get(model) || 0);
514
+ var outputVal = Math.round(modelOutputTotals.get(model) || 0);
515
+ if (inputVal > 0) sankeyLinks.push({ source: 0, target: nodeIdx, value: inputVal });
516
+ if (cacheVal > 0) sankeyLinks.push({ source: 1, target: nodeIdx, value: cacheVal });
517
+ if (cacheCreationVal > 0) sankeyLinks.push({ source: 2, target: nodeIdx, value: cacheCreationVal });
518
+ if (outputVal > 0) sankeyLinks.push({ source: nodeIdx, target: 7, value: outputVal });
519
+ });
520
+
521
+ var tokenFlowSankey: AnalyticsPayload["tokenFlowSankey"] = { nodes: sankeyNodes, links: sankeyLinks };
522
+
411
523
  return {
412
524
  totalCost: totalCost,
413
525
  totalSessions: filtered.length,
@@ -430,6 +542,9 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
430
542
  modelUsage: modelUsage,
431
543
  projectBreakdown: projectBreakdown,
432
544
  toolUsage: toolUsage,
545
+ responseTimeData: responseTimeData,
546
+ contextUtilization: contextUtilization,
547
+ tokenFlowSankey: tokenFlowSankey,
433
548
  };
434
549
  }
435
550
 
@@ -18,6 +18,10 @@ export interface AnalyticsPayload {
18
18
  modelUsage: Array<{ model: string; sessions: number; cost: number; tokens: number; percentage: number }>;
19
19
  projectBreakdown: Array<{ project: string; cost: number; sessions: number; tokens: number }>;
20
20
  toolUsage: Array<{ tool: string; count: number; avgCost: number }>;
21
+
22
+ responseTimeData: Array<{ tokens: number; duration: number; model: string; sessionId: string }>;
23
+ contextUtilization: Array<{ messageIndex: number; contextPercent: number; sessionId: string; title: string }>;
24
+ tokenFlowSankey: { nodes: Array<{ name: string }>; links: Array<{ source: number; target: number; value: number }> };
21
25
  }
22
26
 
23
27
  export type AnalyticsPeriod = "24h" | "7d" | "30d" | "90d" | "all";