agentfit 0.1.2 → 0.1.5

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 (44) hide show
  1. package/.github/workflows/release.yml +4 -0
  2. package/README.md +0 -2
  3. package/app/(dashboard)/ai-insights/page.tsx +271 -0
  4. package/app/(dashboard)/models/page.tsx +21 -0
  5. package/app/(dashboard)/page.tsx +2 -0
  6. package/app/(dashboard)/sessions/[id]/page.tsx +16 -2
  7. package/app/(dashboard)/settings/page.tsx +168 -0
  8. package/app/api/analyze/aggregate/route.ts +88 -0
  9. package/app/api/analyze/estimate/route.ts +62 -0
  10. package/app/api/analyze/route.ts +142 -0
  11. package/app/api/cc-versions/route.ts +84 -0
  12. package/app/api/config/route.ts +35 -0
  13. package/bin/agentfit.mjs +22 -13
  14. package/components/analyze-confirm-dialog.tsx +81 -0
  15. package/components/app-sidebar.tsx +14 -0
  16. package/components/data-provider.tsx +4 -2
  17. package/components/model-usage-chart.tsx +216 -0
  18. package/components/overview-cards.tsx +1 -1
  19. package/components/session-ai-analysis.tsx +318 -0
  20. package/components/sessions-table.tsx +169 -15
  21. package/components/version-lag-chart.tsx +284 -0
  22. package/electron/main.mjs +81 -59
  23. package/generated/prisma/browser.ts +5 -0
  24. package/generated/prisma/client.ts +5 -0
  25. package/generated/prisma/internal/class.ts +14 -4
  26. package/generated/prisma/internal/prismaNamespace.ts +95 -2
  27. package/generated/prisma/internal/prismaNamespaceBrowser.ts +19 -1
  28. package/generated/prisma/models/Session.ts +57 -1
  29. package/generated/prisma/models/SessionAnalysis.ts +1321 -0
  30. package/generated/prisma/models.ts +1 -0
  31. package/lib/config.ts +45 -0
  32. package/lib/db.ts +1 -1
  33. package/lib/openai.ts +253 -0
  34. package/lib/parse-codex.ts +2 -0
  35. package/lib/parse-logs.ts +21 -7
  36. package/lib/queries.ts +5 -1
  37. package/lib/sync.ts +17 -5
  38. package/package.json +2 -1
  39. package/prisma/migrations/20260404151230_add_session_analysis/migration.sql +18 -0
  40. package/prisma/migrations/20260405230736_add_cli_version/migration.sql +41 -0
  41. package/prisma/migrations/20260406205546_add_model_counts/migration.sql +42 -0
  42. package/prisma/schema.prisma +16 -0
  43. package/prisma/schema.sql +20 -0
  44. /package/prisma/migrations/{20260401144555_add_system_prompt_edits → 20260403214556_init}/migration.sql +0 -0
