agentfit 0.1.0 → 0.1.2
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.
- package/.github/workflows/release.yml +111 -0
- package/README.md +41 -38
- package/app/(dashboard)/daily/page.tsx +1 -1
- package/app/(dashboard)/data-management/page.tsx +180 -0
- package/app/(dashboard)/flow/page.tsx +17 -0
- package/app/(dashboard)/layout.tsx +2 -0
- package/app/(dashboard)/page.tsx +24 -5
- package/app/(dashboard)/reports/[id]/page.tsx +72 -0
- package/app/(dashboard)/reports/page.tsx +132 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
- package/app/api/backup/route.ts +215 -0
- package/app/api/check/route.ts +11 -1
- package/app/api/command-insights/route.ts +13 -0
- package/app/api/commands/route.ts +55 -1
- package/app/api/images-analysis/route.ts +3 -4
- package/app/api/reports/[id]/route.ts +23 -0
- package/app/api/reports/route.ts +50 -0
- package/app/api/reset/route.ts +21 -0
- package/app/api/session/route.ts +40 -0
- package/app/api/usage/route.ts +26 -1
- package/app/layout.tsx +1 -1
- package/bin/agentfit.mjs +2 -2
- package/components/agent-coach.tsx +256 -129
- package/components/app-sidebar.tsx +45 -10
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +447 -83
- package/components/dashboard-shell.tsx +29 -31
- package/components/data-provider.tsx +88 -8
- package/components/fitness-score.tsx +95 -54
- package/components/overview-cards.tsx +148 -41
- package/components/report-view.tsx +307 -0
- package/components/screenshots-analysis.tsx +51 -46
- package/components/session-chatlog.tsx +124 -0
- package/components/session-timeline.tsx +184 -0
- package/components/session-workflow.tsx +183 -0
- package/components/sessions-table.tsx +9 -1
- package/components/tool-flow-graph.tsx +144 -0
- package/components/ui/carousel.tsx +242 -0
- package/components/ui/sidebar.tsx +1 -1
- package/components/ui/sonner.tsx +51 -0
- package/electron/entitlements.mac.plist +16 -0
- package/electron/init-db.mjs +37 -0
- package/electron/main.mjs +203 -0
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +97 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +21 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +221 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +571 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +2 -2
- package/lib/parse-codex.ts +6 -0
- package/lib/parse-logs.ts +80 -1
- package/lib/queries-codex.ts +24 -0
- package/lib/queries.ts +45 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +87 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +17 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/migrations/20260401144555_add_system_prompt_edits/migration.sql +80 -0
- package/prisma/schema.prisma +18 -0
- package/prisma/schema.sql +81 -0
- package/.claude/settings.local.json +0 -26
- package/CONTRIBUTING.md +0 -209
- package/prisma/migrations/20260328152517_init/migration.sql +0 -41
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
- package/prisma.config.ts +0 -14
- package/setup.sh +0 -73
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { prisma } from '@/lib/db'
|
|
3
|
+
import { generateReport } from '@/lib/report'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
export async function GET() {
|
|
8
|
+
try {
|
|
9
|
+
const reports = await prisma.report.findMany({
|
|
10
|
+
orderBy: { generatedAt: 'desc' },
|
|
11
|
+
select: { id: true, title: true, generatedAt: true, sessionCount: true },
|
|
12
|
+
})
|
|
13
|
+
return NextResponse.json(reports)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return NextResponse.json({ error: (error as Error).message }, { status: 500 })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function POST() {
|
|
20
|
+
try {
|
|
21
|
+
const { title, contentJson, sessionCount } = await generateReport()
|
|
22
|
+
|
|
23
|
+
// Check if data has changed since last report
|
|
24
|
+
const lastReport = await prisma.report.findFirst({
|
|
25
|
+
orderBy: { generatedAt: 'desc' },
|
|
26
|
+
select: { sessionCount: true },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (lastReport && lastReport.sessionCount === sessionCount) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: 'No new data since the last report. Sync new sessions first.' },
|
|
32
|
+
{ status: 409 }
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const report = await prisma.report.create({
|
|
37
|
+
data: {
|
|
38
|
+
title,
|
|
39
|
+
contentJson: JSON.stringify(contentJson),
|
|
40
|
+
sessionCount,
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
return NextResponse.json({
|
|
44
|
+
...report,
|
|
45
|
+
contentJson: JSON.parse(report.contentJson),
|
|
46
|
+
})
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return NextResponse.json({ error: (error as Error).message }, { status: 500 })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { prisma } from '@/lib/db'
|
|
3
|
+
import { syncLogs } from '@/lib/sync'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
export async function POST() {
|
|
8
|
+
try {
|
|
9
|
+
// Delete all records in order (images reference sessions)
|
|
10
|
+
await prisma.image.deleteMany()
|
|
11
|
+
await prisma.session.deleteMany()
|
|
12
|
+
await prisma.syncLog.deleteMany()
|
|
13
|
+
|
|
14
|
+
// Re-sync from disk
|
|
15
|
+
const result = await syncLogs()
|
|
16
|
+
return NextResponse.json(result)
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error('Reset failed:', error)
|
|
19
|
+
return NextResponse.json({ error: 'Reset failed' }, { status: 500 })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import fs from 'fs'
|
|
5
|
+
import { parseSessionDetail } from '@/lib/session-detail'
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic'
|
|
8
|
+
|
|
9
|
+
export async function GET(request: NextRequest) {
|
|
10
|
+
const sessionId = request.nextUrl.searchParams.get('id')
|
|
11
|
+
if (!sessionId) {
|
|
12
|
+
return NextResponse.json({ error: 'Missing session id' }, { status: 400 })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Search for the JSONL file across all project directories
|
|
17
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects')
|
|
18
|
+
let filePath: string | null = null
|
|
19
|
+
|
|
20
|
+
if (fs.existsSync(projectsDir)) {
|
|
21
|
+
const dirs = fs.readdirSync(projectsDir)
|
|
22
|
+
for (const dir of dirs) {
|
|
23
|
+
const candidate = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
|
24
|
+
if (fs.existsSync(candidate)) {
|
|
25
|
+
filePath = candidate
|
|
26
|
+
break
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!filePath) {
|
|
32
|
+
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const detail = parseSessionDetail(filePath, sessionId)
|
|
36
|
+
return NextResponse.json(detail)
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return NextResponse.json({ error: (error as Error).message }, { status: 500 })
|
|
39
|
+
}
|
|
40
|
+
}
|
package/app/api/usage/route.ts
CHANGED
|
@@ -65,6 +65,8 @@ function mergeUsageData(a: UsageData, b: UsageData): UsageData {
|
|
|
65
65
|
if (existing) {
|
|
66
66
|
existing.sessions += day.sessions
|
|
67
67
|
existing.messages += day.messages
|
|
68
|
+
existing.userMessages += day.userMessages
|
|
69
|
+
existing.assistantMessages += day.assistantMessages
|
|
68
70
|
existing.inputTokens += day.inputTokens
|
|
69
71
|
existing.outputTokens += day.outputTokens
|
|
70
72
|
existing.cacheCreationTokens += day.cacheCreationTokens
|
|
@@ -72,8 +74,13 @@ function mergeUsageData(a: UsageData, b: UsageData): UsageData {
|
|
|
72
74
|
existing.totalTokens += day.totalTokens
|
|
73
75
|
existing.costUSD += day.costUSD
|
|
74
76
|
existing.toolCalls += day.toolCalls
|
|
77
|
+
existing.interruptions += day.interruptions
|
|
78
|
+
existing.rateLimitErrors += day.rateLimitErrors
|
|
79
|
+
for (const [tool, count] of Object.entries(day.toolCallsDetail)) {
|
|
80
|
+
existing.toolCallsDetail[tool] = (existing.toolCallsDetail[tool] || 0) + count
|
|
81
|
+
}
|
|
75
82
|
} else {
|
|
76
|
-
dailyMap.set(day.date, { ...day })
|
|
83
|
+
dailyMap.set(day.date, { ...day, toolCallsDetail: { ...day.toolCallsDetail } })
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
|
|
@@ -107,7 +114,25 @@ function mergeUsageData(a: UsageData, b: UsageData): UsageData {
|
|
|
107
114
|
totalCostUSD: a.overview.totalCostUSD + b.overview.totalCostUSD,
|
|
108
115
|
totalDurationMinutes: a.overview.totalDurationMinutes + b.overview.totalDurationMinutes,
|
|
109
116
|
totalToolCalls: a.overview.totalToolCalls + b.overview.totalToolCalls,
|
|
117
|
+
totalApiErrors: a.overview.totalApiErrors + b.overview.totalApiErrors,
|
|
118
|
+
totalRateLimitDays: a.overview.totalRateLimitDays + b.overview.totalRateLimitDays,
|
|
119
|
+
totalUserInterruptions: a.overview.totalUserInterruptions + b.overview.totalUserInterruptions,
|
|
120
|
+
totalSystemPromptEdits: (a.overview.totalSystemPromptEdits ?? 0) + (b.overview.totalSystemPromptEdits ?? 0),
|
|
110
121
|
models,
|
|
122
|
+
skillUsage: (() => {
|
|
123
|
+
const skills: Record<string, number> = { ...a.overview.skillUsage }
|
|
124
|
+
for (const [s, c] of Object.entries(b.overview.skillUsage)) {
|
|
125
|
+
skills[s] = (skills[s] || 0) + c
|
|
126
|
+
}
|
|
127
|
+
return skills
|
|
128
|
+
})(),
|
|
129
|
+
permissionModes: (() => {
|
|
130
|
+
const modes: Record<string, number> = { ...(a.overview.permissionModes || {}) }
|
|
131
|
+
for (const [m, c] of Object.entries(b.overview.permissionModes || {})) {
|
|
132
|
+
modes[m] = (modes[m] || 0) + c
|
|
133
|
+
}
|
|
134
|
+
return modes
|
|
135
|
+
})(),
|
|
111
136
|
},
|
|
112
137
|
sessions,
|
|
113
138
|
projects,
|
package/app/layout.tsx
CHANGED
|
@@ -12,7 +12,7 @@ const fontMono = Geist_Mono({
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
export const metadata = {
|
|
15
|
-
title: 'AgentFit — Coding
|
|
15
|
+
title: 'AgentFit — Your AI Coding Fitness Tracker',
|
|
16
16
|
description: 'Track your AI coding agent\'s fitness — usage, costs, personality, and behavioral patterns across Claude Code, Codex, and more.',
|
|
17
17
|
}
|
|
18
18
|
|
package/bin/agentfit.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function run(cmd, opts = {}) {
|
|
|
26
26
|
// ─── Ensure .env exists ─────────────────────────────────────────────
|
|
27
27
|
const envPath = path.join(ROOT, '.env')
|
|
28
28
|
if (!existsSync(envPath)) {
|
|
29
|
-
writeFileSync(envPath, 'DATABASE_URL="file:./
|
|
29
|
+
writeFileSync(envPath, 'DATABASE_URL="file:./agentfit.db"\n')
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// ─── First-run setup: prisma generate + migrate ─────────────────────
|
|
@@ -36,7 +36,7 @@ if (!existsSync(generatedClient)) {
|
|
|
36
36
|
run('npx prisma generate')
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const dbPath = path.join(ROOT, '
|
|
39
|
+
const dbPath = path.join(ROOT, 'agentfit.db')
|
|
40
40
|
if (!existsSync(dbPath)) {
|
|
41
41
|
info('Creating database...')
|
|
42
42
|
run('npx prisma migrate deploy')
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useMemo } from 'react'
|
|
3
|
+
import { useMemo, useEffect, useState } from 'react'
|
|
4
4
|
import type { UsageData } from '@/lib/parse-logs'
|
|
5
|
-
import { generateCoachInsights, type CoachInsight, type InsightCategory, type InsightSeverity } from '@/lib/coach'
|
|
5
|
+
import { generateCoachInsights, type CoachInsight, type InsightCategory, type InsightSeverity, type CraftDimension, type CraftScores } from '@/lib/coach'
|
|
6
|
+
import type { CommandInsight } from '@/lib/command-insights'
|
|
6
7
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
7
8
|
import { formatCost, formatDuration, formatNumber } from '@/lib/format'
|
|
8
9
|
import {
|
|
@@ -18,8 +19,11 @@ import {
|
|
|
18
19
|
Calendar,
|
|
19
20
|
Search,
|
|
20
21
|
Clock,
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
Brain,
|
|
23
|
+
Radar,
|
|
24
|
+
Bot,
|
|
25
|
+
Activity,
|
|
26
|
+
Gauge,
|
|
23
27
|
} from 'lucide-react'
|
|
24
28
|
|
|
25
29
|
const CATEGORY_ICONS: Record<InsightCategory, typeof Trophy> = {
|
|
@@ -33,48 +37,90 @@ const CATEGORY_ICONS: Record<InsightCategory, typeof Trophy> = {
|
|
|
33
37
|
streak: Flame,
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
const CATEGORY_LABELS: Record<InsightCategory, string> = {
|
|
37
|
-
cost: 'Cost',
|
|
38
|
-
efficiency: 'Efficiency',
|
|
39
|
-
tools: 'Tools',
|
|
40
|
-
context: 'Context',
|
|
41
|
-
model: 'Model',
|
|
42
|
-
habits: 'Habits',
|
|
43
|
-
discovery: 'Discovery',
|
|
44
|
-
streak: 'Streak',
|
|
45
|
-
}
|
|
46
|
-
|
|
47
40
|
const SEVERITY_STYLES: Record<InsightSeverity, { border: string; icon: typeof Trophy; iconClass: string }> = {
|
|
48
41
|
achievement: { border: 'border-l-chart-2', icon: Trophy, iconClass: 'text-chart-2' },
|
|
49
42
|
warning: { border: 'border-l-chart-5', icon: AlertTriangle, iconClass: 'text-chart-5' },
|
|
50
43
|
tip: { border: 'border-l-chart-1', icon: Lightbulb, iconClass: 'text-chart-1' },
|
|
51
44
|
}
|
|
52
45
|
|
|
46
|
+
const CRAFT_DIMENSIONS: {
|
|
47
|
+
key: CraftDimension
|
|
48
|
+
letter: string
|
|
49
|
+
label: string
|
|
50
|
+
color: string
|
|
51
|
+
icon: typeof Brain
|
|
52
|
+
description: string
|
|
53
|
+
metrics: string[]
|
|
54
|
+
}[] = [
|
|
55
|
+
{
|
|
56
|
+
key: 'context',
|
|
57
|
+
letter: 'C',
|
|
58
|
+
label: 'Context',
|
|
59
|
+
color: 'var(--chart-1)',
|
|
60
|
+
icon: Brain,
|
|
61
|
+
description: 'How well you engineer the context available to the AI — not just window size, but the holistic curation of tokens: system prompts (CLAUDE.md), just-in-time retrieval, structured notes, sub-agent isolation, and cache efficiency.',
|
|
62
|
+
metrics: ['System prompt maintenance (15%)', 'Cache reuse (15%)', 'Overflow avoidance (15%)', 'Just-in-time retrieval (20%)', 'Session length (10%)', 'Note-taking (10%)', 'Output density (10%)', 'Sub-agent isolation (5%)'],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
key: 'reach',
|
|
66
|
+
letter: 'R',
|
|
67
|
+
label: 'Reach',
|
|
68
|
+
color: 'var(--chart-2)',
|
|
69
|
+
icon: Radar,
|
|
70
|
+
description: 'How broadly you leverage available capabilities. Using diverse tools, subagents, and custom skills means you\'re getting more out of the AI assistant.',
|
|
71
|
+
metrics: ['Tool diversity (35%)', 'Subagent parallelization (35%)', 'Skill/command adoption (30%)'],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: 'autonomy',
|
|
75
|
+
letter: 'A',
|
|
76
|
+
label: 'Autonomy',
|
|
77
|
+
color: 'var(--chart-3)',
|
|
78
|
+
icon: Bot,
|
|
79
|
+
description: 'How independently the agent works for you. High autonomy means clear prompts, fewer interruptions, the agent reading before editing, and trusting it with permissions.',
|
|
80
|
+
metrics: ['Assistant/user message ratio (25%)', 'Low interruption rate (25%)', 'Read-before-edit ratio (25%)', 'Permission trust level (25%)'],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: 'flow',
|
|
84
|
+
letter: 'F',
|
|
85
|
+
label: 'Flow',
|
|
86
|
+
color: 'var(--chart-4)',
|
|
87
|
+
icon: Activity,
|
|
88
|
+
description: 'How consistently you maintain a coding rhythm. Regular usage builds mastery faster than sporadic intense sessions.',
|
|
89
|
+
metrics: ['Current streak length (35%)', 'Daily consistency (35%)', 'Active days coverage (30%)'],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: 'throughput',
|
|
93
|
+
letter: 'T',
|
|
94
|
+
label: 'Throughput',
|
|
95
|
+
color: 'var(--chart-6)',
|
|
96
|
+
icon: Gauge,
|
|
97
|
+
description: 'How much output you get for your investment. Efficient sessions produce more output per dollar with fewer errors, and parallel sessions multiply your throughput.',
|
|
98
|
+
metrics: ['Cost efficiency (25%)', 'Output volume (25%)', 'Parallel sessions (25%)', 'Low error rate (25%)'],
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
|
|
53
102
|
function FitnessRing({ score, label }: { score: number; label: string }) {
|
|
54
103
|
const radius = 70
|
|
55
104
|
const circumference = 2 * Math.PI * radius
|
|
56
105
|
const offset = circumference - (score / 100) * circumference
|
|
57
106
|
|
|
107
|
+
const getColor = (s: number) => {
|
|
108
|
+
if (s >= 85) return 'var(--chart-2)'
|
|
109
|
+
if (s >= 70) return 'var(--chart-1)'
|
|
110
|
+
if (s >= 55) return 'var(--chart-3)'
|
|
111
|
+
return 'var(--chart-5)'
|
|
112
|
+
}
|
|
113
|
+
|
|
58
114
|
return (
|
|
59
115
|
<div className="flex flex-col items-center gap-2">
|
|
60
116
|
<div className="relative">
|
|
61
117
|
<svg width="180" height="180" viewBox="0 0 180 180">
|
|
118
|
+
<circle cx="90" cy="90" r={radius} fill="none" stroke="var(--muted)" strokeWidth="10" />
|
|
62
119
|
<circle
|
|
63
120
|
cx="90" cy="90" r={radius}
|
|
64
|
-
fill="none"
|
|
65
|
-
|
|
66
|
-
strokeWidth="10"
|
|
67
|
-
/>
|
|
68
|
-
<circle
|
|
69
|
-
cx="90" cy="90" r={radius}
|
|
70
|
-
fill="none"
|
|
71
|
-
stroke="var(--chart-2)"
|
|
72
|
-
strokeWidth="10"
|
|
73
|
-
strokeLinecap="round"
|
|
74
|
-
strokeDasharray={circumference}
|
|
75
|
-
strokeDashoffset={offset}
|
|
121
|
+
fill="none" stroke={getColor(score)} strokeWidth="10"
|
|
122
|
+
strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset}
|
|
76
123
|
transform="rotate(-90 90 90)"
|
|
77
|
-
className="transition-all duration-1000"
|
|
78
124
|
/>
|
|
79
125
|
</svg>
|
|
80
126
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
@@ -86,9 +132,19 @@ function FitnessRing({ score, label }: { score: number; label: string }) {
|
|
|
86
132
|
)
|
|
87
133
|
}
|
|
88
134
|
|
|
135
|
+
function DimensionBar({ value, color }: { value: number; color: string }) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex-1 h-3 rounded-full bg-muted overflow-hidden">
|
|
138
|
+
<div
|
|
139
|
+
className="h-full rounded-full transition-all duration-500"
|
|
140
|
+
style={{ width: `${value}%`, backgroundColor: color }}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
89
146
|
function InsightCard({ insight }: { insight: CoachInsight }) {
|
|
90
147
|
const style = SEVERITY_STYLES[insight.severity]
|
|
91
|
-
const CategoryIcon = CATEGORY_ICONS[insight.category]
|
|
92
148
|
const SeverityIcon = style.icon
|
|
93
149
|
|
|
94
150
|
return (
|
|
@@ -98,17 +154,11 @@ function InsightCard({ insight }: { insight: CoachInsight }) {
|
|
|
98
154
|
<div className="flex-1 space-y-1.5">
|
|
99
155
|
<div className="flex items-center justify-between gap-2">
|
|
100
156
|
<h4 className="text-sm font-semibold">{insight.title}</h4>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
{insight.metric}
|
|
105
|
-
</span>
|
|
106
|
-
)}
|
|
107
|
-
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
108
|
-
<CategoryIcon className="h-3 w-3" />
|
|
109
|
-
{CATEGORY_LABELS[insight.category]}
|
|
157
|
+
{insight.metric && (
|
|
158
|
+
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-mono font-medium shrink-0">
|
|
159
|
+
{insight.metric}
|
|
110
160
|
</span>
|
|
111
|
-
|
|
161
|
+
)}
|
|
112
162
|
</div>
|
|
113
163
|
<p className="text-sm text-muted-foreground">{insight.description}</p>
|
|
114
164
|
{insight.recommendation && (
|
|
@@ -125,124 +175,201 @@ function InsightCard({ insight }: { insight: CoachInsight }) {
|
|
|
125
175
|
|
|
126
176
|
export function AgentCoach({ data }: { data: UsageData }) {
|
|
127
177
|
const coach = useMemo(() => generateCoachInsights(data), [data])
|
|
178
|
+
const [cmdInsights, setCmdInsights] = useState<CommandInsight[]>([])
|
|
128
179
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
fetch('/api/command-insights')
|
|
182
|
+
.then(r => r.json())
|
|
183
|
+
.then(setCmdInsights)
|
|
184
|
+
.catch(() => {})
|
|
185
|
+
}, [])
|
|
132
186
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<Card className="lg:row-span-2">
|
|
138
|
-
<CardHeader>
|
|
139
|
-
<CardTitle>Agent Fitness Score</CardTitle>
|
|
140
|
-
<CardDescription>Overall health of your human-AI collaboration</CardDescription>
|
|
141
|
-
</CardHeader>
|
|
142
|
-
<CardContent className="flex justify-center pb-6">
|
|
143
|
-
<FitnessRing score={coach.score} label={coach.scoreLabel} />
|
|
144
|
-
</CardContent>
|
|
145
|
-
</Card>
|
|
187
|
+
const allInsights: CoachInsight[] = [
|
|
188
|
+
...coach.insights,
|
|
189
|
+
...cmdInsights.map(i => ({ ...i, category: 'discovery' as InsightCategory, craft: 'reach' as CraftDimension })),
|
|
190
|
+
]
|
|
146
191
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
151
|
-
</CardHeader>
|
|
152
|
-
<CardContent>
|
|
153
|
-
<div className="text-2xl font-bold">{formatCost(coach.stats.avgCostPerSession)}</div>
|
|
154
|
-
</CardContent>
|
|
155
|
-
</Card>
|
|
192
|
+
// Group insights by CRAFT dimension
|
|
193
|
+
const insightsByDimension = (dim: CraftDimension) =>
|
|
194
|
+
allInsights.filter(i => i.craft === dim)
|
|
156
195
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
</CardHeader>
|
|
162
|
-
<CardContent>
|
|
163
|
-
<div className="text-2xl font-bold">{formatDuration(coach.stats.avgDurationMinutes)}</div>
|
|
164
|
-
</CardContent>
|
|
165
|
-
</Card>
|
|
196
|
+
// Ungrouped insights (no craft tag)
|
|
197
|
+
const ungroupedWarnings = allInsights.filter(i => !i.craft && i.severity === 'warning')
|
|
198
|
+
const ungroupedTips = allInsights.filter(i => !i.craft && i.severity === 'tip')
|
|
199
|
+
const ungroupedAchievements = allInsights.filter(i => !i.craft && i.severity === 'achievement')
|
|
166
200
|
|
|
201
|
+
return (
|
|
202
|
+
<div className="space-y-6">
|
|
203
|
+
{/* Hero: CRAFT Score + Framework Overview */}
|
|
204
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
167
205
|
<Card>
|
|
168
|
-
<CardHeader className="
|
|
169
|
-
<CardTitle
|
|
170
|
-
<
|
|
206
|
+
<CardHeader className="pb-2">
|
|
207
|
+
<CardTitle>CRAFT Score</CardTitle>
|
|
208
|
+
<CardDescription>
|
|
209
|
+
Your overall AI coding proficiency, measured across 5 dimensions
|
|
210
|
+
</CardDescription>
|
|
171
211
|
</CardHeader>
|
|
172
|
-
<CardContent>
|
|
173
|
-
<
|
|
212
|
+
<CardContent className="flex items-center gap-6">
|
|
213
|
+
<FitnessRing score={coach.score} label={coach.scoreLabel} />
|
|
214
|
+
<div className="flex-1 space-y-3">
|
|
215
|
+
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
216
|
+
<span className="flex items-center gap-1">
|
|
217
|
+
<Flame className="h-3.5 w-3.5" />
|
|
218
|
+
{coach.stats.currentStreak > 0 ? `${coach.stats.currentStreak}d streak` : 'No streak'}
|
|
219
|
+
{coach.stats.longestStreak > 0 && (
|
|
220
|
+
<span className="text-xs"> (best: {coach.stats.longestStreak}d)</span>
|
|
221
|
+
)}
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="space-y-2">
|
|
225
|
+
{CRAFT_DIMENSIONS.map(({ key, letter, label, color }) => (
|
|
226
|
+
<div key={key} className="flex items-center gap-2">
|
|
227
|
+
<span className="w-5 text-xs font-bold" style={{ color }}>{letter}</span>
|
|
228
|
+
<span className="w-20 text-xs text-muted-foreground">{label}</span>
|
|
229
|
+
<DimensionBar value={coach.craft[key]} color={color} />
|
|
230
|
+
<span className="w-7 text-xs font-semibold text-right">{coach.craft[key]}</span>
|
|
231
|
+
</div>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
<div className="text-[10px] text-muted-foreground">
|
|
235
|
+
Weights: A 25% · C/R/T 20% each · F 15%
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
174
238
|
</CardContent>
|
|
175
239
|
</Card>
|
|
176
240
|
|
|
241
|
+
{/* What is CRAFT */}
|
|
177
242
|
<Card>
|
|
178
|
-
<CardHeader className="
|
|
179
|
-
<CardTitle
|
|
180
|
-
<
|
|
243
|
+
<CardHeader className="pb-2">
|
|
244
|
+
<CardTitle>What is CRAFT?</CardTitle>
|
|
245
|
+
<CardDescription>
|
|
246
|
+
A framework for measuring Human-AI coding proficiency
|
|
247
|
+
</CardDescription>
|
|
181
248
|
</CardHeader>
|
|
182
|
-
<CardContent>
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
249
|
+
<CardContent className="text-sm space-y-3">
|
|
250
|
+
<p className="text-muted-foreground">
|
|
251
|
+
Every CRAFT metric is derived directly from your local conversation logs.
|
|
252
|
+
No external integrations or surveys needed.
|
|
253
|
+
</p>
|
|
254
|
+
<div className="grid grid-cols-5 gap-2 text-center">
|
|
255
|
+
{CRAFT_DIMENSIONS.map(({ letter, label, color, icon: Icon }) => (
|
|
256
|
+
<div key={letter} className="space-y-1">
|
|
257
|
+
<div className="flex justify-center">
|
|
258
|
+
<Icon className="h-5 w-5" style={{ color }} />
|
|
259
|
+
</div>
|
|
260
|
+
<div className="text-xs font-bold" style={{ color }}>{letter}</div>
|
|
261
|
+
<div className="text-[10px] text-muted-foreground">{label}</div>
|
|
262
|
+
</div>
|
|
263
|
+
))}
|
|
188
264
|
</div>
|
|
265
|
+
<p className="text-xs text-muted-foreground">
|
|
266
|
+
Each dimension is scored 0-100 based on your actual usage patterns. The overall score is a
|
|
267
|
+
weighted average prioritizing behavioral quality (Autonomy) over volume (Throughput).
|
|
268
|
+
</p>
|
|
189
269
|
</CardContent>
|
|
190
270
|
</Card>
|
|
191
271
|
</div>
|
|
192
272
|
|
|
193
|
-
{/*
|
|
194
|
-
{
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<
|
|
273
|
+
{/* Per-dimension breakdown with insights */}
|
|
274
|
+
{CRAFT_DIMENSIONS.map(({ key, letter, label, color, icon: Icon, description, metrics }) => {
|
|
275
|
+
const dimInsights = insightsByDimension(key)
|
|
276
|
+
const dimScore = coach.craft[key]
|
|
277
|
+
return (
|
|
278
|
+
<Card key={key}>
|
|
279
|
+
<CardHeader>
|
|
280
|
+
<div className="flex items-center justify-between">
|
|
281
|
+
<div className="flex items-center gap-3">
|
|
282
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `color-mix(in srgb, ${color} 15%, transparent)` }}>
|
|
283
|
+
<Icon className="h-5 w-5" style={{ color }} />
|
|
284
|
+
</div>
|
|
285
|
+
<div>
|
|
286
|
+
<CardTitle className="flex items-center gap-2">
|
|
287
|
+
<span className="text-lg font-bold" style={{ color }}>{letter}</span>
|
|
288
|
+
{label}
|
|
289
|
+
</CardTitle>
|
|
290
|
+
<CardDescription>{description}</CardDescription>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<div className="text-right">
|
|
294
|
+
<div className="text-2xl font-bold" style={{ color }}>{dimScore}</div>
|
|
295
|
+
<div className="text-xs text-muted-foreground">/ 100</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</CardHeader>
|
|
299
|
+
<CardContent className="space-y-4">
|
|
300
|
+
{/* How this score is calculated */}
|
|
301
|
+
<div className="rounded-md bg-muted/50 p-3">
|
|
302
|
+
<div className="text-xs font-medium mb-2">How this score is calculated:</div>
|
|
303
|
+
<div className="grid gap-1">
|
|
304
|
+
{metrics.map((m) => (
|
|
305
|
+
<div key={m} className="text-xs text-muted-foreground flex items-center gap-2">
|
|
306
|
+
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
|
|
307
|
+
{m}
|
|
308
|
+
</div>
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
202
311
|
</div>
|
|
203
|
-
</div>
|
|
204
|
-
</CardHeader>
|
|
205
|
-
<CardContent className="space-y-3">
|
|
206
|
-
{achievements.map(i => <InsightCard key={i.id} insight={i} />)}
|
|
207
|
-
</CardContent>
|
|
208
|
-
</Card>
|
|
209
|
-
)}
|
|
210
312
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
<div className="flex items-center gap-2">
|
|
216
|
-
<AlertTriangle className="h-5 w-5 text-chart-5" />
|
|
217
|
-
<div>
|
|
218
|
-
<CardTitle>Areas to Improve</CardTitle>
|
|
219
|
-
<CardDescription>Issues that may be costing you time or money</CardDescription>
|
|
313
|
+
{/* Score bar */}
|
|
314
|
+
<div className="flex items-center gap-3">
|
|
315
|
+
<DimensionBar value={dimScore} color={color} />
|
|
316
|
+
<span className="text-sm font-semibold w-10 text-right">{dimScore}/100</span>
|
|
220
317
|
</div>
|
|
221
|
-
</div>
|
|
222
|
-
</CardHeader>
|
|
223
|
-
<CardContent className="space-y-3">
|
|
224
|
-
{warnings.map(i => <InsightCard key={i.id} insight={i} />)}
|
|
225
|
-
</CardContent>
|
|
226
|
-
</Card>
|
|
227
|
-
)}
|
|
228
318
|
|
|
229
|
-
|
|
230
|
-
|
|
319
|
+
{/* Insights for this dimension */}
|
|
320
|
+
{dimInsights.length > 0 ? (
|
|
321
|
+
<div className="space-y-3">
|
|
322
|
+
{dimInsights.map(i => <InsightCard key={i.id} insight={i} />)}
|
|
323
|
+
</div>
|
|
324
|
+
) : (
|
|
325
|
+
<p className="text-sm text-muted-foreground italic">No specific insights for this dimension yet.</p>
|
|
326
|
+
)}
|
|
327
|
+
</CardContent>
|
|
328
|
+
</Card>
|
|
329
|
+
)
|
|
330
|
+
})}
|
|
331
|
+
|
|
332
|
+
{/* Ungrouped insights (if any) */}
|
|
333
|
+
{(ungroupedWarnings.length > 0 || ungroupedTips.length > 0 || ungroupedAchievements.length > 0) && (
|
|
231
334
|
<Card>
|
|
232
335
|
<CardHeader>
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
<div>
|
|
236
|
-
<CardTitle>Tips & Suggestions</CardTitle>
|
|
237
|
-
<CardDescription>Ways to get more out of your coding agent</CardDescription>
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
336
|
+
<CardTitle>Other Insights</CardTitle>
|
|
337
|
+
<CardDescription>General recommendations not tied to a specific CRAFT dimension</CardDescription>
|
|
240
338
|
</CardHeader>
|
|
241
339
|
<CardContent className="space-y-3">
|
|
242
|
-
{
|
|
340
|
+
{[...ungroupedWarnings, ...ungroupedTips, ...ungroupedAchievements].map(i => (
|
|
341
|
+
<InsightCard key={i.id} insight={i} />
|
|
342
|
+
))}
|
|
243
343
|
</CardContent>
|
|
244
344
|
</Card>
|
|
245
345
|
)}
|
|
346
|
+
|
|
347
|
+
{/* Session Averages */}
|
|
348
|
+
<Card>
|
|
349
|
+
<CardHeader>
|
|
350
|
+
<CardTitle className="text-sm">Session Averages</CardTitle>
|
|
351
|
+
</CardHeader>
|
|
352
|
+
<CardContent>
|
|
353
|
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 text-sm">
|
|
354
|
+
<div>
|
|
355
|
+
<div className="text-muted-foreground">Cost / Session</div>
|
|
356
|
+
<div className="font-semibold">{formatCost(coach.stats.avgCostPerSession)}</div>
|
|
357
|
+
</div>
|
|
358
|
+
<div>
|
|
359
|
+
<div className="text-muted-foreground">Duration</div>
|
|
360
|
+
<div className="font-semibold">{formatDuration(coach.stats.avgDurationMinutes)}</div>
|
|
361
|
+
</div>
|
|
362
|
+
<div>
|
|
363
|
+
<div className="text-muted-foreground">Messages</div>
|
|
364
|
+
<div className="font-semibold">{formatNumber(Math.round(coach.stats.avgMessagesPerSession))}</div>
|
|
365
|
+
</div>
|
|
366
|
+
<div>
|
|
367
|
+
<div className="text-muted-foreground">Peak Hour</div>
|
|
368
|
+
<div className="font-semibold">{coach.stats.peakHour}:00</div>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</CardContent>
|
|
372
|
+
</Card>
|
|
246
373
|
</div>
|
|
247
374
|
)
|
|
248
375
|
}
|