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
@@ -1,8 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useEffect, useCallback } from 'react'
4
4
  import Link from 'next/link'
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
6
+ import { Button } from '@/components/ui/button'
6
7
  import {
7
8
  Table,
8
9
  TableBody,
@@ -21,12 +22,124 @@ import {
21
22
  import { Badge } from '@/components/ui/badge'
22
23
  import { PaginationControls } from '@/components/pagination-controls'
23
24
  import { usePagination } from '@/hooks/use-pagination'
24
- import { Eye } from 'lucide-react'
25
+ import { AnalyzeConfirmDialog } from '@/components/analyze-confirm-dialog'
26
+ import { Eye, Sparkles, Loader2 } from 'lucide-react'
25
27
  import type { SessionSummary } from '@/lib/parse-logs'
26
28
  import { formatCost, formatTokens, formatDuration, formatNumber } from '@/lib/format'
27
29
 
28
30
  export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
29
31
  const [projectFilter, setProjectFilter] = useState<string>('all')
32
+ const [analyzedIds, setAnalyzedIds] = useState<Set<string>>(new Set())
33
+ const [analyzingIds, setAnalyzingIds] = useState<Set<string>>(new Set())
34
+ const [hasApiKey, setHasApiKey] = useState(false)
35
+ const [bulkAnalyzing, setBulkAnalyzing] = useState(false)
36
+ const [confirmOpen, setConfirmOpen] = useState(false)
37
+ const [bulkEstimate, setBulkEstimate] = useState<{
38
+ sessionCount: number
39
+ messageCount: number
40
+ estimatedCostUSD: number
41
+ } | null>(null)
42
+ const [bulkSessionIds, setBulkSessionIds] = useState<string[]>([])
43
+
44
+ const loadAnalysisStatus = useCallback(async () => {
45
+ try {
46
+ const [analyzeRes, configRes] = await Promise.all([
47
+ fetch('/api/analyze?status=true'),
48
+ fetch('/api/config'),
49
+ ])
50
+ const analyzeData = await analyzeRes.json()
51
+ const configData = await configRes.json()
52
+ if (analyzeData.analyses) {
53
+ setAnalyzedIds(new Set(analyzeData.analyses.map((a: { sessionId: string }) => a.sessionId)))
54
+ }
55
+ setHasApiKey(configData.hasOpenAIKey || false)
56
+ } catch {
57
+ // ignore
58
+ }
59
+ }, [])
60
+
61
+ useEffect(() => {
62
+ loadAnalysisStatus()
63
+ }, [loadAnalysisStatus])
64
+
65
+ async function handleAnalyzeOne(sessionId: string) {
66
+ setAnalyzingIds((prev) => new Set(prev).add(sessionId))
67
+ try {
68
+ const res = await fetch('/api/analyze', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ sessionId }),
72
+ })
73
+ if (res.ok) {
74
+ setAnalyzedIds((prev) => new Set(prev).add(sessionId))
75
+ }
76
+ } catch {
77
+ // ignore
78
+ } finally {
79
+ setAnalyzingIds((prev) => {
80
+ const next = new Set(prev)
81
+ next.delete(sessionId)
82
+ return next
83
+ })
84
+ }
85
+ }
86
+
87
+ async function handleBulkClick() {
88
+ const unanalyzed = filtered
89
+ .filter((s) => !analyzedIds.has(s.sessionId))
90
+ .map((s) => s.sessionId)
91
+
92
+ if (unanalyzed.length === 0) return
93
+
94
+ setBulkSessionIds(unanalyzed)
95
+ setBulkEstimate(null)
96
+ setConfirmOpen(true)
97
+
98
+ try {
99
+ const res = await fetch('/api/analyze/estimate', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ sessionIds: unanalyzed }),
103
+ })
104
+ const data = await res.json()
105
+ setBulkEstimate({
106
+ sessionCount: unanalyzed.length,
107
+ messageCount: data.messageCount,
108
+ estimatedCostUSD: data.estimatedCostUSD,
109
+ })
110
+ } catch {
111
+ setConfirmOpen(false)
112
+ }
113
+ }
114
+
115
+ async function handleBulkConfirm() {
116
+ setConfirmOpen(false)
117
+ setBulkAnalyzing(true)
118
+
119
+ for (const sessionId of bulkSessionIds) {
120
+ setAnalyzingIds((prev) => new Set(prev).add(sessionId))
121
+ try {
122
+ const res = await fetch('/api/analyze', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ sessionId }),
126
+ })
127
+ if (res.ok) {
128
+ setAnalyzedIds((prev) => new Set(prev).add(sessionId))
129
+ }
130
+ } catch {
131
+ // continue with next
132
+ } finally {
133
+ setAnalyzingIds((prev) => {
134
+ const next = new Set(prev)
135
+ next.delete(sessionId)
136
+ return next
137
+ })
138
+ }
139
+ }
140
+
141
+ setBulkAnalyzing(false)
142
+ }
30
143
 
