tokentrace 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.
Files changed (224) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +167 -0
  3. package/.next/app-path-routes-manifest.json +22 -0
  4. package/.next/build-manifest.json +33 -0
  5. package/.next/export-marker.json +6 -0
  6. package/.next/images-manifest.json +58 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +37 -0
  11. package/.next/react-loadable-manifest.json +1 -0
  12. package/.next/required-server-files.json +323 -0
  13. package/.next/routes-manifest.json +119 -0
  14. package/.next/server/app/_not-found/page.js +2 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +8 -0
  19. package/.next/server/app/_not-found.rsc +37 -0
  20. package/.next/server/app/api/analytics/route.js +1 -0
  21. package/.next/server/app/api/analytics/route.js.nft.json +1 -0
  22. package/.next/server/app/api/analytics/route_client-reference-manifest.js +1 -0
  23. package/.next/server/app/api/data/route.js +151 -0
  24. package/.next/server/app/api/data/route.js.nft.json +1 -0
  25. package/.next/server/app/api/data/route_client-reference-manifest.js +1 -0
  26. package/.next/server/app/api/export/route.js +1 -0
  27. package/.next/server/app/api/export/route.js.nft.json +1 -0
  28. package/.next/server/app/api/export/route_client-reference-manifest.js +1 -0
  29. package/.next/server/app/api/files/route.js +1 -0
  30. package/.next/server/app/api/files/route.js.nft.json +1 -0
  31. package/.next/server/app/api/files/route_client-reference-manifest.js +1 -0
  32. package/.next/server/app/api/prices/route.js +151 -0
  33. package/.next/server/app/api/prices/route.js.nft.json +1 -0
  34. package/.next/server/app/api/prices/route_client-reference-manifest.js +1 -0
  35. package/.next/server/app/api/scan/route.js +144 -0
  36. package/.next/server/app/api/scan/route.js.nft.json +1 -0
  37. package/.next/server/app/api/scan/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/settings/route.js +128 -0
  39. package/.next/server/app/api/settings/route.js.nft.json +1 -0
  40. package/.next/server/app/api/settings/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/debug/page.js +2 -0
  42. package/.next/server/app/debug/page.js.nft.json +1 -0
  43. package/.next/server/app/debug/page_client-reference-manifest.js +1 -0
  44. package/.next/server/app/diagnostics/page.js +2 -0
  45. package/.next/server/app/diagnostics/page.js.nft.json +1 -0
  46. package/.next/server/app/diagnostics/page_client-reference-manifest.js +1 -0
  47. package/.next/server/app/discovery/page.js +2 -0
  48. package/.next/server/app/discovery/page.js.nft.json +1 -0
  49. package/.next/server/app/discovery/page_client-reference-manifest.js +1 -0
  50. package/.next/server/app/models/page.js +2 -0
  51. package/.next/server/app/models/page.js.nft.json +1 -0
  52. package/.next/server/app/models/page_client-reference-manifest.js +1 -0
  53. package/.next/server/app/optimisation/page.js +2 -0
  54. package/.next/server/app/optimisation/page.js.nft.json +1 -0
  55. package/.next/server/app/optimisation/page_client-reference-manifest.js +1 -0
  56. package/.next/server/app/page.js +2 -0
  57. package/.next/server/app/page.js.nft.json +1 -0
  58. package/.next/server/app/page_client-reference-manifest.js +1 -0
  59. package/.next/server/app/parser-debug/page.js +2 -0
  60. package/.next/server/app/parser-debug/page.js.nft.json +1 -0
  61. package/.next/server/app/parser-debug/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/pricing/page.js +152 -0
  63. package/.next/server/app/pricing/page.js.nft.json +1 -0
  64. package/.next/server/app/pricing/page_client-reference-manifest.js +1 -0
  65. package/.next/server/app/projects/page.js +2 -0
  66. package/.next/server/app/projects/page.js.nft.json +1 -0
  67. package/.next/server/app/projects/page_client-reference-manifest.js +1 -0
  68. package/.next/server/app/sessions/page.js +2 -0
  69. package/.next/server/app/sessions/page.js.nft.json +1 -0
  70. package/.next/server/app/sessions/page_client-reference-manifest.js +1 -0
  71. package/.next/server/app/settings/page.js +129 -0
  72. package/.next/server/app/settings/page.js.nft.json +1 -0
  73. package/.next/server/app/settings/page_client-reference-manifest.js +1 -0
  74. package/.next/server/app/tools/page.js +2 -0
  75. package/.next/server/app/tools/page.js.nft.json +1 -0
  76. package/.next/server/app/tools/page_client-reference-manifest.js +1 -0
  77. package/.next/server/app-paths-manifest.json +22 -0
  78. package/.next/server/chunks/123.js +9 -0
  79. package/.next/server/chunks/153.js +1 -0
  80. package/.next/server/chunks/237.js +13 -0
  81. package/.next/server/chunks/331.js +22 -0
  82. package/.next/server/chunks/366.js +1 -0
  83. package/.next/server/chunks/444.js +267 -0
  84. package/.next/server/chunks/611.js +6 -0
  85. package/.next/server/chunks/692.js +1 -0
  86. package/.next/server/chunks/779.js +1 -0
  87. package/.next/server/chunks/815.js +1 -0
  88. package/.next/server/chunks/868.js +1 -0
  89. package/.next/server/functions-config-manifest.json +4 -0
  90. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  91. package/.next/server/middleware-build-manifest.js +1 -0
  92. package/.next/server/middleware-manifest.json +6 -0
  93. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  94. package/.next/server/next-font-manifest.js +1 -0
  95. package/.next/server/next-font-manifest.json +1 -0
  96. package/.next/server/pages/404.html +1 -0
  97. package/.next/server/pages/500.html +1 -0
  98. package/.next/server/pages/_app.js +1 -0
  99. package/.next/server/pages/_app.js.nft.json +1 -0
  100. package/.next/server/pages/_document.js +1 -0
  101. package/.next/server/pages/_document.js.nft.json +1 -0
  102. package/.next/server/pages/_error.js +19 -0
  103. package/.next/server/pages/_error.js.nft.json +1 -0
  104. package/.next/server/pages-manifest.json +6 -0
  105. package/.next/server/server-reference-manifest.js +1 -0
  106. package/.next/server/server-reference-manifest.json +1 -0
  107. package/.next/server/webpack-runtime.js +1 -0
  108. package/.next/static/Fh8usqK3dgfncUx9s3VR1/_buildManifest.js +1 -0
  109. package/.next/static/Fh8usqK3dgfncUx9s3VR1/_ssgManifest.js +1 -0
  110. package/.next/static/chunks/125-ab0f8db8f84c1166.js +1 -0
  111. package/.next/static/chunks/255-e881f48ae1d2333a.js +1 -0
  112. package/.next/static/chunks/4bd1b696-409494caf8c83275.js +1 -0
  113. package/.next/static/chunks/619-f072ac750404f9da.js +1 -0
  114. package/.next/static/chunks/850-8bc31e41590b5831.js +1 -0
  115. package/.next/static/chunks/938-23236de1c47554ea.js +1 -0
  116. package/.next/static/chunks/app/_not-found/page-6d75243350d9e0b5.js +1 -0
  117. package/.next/static/chunks/app/api/analytics/route-33d3f29973de91a4.js +1 -0
  118. package/.next/static/chunks/app/api/data/route-33d3f29973de91a4.js +1 -0
  119. package/.next/static/chunks/app/api/export/route-33d3f29973de91a4.js +1 -0
  120. package/.next/static/chunks/app/api/files/route-33d3f29973de91a4.js +1 -0
  121. package/.next/static/chunks/app/api/prices/route-33d3f29973de91a4.js +1 -0
  122. package/.next/static/chunks/app/api/scan/route-33d3f29973de91a4.js +1 -0
  123. package/.next/static/chunks/app/api/settings/route-33d3f29973de91a4.js +1 -0
  124. package/.next/static/chunks/app/debug/page-33d3f29973de91a4.js +1 -0
  125. package/.next/static/chunks/app/diagnostics/page-053a5e810a59e548.js +1 -0
  126. package/.next/static/chunks/app/discovery/page-33d3f29973de91a4.js +1 -0
  127. package/.next/static/chunks/app/layout-8942804176ff26f3.js +1 -0
  128. package/.next/static/chunks/app/models/page-c0acf74dd8197e01.js +1 -0
  129. package/.next/static/chunks/app/optimisation/page-33d3f29973de91a4.js +1 -0
  130. package/.next/static/chunks/app/page-b6886ec802c03cbf.js +1 -0
  131. package/.next/static/chunks/app/parser-debug/page-33d3f29973de91a4.js +1 -0
  132. package/.next/static/chunks/app/pricing/page-5e27b1ae27314539.js +1 -0
  133. package/.next/static/chunks/app/projects/page-b6886ec802c03cbf.js +1 -0
  134. package/.next/static/chunks/app/sessions/page-0abcdc88aac9dcaf.js +1 -0
  135. package/.next/static/chunks/app/settings/page-59fc80673f0750cd.js +1 -0
  136. package/.next/static/chunks/app/tools/page-c0acf74dd8197e01.js +1 -0
  137. package/.next/static/chunks/framework-3457b9c2619cdd96.js +1 -0
  138. package/.next/static/chunks/main-8744520a8a31e6ae.js +1 -0
  139. package/.next/static/chunks/main-app-e9ccddef393e28c3.js +1 -0
  140. package/.next/static/chunks/pages/_app-5addca2b3b969fde.js +1 -0
  141. package/.next/static/chunks/pages/_error-022e4ac7bbb9914f.js +1 -0
  142. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  143. package/.next/static/chunks/webpack-3fcacae817f3ffab.js +1 -0
  144. package/.next/static/css/366bb38b386229a5.css +3 -0
  145. package/LICENSE +21 -0
  146. package/README.md +216 -0
  147. package/app/api/analytics/route.ts +8 -0
  148. package/app/api/data/route.ts +9 -0
  149. package/app/api/export/route.ts +26 -0
  150. package/app/api/files/route.ts +8 -0
  151. package/app/api/prices/route.ts +33 -0
  152. package/app/api/scan/route.ts +15 -0
  153. package/app/api/settings/route.ts +25 -0
  154. package/app/debug/page.tsx +101 -0
  155. package/app/diagnostics/page.tsx +113 -0
  156. package/app/discovery/page.tsx +61 -0
  157. package/app/globals.css +51 -0
  158. package/app/layout.tsx +30 -0
  159. package/app/models/page.tsx +97 -0
  160. package/app/optimisation/page.tsx +67 -0
  161. package/app/page.tsx +164 -0
  162. package/app/parser-debug/page.tsx +57 -0
  163. package/app/pricing/page.tsx +18 -0
  164. package/app/projects/page.tsx +111 -0
  165. package/app/sessions/page.tsx +24 -0
  166. package/app/settings/page.tsx +26 -0
  167. package/app/tools/page.tsx +92 -0
  168. package/bin/tokentrace.js +316 -0
  169. package/components/charts/rank-bar-chart.tsx +69 -0
  170. package/components/charts/trend-chart.tsx +123 -0
  171. package/components/empty-state.tsx +14 -0
  172. package/components/pricing-settings.tsx +171 -0
  173. package/components/session-explorer.tsx +210 -0
  174. package/components/settings-panel.tsx +203 -0
  175. package/components/sidebar.tsx +88 -0
  176. package/components/ui/badge.tsx +30 -0
  177. package/components/ui/button.tsx +47 -0
  178. package/components/ui/card.tsx +22 -0
  179. package/components/ui/input.tsx +19 -0
  180. package/components/ui/label.tsx +6 -0
  181. package/components/ui/table.tsx +31 -0
  182. package/components/ui/textarea.tsx +18 -0
  183. package/components.json +16 -0
  184. package/dist/runtime/db-migrate.mjs +410 -0
  185. package/dist/runtime/db-seed.mjs +506 -0
  186. package/dist/runtime/reset.mjs +519 -0
  187. package/dist/runtime/scan.mjs +1817 -0
  188. package/fixtures/generic-jsonl/sample.jsonl +2 -0
  189. package/next.config.mjs +7 -0
  190. package/package.json +96 -0
  191. package/postcss.config.mjs +8 -0
  192. package/scripts/build-cli-runtime.mjs +40 -0
  193. package/scripts/db-migrate.ts +5 -0
  194. package/scripts/db-seed.ts +5 -0
  195. package/scripts/reset.ts +5 -0
  196. package/scripts/scan.ts +30 -0
  197. package/src/db/client.ts +32 -0
  198. package/src/db/migrate-core.ts +147 -0
  199. package/src/db/reset.ts +14 -0
  200. package/src/db/schema.ts +259 -0
  201. package/src/db/seed.ts +110 -0
  202. package/src/db/settings.ts +47 -0
  203. package/src/ingestion/adapters/claude-code.ts +78 -0
  204. package/src/ingestion/adapters/codex-cli.ts +82 -0
  205. package/src/ingestion/adapters/generic-json.ts +93 -0
  206. package/src/ingestion/adapters/generic-jsonl.ts +62 -0
  207. package/src/ingestion/adapters/generic-log.ts +144 -0
  208. package/src/ingestion/adapters/generic-records.ts +178 -0
  209. package/src/ingestion/adapters/helpers.ts +309 -0
  210. package/src/ingestion/adapters/index.ts +15 -0
  211. package/src/ingestion/discovery.ts +130 -0
  212. package/src/ingestion/persist.ts +283 -0
  213. package/src/ingestion/scan.ts +247 -0
  214. package/src/ingestion/types.ts +78 -0
  215. package/src/lib/analytics.ts +592 -0
  216. package/src/lib/cost.ts +62 -0
  217. package/src/lib/csv.ts +15 -0
  218. package/src/lib/format.ts +51 -0
  219. package/src/lib/ids.ts +23 -0
  220. package/src/lib/pricing.ts +86 -0
  221. package/src/lib/token-estimator.ts +24 -0
  222. package/src/lib/utils.ts +6 -0
  223. package/tailwind.config.ts +53 -0
  224. package/tsconfig.json +28 -0
