@cryptiklemur/lattice 1.4.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.
Files changed (31) hide show
  1. package/bun.lock +71 -0
  2. package/client/package.json +1 -0
  3. package/client/src/components/analytics/AnalyticsView.tsx +119 -0
  4. package/client/src/components/analytics/ChartCard.tsx +22 -0
  5. package/client/src/components/analytics/PeriodSelector.tsx +42 -0
  6. package/client/src/components/analytics/QuickStats.tsx +99 -0
  7. package/client/src/components/analytics/charts/CacheEfficiencyChart.tsx +60 -0
  8. package/client/src/components/analytics/charts/ContextUtilizationChart.tsx +110 -0
  9. package/client/src/components/analytics/charts/CostAreaChart.tsx +83 -0
  10. package/client/src/components/analytics/charts/CostDistributionChart.tsx +62 -0
  11. package/client/src/components/analytics/charts/CostDonutChart.tsx +93 -0
  12. package/client/src/components/analytics/charts/CumulativeCostChart.tsx +62 -0
  13. package/client/src/components/analytics/charts/ResponseTimeScatter.tsx +101 -0
  14. package/client/src/components/analytics/charts/SessionBubbleChart.tsx +122 -0
  15. package/client/src/components/analytics/charts/TokenFlowChart.tsx +82 -0
  16. package/client/src/components/analytics/charts/TokenSankeyChart.tsx +89 -0
  17. package/client/src/components/dashboard/DashboardView.tsx +5 -0
  18. package/client/src/components/sidebar/Sidebar.tsx +10 -2
  19. package/client/src/hooks/useAnalytics.ts +75 -0
  20. package/client/src/router.tsx +4 -0
  21. package/client/src/stores/analytics.ts +54 -0
  22. package/client/src/stores/sidebar.ts +8 -0
  23. package/client/vite.config.ts +1 -0
  24. package/package.json +1 -1
  25. package/server/src/analytics/engine.ts +606 -0
  26. package/server/src/daemon.ts +1 -0
  27. package/server/src/handlers/analytics.ts +34 -0
  28. package/server/src/project/session.ts +4 -4
  29. package/shared/src/analytics.ts +28 -0
  30. package/shared/src/index.ts +1 -0
  31. package/shared/src/messages.ts +30 -2