31
144
  const projects = [...new Set(sessions.map((s) => s.project))].sort()
32
145
  const filtered =
@@ -42,19 +155,33 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
42
155
  <CardTitle>Sessions</CardTitle>
43
156
  <CardDescription>{filtered.length} sessions recorded</CardDescription>
44
157
  </div>
45
- <Select value={projectFilter} onValueChange={(v) => setProjectFilter(v ?? 'all')}>
46
- <SelectTrigger className="w-[200px]">
47
- <SelectValue placeholder="Filter by project" />
48
- </SelectTrigger>
49
- <SelectContent>
50
- <SelectItem value="all">All projects</SelectItem>
51
- {projects.map((p) => (
52
- <SelectItem key={p} value={p}>
53
- {p}
54
- </SelectItem>
55
- ))}
56
- </SelectContent>
57
- </Select>
158
+ <div className="flex items-center gap-2">
159
+ {hasApiKey && (
160
+ <Button
161
+ variant="outline"
162
+ size="sm"
163
+ onClick={handleBulkClick}
164
+ disabled={bulkAnalyzing || filtered.every((s) => analyzedIds.has(s.sessionId))}
165
+ >
166
+ {bulkAnalyzing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
167
+ <Sparkles className="mr-1 h-3 w-3" />
168
+ Analyze All
169
+ </Button>
170
+ )}
171
+ <Select value={projectFilter} onValueChange={(v) => setProjectFilter(v ?? 'all')}>
172
+ <SelectTrigger className="w-[200px]">
173
+ <SelectValue placeholder="Filter by project" />
174
+ </SelectTrigger>
175
+ <SelectContent>
176
+ <SelectItem value="all">All projects</SelectItem>
177
+ {projects.map((p) => (
178
+ <SelectItem key={p} value={p}>
179
+ {p}
180
+ </SelectItem>
181
+ ))}
182
+ </SelectContent>
183
+ </Select>
184
+ </div>
58
185
  </div>
59
186
  </CardHeader>
60
187
  <CardContent>
@@ -69,6 +196,7 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
69
196
  <TableHead className="text-right">Cost</TableHead>
70
197
  <TableHead className="text-right">Duration</TableHead>
71
198
  <TableHead className="text-right">Tools</TableHead>
199
+ <TableHead className="text-center">AI</TableHead>
72
200
  <TableHead className="w-[60px]"></TableHead>
73
201
  </TableRow>
74
202
  </TableHeader>
@@ -89,6 +217,25 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
89
217
  <TableCell className="text-right">{formatCost(s.costUSD)}</TableCell>
90
218
  <TableCell className="text-right">{formatDuration(s.durationMinutes)}</TableCell>
91
219
  <TableCell className="text-right">{formatNumber(s.toolCallsTotal)}</TableCell>
220
+ <TableCell className="text-center">
221
+ {analyzedIds.has(s.sessionId) ? (
222
+ <Badge variant="outline" className="text-[10px] text-green-600 border-green-600">
223
+ Done
224
+ </Badge>
225
+ ) : analyzingIds.has(s.sessionId) ? (
226
+ <Loader2 className="h-3 w-3 animate-spin mx-auto text-muted-foreground" />
227
+ ) : hasApiKey ? (
228
+ <button
229
+ onClick={() => handleAnalyzeOne(s.sessionId)}
230
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
231
+ title="Analyze with AI"
232
+ >
233
+ <Sparkles className="h-3 w-3" />
234
+ </button>
235
+ ) : (
236
+ <span className="text-xs text-muted-foreground">—</span>
237
+ )}
238
+ </TableCell>
92
239
  <TableCell>
93
240
  <Link href={`/sessions/${s.sessionId}`} className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
94
241
  <Eye className="h-4 w-4" />
@@ -99,6 +246,13 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
99
246
  </TableBody>
100
247
  </Table>
101
248
  <PaginationControls pagination={pagination} noun="sessions" />
249
+ <AnalyzeConfirmDialog
250
+ open={confirmOpen}
251
+ onOpenChange={setConfirmOpen}
252
+ onConfirm={handleBulkConfirm}
253
+ loading={bulkAnalyzing}
254
+ estimate={bulkEstimate}
255
+ />
102
256
  </CardContent>
103
257
  </Card>
104
258
  )