@@ -0,0 +1,216 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import {
5
+ Bar,
6
+ BarChart,
7
+ Area,
8
+ AreaChart,
9
+ CartesianGrid,
10
+ XAxis,
11
+ YAxis,
12
+ Cell,
13
+ } from 'recharts'
14
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
15
+ import {
16
+ ChartContainer,
17
+ ChartTooltip,
18
+ ChartTooltipContent,
19
+ type ChartConfig,
20
+ } from '@/components/ui/chart'
21
+ import type { SessionSummary, DailyUsage } from '@/lib/parse-logs'
22
+
23
+ // ─── 1. Model Distribution (horizontal bar) ────────────────────────
24
+
25
+ const barConfig = {
26
+ messages: { label: 'Messages', color: 'var(--chart-1)' },
27
+ } satisfies ChartConfig
28
+
29
+ const CHART_COLORS = [
30
+ 'var(--chart-1)',
31
+ 'var(--chart-2)',
32
+ 'var(--chart-3)',
33
+ 'var(--chart-4)',
34
+ 'var(--chart-5)',
35
+ 'var(--chart-6)',
36
+ 'var(--chart-7)',
37
+ 'var(--chart-8)',
38
+ ]
39
+
40
+ function shortenModelName(name: string): string {
41
+ return name
42
+ .replace('claude-', '')
43
+ .replace('anthropic/', '')
44
+ .replace(/-\d{8}$/, '')
45
+ }
46
+
47
+ export function ModelDistributionChart({ sessions }: { sessions: SessionSummary[] }) {
48
+ const data = useMemo(() => {
49
+ const counts: Record<string, number> = {}
50
+ for (const s of sessions) {
51
+ for (const [m, count] of Object.entries(s.modelCounts || {})) {
52
+ if (m !== 'unknown') {
53
+ counts[m] = (counts[m] || 0) + count
54
+ }
55
+ }
56
+ }
57
+ return Object.entries(counts)
58
+ .sort((a, b) => b[1] - a[1])
59
+ .map(([name, messages], i) => ({
60
+ name: shortenModelName(name),
61
+ fullName: name,
62
+ messages,
63
+ fill: CHART_COLORS[i % CHART_COLORS.length],
64
+ }))
65
+ }, [sessions])
66
+
67
+ if (data.length === 0) {
68
+ return (
69
+ <Card>
70
+ <CardHeader>
71
+ <CardTitle>Model Distribution</CardTitle>
72
+ <CardDescription>Messages per model</CardDescription>
73
+ </CardHeader>
74
+ <CardContent>
75
+ <div className="flex min-h-[200px] items-center justify-center text-sm text-muted-foreground">
76
+ No model data available
77
+ </div>
78
+ </CardContent>
79
+ </Card>
80
+ )
81
+ }
82
+
83
+ const total = data.reduce((a, d) => a + d.messages, 0)
84
+
85
+ return (
86
+ <Card>
87
+ <CardHeader>
88
+ <CardTitle>Model Distribution</CardTitle>
89
+ <CardDescription>{total.toLocaleString()} total assistant messages across {data.length} model{data.length !== 1 ? 's' : ''}</CardDescription>
90
+ </CardHeader>
91
+ <CardContent>
92
+ <ChartContainer config={barConfig} className="w-full" style={{ minHeight: Math.max(200, data.length * 40) }}>
93
+ <BarChart data={data} layout="vertical" margin={{ left: 8 }} accessibilityLayer>
94
+ <XAxis type="number" tickLine={false} axisLine={false} />
95
+ <YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={160} />
96
+ <ChartTooltip
97
+ content={({ active, payload }) => {
98
+ if (!active || !payload?.length) return null
99
+ const d = payload[0].payload
100
+ const pct = ((d.messages / total) * 100).toFixed(1)
101
+ return (
102
+ <div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
103
+ <div className="font-semibold">{d.fullName}</div>
104
+ <div className="mt-1 text-muted-foreground">
105
+ <span className="font-mono font-medium text-foreground">{d.messages.toLocaleString()}</span> messages ({pct}%)
106
+ </div>
107
+ </div>
108
+ )
109
+ }}
110
+ />
111
+ <Bar dataKey="messages" radius={[0, 4, 4, 0]}>
112
+ {data.map((entry, i) => (
113
+ <Cell key={i} fill={entry.fill} />
114
+ ))}
115
+ </Bar>
116
+ </BarChart>
117
+ </ChartContainer>
118
+ </CardContent>
119
+ </Card>
120
+ )
121
+ }
122
+
123
+ // ─── 2. Model Usage Over Time (stacked area) ───────────────────────
124
+
125
+ export function ModelUsageOverTimeChart({ sessions }: { sessions: SessionSummary[] }) {
126
+ const { data, topModels, config } = useMemo(() => {
127
+ // Find top models by total message count
128
+ const totalCounts: Record<string, number> = {}
129
+ for (const s of sessions) {
130
+ for (const [m, count] of Object.entries(s.modelCounts || {})) {
131
+ if (m !== 'unknown') {
132
+ totalCounts[m] = (totalCounts[m] || 0) + count
133
+ }
134
+ }
135
+ }
136
+ const sorted = Object.entries(totalCounts).sort((a, b) => b[1] - a[1])
137
+ const top = sorted.slice(0, 6).map(([name]) => name)
138
+
139
+ // Group by date
140
+ const byDate = new Map<string, Record<string, number>>()
141
+ for (const s of sessions) {
142
+ const date = s.startTime.slice(0, 10)
143
+ if (!byDate.has(date)) byDate.set(date, {})
144
+ const day = byDate.get(date)!
145
+ for (const [m, count] of Object.entries(s.modelCounts || {})) {
146
+ if (m !== 'unknown') {
147
+ day[m] = (day[m] || 0) + count
148
+ }
149
+ }
150
+ }
151
+
152
+ const sortedDates = Array.from(byDate.keys()).sort()
153
+ const points = sortedDates.map((date) => {
154
+ const day = byDate.get(date)!
155
+ const row: Record<string, string | number> = { date: date.slice(5) }
156
+ for (const m of top) {
157
+ row[shortenModelName(m)] = day[m] || 0
158
+ }
159
+ return row
160
+ })
161
+
162
+ const cfg: ChartConfig = {}
163
+ for (let i = 0; i < top.length; i++) {
164
+ const short = shortenModelName(top[i])
165
+ cfg[short] = { label: short, color: CHART_COLORS[i % CHART_COLORS.length] }
166
+ }
167
+
168
+ return { data: points, topModels: top.map(shortenModelName), config: cfg }
169
+ }, [sessions])
170
+
171
+ if (data.length === 0) {
172
+ return (
173
+ <Card>
174
+ <CardHeader>
175
+ <CardTitle>Model Usage Over Time</CardTitle>
176
+ <CardDescription>Daily messages by model</CardDescription>
177
+ </CardHeader>
178
+ <CardContent>
179
+ <div className="flex min-h-[250px] items-center justify-center text-sm text-muted-foreground">
180
+ No model data available
181
+ </div>
182
+ </CardContent>
183
+ </Card>
184
+ )
185
+ }
186
+
187
+ return (
188
+ <Card>
189
+ <CardHeader>
190
+ <CardTitle>Model Usage Over Time</CardTitle>
191
+ <CardDescription>Daily assistant messages by model</CardDescription>
192
+ </CardHeader>
193
+ <CardContent>
194
+ <ChartContainer config={config} className="min-h-[250px] w-full">
195
+ <AreaChart data={data} accessibilityLayer>
196
+ <CartesianGrid vertical={false} />
197
+ <XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
198
+ <YAxis tickLine={false} axisLine={false} />
199
+ <ChartTooltip content={<ChartTooltipContent />} />
200
+ {topModels.map((m) => (
201
+ <Area
202
+ key={m}
203
+ dataKey={m}
204
+ type="monotone"
205
+ stackId="1"
206
+ stroke={config[m]?.color}
207
+ fill={config[m]?.color}
208
+ fillOpacity={0.4}
209
+ />
210
+ ))}
211
+ </AreaChart>
212
+ </ChartContainer>
213
+ </CardContent>
214
+ </Card>
215
+ )
216
+ }
@@ -219,7 +219,7 @@ export function OverviewCards({ overview, sessions }: { overview: OverviewStats;
219
219
  },
220
220
  {
221
221
  title: 'Top Model',
222
- value: Object.entries(overview.models).sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A',
222
+ value: Object.entries(overview.models).filter(([k]) => k !== 'unknown').sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A',
223
223
  icon: Cpu,
224
224
  explanation: 'The Claude model used in the most sessions. Determined by the model field in assistant responses. If a session uses multiple models, the last one seen is recorded.',
225
225
  },
@@ -0,0 +1,318 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, useCallback } from 'react'
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5
+ import { Button } from '@/components/ui/button'
6
+ import { Badge } from '@/components/ui/badge'
7
+ import {
8
+ ChartContainer,
9
+ ChartTooltip,
10
+ ChartTooltipContent,
11
+ type ChartConfig,
12
+ } from '@/components/ui/chart'
13
+ import { Bar, BarChart, XAxis, YAxis, Cell } from 'recharts'
14
+ import { Sparkles, Loader2, AlertCircle } from 'lucide-react'
15
+ import { AnalyzeConfirmDialog } from './analyze-confirm-dialog'
16
+ import { formatCost } from '@/lib/format'
17
+ import type { MessageClassification } from '@/lib/openai'
18
+
19
+
20
+ const CHART_COLORS = [
21
+ 'var(--chart-1)',
22
+ 'var(--chart-2)',
23
+ 'var(--chart-3)',
24
+ 'var(--chart-4)',
25
+ 'var(--chart-5)',
26
+ 'var(--chart-6)',
27
+ 'var(--chart-7)',
28
+ 'var(--chart-8)',
29
+ 'var(--chart-9)',
30
+ 'var(--chart-10)',
31
+ ]
32
+
33
+ function DistributionChart({
34
+ title,
35
+ description,
36
+ data,
37
+ }: {
38
+ title: string
39
+ description: string
40
+ data: Record<string, number>
41
+ }) {
42
+ const chartData = Object.entries(data)
43
+ .sort((a, b) => b[1] - a[1])
44
+ .map(([name, count]) => ({ name, count }))
45
+
46
+ const config: ChartConfig = {
47
+ count: { label: 'Count', color: 'var(--chart-1)' },
48
+ }
49
+
50
+ const total = chartData.reduce((sum, d) => sum + d.count, 0)
51
+
52
+ return (
53
+ <Card>
54
+ <CardHeader className="pb-2">
55
+ <CardTitle className="text-sm">{title}</CardTitle>
56
+ <CardDescription>{description}</CardDescription>
57
+ </CardHeader>
58
+ <CardContent>
59
+ <ChartContainer
60
+ config={config}
61
+ className="w-full"
62
+ style={{ minHeight: Math.max(150, chartData.length * 32) }}
63
+ >
64
+ <BarChart data={chartData} layout="vertical" margin={{ left: 8 }} accessibilityLayer>
65
+ <XAxis type="number" tickLine={false} axisLine={false} />
66
+ <YAxis
67
+ type="category"
68
+ dataKey="name"
69
+ tickLine={false}
70
+ axisLine={false}
71
+ width={110}
72
+ tick={{ fontSize: 12 }}
73
+ />
74
+ <ChartTooltip
75
+ content={
76
+ <ChartTooltipContent
77
+ formatter={(value) => {
78
+ const n = Number(value)
79
+ return `${n} (${((n / total) * 100).toFixed(1)}%)`
80
+ }}
81
+ />
82
+ }
83
+ />
84
+ <Bar dataKey="count" radius={[0, 4, 4, 0]}>
85
+ {chartData.map((_, i) => (
86
+ <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
87
+ ))}
88
+ </Bar>
89
+ </BarChart>
90
+ </ChartContainer>
91
+ </CardContent>
92
+ </Card>
93
+ )
94
+ }
95
+
96
+ interface AnalysisData {
97
+ classifications: MessageClassification[]
98
+ model: string
99
+ totalMessages: number
100
+ inputTokens: number
101
+ outputTokens: number
102
+ costUSD: number
103
+ analyzedAt: string
104
+ }
105
+
106
+ export function SessionAIAnalysis({ sessionId }: { sessionId: string }) {
107
+ const [analysis, setAnalysis] = useState<AnalysisData | null>(null)
108
+ const [loading, setLoading] = useState(true)
109
+ const [analyzing, setAnalyzing] = useState(false)
110
+ const [error, setError] = useState<string | null>(null)
111
+ const [confirmOpen, setConfirmOpen] = useState(false)
112
+ const [estimate, setEstimate] = useState<{
113
+ sessionCount: number
114
+ messageCount: number
115
+ estimatedCostUSD: number
116
+ } | null>(null)
117
+
118
+ const loadAnalysis = useCallback(async () => {
119
+ try {
120
+ const res = await fetch(`/api/analyze?sessionId=${sessionId}`)
121
+ const data = await res.json()
122
+ if (data.analysis) setAnalysis(data.analysis)
123
+ } catch {
124
+ // No analysis yet — that's fine
125
+ } finally {
126
+ setLoading(false)
127
+ }
128
+ }, [sessionId])
129
+
130
+ useEffect(() => {
131
+ loadAnalysis()
132
+ }, [loadAnalysis])
133
+
134
+ async function handleAnalyzeClick() {
135
+ // Fetch estimate
136
+ setEstimate(null)
137
+ setConfirmOpen(true)
138
+ try {
139
+ const res = await fetch('/api/analyze/estimate', {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({ sessionId }),
143
+ })
144
+ const data = await res.json()
145
+ setEstimate({
146
+ sessionCount: 1,
147
+ messageCount: data.messageCount,
148
+ estimatedCostUSD: data.estimatedCostUSD,
149
+ })
150
+ } catch {
151
+ setError('Failed to estimate cost')
152
+ setConfirmOpen(false)
153
+ }
154
+ }
155
+
156
+ async function handleConfirm() {
157
+ setAnalyzing(true)
158
+ setError(null)
159
+ setConfirmOpen(false)
160
+ try {
161
+ const res = await fetch('/api/analyze', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ sessionId }),
165
+ })
166
+ if (!res.ok) {
167
+ const data = await res.json()
168
+ throw new Error(data.error || 'Analysis failed')
169
+ }
170
+ const data = await res.json()
171
+ setAnalysis(data.analysis)
172
+ } catch (e) {
173
+ setError((e as Error).message)
174
+ } finally {
175
+ setAnalyzing(false)
176
+ }
177
+ }
178
+
179
+ if (loading) {
180
+ return (
181
+ <div className="flex items-center justify-center py-12 text-muted-foreground">
182
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading...
183
+ </div>
184
+ )
185
+ }
186
+
187
+ if (!analysis) {
188
+ return (
189
+ <Card>
190
+ <CardContent className="flex flex-col items-center justify-center py-12 space-y-4">
191
+ <Sparkles className="h-10 w-10 text-muted-foreground" />
192
+ <div className="text-center space-y-1">
193
+ <p className="font-medium">No AI analysis yet</p>
194
+ <p className="text-sm text-muted-foreground">
195
+ Classify user messages by type, role, skill level, and sentiment
196
+ </p>
197
+ </div>
198
+ {error && (
199
+ <div className="flex items-center gap-2 text-sm text-destructive">
200
+ <AlertCircle className="h-4 w-4" /> {error}
201
+ </div>
202
+ )}
203
+ <Button onClick={handleAnalyzeClick} disabled={analyzing}>
204
+ {analyzing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
205
+ <Sparkles className="mr-1 h-3 w-3" />
206
+ Analyze with AI
207
+ </Button>
208
+ <AnalyzeConfirmDialog
209
+ open={confirmOpen}
210
+ onOpenChange={setConfirmOpen}
211
+ onConfirm={handleConfirm}
212
+ loading={analyzing}
213
+ estimate={estimate}
214
+ />
215
+ </CardContent>
216
+ </Card>
217
+ )
218
+ }
219
+
220
+ // Build distributions from classifications
221
+ const messageTypes: Record<string, number> = {}
222
+ const roles: Record<string, number> = {}
223
+ const skillLevels: Record<string, number> = {}
224
+ const sentiments: Record<string, number> = {}
225
+
226
+ for (const cls of analysis.classifications) {
227
+ messageTypes[cls.messageType] = (messageTypes[cls.messageType] || 0) + 1
228
+ roles[cls.role] = (roles[cls.role] || 0) + 1
229
+ skillLevels[cls.skillLevel] = (skillLevels[cls.skillLevel] || 0) + 1
230
+ sentiments[cls.sentiment] = (sentiments[cls.sentiment] || 0) + 1
231
+ }
232
+
233
+ return (
234
+ <div className="space-y-4">
235
+ <div className="flex items-center justify-between">
236
+ <div className="flex items-center gap-2">
237
+ <Badge variant="outline" className="text-xs">
238
+ {analysis.totalMessages} messages classified
239
+ </Badge>
240
+ <Badge variant="outline" className="text-xs">
241
+ {analysis.model}
242
+ </Badge>
243
+ <Badge variant="outline" className="text-xs">
244
+ Cost: {formatCost(analysis.costUSD)}
245
+ </Badge>
246
+ </div>
247
+ </div>
248
+
249
+ <div className="grid gap-4 md:grid-cols-2">
250
+ <DistributionChart
251
+ title="Message Type"
252
+ description="What kind of messages you sent"
253
+ data={messageTypes}
254
+ />
255
+ <DistributionChart
256
+ title="Role"
257
+ description="Professional role implied by each message"
258
+ data={roles}
259
+ />
260
+ <DistributionChart
261
+ title="Skill Level"
262
+ description="Technical skill level of each message"
263
+ data={skillLevels}
264
+ />
265
+ <DistributionChart
266
+ title="Sentiment"
267
+ description="Emotional tone of each message"
268
+ data={sentiments}
269
+ />
270
+ </div>
271
+
272
+ <Card>
273
+ <CardHeader className="pb-2">
274
+ <CardTitle className="text-sm">Message Classifications</CardTitle>
275
+ <CardDescription>Per-message breakdown</CardDescription>
276
+ </CardHeader>
277
+ <CardContent>
278
+ <div className="space-y-2 max-h-96 overflow-y-auto">
279
+ {analysis.classifications.map((cls, i) => (
280
+ <div
281
+ key={i}
282
+ className="flex items-start gap-2 rounded-md border p-2 text-xs"
283
+ >
284
+ <span className="shrink-0 text-muted-foreground w-6 text-right">
285
+ {i + 1}.
286
+ </span>
287
+ <span className="flex-1 line-clamp-1">{cls.messagePreview}</span>
288
+ <div className="flex gap-1 shrink-0">
289
+ <Badge variant="secondary" className="text-[10px] px-1.5">
290
+ {cls.messageType}
291
+ </Badge>
292
+ <Badge variant="outline" className="text-[10px] px-1.5">
293
+ {cls.role}
294
+ </Badge>
295
+ <Badge variant="outline" className="text-[10px] px-1.5">
296
+ {cls.skillLevel}
297
+ </Badge>
298
+ <Badge
299
+ variant="outline"
300
+ className={`text-[10px] px-1.5 ${
301
+ cls.sentiment === 'frustrated'
302
+ ? 'border-destructive text-destructive'
303
+ : cls.sentiment === 'positive'
304
+ ? 'border-green-600 text-green-600'
305
+ : ''
306
+ }`}
307
+ >
308
+ {cls.sentiment}
309
+ </Badge>
310
+ </div>
311
+ </div>
312
+ ))}
313
+ </div>
314
+ </CardContent>
315
+ </Card>
316
+ </div>
317
+ )
318
+ }