@@ -0,0 +1,606 @@
1
+ import { readdirSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { AnalyticsPayload, AnalyticsPeriod, AnalyticsScope } from "@lattice/shared";
5
+ import { estimateCost, projectPathToHash } from "../project/session";
6
+ import { loadConfig } from "../config";
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
+
20
+ interface SessionData {
21
+ id: string;
22
+ title: string;
23
+ project: string;
24
+ cost: number;
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ cacheReadTokens: number;
28
+ cacheCreationTokens: number;
29
+ models: Map<string, { cost: number; tokens: number }>;
30
+ tools: Map<string, number>;
31
+ startTime: number;
32
+ endTime: number;
33
+ responseTimePoints: ResponseTimeDatum[];
34
+ contextMessages: ContextMessage[];
35
+ }
36
+
37
+ interface CacheEntry {
38
+ data: AnalyticsPayload;
39
+ timestamp: number;
40
+ }
41
+
42
+ var cache = new Map<string, CacheEntry>();
43
+ var CACHE_TTL = 5 * 60 * 1000;
44
+
45
+ function bucketModel(model: string): "opus" | "sonnet" | "haiku" | "other" {
46
+ if (model.includes("opus")) return "opus";
47
+ if (model.includes("haiku")) return "haiku";
48
+ if (model.includes("sonnet")) return "sonnet";
49
+ return "other";
50
+ }
51
+
52
+ function getPeriodCutoff(period: AnalyticsPeriod): number {
53
+ if (period === "all") return 0;
54
+ var now = Date.now();
55
+ var hours: Record<string, number> = { "24h": 24, "7d": 168, "30d": 720, "90d": 2160 };
56
+ return now - (hours[period] || 0) * 60 * 60 * 1000;
57
+ }
58
+
59
+ function formatDate(ts: number): string {
60
+ var d = new Date(ts);
61
+ var year = d.getFullYear();
62
+ var month = String(d.getMonth() + 1).padStart(2, "0");
63
+ var day = String(d.getDate()).padStart(2, "0");
64
+ return year + "-" + month + "-" + day;
65
+ }
66
+
67
+ function getCostBucket(cost: number): string {
68
+ if (cost < 0.01) return "$0-0.01";
69
+ if (cost < 0.05) return "$0.01-0.05";
70
+ if (cost < 0.10) return "$0.05-0.10";
71
+ if (cost < 0.50) return "$0.10-0.50";
72
+ if (cost < 1.00) return "$0.50-1.00";
73
+ if (cost < 5.00) return "$1.00-5.00";
74
+ return "$5.00+";
75
+ }
76
+
77
+ function parseSessionFile(filePath: string, sessionId: string, projectSlug: string): SessionData | null {
78
+ try {
79
+ var text: string;
80
+ try {
81
+ text = require("node:fs").readFileSync(filePath, "utf-8");
82
+ } catch {
83
+ return null;
84
+ }
85
+
86
+ var lines = text.split("\n");
87
+ var data: SessionData = {
88
+ id: sessionId,
89
+ title: "",
90
+ project: projectSlug,
91
+ cost: 0,
92
+ inputTokens: 0,
93
+ outputTokens: 0,
94
+ cacheReadTokens: 0,
95
+ cacheCreationTokens: 0,
96
+ models: new Map(),
97
+ tools: new Map(),
98
+ startTime: 0,
99
+ endTime: 0,
100
+ responseTimePoints: [],
101
+ contextMessages: [],
102
+ };
103
+
104
+ var lastUserTimestamp = 0;
105
+ var assistantIndex = 0;
106
+
107
+ for (var i = 0; i < lines.length; i++) {
108
+ var line = lines[i].trim();
109
+ if (!line) continue;
110
+
111
+ var parsed: Record<string, unknown>;
112
+ try {
113
+ parsed = JSON.parse(line);
114
+ } catch {
115
+ continue;
116
+ }
117
+
118
+ var timestamp = 0;
119
+ if (typeof parsed.timestamp === "string") {
120
+ var ts = new Date(parsed.timestamp as string).getTime();
121
+ if (!isNaN(ts)) timestamp = ts;
122
+ }
123
+
124
+ if (timestamp > 0) {
125
+ if (data.startTime === 0 || timestamp < data.startTime) data.startTime = timestamp;
126
+ if (timestamp > data.endTime) data.endTime = timestamp;
127
+ }
128
+
129
+ if (parsed.type === "assistant") {
130
+ var message = parsed.message as Record<string, unknown> | undefined;
131
+ if (!message) continue;
132
+
133
+ var usage = message.usage as Record<string, number> | undefined;
134
+ var model = (message.model as string) || "";
135
+
136
+ if (usage) {
137
+ var inTok = usage.input_tokens || 0;
138
+ var outTok = usage.output_tokens || 0;
139
+ var cacheRead = usage.cache_read_input_tokens || 0;
140
+ var cacheCreation = usage.cache_creation_input_tokens || 0;
141
+
142
+ data.inputTokens += inTok;
143
+ data.outputTokens += outTok;
144
+ data.cacheReadTokens += cacheRead;
145
+ data.cacheCreationTokens += cacheCreation;
146
+
147
+ var cost = estimateCost(model, inTok, outTok, cacheRead, cacheCreation);
148
+ data.cost += cost;
149
+
150
+ var bucket = bucketModel(model);
151
+ var existing = data.models.get(bucket);
152
+ if (existing) {
153
+ existing.cost += cost;
154
+ existing.tokens += inTok + outTok;
155
+ } else {
156
+ data.models.set(bucket, { cost: cost, tokens: inTok + outTok });
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++;
168
+ }
169
+
170
+ if (!data.title && message.content) {
171
+ if (typeof message.content === "string" && message.content.length > 0) {
172
+ data.title = message.content.slice(0, 80);
173
+ }
174
+ }
175
+ } else if (parsed.type === "user") {
176
+ if (timestamp > 0) lastUserTimestamp = timestamp;
177
+ var userMsg = parsed.message as Record<string, unknown> | undefined;
178
+ if (!userMsg || !Array.isArray(userMsg.content)) continue;
179
+
180
+ var contentArr = userMsg.content as Array<Record<string, unknown>>;
181
+ for (var j = 0; j < contentArr.length; j++) {
182
+ var block = contentArr[j];
183
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
184
+ var toolName = (block.name as string) || "unknown";
185
+ data.tools.set(toolName, (data.tools.get(toolName) || 0) + 1);
186
+ }
187
+ }
188
+
189
+ if (!data.title && Array.isArray(userMsg.content)) {
190
+ for (var k = 0; k < contentArr.length; k++) {
191
+ if (contentArr[k].type === "text" && typeof contentArr[k].text === "string") {
192
+ data.title = (contentArr[k].text as string).slice(0, 80);
193
+ break;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ if (!data.title) data.title = "Session " + sessionId.slice(0, 8);
201
+
202
+ return data;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ function getSessionFilesForProject(projectPath: string): Array<{ path: string; id: string }> {
209
+ var hash = projectPathToHash(projectPath);
210
+ var dir = join(homedir(), ".claude", "projects", hash);
211
+ if (!existsSync(dir)) return [];
212
+
213
+ var files: Array<{ path: string; id: string }> = [];
214
+ try {
215
+ var entries = readdirSync(dir);
216
+ for (var i = 0; i < entries.length; i++) {
217
+ if (entries[i].endsWith(".jsonl")) {
218
+ files.push({
219
+ path: join(dir, entries[i]),
220
+ id: entries[i].replace(".jsonl", ""),
221
+ });
222
+ }
223
+ }
224
+ } catch {
225
+ return [];
226
+ }
227
+ return files;
228
+ }
229
+
230
+ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsPayload {
231
+ var cutoff = getPeriodCutoff(period);
232
+ var filtered: SessionData[] = [];
233
+ for (var i = 0; i < sessions.length; i++) {
234
+ var s = sessions[i];
235
+ var sessionTime = s.endTime > 0 ? s.endTime : s.startTime;
236
+ if (sessionTime >= cutoff) filtered.push(s);
237
+ }
238
+
239
+ var totalCost = 0;
240
+ var totalInput = 0;
241
+ var totalOutput = 0;
242
+ var totalCacheRead = 0;
243
+ var totalCacheCreation = 0;
244
+ var totalDuration = 0;
245
+ var durationCount = 0;
246
+
247
+ var dailyCost = new Map<string, { total: number; opus: number; sonnet: number; haiku: number; other: number }>();
248
+ var dailySessions = new Map<string, number>();
249
+ var dailyTokens = new Map<string, { input: number; output: number; cacheRead: number }>();
250
+ var dailyCacheHit = new Map<string, { cacheRead: number; totalInput: number }>();
251
+
252
+ var modelStats = new Map<string, { sessions: number; cost: number; tokens: number }>();
253
+ var projectStats = new Map<string, { cost: number; sessions: number; tokens: number }>();
254
+ var toolStats = new Map<string, { count: number; totalCost: number; sessions: number }>();
255
+
256
+ var costBuckets = new Map<string, number>();
257
+ var bucketOrder = ["$0-0.01", "$0.01-0.05", "$0.05-0.10", "$0.10-0.50", "$0.50-1.00", "$1.00-5.00", "$5.00+"];
258
+ for (var b = 0; b < bucketOrder.length; b++) {
259
+ costBuckets.set(bucketOrder[b], 0);
260
+ }
261
+
262
+ for (var si = 0; si < filtered.length; si++) {
263
+ var sess = filtered[si];
264
+ totalCost += sess.cost;
265
+ totalInput += sess.inputTokens;
266
+ totalOutput += sess.outputTokens;
267
+ totalCacheRead += sess.cacheReadTokens;
268
+ totalCacheCreation += sess.cacheCreationTokens;
269
+
270
+ if (sess.startTime > 0 && sess.endTime > 0 && sess.endTime > sess.startTime) {
271
+ totalDuration += sess.endTime - sess.startTime;
272
+ durationCount++;
273
+ }
274
+
275
+ var date = formatDate(sess.endTime > 0 ? sess.endTime : sess.startTime);
276
+
277
+ var dc = dailyCost.get(date);
278
+ if (!dc) {
279
+ dc = { total: 0, opus: 0, sonnet: 0, haiku: 0, other: 0 };
280
+ dailyCost.set(date, dc);
281
+ }
282
+ dc.total += sess.cost;
283
+ sess.models.forEach(function (val, key) {
284
+ dc![key as "opus" | "sonnet" | "haiku" | "other"] += val.cost;
285
+ });
286
+
287
+ dailySessions.set(date, (dailySessions.get(date) || 0) + 1);
288
+
289
+ var dt = dailyTokens.get(date);
290
+ if (!dt) {
291
+ dt = { input: 0, output: 0, cacheRead: 0 };
292
+ dailyTokens.set(date, dt);
293
+ }
294
+ dt.input += sess.inputTokens;
295
+ dt.output += sess.outputTokens;
296
+ dt.cacheRead += sess.cacheReadTokens;
297
+
298
+ var dch = dailyCacheHit.get(date);
299
+ if (!dch) {
300
+ dch = { cacheRead: 0, totalInput: 0 };
301
+ dailyCacheHit.set(date, dch);
302
+ }
303
+ dch.cacheRead += sess.cacheReadTokens;
304
+ dch.totalInput += sess.inputTokens;
305
+
306
+ sess.models.forEach(function (val, key) {
307
+ var ms = modelStats.get(key);
308
+ if (!ms) {
309
+ ms = { sessions: 0, cost: 0, tokens: 0 };
310
+ modelStats.set(key, ms);
311
+ }
312
+ ms.sessions++;
313
+ ms.cost += val.cost;
314
+ ms.tokens += val.tokens;
315
+ });
316
+
317
+ var ps = projectStats.get(sess.project);
318
+ if (!ps) {
319
+ ps = { cost: 0, sessions: 0, tokens: 0 };
320
+ projectStats.set(sess.project, ps);
321
+ }
322
+ ps.cost += sess.cost;
323
+ ps.sessions++;
324
+ ps.tokens += sess.inputTokens + sess.outputTokens;
325
+
326
+ sess.tools.forEach(function (count, tool) {
327
+ var ts = toolStats.get(tool);
328
+ if (!ts) {
329
+ ts = { count: 0, totalCost: 0, sessions: 0 };
330
+ toolStats.set(tool, ts);
331
+ }
332
+ ts.count += count;
333
+ ts.totalCost += sess.cost;
334
+ ts.sessions++;
335
+ });
336
+
337
+ var bucket = getCostBucket(sess.cost);
338
+ costBuckets.set(bucket, (costBuckets.get(bucket) || 0) + 1);
339
+ }
340
+
341
+ var totalTokensAll = totalInput + totalOutput + totalCacheRead + totalCacheCreation;
342
+ var cacheHitRate = (totalInput + totalCacheRead) > 0 ? totalCacheRead / (totalInput + totalCacheRead) : 0;
343
+
344
+ var dates = Array.from(dailyCost.keys()).sort();
345
+
346
+ var costOverTime: AnalyticsPayload["costOverTime"] = [];
347
+ var cumulativeCost: AnalyticsPayload["cumulativeCost"] = [];
348
+ var sessionsOverTime: AnalyticsPayload["sessionsOverTime"] = [];
349
+ var tokensOverTime: AnalyticsPayload["tokensOverTime"] = [];
350
+ var cacheHitRateOverTime: AnalyticsPayload["cacheHitRateOverTime"] = [];
351
+
352
+ var cumTotal = 0;
353
+ for (var di = 0; di < dates.length; di++) {
354
+ var d = dates[di];
355
+ var dcEntry = dailyCost.get(d)!;
356
+ cumTotal += dcEntry.total;
357
+
358
+ costOverTime.push({
359
+ date: d,
360
+ total: dcEntry.total,
361
+ opus: dcEntry.opus,
362
+ sonnet: dcEntry.sonnet,
363
+ haiku: dcEntry.haiku,
364
+ other: dcEntry.other,
365
+ });
366
+ cumulativeCost.push({ date: d, total: cumTotal });
367
+ sessionsOverTime.push({ date: d, count: dailySessions.get(d) || 0 });
368
+
369
+ var dtEntry = dailyTokens.get(d);
370
+ tokensOverTime.push({
371
+ date: d,
372
+ input: dtEntry ? dtEntry.input : 0,
373
+ output: dtEntry ? dtEntry.output : 0,
374
+ cacheRead: dtEntry ? dtEntry.cacheRead : 0,
375
+ });
376
+
377
+ var dchEntry = dailyCacheHit.get(d);
378
+ var rate = dchEntry && (dchEntry.totalInput + dchEntry.cacheRead) > 0 ? dchEntry.cacheRead / (dchEntry.totalInput + dchEntry.cacheRead) : 0;
379
+ cacheHitRateOverTime.push({ date: d, rate: rate });
380
+ }
381
+
382
+ var costDistribution: AnalyticsPayload["costDistribution"] = [];
383
+ for (var bi = 0; bi < bucketOrder.length; bi++) {
384
+ costDistribution.push({
385
+ bucket: bucketOrder[bi],
386
+ count: costBuckets.get(bucketOrder[bi]) || 0,
387
+ });
388
+ }
389
+
390
+ var sessionBubbles: AnalyticsPayload["sessionBubbles"] = [];
391
+ var sorted = filtered.slice().sort(function (a, b) {
392
+ return (b.endTime || b.startTime) - (a.endTime || a.startTime);
393
+ });
394
+ var bubbleCap = Math.min(sorted.length, 200);
395
+ for (var sbi = 0; sbi < bubbleCap; sbi++) {
396
+ var sb = sorted[sbi];
397
+ sessionBubbles.push({
398
+ id: sb.id,
399
+ title: sb.title,
400
+ cost: sb.cost,
401
+ tokens: sb.inputTokens + sb.outputTokens,
402
+ timestamp: sb.endTime > 0 ? sb.endTime : sb.startTime,
403
+ project: sb.project,
404
+ });
405
+ }
406
+
407
+ var modelUsage: AnalyticsPayload["modelUsage"] = [];
408
+ var totalModelCost = totalCost || 1;
409
+ modelStats.forEach(function (val, key) {
410
+ modelUsage.push({
411
+ model: key,
412
+ sessions: val.sessions,
413
+ cost: val.cost,
414
+ tokens: val.tokens,
415
+ percentage: (val.cost / totalModelCost) * 100,
416
+ });
417
+ });
418
+ modelUsage.sort(function (a, b) { return b.cost - a.cost; });
419
+
420
+ var projectBreakdown: AnalyticsPayload["projectBreakdown"] = [];
421
+ projectStats.forEach(function (val, key) {
422
+ projectBreakdown.push({
423
+ project: key,
424
+ cost: val.cost,
425
+ sessions: val.sessions,
426
+ tokens: val.tokens,
427
+ });
428
+ });
429
+ projectBreakdown.sort(function (a, b) { return b.cost - a.cost; });
430
+
431
+ var toolUsage: AnalyticsPayload["toolUsage"] = [];
432
+ toolStats.forEach(function (val, key) {
433
+ toolUsage.push({
434
+ tool: key,
435
+ count: val.count,
436
+ avgCost: val.sessions > 0 ? val.totalCost / val.sessions : 0,
437
+ });
438
+ });
439
+ toolUsage.sort(function (a, b) { return b.count - a.count; });
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
+
523
+ return {
524
+ totalCost: totalCost,
525
+ totalSessions: filtered.length,
526
+ totalTokens: {
527
+ input: totalInput,
528
+ output: totalOutput,
529
+ cacheRead: totalCacheRead,
530
+ cacheCreation: totalCacheCreation,
531
+ },
532
+ cacheHitRate: cacheHitRate,
533
+ avgSessionCost: filtered.length > 0 ? totalCost / filtered.length : 0,
534
+ avgSessionDuration: durationCount > 0 ? totalDuration / durationCount : 0,
535
+ costOverTime: costOverTime,
536
+ cumulativeCost: cumulativeCost,
537
+ sessionsOverTime: sessionsOverTime,
538
+ tokensOverTime: tokensOverTime,
539
+ cacheHitRateOverTime: cacheHitRateOverTime,
540
+ costDistribution: costDistribution,
541
+ sessionBubbles: sessionBubbles,
542
+ modelUsage: modelUsage,
543
+ projectBreakdown: projectBreakdown,
544
+ toolUsage: toolUsage,
545
+ responseTimeData: responseTimeData,
546
+ contextUtilization: contextUtilization,
547
+ tokenFlowSankey: tokenFlowSankey,
548
+ };
549
+ }
550
+
551
+ export function getAnalytics(
552
+ scope: AnalyticsScope,
553
+ period: AnalyticsPeriod,
554
+ projectSlug?: string,
555
+ sessionId?: string,
556
+ forceRefresh?: boolean,
557
+ ): Promise<AnalyticsPayload> {
558
+ var cacheKey = scope + ":" + period + ":" + (projectSlug || "all");
559
+ if (sessionId) cacheKey += ":" + sessionId;
560
+
561
+ if (!forceRefresh) {
562
+ var cached = cache.get(cacheKey);
563
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
564
+ return Promise.resolve(cached.data);
565
+ }
566
+ }
567
+
568
+ var sessions: SessionData[] = [];
569
+ var config = loadConfig();
570
+
571
+ if (scope === "global") {
572
+ for (var i = 0; i < config.projects.length; i++) {
573
+ var proj = config.projects[i];
574
+ var files = getSessionFilesForProject(proj.path);
575
+ for (var j = 0; j < files.length; j++) {
576
+ var data = parseSessionFile(files[j].path, files[j].id, proj.slug);
577
+ if (data) sessions.push(data);
578
+ }
579
+ }
580
+ } else if (scope === "project" && projectSlug) {
581
+ var project = config.projects.find(function (p) { return p.slug === projectSlug; });
582
+ if (project) {
583
+ var projFiles = getSessionFilesForProject(project.path);
584
+ for (var pf = 0; pf < projFiles.length; pf++) {
585
+ var pData = parseSessionFile(projFiles[pf].path, projFiles[pf].id, projectSlug);
586
+ if (pData) sessions.push(pData);
587
+ }
588
+ }
589
+ } else if (scope === "session" && projectSlug && sessionId) {
590
+ var sessProject = config.projects.find(function (p) { return p.slug === projectSlug; });
591
+ if (sessProject) {
592
+ var hash = projectPathToHash(sessProject.path);
593
+ var filePath = join(homedir(), ".claude", "projects", hash, sessionId + ".jsonl");
594
+ if (existsSync(filePath)) {
595
+ var sData = parseSessionFile(filePath, sessionId, projectSlug);
596
+ if (sData) sessions.push(sData);
597
+ }
598
+ }
599
+ }
600
+
601
+ var result = aggregate(sessions, period);
602
+
603
+ cache.set(cacheKey, { data: result, timestamp: Date.now() });
604
+
605
+ return Promise.resolve(result);
606
+ }
@@ -28,6 +28,7 @@ import "./handlers/notes";
28
28
  import "./handlers/skills";
29
29
  import "./handlers/memory";
30
30
  import "./handlers/editor";
31
+ import "./handlers/analytics";
31
32
  import { startScheduler } from "./features/scheduler";
32
33
  import { loadNotes } from "./features/sticky-notes";
33
34
  import { cleanupClientTerminals } from "./handlers/terminal";
@@ -0,0 +1,34 @@
1
+ import type { ClientMessage } from "@lattice/shared";
2
+ import { registerHandler } from "../ws/router";
3
+ import { sendTo } from "../ws/broadcast";
4
+ import { getAnalytics } from "../analytics/engine";
5
+
6
+ registerHandler("analytics", function (clientId: string, message: ClientMessage) {
7
+ if (message.type === "analytics:request") {
8
+ var msg = message as { type: string; requestId: string; scope: string; projectSlug?: string; sessionId?: string; period: string; forceRefresh?: boolean };
9
+
10
+ getAnalytics(
11
+ msg.scope as "global" | "project" | "session",
12
+ msg.period as "24h" | "7d" | "30d" | "90d" | "all",
13
+ msg.projectSlug,
14
+ msg.sessionId,
15
+ msg.forceRefresh
16
+ ).then(function (data) {
17
+ sendTo(clientId, {
18
+ type: "analytics:data",
19
+ requestId: msg.requestId,
20
+ scope: msg.scope,
21
+ period: msg.period,
22
+ data: data,
23
+ });
24
+ }).catch(function (err) {
25
+ sendTo(clientId, {
26
+ type: "analytics:error",
27
+ scope: msg.scope,
28
+ message: err instanceof Error ? err.message : "Analytics computation failed",
29
+ });
30
+ });
31
+
32
+ return;
33
+ }
34
+ });
@@ -18,7 +18,7 @@ function getProjectPath(projectSlug: string): string | null {
18
18
  return project ? project.path : null;
19
19
  }
20
20
 
21
- function projectPathToHash(projectPath: string): string {
21
+ export function projectPathToHash(projectPath: string): string {
22
22
  return projectPath.replace(/\//g, "-");
23
23
  }
24
24
 
@@ -43,7 +43,7 @@ var FALLBACK_PRICING: Record<string, { input: number; output: number }> = {
43
43
  "claude-haiku-4-5": { input: 0.80, output: 4 },
44
44
  };
45
45
 
46
- function loadPricing(): void {
46
+ export function loadPricing(): void {
47
47
  if (pricingLoaded) return;
48
48
  pricingLoaded = true;
49
49
  fetch(LITELLM_PRICING_URL).then(function (res) {
@@ -69,7 +69,7 @@ function loadPricing(): void {
69
69
 
70
70
  loadPricing();
71
71
 
72
- function getPricing(model: string): { input: number; output: number; cacheRead?: number; cacheCreation?: number } {
72
+ export function getPricing(model: string): { input: number; output: number; cacheRead?: number; cacheCreation?: number } {
73
73
  if (pricingCache[model]) return pricingCache[model];
74
74
  for (var key in pricingCache) {
75
75
  if (key.includes(model) || model.includes(key)) return pricingCache[key];
@@ -84,7 +84,7 @@ function getPricing(model: string): { input: number; output: number; cacheRead?:
84
84
  return FALLBACK_PRICING["claude-sonnet-4-6"];
85
85
  }
86
86
 
87
- function estimateCost(model: string, inputTokens: number, outputTokens: number, cacheRead: number, cacheCreation: number): number {
87
+ export function estimateCost(model: string, inputTokens: number, outputTokens: number, cacheRead: number, cacheCreation: number): number {
88
88
  var pricing = getPricing(model);
89
89
  var normalInput = inputTokens - cacheRead - cacheCreation;
90
90
  var inputCost = (normalInput * pricing.input) / 1000000;