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,132 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Badge } from '@/components/ui/badge'
|
|
8
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
9
|
+
import { FileText, Plus, Loader2, ChevronRight } from 'lucide-react'
|
|
10
|
+
import { toast } from 'sonner'
|
|
11
|
+
import { useData } from '@/components/data-provider'
|
|
12
|
+
|
|
13
|
+
interface ReportSummary {
|
|
14
|
+
id: string
|
|
15
|
+
title: string
|
|
16
|
+
generatedAt: string
|
|
17
|
+
sessionCount: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function ReportsPage() {
|
|
21
|
+
const { data } = useData()
|
|
22
|
+
const [reports, setReports] = useState<ReportSummary[]>([])
|
|
23
|
+
const [loading, setLoading] = useState(true)
|
|
24
|
+
const [generating, setGenerating] = useState(false)
|
|
25
|
+
|
|
26
|
+
async function fetchReports() {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch('/api/reports')
|
|
29
|
+
setReports(await res.json())
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
useEffect(() => { fetchReports() }, [])
|
|
38
|
+
|
|
39
|
+
const currentSessionCount = data?.overview.totalSessions || 0
|
|
40
|
+
const lastReportSessionCount = reports[0]?.sessionCount || 0
|
|
41
|
+
const hasNewData = reports.length === 0 || currentSessionCount !== lastReportSessionCount
|
|
42
|
+
|
|
43
|
+
async function handleGenerate() {
|
|
44
|
+
setGenerating(true)
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch('/api/reports', { method: 'POST' })
|
|
47
|
+
const report = await res.json()
|
|
48
|
+
if (res.status === 409) {
|
|
49
|
+
toast.info('No new data', { description: report.error })
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok) throw new Error(report.error || 'Failed to generate report')
|
|
53
|
+
toast.success('Report generated', { description: report.title })
|
|
54
|
+
await fetchReports()
|
|
55
|
+
} catch (e) {
|
|
56
|
+
toast.error('Failed to generate report', { description: (e as Error).message })
|
|
57
|
+
} finally {
|
|
58
|
+
setGenerating(false)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const buttonDisabled = generating || !hasNewData
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="space-y-6 max-w-3xl">
|
|
66
|
+
<div className="flex items-center justify-between">
|
|
67
|
+
<div>
|
|
68
|
+
<h2 className="text-lg font-semibold">Insights Reports</h2>
|
|
69
|
+
<p className="text-sm text-muted-foreground">
|
|
70
|
+
Generate point-in-time snapshots of your coding agent fitness
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
<Tooltip>
|
|
74
|
+
<TooltipTrigger
|
|
75
|
+
render={<span />}
|
|
76
|
+
>
|
|
77
|
+
<Button onClick={handleGenerate} disabled={buttonDisabled} className="gap-2">
|
|
78
|
+
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
|
79
|
+
{generating ? 'Generating...' : 'Generate Report'}
|
|
80
|
+
</Button>
|
|
81
|
+
</TooltipTrigger>
|
|
82
|
+
{!hasNewData && (
|
|
83
|
+
<TooltipContent>
|
|
84
|
+
No new sessions since last report. Sync new data first.
|
|
85
|
+
</TooltipContent>
|
|
86
|
+
)}
|
|
87
|
+
</Tooltip>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{loading ? (
|
|
91
|
+
<div className="flex items-center gap-2 py-8 text-muted-foreground">
|
|
92
|
+
<Loader2 className="h-4 w-4 animate-spin" /> Loading reports...
|
|
93
|
+
</div>
|
|
94
|
+
) : reports.length === 0 ? (
|
|
95
|
+
<Card>
|
|
96
|
+
<CardContent className="py-12 text-center">
|
|
97
|
+
<FileText className="h-10 w-10 text-muted-foreground/30 mx-auto mb-3" />
|
|
98
|
+
<p className="text-sm text-muted-foreground">
|
|
99
|
+
No reports yet. Click "Generate Report" to create your first fitness snapshot.
|
|
100
|
+
</p>
|
|
101
|
+
</CardContent>
|
|
102
|
+
</Card>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="space-y-2">
|
|
105
|
+
{reports.map(report => (
|
|
106
|
+
<Link key={report.id} href={`/reports/${report.id}`}>
|
|
107
|
+
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
|
108
|
+
<CardContent className="flex items-center justify-between py-4">
|
|
109
|
+
<div className="flex items-center gap-3">
|
|
110
|
+
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
111
|
+
<div>
|
|
112
|
+
<div className="text-sm font-medium">{report.title}</div>
|
|
113
|
+
<div className="text-xs text-muted-foreground">
|
|
114
|
+
{new Date(report.generatedAt).toLocaleString()}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex items-center gap-3">
|
|
119
|
+
<Badge variant="secondary" className="text-xs">
|
|
120
|
+
{report.sessionCount} sessions
|
|
121
|
+
</Badge>
|
|
122
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
123
|
+
</div>
|
|
124
|
+
</CardContent>
|
|
125
|
+
</Card>
|
|
126
|
+
</Link>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useParams } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
7
|
+
import { ArrowLeft, Clock, Coins, MessageSquare, Wrench, CheckCircle, XCircle } from 'lucide-react'
|
|
8
|
+
import { SessionWorkflow } from '@/components/session-workflow'
|
|
9
|
+
import { SessionChatLog } from '@/components/session-chatlog'
|
|
10
|
+
import type { SessionDetail } from '@/lib/session-detail'
|
|
11
|
+
|
|
12
|
+
export default function SessionDetailPage() {
|
|
13
|
+
const params = useParams()
|
|
14
|
+
const sessionId = params.id as string
|
|
15
|
+
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
|
16
|
+
const [loading, setLoading] = useState(true)
|
|
17
|
+
const [error, setError] = useState<string | null>(null)
|
|
18
|
+
const [activeTab, setActiveTab] = useState<'workflow' | 'dialog'>('dialog')
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
async function load() {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`/api/session?id=${sessionId}`)
|
|
24
|
+
if (!res.ok) throw new Error('Session not found')
|
|
25
|
+
const data = await res.json()
|
|
26
|
+
setDetail(data)
|
|
27
|
+
} catch (e) {
|
|
28
|
+
setError((e as Error).message)
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
load()
|
|
34
|
+
}, [sessionId])
|
|
35
|
+
|
|
36
|
+
if (loading) {
|
|
37
|
+
return <div className="text-muted-foreground">Loading session...</div>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (error || !detail) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-4">
|
|
43
|
+
<Link href="/sessions" className="flex items-center gap-1 text-sm text-primary hover:underline">
|
|
44
|
+
<ArrowLeft className="h-4 w-4" /> Back to sessions
|
|
45
|
+
</Link>
|
|
46
|
+
<div className="text-destructive">{error || 'Session not found'}</div>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const s = detail.stats
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="space-y-4">
|
|
55
|
+
<Link href="/sessions" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
|
56
|
+
<ArrowLeft className="h-4 w-4" /> Back to sessions
|
|
57
|
+
</Link>
|
|
58
|
+
|
|
59
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
|
60
|
+
<Card>
|
|
61
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
62
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
|
|
63
|
+
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
64
|
+
</CardHeader>
|
|
65
|
+
<CardContent>
|
|
66
|
+
<div className="text-2xl font-bold tracking-tight">{s.duration}</div>
|
|
67
|
+
</CardContent>
|
|
68
|
+
</Card>
|
|
69
|
+
<Card>
|
|
70
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
71
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Tokens</CardTitle>
|
|
72
|
+
<Coins className="h-4 w-4 text-muted-foreground" />
|
|
73
|
+
</CardHeader>
|
|
74
|
+
<CardContent>
|
|
75
|
+
<div className="text-2xl font-bold tracking-tight">{s.tokens.toLocaleString()}</div>
|
|
76
|
+
</CardContent>
|
|
77
|
+
</Card>
|
|
78
|
+
<Card>
|
|
79
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
80
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Messages</CardTitle>
|
|
81
|
+
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
|
82
|
+
</CardHeader>
|
|
83
|
+
<CardContent>
|
|
84
|
+
<div className="text-2xl font-bold tracking-tight">{s.totalMessages}</div>
|
|
85
|
+
<p className="text-xs text-muted-foreground">{s.userTurns} user / {s.assistantTurns} assistant</p>
|
|
86
|
+
</CardContent>
|
|
87
|
+
</Card>
|
|
88
|
+
<Card>
|
|
89
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
90
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Tool Calls</CardTitle>
|
|
91
|
+
<Wrench className="h-4 w-4 text-muted-foreground" />
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent>
|
|
94
|
+
<div className="text-2xl font-bold tracking-tight">{s.toolCalls}</div>
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
<Card>
|
|
98
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
99
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Success</CardTitle>
|
|
100
|
+
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
|
101
|
+
</CardHeader>
|
|
102
|
+
<CardContent>
|
|
103
|
+
<div className="text-2xl font-bold tracking-tight">{s.successCount}</div>
|
|
104
|
+
</CardContent>
|
|
105
|
+
</Card>
|
|
106
|
+
<Card>
|
|
107
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
108
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Failures</CardTitle>
|
|
109
|
+
<XCircle className="h-4 w-4 text-muted-foreground" />
|
|
110
|
+
</CardHeader>
|
|
111
|
+
<CardContent>
|
|
112
|
+
<div className={`text-2xl font-bold tracking-tight ${s.failureCount > 0 ? 'text-destructive' : ''}`}>{s.failureCount}</div>
|
|
113
|
+
</CardContent>
|
|
114
|
+
</Card>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex gap-1.5 border-b pb-1">
|
|
118
|
+
<button
|
|
119
|
+
onClick={() => setActiveTab('dialog')}
|
|
120
|
+
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
121
|
+
activeTab === 'dialog'
|
|
122
|
+
? 'bg-primary text-primary-foreground'
|
|
123
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
Dialog
|
|
127
|
+
</button>
|
|
128
|
+
<button
|
|
129
|
+
onClick={() => setActiveTab('workflow')}
|
|
130
|
+
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
131
|
+
activeTab === 'workflow'
|
|
132
|
+
? 'bg-primary text-primary-foreground'
|
|
133
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
134
|
+
}`}
|
|
135
|
+
>
|
|
136
|
+
Workflow
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{activeTab === 'dialog' && (
|
|
141
|
+
<Card>
|
|
142
|
+
<CardHeader>
|
|
143
|
+
<CardTitle>Chat Log</CardTitle>
|
|
144
|
+
<CardDescription>{detail.chatLog.length} records</CardDescription>
|
|
145
|
+
</CardHeader>
|
|
146
|
+
<CardContent>
|
|
147
|
+
<SessionChatLog messages={detail.chatLog} />
|
|
148
|
+
</CardContent>
|
|
149
|
+
</Card>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{activeTab === 'workflow' && (
|
|
153
|
+
<Card>
|
|
154
|
+
<CardHeader>
|
|
155
|
+
<CardTitle>Workflow</CardTitle>
|
|
156
|
+
<CardDescription>
|
|
157
|
+
Tool calls, reasoning, and execution flow — {detail.workflowNodes.length} nodes
|
|
158
|
+
</CardDescription>
|
|
159
|
+
</CardHeader>
|
|
160
|
+
<CardContent>
|
|
161
|
+
<SessionWorkflow nodes={detail.workflowNodes} edges={detail.workflowEdges} />
|
|
162
|
+
</CardContent>
|
|
163
|
+
</Card>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
import { existsSync } from 'fs'
|
|
4
|
+
import { homedir } from 'os'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
|
|
7
|
+
interface FolderStatus {
|
|
8
|
+
exists: boolean
|
|
9
|
+
hasGit: boolean
|
|
10
|
+
hasRemote: boolean
|
|
11
|
+
remoteUrl: string | null
|
|
12
|
+
isDirty: boolean
|
|
13
|
+
lastCommit: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface BackupStatus {
|
|
17
|
+
ghInstalled: boolean
|
|
18
|
+
ghAuthenticated: boolean
|
|
19
|
+
ghUser: string | null
|
|
20
|
+
claude: FolderStatus
|
|
21
|
+
codex: FolderStatus
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function run(cmd: string, cwd: string): string {
|
|
25
|
+
try {
|
|
26
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 30000 }).trim()
|
|
27
|
+
} catch {
|
|
28
|
+
return ''
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getFolderStatus(folderPath: string): FolderStatus {
|
|
33
|
+
if (!existsSync(folderPath)) {
|
|
34
|
+
return { exists: false, hasGit: false, hasRemote: false, remoteUrl: null, isDirty: false, lastCommit: null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hasGit = existsSync(path.join(folderPath, '.git'))
|
|
38
|
+
let hasRemote = false
|
|
39
|
+
let remoteUrl: string | null = null
|
|
40
|
+
let isDirty = false
|
|
41
|
+
let lastCommit: string | null = null
|
|
42
|
+
|
|
43
|
+
if (hasGit) {
|
|
44
|
+
remoteUrl = run('git remote get-url origin', folderPath) || null
|
|
45
|
+
hasRemote = !!remoteUrl
|
|
46
|
+
const status = run('git status --porcelain', folderPath)
|
|
47
|
+
isDirty = status.length > 0
|
|
48
|
+
lastCommit = run('git log -1 --format=%ci', folderPath) || null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { exists: true, hasGit, hasRemote, remoteUrl, isDirty, lastCommit }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function GET() {
|
|
55
|
+
const home = homedir()
|
|
56
|
+
const ghInstalled = !!run('which gh', home)
|
|
57
|
+
let ghAuthenticated = false
|
|
58
|
+
let ghUser: string | null = null
|
|
59
|
+
|
|
60
|
+
if (ghInstalled) {
|
|
61
|
+
ghUser = run('gh api user -q .login', home) || null
|
|
62
|
+
ghAuthenticated = !!ghUser
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const status: BackupStatus = {
|
|
66
|
+
ghInstalled,
|
|
67
|
+
ghAuthenticated,
|
|
68
|
+
ghUser,
|
|
69
|
+
claude: getFolderStatus(path.join(home, '.claude')),
|
|
70
|
+
codex: getFolderStatus(path.join(home, '.codex')),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return NextResponse.json(status)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CLAUDE_GITIGNORE = `# Sensitive / ephemeral files
|
|
77
|
+
*.lock
|
|
78
|
+
*.lock.lock
|
|
79
|
+
cache/
|
|
80
|
+
chrome/
|
|
81
|
+
debug/
|
|
82
|
+
downloads/
|
|
83
|
+
ide/
|
|
84
|
+
image-cache/
|
|
85
|
+
paste-cache/
|
|
86
|
+
session-env/
|
|
87
|
+
telemetry/
|
|
88
|
+
mcp-needs-auth-cache.json
|
|
89
|
+
.DS_Store
|
|
90
|
+
`
|
|
91
|
+
|
|
92
|
+
const CODEX_GITIGNORE = `# Sensitive files
|
|
93
|
+
auth.json
|
|
94
|
+
*.lock
|
|
95
|
+
tmp/
|
|
96
|
+
.DS_Store
|
|
97
|
+
`
|
|
98
|
+
|
|
99
|
+
function initGit(folderPath: string, gitignoreContent: string) {
|
|
100
|
+
const gitignorePath = path.join(folderPath, '.gitignore')
|
|
101
|
+
if (!existsSync(gitignorePath)) {
|
|
102
|
+
require('fs').writeFileSync(gitignorePath, gitignoreContent)
|
|
103
|
+
}
|
|
104
|
+
if (!existsSync(path.join(folderPath, '.git'))) {
|
|
105
|
+
run('git init -b main', folderPath)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function commitAll(folderPath: string, message: string): boolean {
|
|
110
|
+
run('git add -A', folderPath)
|
|
111
|
+
const status = run('git status --porcelain', folderPath)
|
|
112
|
+
if (!status) return false
|
|
113
|
+
execSync(`git commit -m "${message}"`, { cwd: folderPath, encoding: 'utf-8', timeout: 30000 })
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function POST(request: Request) {
|
|
118
|
+
const home = homedir()
|
|
119
|
+
const body = await request.json()
|
|
120
|
+
const { action, folder } = body as { action: string; folder?: 'claude' | 'codex' }
|
|
121
|
+
|
|
122
|
+
if (action === 'init') {
|
|
123
|
+
// Initialize and push to GitHub for the first time
|
|
124
|
+
if (!folder) return NextResponse.json({ error: 'folder required' }, { status: 400 })
|
|
125
|
+
|
|
126
|
+
const ghUser = run('gh api user -q .login', home)
|
|
127
|
+
if (!ghUser) {
|
|
128
|
+
return NextResponse.json({ error: 'Not authenticated with GitHub. Run: gh auth login' }, { status: 401 })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const folderPath = path.join(home, `.${folder}`)
|
|
132
|
+
if (!existsSync(folderPath)) {
|
|
133
|
+
return NextResponse.json({ error: `~/.${folder} does not exist` }, { status: 404 })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const repoName = `my-${folder}-backup`
|
|
137
|
+
const gitignore = folder === 'claude' ? CLAUDE_GITIGNORE : CODEX_GITIGNORE
|
|
138
|
+
|
|
139
|
+
// Init git and .gitignore
|
|
140
|
+
initGit(folderPath, gitignore)
|
|
141
|
+
|
|
142
|
+
// Create private repo if it doesn't exist
|
|
143
|
+
const existing = run(`gh repo view ${ghUser}/${repoName} --json name -q .name`, home)
|
|
144
|
+
if (!existing) {
|
|
145
|
+
try {
|
|
146
|
+
execSync(`gh repo create ${repoName} --private --description "Backup of ~/.${folder}"`, {
|
|
147
|
+
cwd: home, encoding: 'utf-8', timeout: 30000,
|
|
148
|
+
})
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return NextResponse.json({ error: `Failed to create repo: ${(e as Error).message}` }, { status: 500 })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Set remote
|
|
155
|
+
const currentRemote = run('git remote get-url origin', folderPath)
|
|
156
|
+
const repoUrl = `https://github.com/${ghUser}/${repoName}.git`
|
|
157
|
+
if (!currentRemote) {
|
|
158
|
+
run(`git remote add origin ${repoUrl}`, folderPath)
|
|
159
|
+
} else if (currentRemote !== repoUrl) {
|
|
160
|
+
run(`git remote set-url origin ${repoUrl}`, folderPath)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Commit and push
|
|
164
|
+
const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
165
|
+
commitAll(folderPath, `backup: ${timestamp}`)
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
execSync('git push -u origin main', { cwd: folderPath, encoding: 'utf-8', timeout: 60000 })
|
|
169
|
+
} catch {
|
|
170
|
+
// Try force push on first init (empty remote)
|
|
171
|
+
try {
|
|
172
|
+
execSync('git push -u origin main --force-with-lease', { cwd: folderPath, encoding: 'utf-8', timeout: 60000 })
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return NextResponse.json({ error: `Push failed: ${(e as Error).message}` }, { status: 500 })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return NextResponse.json({
|
|
179
|
+
success: true,
|
|
180
|
+
repoUrl: `https://github.com/${ghUser}/${repoName}`,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (action === 'sync') {
|
|
185
|
+
// Sync changes to existing remote
|
|
186
|
+
if (!folder) return NextResponse.json({ error: 'folder required' }, { status: 400 })
|
|
187
|
+
|
|
188
|
+
const folderPath = path.join(home, `.${folder}`)
|
|
189
|
+
if (!existsSync(path.join(folderPath, '.git'))) {
|
|
190
|
+
return NextResponse.json({ error: 'Not initialized. Set up backup first.' }, { status: 400 })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const remote = run('git remote get-url origin', folderPath)
|
|
194
|
+
if (!remote) {
|
|
195
|
+
return NextResponse.json({ error: 'No remote configured. Set up backup first.' }, { status: 400 })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
199
|
+
const hadChanges = commitAll(folderPath, `backup: ${timestamp}`)
|
|
200
|
+
|
|
201
|
+
if (!hadChanges) {
|
|
202
|
+
return NextResponse.json({ success: true, message: 'Already up to date' })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
execSync('git push origin main', { cwd: folderPath, encoding: 'utf-8', timeout: 60000 })
|
|
207
|
+
} catch (e) {
|
|
208
|
+
return NextResponse.json({ error: `Push failed: ${(e as Error).message}` }, { status: 500 })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return NextResponse.json({ success: true, message: 'Synced to GitHub' })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
|
215
|
+
}
|
package/app/api/check/route.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
2
4
|
import { checkForNewSessions } from '@/lib/sync'
|
|
3
5
|
|
|
4
6
|
export const dynamic = 'force-dynamic'
|
|
@@ -6,7 +8,15 @@ export const dynamic = 'force-dynamic'
|
|
|
6
8
|
export async function GET() {
|
|
7
9
|
try {
|
|
8
10
|
const newSessions = await checkForNewSessions()
|
|
9
|
-
return NextResponse.json({
|
|
11
|
+
return NextResponse.json({
|
|
12
|
+
newSessions,
|
|
13
|
+
paths: {
|
|
14
|
+
database: path.resolve(process.cwd(), 'agentfit.db'),
|
|
15
|
+
images: path.resolve(process.cwd(), 'data', 'images'),
|
|
16
|
+
claudeLogs: path.join(os.homedir(), '.claude', 'projects'),
|
|
17
|
+
codexLogs: path.join(os.homedir(), '.codex', 'sessions'),
|
|
18
|
+
},
|
|
19
|
+
})
|
|
10
20
|
} catch (error) {
|
|
11
21
|
return NextResponse.json({ newSessions: 0, error: (error as Error).message }, { status: 500 })
|
|
12
22
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { generateCommandInsights } from '@/lib/command-insights'
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic'
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
try {
|
|
8
|
+
const insights = generateCommandInsights()
|
|
9
|
+
return NextResponse.json(insights)
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return NextResponse.json([], { status: 500 })
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -1,12 +1,66 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { analyzeCommands } from '@/lib/commands'
|
|
3
|
+
import { prisma } from '@/lib/db'
|
|
3
4
|
|
|
4
5
|
export const dynamic = 'force-dynamic'
|
|
5
6
|
|
|
6
7
|
export async function GET() {
|
|
7
8
|
try {
|
|
8
9
|
const analysis = analyzeCommands()
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
// Merge skill invocations from session data (Skill tool calls tracked in DB)
|
|
12
|
+
// These capture cases where the user asked Claude to invoke a skill
|
|
13
|
+
// (e.g. "use /doc-writer to...") rather than typing the slash command directly
|
|
14
|
+
const dbSessions = await prisma.session.findMany({
|
|
15
|
+
select: { skillCallsJson: true },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const dbSkillCounts = new Map<string, number>()
|
|
19
|
+
for (const s of dbSessions) {
|
|
20
|
+
const skills = JSON.parse(s.skillCallsJson) as Record<string, number>
|
|
21
|
+
for (const [skill, count] of Object.entries(skills)) {
|
|
22
|
+
const cmd = skill.startsWith('/') ? skill : `/${skill}`
|
|
23
|
+
dbSkillCounts.set(cmd, (dbSkillCounts.get(cmd) || 0) + count)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Build a map of history.jsonl counts for custom commands
|
|
28
|
+
const historyMap = new Map<string, number>()
|
|
29
|
+
for (const c of analysis.customCommands) {
|
|
30
|
+
historyMap.set(c.command, c.count)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Merge DB skill counts into customCommands with breakdown
|
|
34
|
+
const customMap = new Map<string, { command: string; count: number; historyCount: number; sessionCount: number }>()
|
|
35
|
+
for (const c of analysis.customCommands) {
|
|
36
|
+
customMap.set(c.command, { command: c.command, count: c.count, historyCount: c.count, sessionCount: 0 })
|
|
37
|
+
}
|
|
38
|
+
for (const [cmd, count] of dbSkillCounts) {
|
|
39
|
+
const existing = analysis.commands.find(c => c.command === cmd)
|
|
40
|
+
if (existing) {
|
|
41
|
+
existing.count += count
|
|
42
|
+
existing.used = existing.count > 0
|
|
43
|
+
} else {
|
|
44
|
+
const prev = customMap.get(cmd)
|
|
45
|
+
if (prev) {
|
|
46
|
+
prev.sessionCount += count
|
|
47
|
+
prev.count += count
|
|
48
|
+
} else {
|
|
49
|
+
customMap.set(cmd, { command: cmd, count, historyCount: 0, sessionCount: count })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
analysis.customCommands = Array.from(customMap.values())
|
|
55
|
+
.sort((a, b) => b.count - a.count)
|
|
56
|
+
|
|
57
|
+
// Expose session skill counts so the chart can show breakdown
|
|
58
|
+
const responseData = {
|
|
59
|
+
...analysis,
|
|
60
|
+
dbSkillCounts: Object.fromEntries(dbSkillCounts),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return NextResponse.json(responseData)
|
|
10
64
|
} catch (error) {
|
|
11
65
|
return NextResponse.json(
|
|
12
66
|
{ error: (error as Error).message },
|
|
@@ -113,9 +113,8 @@ export async function GET() {
|
|
|
113
113
|
}
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
-
// ---
|
|
117
|
-
const
|
|
118
|
-
.slice(-20)
|
|
116
|
+
// --- All images (newest first, for gallery with pagination) ---
|
|
117
|
+
const allImages = [...images]
|
|
119
118
|
.reverse()
|
|
120
119
|
.map((img) => ({
|
|
121
120
|
filename: img.filename,
|
|
@@ -168,7 +167,7 @@ export async function GET() {
|
|
|
168
167
|
under5Percent: gaps.length ? Math.round((under5 / gaps.length) * 100) : 0,
|
|
169
168
|
},
|
|
170
169
|
topSessions,
|
|
171
|
-
|
|
170
|
+
allImages,
|
|
172
171
|
})
|
|
173
172
|
} catch (error) {
|
|
174
173
|
console.error('Failed to analyze images:', error)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { prisma } from '@/lib/db'
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic'
|
|
5
|
+
|
|
6
|
+
export async function GET(
|
|
7
|
+
request: NextRequest,
|
|
8
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
9
|
+
) {
|
|
10
|
+
try {
|
|
11
|
+
const { id } = await params
|
|
12
|
+
const report = await prisma.report.findUnique({ where: { id } })
|
|
13
|
+
if (!report) {
|
|
14
|
+
return NextResponse.json({ error: 'Report not found' }, { status: 404 })
|
|
15
|
+
}
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
...report,
|
|
18
|
+
contentJson: JSON.parse(report.contentJson),
|
|
19
|
+
})
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return NextResponse.json({ error: (error as Error).message }, { status: 500 })
|
|
22
|
+
}
|
|
23
|
+
}
|