@@ -0,0 +1,284 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState, useEffect } from 'react'
4
+ import {
5
+ Bar,
6
+ Line,
7
+ ComposedChart,
8
+ CartesianGrid,
9
+ XAxis,
10
+ YAxis,
11
+ } from 'recharts'
12
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
13
+ import {
14
+ ChartContainer,
15
+ ChartTooltip,
16
+ type ChartConfig,
17
+ } from '@/components/ui/chart'
18
+ import type { SessionSummary } from '@/lib/parse-logs'
19
+
20
+ const chartConfig = {
21
+ releases: { label: 'New Releases', color: 'var(--chart-2)' },
22
+ behind: { label: 'Versions Behind', color: 'var(--chart-5)' },
23
+ } satisfies ChartConfig
24
+
25
+ function semverToNum(v: string): number {
26
+ const parts = v.split('.').map(Number)
27
+ return (parts[0] || 0) * 1_000_000 + (parts[1] || 0) * 1_000 + (parts[2] || 0)
28
+ }
29
+
30
+ // Only match stable semver (no prerelease/beta suffixes)
31
+ function isStableVersion(v: string): boolean {
32
+ return /^\d+\.\d+\.\d+$/.test(v)
33
+ }
34
+
35
+ export function VersionLagChart({ sessions }: { sessions: SessionSummary[] }) {
36
+ const [npmVersions, setNpmVersions] = useState<Record<string, string>>({})
37
+
38
+ useEffect(() => {
39
+ fetch('/api/cc-versions')
40
+ .then((r) => r.json())
41
+ .then((data) => setNpmVersions(data))
42
+ .catch(() => {})
43
+ }, [])
44
+
45
+ const { data, currentLag, currentUserVersion, currentLatestVersion, totalReleases } = useMemo(() => {
46
+ const empty = { data: [], currentLag: 0, currentUserVersion: '', currentLatestVersion: '', totalReleases: 0 }
47
+ if (!Object.keys(npmVersions).length || !sessions.length) return empty
48
+
49
+ // Filter to stable versions only
50
+ const stableVersions = Object.entries(npmVersions)
51
+ .filter(([v]) => isStableVersion(v))
52
+ .map(([version, date]) => ({
53
+ version,
54
+ date: date.slice(0, 10),
55
+ num: semverToNum(version),
56
+ }))
57
+ .sort((a, b) => a.num - b.num)
58
+
59
+ // Determine session date range first
60
+ const sessionDates = sessions
61
+ .filter(s => s.cliVersion && s.cliVersion !== 'unknown')
62
+ .map(s => s.startTime.slice(0, 10))
63
+ .sort()
64
+ const rangeStart = sessionDates[0] || ''
65
+ const rangeEnd = sessionDates[sessionDates.length - 1] || ''
66
+
67
+ // Count releases and build per-day map within the session date range
68
+ let total = 0
69
+ const releasesPerDay = new Map<string, number>()
70
+ for (const v of stableVersions) {
71
+ if (v.date >= rangeStart && v.date <= rangeEnd) {
72
+ total++
73
+ releasesPerDay.set(v.date, (releasesPerDay.get(v.date) || 0) + 1)
74
+ }
75
+ }
76
+
77
+ // Count how many stable versions are newer than userVersion as of date
78
+ function countBehind(userVersion: string, date: string): number {
79
+ const userNum = semverToNum(userVersion)
80
+ let count = 0
81
+ for (const v of stableVersions) {
82
+ if (v.date <= date && v.num > userNum) count++
83
+ }
84
+ return count
85
+ }
86
+
87
+ function latestAtDate(date: string): string {
88
+ let best = ''
89
+ let bestNum = 0
90
+ for (const v of stableVersions) {
91
+ if (v.date <= date && v.num > bestNum) {
92
+ best = v.version
93
+ bestNum = v.num
94
+ }
95
+ }
96
+ return best
97
+ }
98
+
99
+ // Group sessions by date
100
+ const byDate = new Map<string, string[]>()
101
+ for (const s of sessions) {
102
+ if (!s.cliVersion || s.cliVersion === 'unknown') continue
103
+ const date = s.startTime.slice(0, 10)
104
+ if (!byDate.has(date)) byDate.set(date, [])
105
+ byDate.get(date)!.push(s.cliVersion)
106
+ }
107
+
108
+ if (byDate.size === 0) return empty
109
+
110
+ // Build date range: from first session date to last
111
+ const sortedDates = Array.from(byDate.keys()).sort()
112
+ const startDate = new Date(sortedDates[0])
113
+ const endDate = new Date(sortedDates[sortedDates.length - 1])
114
+
115
+ // Fill every day in the range
116
+ const points: {
117
+ date: string
118
+ releases: number
119
+ behind: number
120
+ userVersion: string
121
+ latestVersion: string
122
+ }[] = []
123
+
124
+ let lastUserVersion = ''
125
+ const d = new Date(startDate)
126
+ while (d <= endDate) {
127
+ const dateStr = d.toLocaleDateString('en-CA') // YYYY-MM-DD
128
+ const dayVersions = byDate.get(dateStr)
129
+
130
+ if (dayVersions) {
131
+ // Pick most common version that day
132
+ const counts = new Map<string, number>()
133
+ for (const v of dayVersions) {
134
+ counts.set(v, (counts.get(v) || 0) + 1)
135
+ }
136
+ lastUserVersion = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0][0]
137
+ }
138
+
139
+ if (lastUserVersion) {
140
+ const latest = latestAtDate(dateStr)
141
+ const behind = countBehind(lastUserVersion, dateStr)
142
+
143
+ points.push({
144
+ date: dateStr.slice(5),
145
+ releases: releasesPerDay.get(dateStr) || 0,
146
+ behind,
147
+ userVersion: lastUserVersion,
148
+ latestVersion: latest,
149
+ })
150
+ }
151
+
152
+ d.setDate(d.getDate() + 1)
153
+ }
154
+
155
+ const last = points[points.length - 1]
156
+ return {
157
+ data: points,
158
+ currentLag: last?.behind ?? 0,
159
+ currentUserVersion: last?.userVersion ?? '',
160
+ currentLatestVersion: last?.latestVersion ?? '',
161
+ totalReleases: total,
162
+ }
163
+ }, [sessions, npmVersions])
164
+
165
+ if (data.length === 0) {
166
+ return (
167
+ <Card>
168
+ <CardHeader>
169
+ <CardTitle>Version Freshness</CardTitle>
170
+ <CardDescription>Your Claude Code version vs latest release over time</CardDescription>
171
+ </CardHeader>
172
+ <CardContent>
173
+ <div className="flex min-h-[250px] items-center justify-center text-sm text-muted-foreground">
174
+ No version data available. Re-sync to capture CLI versions.
175
+ </div>
176
+ </CardContent>
177
+ </Card>
178
+ )
179
+ }
180
+
181
+ return (
182
+ <Card>
183
+ <CardHeader>
184
+ <CardTitle>Version Freshness</CardTitle>
185
+ <CardDescription className="flex flex-wrap items-center gap-x-4 gap-y-1">
186
+ {currentLag === 0 ? (
187
+ <span className="text-green-600 dark:text-green-400">
188
+ Up to date (v{currentUserVersion})
189
+ </span>
190
+ ) : (
191
+ <span>
192
+ v{currentUserVersion} —{' '}
193
+ <strong className="text-amber-600 dark:text-amber-400">
194
+ {currentLag} release{currentLag !== 1 ? 's' : ''} behind
195
+ </strong>{' '}
196
+ (latest: v{currentLatestVersion})
197
+ </span>
198
+ )}
199
+ <span className="text-xs text-muted-foreground">
200
+ {totalReleases} stable releases
201
+ </span>
202
+ </CardDescription>
203
+ </CardHeader>
204
+ <CardContent>
205
+ <ChartContainer config={chartConfig} className="min-h-[250px] w-full">
206
+ <ComposedChart data={data} accessibilityLayer>
207
+ <CartesianGrid vertical={false} />
208
+ <XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
209
+ <YAxis
210
+ yAxisId="left"
211
+ tickLine={false}
212
+ axisLine={false}
213
+ allowDecimals={false}
214
+ />
215
+ <ChartTooltip
216
+ content={({ active, payload }) => {
217
+ if (!active || !payload?.length) return null
218
+ const d = payload[0].payload
219
+ return (
220
+ <div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
221
+ <div className="font-semibold">{d.date}</div>
222
+ <div className="mt-1 space-y-0.5 text-muted-foreground">
223
+ <div className="flex justify-between gap-4">
224
+ <span className="flex items-center gap-1.5">
225
+ <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'var(--chart-2)' }} />
226
+ New releases
227
+ </span>
228
+ <span className="font-mono font-medium text-foreground">{d.releases}</span>
229
+ </div>
230
+ <div className="flex justify-between gap-4">
231
+ <span>Your version</span>
232
+ <span className="font-mono font-medium text-foreground">v{d.userVersion}</span>
233
+ </div>
234
+ <div className="flex justify-between gap-4">
235
+ <span>Latest</span>
236
+ <span className="font-mono font-medium text-foreground">v{d.latestVersion}</span>
237
+ </div>
238
+ <div className="flex justify-between gap-4 border-t pt-0.5">
239
+ <span className="flex items-center gap-1.5">
240
+ <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'var(--chart-5)' }} />
241
+ Behind
242
+ </span>
243
+ <span className={`font-mono font-semibold ${d.behind === 0 ? 'text-green-600' : d.behind <= 3 ? 'text-amber-500' : 'text-red-500'}`}>
244
+ {d.behind === 0 ? 'up to date' : `${d.behind} release${d.behind !== 1 ? 's' : ''}`}
245
+ </span>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ )
250
+ }}
251
+ />
252
+ <Bar
253
+ yAxisId="left"
254
+ dataKey="releases"
255
+ fill="var(--color-releases)"
256
+ fillOpacity={0.3}
257
+ radius={[2, 2, 0, 0]}
258
+ />
259
+ <Line
260
+ yAxisId="left"
261
+ dataKey="behind"
262
+ type="monotone"
263
+ stroke="var(--color-behind)"
264
+ strokeWidth={2}
265
+ dot={false}
266
+ activeDot={{ r: 4, fill: 'var(--color-behind)' }}
267
+ />
268
+ </ComposedChart>
269
+ </ChartContainer>
270
+ {/* Legend */}
271
+ <div className="mt-2 flex items-center justify-end gap-4 text-xs text-muted-foreground">
272
+ <span className="flex items-center gap-1.5">
273
+ <span className="inline-block h-2.5 w-2.5 rounded-sm opacity-40" style={{ backgroundColor: 'var(--chart-2)' }} />
274
+ Daily releases
275
+ </span>
276
+ <span className="flex items-center gap-1.5">
277
+ <span className="inline-block h-0.5 w-4 rounded" style={{ backgroundColor: 'var(--chart-5)' }} />
278
+ Versions behind
279
+ </span>
280
+ </div>
281
+ </CardContent>
282
+ </Card>
283
+ )
284
+ }