@@ -0,0 +1,592 @@
1
+ import { sqlite } from "@/src/db/client";
2
+
3
+ export type TrendPoint = {
4
+ date: string;
5
+ totalTokens: number;
6
+ inputTokens: number;
7
+ outputTokens: number;
8
+ cachedTokens: number;
9
+ reasoningTokens: number;
10
+ cost: number;
11
+ };
12
+
13
+ export type SummaryMetrics = {
14
+ totalTokens: number;
15
+ inputTokens: number;
16
+ outputTokens: number;
17
+ cachedTokens: number;
18
+ reasoningTokens: number;
19
+ totalCost: number;
20
+ exactCost: number;
21
+ estimatedCost: number;
22
+ unknownCostInteractions: number;
23
+ sessions: number;
24
+ interactions: number;
25
+ mostUsedTool: string;
26
+ mostUsedModel: string;
27
+ };
28
+
29
+ export type ToolComparisonRow = {
30
+ tool: string;
31
+ provider: string;
32
+ totalTokens: number;
33
+ cost: number;
34
+ sessions: number;
35
+ interactions: number;
36
+ averageTokensPerSession: number;
37
+ averageTokensPerInteraction: number;
38
+ outputInputRatio: number;
39
+ cacheEfficiency: number;
40
+ mostExpensiveModel: string;
41
+ };
42
+
43
+ export type ModelAnalyticsRow = {
44
+ model: string;
45
+ provider: string;
46
+ totalTokens: number;
47
+ inputTokens: number;
48
+ outputTokens: number;
49
+ cost: number;
50
+ interactions: number;
51
+ averageOutputTokens: number;
52
+ tokenEfficiency: number;
53
+ suggestedAlternative: string | null;
54
+ overuseFlag: string | null;
55
+ };
56
+
57
+ export type ProjectAnalyticsRow = {
58
+ id: string;
59
+ project: string;
60
+ path: string;
61
+ totalTokens: number;
62
+ cost: number;
63
+ sessions: number;
64
+ interactions: number;
65
+ outputInputRatio: number;
66
+ lastUsedAt: number | null;
67
+ };
68
+
69
+ export type SessionRow = {
70
+ id: string;
71
+ startedAt: number | null;
72
+ endedAt: number | null;
73
+ title: string | null;
74
+ sourceFile: string;
75
+ tool: string;
76
+ provider: string;
77
+ project: string;
78
+ projectPath: string;
79
+ models: string;
80
+ totalTokens: number;
81
+ inputTokens: number;
82
+ outputTokens: number;
83
+ cachedTokens: number;
84
+ reasoningTokens: number;
85
+ cost: number | null;
86
+ costEstimated: boolean;
87
+ estimatedTokens: boolean;
88
+ tokenConfidence: string;
89
+ interactionCount: number;
90
+ durationMs: number | null;
91
+ };
92
+
93
+ export type Insight = {
94
+ id: string;
95
+ severity: "high" | "medium" | "low";
96
+ problem: string;
97
+ evidence: string;
98
+ savingOpportunity: string;
99
+ recommendation: string;
100
+ };
101
+
102
+ export type DebugScanFile = {
103
+ id: string;
104
+ scanRunId: string;
105
+ path: string;
106
+ modifiedTime: number | null;
107
+ sizeBytes: number;
108
+ fileHash: string | null;
109
+ parser: string | null;
110
+ status: string;
111
+ recordsImported: number;
112
+ warnings: string[];
113
+ errors: string[];
114
+ rawMetadata: Record<string, unknown>;
115
+ scanStartedAt: number;
116
+ };
117
+
118
+ export type DebugScanRun = {
119
+ id: string;
120
+ startedAt: number;
121
+ completedAt: number | null;
122
+ filesScanned: number;
123
+ recordsImported: number;
124
+ warnings: string[];
125
+ errors: string[];
126
+ };
127
+
128
+ export type AnalyticsData = {
129
+ summary: SummaryMetrics;
130
+ trends: TrendPoint[];
131
+ tools: ToolComparisonRow[];
132
+ models: ModelAnalyticsRow[];
133
+ projects: ProjectAnalyticsRow[];
134
+ sessions: SessionRow[];
135
+ insights: Insight[];
136
+ };
137
+
138
+ function number(value: unknown) {
139
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
140
+ }
141
+
142
+ function parseJson<T>(value: unknown, fallback: T): T {
143
+ if (Array.isArray(value) || (value && typeof value === "object")) return value as T;
144
+ if (typeof value !== "string") return fallback;
145
+ try {
146
+ return JSON.parse(value) as T;
147
+ } catch {
148
+ return fallback;
149
+ }
150
+ }
151
+
152
+ function rows<T>(sql: string, ...params: unknown[]) {
153
+ return sqlite.prepare(sql).all(...params) as T[];
154
+ }
155
+
156
+ function getSummary(): SummaryMetrics {
157
+ const aggregate = sqlite
158
+ .prepare(
159
+ `SELECT
160
+ COALESCE(SUM(total_tokens), 0) AS totalTokens,
161
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
162
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
163
+ COALESCE(SUM(cache_read_tokens + cache_write_tokens), 0) AS cachedTokens,
164
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens,
165
+ COALESCE(SUM(cost), 0) AS totalCost,
166
+ COALESCE(SUM(CASE WHEN cost_estimated = 0 AND cost IS NOT NULL THEN cost ELSE 0 END), 0) AS exactCost,
167
+ COALESCE(SUM(CASE WHEN cost_estimated = 1 AND cost IS NOT NULL THEN cost ELSE 0 END), 0) AS estimatedCost,
168
+ COALESCE(SUM(CASE WHEN cost IS NULL THEN 1 ELSE 0 END), 0) AS unknownCostInteractions,
169
+ COUNT(*) AS interactions,
170
+ COUNT(DISTINCT session_id) AS sessions
171
+ FROM interactions`
172
+ )
173
+ .get() as Omit<SummaryMetrics, "mostUsedTool" | "mostUsedModel">;
174
+
175
+ const tool = sqlite
176
+ .prepare(
177
+ `SELECT t.name
178
+ FROM interactions i
179
+ JOIN sessions s ON s.id = i.session_id
180
+ JOIN tools t ON t.id = s.tool_id
181
+ GROUP BY t.id
182
+ ORDER BY SUM(i.total_tokens) DESC
183
+ LIMIT 1`
184
+ )
185
+ .get() as { name: string } | undefined;
186
+
187
+ const model = sqlite
188
+ .prepare(
189
+ `SELECT m.name
190
+ FROM interactions i
191
+ LEFT JOIN models m ON m.id = i.model_id
192
+ GROUP BY m.id
193
+ ORDER BY SUM(i.total_tokens) DESC
194
+ LIMIT 1`
195
+ )
196
+ .get() as { name: string } | undefined;
197
+
198
+ return {
199
+ totalTokens: number(aggregate.totalTokens),
200
+ inputTokens: number(aggregate.inputTokens),
201
+ outputTokens: number(aggregate.outputTokens),
202
+ cachedTokens: number(aggregate.cachedTokens),
203
+ reasoningTokens: number(aggregate.reasoningTokens),
204
+ totalCost: number(aggregate.totalCost),
205
+ exactCost: number(aggregate.exactCost),
206
+ estimatedCost: number(aggregate.estimatedCost),
207
+ unknownCostInteractions: number(aggregate.unknownCostInteractions),
208
+ sessions: number(aggregate.sessions),
209
+ interactions: number(aggregate.interactions),
210
+ mostUsedTool: tool?.name ?? "No data",
211
+ mostUsedModel: model?.name ?? "No data"
212
+ };
213
+ }
214
+
215
+ function getTrends(): TrendPoint[] {
216
+ return rows<TrendPoint>(
217
+ `SELECT
218
+ date(COALESCE(timestamp, 0) / 1000, 'unixepoch') AS date,
219
+ COALESCE(SUM(total_tokens), 0) AS totalTokens,
220
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
221
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
222
+ COALESCE(SUM(cache_read_tokens + cache_write_tokens), 0) AS cachedTokens,
223
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens,
224
+ COALESCE(SUM(cost), 0) AS cost
225
+ FROM interactions
226
+ WHERE timestamp IS NOT NULL
227
+ GROUP BY date
228
+ ORDER BY date ASC`
229
+ ).map((row) => ({
230
+ ...row,
231
+ totalTokens: number(row.totalTokens),
232
+ inputTokens: number(row.inputTokens),
233
+ outputTokens: number(row.outputTokens),
234
+ cachedTokens: number(row.cachedTokens),
235
+ reasoningTokens: number(row.reasoningTokens),
236
+ cost: number(row.cost)
237
+ }));
238
+ }
239
+
240
+ function getToolComparison(): ToolComparisonRow[] {
241
+ return rows<
242
+ ToolComparisonRow & {
243
+ inputTokens: number;
244
+ outputTokens: number;
245
+ cachedTokens: number;
246
+ }
247
+ >(
248
+ `SELECT
249
+ t.name AS tool,
250
+ p.name AS provider,
251
+ COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
252
+ COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
253
+ COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
254
+ COALESCE(SUM(i.cache_read_tokens + i.cache_write_tokens), 0) AS cachedTokens,
255
+ COALESCE(SUM(i.cost), 0) AS cost,
256
+ COUNT(DISTINCT s.id) AS sessions,
257
+ COUNT(i.id) AS interactions,
258
+ COALESCE((
259
+ SELECT m2.name
260
+ FROM interactions i2
261
+ JOIN sessions s2 ON s2.id = i2.session_id
262
+ LEFT JOIN models m2 ON m2.id = i2.model_id
263
+ WHERE s2.tool_id = t.id
264
+ GROUP BY m2.id
265
+ ORDER BY SUM(COALESCE(i2.cost, 0)) DESC
266
+ LIMIT 1
267
+ ), 'Unknown') AS mostExpensiveModel
268
+ FROM interactions i
269
+ JOIN sessions s ON s.id = i.session_id
270
+ JOIN tools t ON t.id = s.tool_id
271
+ JOIN providers p ON p.id = t.provider_id
272
+ GROUP BY t.id, p.id
273
+ ORDER BY totalTokens DESC`
274
+ ).map((row) => ({
275
+ tool: row.tool,
276
+ provider: row.provider,
277
+ totalTokens: number(row.totalTokens),
278
+ cost: number(row.cost),
279
+ sessions: number(row.sessions),
280
+ interactions: number(row.interactions),
281
+ averageTokensPerSession: row.sessions ? number(row.totalTokens) / number(row.sessions) : 0,
282
+ averageTokensPerInteraction: row.interactions ? number(row.totalTokens) / number(row.interactions) : 0,
283
+ outputInputRatio: row.inputTokens ? number(row.outputTokens) / number(row.inputTokens) : 0,
284
+ cacheEfficiency:
285
+ row.inputTokens + row.cachedTokens
286
+ ? number(row.cachedTokens) / (number(row.inputTokens) + number(row.cachedTokens))
287
+ : 0,
288
+ mostExpensiveModel: row.mostExpensiveModel
289
+ }));
290
+ }
291
+
292
+ function getModelRows(): ModelAnalyticsRow[] {
293
+ const baseRows = rows<
294
+ ModelAnalyticsRow & {
295
+ providerId: string;
296
+ inputPrice: number | null;
297
+ outputPrice: number | null;
298
+ }
299
+ >(
300
+ `SELECT
301
+ COALESCE(m.name, 'unknown') AS model,
302
+ p.name AS provider,
303
+ p.id AS providerId,
304
+ m.input_token_price AS inputPrice,
305
+ m.output_token_price AS outputPrice,
306
+ COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
307
+ COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
308
+ COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
309
+ COALESCE(SUM(i.cost), 0) AS cost,
310
+ COUNT(i.id) AS interactions,
311
+ COALESCE(AVG(i.output_tokens), 0) AS averageOutputTokens
312
+ FROM interactions i
313
+ LEFT JOIN models m ON m.id = i.model_id
314
+ LEFT JOIN providers p ON p.id = m.provider_id
315
+ GROUP BY m.id
316
+ ORDER BY totalTokens DESC`
317
+ );
318
+
319
+ const configuredPrices = rows<{
320
+ providerId: string;
321
+ name: string;
322
+ combinedPrice: number;
323
+ }>(
324
+ `SELECT provider_id AS providerId, name,
325
+ COALESCE(input_token_price, 999999) + COALESCE(output_token_price, 999999) AS combinedPrice
326
+ FROM models
327
+ WHERE input_token_price IS NOT NULL AND output_token_price IS NOT NULL`
328
+ );
329
+
330
+ return baseRows.map((row) => {
331
+ const currentPrice = number(row.inputPrice) + number(row.outputPrice);
332
+ const cheaper = configuredPrices
333
+ .filter((candidate) => candidate.providerId === row.providerId)
334
+ .filter((candidate) => candidate.name !== row.model)
335
+ .filter((candidate) => !currentPrice || candidate.combinedPrice < currentPrice)
336
+ .sort((a, b) => a.combinedPrice - b.combinedPrice)[0];
337
+ const tokenEfficiency = row.inputTokens
338
+ ? number(row.outputTokens) / number(row.inputTokens)
339
+ : number(row.outputTokens);
340
+
341
+ return {
342
+ model: row.model,
343
+ provider: row.provider ?? "Unknown",
344
+ totalTokens: number(row.totalTokens),
345
+ inputTokens: number(row.inputTokens),
346
+ outputTokens: number(row.outputTokens),
347
+ cost: number(row.cost),
348
+ interactions: number(row.interactions),
349
+ averageOutputTokens: number(row.averageOutputTokens),
350
+ tokenEfficiency,
351
+ suggestedAlternative: cheaper?.name ?? null,
352
+ overuseFlag:
353
+ number(row.cost) > 0 && cheaper && number(row.totalTokens) > 25_000
354
+ ? "Cheaper configured alternative exists"
355
+ : null
356
+ };
357
+ });
358
+ }
359
+
360
+ function getProjectRows(): ProjectAnalyticsRow[] {
361
+ return rows<ProjectAnalyticsRow & { inputTokens: number; outputTokens: number }>(
362
+ `SELECT
363
+ pr.id,
364
+ pr.name AS project,
365
+ pr.path,
366
+ COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
367
+ COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
368
+ COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
369
+ COALESCE(SUM(i.cost), 0) AS cost,
370
+ COUNT(DISTINCT s.id) AS sessions,
371
+ COUNT(i.id) AS interactions,
372
+ MAX(i.timestamp) AS lastUsedAt
373
+ FROM projects pr
374
+ JOIN sessions s ON s.project_id = pr.id
375
+ LEFT JOIN interactions i ON i.session_id = s.id
376
+ GROUP BY pr.id
377
+ ORDER BY totalTokens DESC`
378
+ ).map((row) => ({
379
+ id: row.id,
380
+ project: row.project,
381
+ path: row.path,
382
+ totalTokens: number(row.totalTokens),
383
+ cost: number(row.cost),
384
+ sessions: number(row.sessions),
385
+ interactions: number(row.interactions),
386
+ outputInputRatio: row.inputTokens ? number(row.outputTokens) / number(row.inputTokens) : 0,
387
+ lastUsedAt: row.lastUsedAt
388
+ }));
389
+ }
390
+
391
+ function getSessions(): SessionRow[] {
392
+ return rows<
393
+ Omit<SessionRow, "costEstimated" | "estimatedTokens"> & {
394
+ costEstimated: 0 | 1;
395
+ estimatedTokens: 0 | 1;
396
+ }
397
+ >(
398
+ `SELECT
399
+ s.id,
400
+ s.started_at AS startedAt,
401
+ s.ended_at AS endedAt,
402
+ s.title,
403
+ s.source_file AS sourceFile,
404
+ t.name AS tool,
405
+ provider.name AS provider,
406
+ pr.name AS project,
407
+ pr.path AS projectPath,
408
+ COALESCE(group_concat(DISTINCT m.name), 'unknown') AS models,
409
+ COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
410
+ COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
411
+ COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
412
+ COALESCE(SUM(i.cache_read_tokens + i.cache_write_tokens), 0) AS cachedTokens,
413
+ COALESCE(SUM(i.reasoning_tokens), 0) AS reasoningTokens,
414
+ SUM(i.cost) AS cost,
415
+ MAX(i.cost_estimated) AS costEstimated,
416
+ MAX(i.estimated_tokens) AS estimatedTokens,
417
+ CASE
418
+ WHEN SUM(CASE WHEN i.token_confidence = 'unknown' THEN 1 ELSE 0 END) > 0 THEN 'unknown'
419
+ WHEN SUM(CASE WHEN i.token_confidence = 'low-confidence estimate' THEN 1 ELSE 0 END) > 0 THEN 'low-confidence estimate'
420
+ WHEN SUM(CASE WHEN i.token_confidence = 'high-confidence estimate' THEN 1 ELSE 0 END) > 0 THEN 'high-confidence estimate'
421
+ ELSE 'exact'
422
+ END AS tokenConfidence,
423
+ COUNT(i.id) AS interactionCount,
424
+ CASE WHEN s.started_at IS NOT NULL AND s.ended_at IS NOT NULL THEN s.ended_at - s.started_at ELSE NULL END AS durationMs
425
+ FROM sessions s
426
+ JOIN tools t ON t.id = s.tool_id
427
+ JOIN providers provider ON provider.id = t.provider_id
428
+ LEFT JOIN projects pr ON pr.id = s.project_id
429
+ LEFT JOIN interactions i ON i.session_id = s.id
430
+ LEFT JOIN models m ON m.id = i.model_id
431
+ GROUP BY s.id
432
+ ORDER BY COALESCE(s.started_at, 0) DESC
433
+ LIMIT 1000`
434
+ ).map((row) => ({
435
+ ...row,
436
+ totalTokens: number(row.totalTokens),
437
+ inputTokens: number(row.inputTokens),
438
+ outputTokens: number(row.outputTokens),
439
+ cachedTokens: number(row.cachedTokens),
440
+ reasoningTokens: number(row.reasoningTokens),
441
+ cost: row.cost == null ? null : number(row.cost),
442
+ costEstimated: Boolean(row.costEstimated),
443
+ estimatedTokens: Boolean(row.estimatedTokens),
444
+ interactionCount: number(row.interactionCount)
445
+ }));
446
+ }
447
+
448
+ function buildInsights(data: {
449
+ summary: SummaryMetrics;
450
+ projects: ProjectAnalyticsRow[];
451
+ sessions: SessionRow[];
452
+ models: ModelAnalyticsRow[];
453
+ trends: TrendPoint[];
454
+ }): Insight[] {
455
+ const insights: Insight[] = [];
456
+ const totalCost = data.summary.totalCost;
457
+ const topSessions = [...data.sessions].sort((a, b) => b.totalTokens - a.totalTokens);
458
+ const topTenTokens = topSessions.slice(0, Math.max(1, Math.ceil(topSessions.length * 0.1))).reduce(
459
+ (sum, session) => sum + session.totalTokens,
460
+ 0
461
+ );
462
+
463
+ if (data.summary.totalTokens > 0 && topTenTokens / data.summary.totalTokens > 0.5) {
464
+ insights.push({
465
+ id: "concentrated-usage",
466
+ severity: "high",
467
+ problem: "A small number of sessions account for most token usage.",
468
+ evidence: `Top sessions represent ${Math.round((topTenTokens / data.summary.totalTokens) * 100)}% of all tokens.`,
469
+ savingOpportunity: totalCost ? `Reviewing these sessions targets about $${(totalCost * 0.5).toFixed(2)} of spend.` : "High token concentration even when cost is unknown.",
470
+ recommendation: "Split large tasks into smaller prompts and add checkpoints before long coding runs."
471
+ });
472
+ }
473
+
474
+ const highOutputProject = data.projects.find((project) => project.outputInputRatio > 2 && project.totalTokens > 5_000);
475
+ if (highOutputProject) {
476
+ insights.push({
477
+ id: "high-output-project",
478
+ severity: "medium",
479
+ problem: "One project uses unusually high output tokens.",
480
+ evidence: `${highOutputProject.project} has an output/input ratio of ${highOutputProject.outputInputRatio.toFixed(1)}x.`,
481
+ savingOpportunity: highOutputProject.cost ? `Potential review pool: $${highOutputProject.cost.toFixed(2)}.` : "Savings depend on configured pricing.",
482
+ recommendation: "Ask for concise diffs, summaries, or file-scoped edits when working in this project."
483
+ });
484
+ }
485
+
486
+ const cacheEfficiency =
487
+ data.summary.inputTokens + data.summary.cachedTokens
488
+ ? data.summary.cachedTokens / (data.summary.inputTokens + data.summary.cachedTokens)
489
+ : 0;
490
+ if (data.summary.inputTokens > 10_000 && cacheEfficiency < 0.05) {
491
+ insights.push({
492
+ id: "low-cache",
493
+ severity: "medium",
494
+ problem: "Cache usage is low.",
495
+ evidence: `Cached tokens are ${Math.round(cacheEfficiency * 100)}% of reusable input volume.`,
496
+ savingOpportunity: "Better context reuse can reduce repeated input-token spend on supported models.",
497
+ recommendation: "Keep stable instructions and repo context consistent across related runs where the CLI supports caching."
498
+ });
499
+ }
500
+
501
+ const costlyAlternative = data.models.find((model) => model.overuseFlag && model.suggestedAlternative);
502
+ if (costlyAlternative) {
503
+ insights.push({
504
+ id: "expensive-model-overuse",
505
+ severity: "medium",
506
+ problem: "Configured cheaper models may fit some low-complexity work.",
507
+ evidence: `${costlyAlternative.model} has ${costlyAlternative.totalTokens.toLocaleString()} tokens and ${costlyAlternative.suggestedAlternative} is cheaper in your pricing table.`,
508
+ savingOpportunity: costlyAlternative.cost ? `Candidate spend: $${costlyAlternative.cost.toFixed(2)}.` : "Savings require complete pricing.",
509
+ recommendation: "Use cheaper models for refactoring, search-heavy, or mechanical edits, and reserve expensive models for ambiguous architecture work."
510
+ });
511
+ }
512
+
513
+ if (data.trends.length >= 14) {
514
+ const last = data.trends.slice(-7).reduce((sum, day) => sum + day.totalTokens, 0) / 7;
515
+ const previous = data.trends.slice(-14, -7).reduce((sum, day) => sum + day.totalTokens, 0) / 7;
516
+ if (previous > 0 && last / previous > 1.25) {
517
+ insights.push({
518
+ id: "session-length-growing",
519
+ severity: "low",
520
+ problem: "Average usage is increasing.",
521
+ evidence: `Last 7-day average is ${Math.round((last / previous - 1) * 100)}% above the prior week.`,
522
+ savingOpportunity: "Reducing drift can slow recurring spend growth.",
523
+ recommendation: "Use planning prompts before long coding runs and prune stale context between unrelated tasks."
524
+ });
525
+ }
526
+ }
527
+
528
+ if (!insights.length) {
529
+ insights.push({
530
+ id: "baseline",
531
+ severity: "low",
532
+ problem: "No strong optimization pattern detected yet.",
533
+ evidence: "Scan more sessions or configure prices for richer recommendations.",
534
+ savingOpportunity: "Unknown until more local usage is imported.",
535
+ recommendation: "Run a scan after several CLI sessions and revisit this page."
536
+ });
537
+ }
538
+
539
+ return insights;
540
+ }
541
+
542
+ export function getAnalyticsData(): AnalyticsData {
543
+ const summary = getSummary();
544
+ const trends = getTrends();
545
+ const tools = getToolComparison();
546
+ const models = getModelRows();
547
+ const projects = getProjectRows();
548
+ const sessions = getSessions();
549
+ const insights = buildInsights({ summary, trends, models, projects, sessions });
550
+
551
+ return {
552
+ summary,
553
+ trends,
554
+ tools,
555
+ models,
556
+ projects,
557
+ sessions,
558
+ insights
559
+ };
560
+ }
561
+
562
+ export function getDebugData() {
563
+ const scanRuns = rows<DebugScanRun>(
564
+ `SELECT id, started_at AS startedAt, completed_at AS completedAt,
565
+ files_scanned AS filesScanned, records_imported AS recordsImported, warnings, errors
566
+ FROM scan_runs
567
+ ORDER BY started_at DESC
568
+ LIMIT 50`
569
+ ).map((row) => ({
570
+ ...row,
571
+ warnings: parseJson<string[]>(row.warnings, []),
572
+ errors: parseJson<string[]>(row.errors, [])
573
+ }));
574
+
575
+ const scanFiles = rows<DebugScanFile>(
576
+ `SELECT sf.id, sf.scan_run_id AS scanRunId, sf.path, sf.modified_time AS modifiedTime,
577
+ sf.size_bytes AS sizeBytes, sf.file_hash AS fileHash, sf.parser, sf.status,
578
+ sf.records_imported AS recordsImported, sf.warnings, sf.errors, sf.raw_metadata AS rawMetadata,
579
+ sr.started_at AS scanStartedAt
580
+ FROM scan_files sf
581
+ JOIN scan_runs sr ON sr.id = sf.scan_run_id
582
+ ORDER BY sr.started_at DESC, sf.path ASC
583
+ LIMIT 500`
584
+ ).map((row) => ({
585
+ ...row,
586
+ warnings: parseJson<string[]>(row.warnings, []),
587
+ errors: parseJson<string[]>(row.errors, []),
588
+ rawMetadata: parseJson<Record<string, unknown>>(row.rawMetadata, {})
589
+ }));
590
+
591
+ return { scanRuns, scanFiles };
592
+ }
@@ -0,0 +1,62 @@
1
+ export type PriceConfig = {
2
+ inputTokenPrice: number | null;
3
+ outputTokenPrice: number | null;
4
+ cachedInputTokenPrice: number | null;
5
+ currency: string;
6
+ };
7
+
8
+ export type TokenUsageForCost = {
9
+ inputTokens: number;
10
+ outputTokens: number;
11
+ cacheReadTokens: number;
12
+ cacheWriteTokens: number;
13
+ reasoningTokens: number;
14
+ estimatedTokens: boolean;
15
+ };
16
+
17
+ export type CostResult = {
18
+ amount: number | null;
19
+ currency: string;
20
+ estimated: boolean;
21
+ status: "exact" | "estimated" | "unknown";
22
+ explanation: string;
23
+ };
24
+
25
+ function pricePart(tokens: number, pricePerMillion: number | null | undefined) {
26
+ if (!tokens || pricePerMillion == null) return 0;
27
+ return (tokens * pricePerMillion) / 1_000_000;
28
+ }
29
+
30
+ export function calculateInteractionCost(
31
+ usage: TokenUsageForCost,
32
+ price: PriceConfig | null | undefined
33
+ ): CostResult {
34
+ if (!price || price.inputTokenPrice == null || price.outputTokenPrice == null) {
35
+ return {
36
+ amount: null,
37
+ currency: price?.currency ?? "USD",
38
+ estimated: usage.estimatedTokens,
39
+ status: "unknown",
40
+ explanation: "No complete model pricing is configured."
41
+ };
42
+ }
43
+
44
+ const input = pricePart(usage.inputTokens, price.inputTokenPrice);
45
+ const output = pricePart(usage.outputTokens + usage.reasoningTokens, price.outputTokenPrice);
46
+ const cacheRead = pricePart(
47
+ usage.cacheReadTokens,
48
+ price.cachedInputTokenPrice ?? price.inputTokenPrice
49
+ );
50
+ const cacheWrite = pricePart(usage.cacheWriteTokens, price.inputTokenPrice);
51
+ const amount = input + output + cacheRead + cacheWrite;
52
+
53
+ return {
54
+ amount,
55
+ currency: price.currency,
56
+ estimated: usage.estimatedTokens,
57
+ status: usage.estimatedTokens ? "estimated" : "exact",
58
+ explanation: usage.estimatedTokens
59
+ ? "Token counts were estimated before applying configured prices."
60
+ : "Exact token counts were multiplied by configured prices."
61
+ };
62
+ }
package/src/lib/csv.ts ADDED
@@ -0,0 +1,15 @@
1
+ function escapeCsv(value: unknown) {
2
+ if (value == null) return "";
3
+ const text = String(value);
4
+ if (!/[",\n\r]/.test(text)) return text;
5
+ return `"${text.replace(/"/g, '""')}"`;
6
+ }
7
+
8
+ export function toCsv<T extends Record<string, unknown>>(rows: T[]) {
9
+ if (!rows.length) return "";
10
+ const headers = Object.keys(rows[0]);
11
+ return [
12
+ headers.map(escapeCsv).join(","),
13
+ ...rows.map((row) => headers.map((header) => escapeCsv(row[header])).join(","))
14
+ ].join("\n");
15
+ }