agentfit 0.1.0 → 0.1.1
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/README.md +30 -34
- package/app/(dashboard)/daily/page.tsx +1 -1
- 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/(dashboard)/settings/page.tsx +180 -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/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 +25 -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 +258 -8
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +404 -83
- package/components/dashboard-shell.tsx +9 -24
- package/components/data-provider.tsx +66 -2
- 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/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 +96 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +20 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +187 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +530 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +1 -1
- package/lib/parse-codex.ts +5 -0
- package/lib/parse-logs.ts +65 -0
- package/lib/queries-codex.ts +22 -0
- package/lib/queries.ts +42 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +77 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +16 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/schema.prisma +17 -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/migrations/migration_lock.toml +0 -3
- package/prisma.config.ts +0 -14
- package/setup.sh +0 -73
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { useData } from '@/components/data-provider'
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from '@/components/ui/dialog'
|
|
14
|
+
import { Badge } from '@/components/ui/badge'
|
|
15
|
+
import {
|
|
16
|
+
RefreshCw,
|
|
17
|
+
RotateCcw,
|
|
18
|
+
AlertTriangle,
|
|
19
|
+
Database,
|
|
20
|
+
HardDrive,
|
|
21
|
+
FolderSync,
|
|
22
|
+
} from 'lucide-react'
|
|
23
|
+
import { BackupCard } from '@/components/backup-section'
|
|
24
|
+
|
|
25
|
+
export default function SettingsPage() {
|
|
26
|
+
const { syncing, resetting, lastSyncResult, lastSyncTime, handleSync, handleReset } = useData()
|
|
27
|
+
const [resetOpen, setResetOpen] = useState(false)
|
|
28
|
+
const [paths, setPaths] = useState<{ database: string; images: string; claudeLogs: string; codexLogs: string } | null>(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
fetch('/api/check')
|
|
32
|
+
.then((r) => r.json())
|
|
33
|
+
.then((d) => { if (d.paths) setPaths(d.paths) })
|
|
34
|
+
.catch(() => {})
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-6 max-w-2xl">
|
|
39
|
+
{/* Local Data */}
|
|
40
|
+
<Card>
|
|
41
|
+
<CardHeader>
|
|
42
|
+
<div className="flex items-center gap-2">
|
|
43
|
+
<Database className="h-5 w-5 text-muted-foreground" />
|
|
44
|
+
<div>
|
|
45
|
+
<CardTitle>Local Data</CardTitle>
|
|
46
|
+
<CardDescription>Manage your synced session data</CardDescription>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</CardHeader>
|
|
50
|
+
<CardContent className="space-y-4">
|
|
51
|
+
{/* Sync */}
|
|
52
|
+
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
53
|
+
<div className="space-y-1">
|
|
54
|
+
<div className="flex items-center gap-2">
|
|
55
|
+
<FolderSync className="h-4 w-4 text-muted-foreground" />
|
|
56
|
+
<span className="text-sm font-medium">Sync Logs</span>
|
|
57
|
+
</div>
|
|
58
|
+
<p className="text-xs text-muted-foreground">
|
|
59
|
+
Import new sessions from ~/.claude and ~/.codex into the local database.
|
|
60
|
+
</p>
|
|
61
|
+
{lastSyncTime && (
|
|
62
|
+
<p className="text-xs text-muted-foreground">
|
|
63
|
+
Last synced: {lastSyncTime.toLocaleString()}
|
|
64
|
+
{lastSyncResult && (
|
|
65
|
+
<span> — {lastSyncResult.sessionsAdded} added, {lastSyncResult.sessionsSkipped} skipped</span>
|
|
66
|
+
)}
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
<Button
|
|
71
|
+
variant="outline"
|
|
72
|
+
onClick={handleSync}
|
|
73
|
+
disabled={syncing || resetting}
|
|
74
|
+
className="gap-2 shrink-0"
|
|
75
|
+
>
|
|
76
|
+
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
|
77
|
+
{syncing ? 'Syncing...' : 'Sync'}
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Reset */}
|
|
82
|
+
<div className="flex items-center justify-between rounded-lg border border-destructive/20 p-4">
|
|
83
|
+
<div className="space-y-1">
|
|
84
|
+
<div className="flex items-center gap-2">
|
|
85
|
+
<RotateCcw className="h-4 w-4 text-destructive" />
|
|
86
|
+
<span className="text-sm font-medium text-destructive">Reset Database</span>
|
|
87
|
+
</div>
|
|
88
|
+
<p className="text-xs text-muted-foreground">
|
|
89
|
+
Delete all synced data and re-import from log files on disk. Use if data seems corrupted.
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
<Button
|
|
93
|
+
variant="destructive"
|
|
94
|
+
onClick={() => setResetOpen(true)}
|
|
95
|
+
disabled={syncing || resetting}
|
|
96
|
+
className="gap-2 shrink-0"
|
|
97
|
+
>
|
|
98
|
+
<RotateCcw className={`h-4 w-4 ${resetting ? 'animate-spin' : ''}`} />
|
|
99
|
+
{resetting ? 'Resetting...' : 'Reset'}
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
</CardContent>
|
|
103
|
+
</Card>
|
|
104
|
+
|
|
105
|
+
{/* Database Info */}
|
|
106
|
+
<Card>
|
|
107
|
+
<CardHeader>
|
|
108
|
+
<div className="flex items-center gap-2">
|
|
109
|
+
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
|
110
|
+
<div>
|
|
111
|
+
<CardTitle>Storage</CardTitle>
|
|
112
|
+
<CardDescription>Where your data lives</CardDescription>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</CardHeader>
|
|
116
|
+
<CardContent>
|
|
117
|
+
<div className="space-y-3 text-sm">
|
|
118
|
+
<div className="flex items-center justify-between">
|
|
119
|
+
<span className="text-muted-foreground">Database</span>
|
|
120
|
+
<code className="rounded bg-muted px-2 py-0.5 text-xs">{paths?.database ?? 'agentfit.db'}</code>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="flex items-center justify-between">
|
|
123
|
+
<span className="text-muted-foreground">Claude Code logs</span>
|
|
124
|
+
<code className="rounded bg-muted px-2 py-0.5 text-xs">{paths?.claudeLogs ?? '~/.claude/projects/'}</code>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="flex items-center justify-between">
|
|
127
|
+
<span className="text-muted-foreground">Codex logs</span>
|
|
128
|
+
<code className="rounded bg-muted px-2 py-0.5 text-xs">{paths?.codexLogs ?? '~/.codex/sessions/'}</code>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex items-center justify-between">
|
|
131
|
+
<span className="text-muted-foreground">Extracted images</span>
|
|
132
|
+
<code className="rounded bg-muted px-2 py-0.5 text-xs">{paths?.images ?? 'data/images/'}</code>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</CardContent>
|
|
136
|
+
</Card>
|
|
137
|
+
|
|
138
|
+
{/* GitHub Backup */}
|
|
139
|
+
<BackupCard />
|
|
140
|
+
|
|
141
|
+
{/* Reset confirmation dialog */}
|
|
142
|
+
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
|
|
143
|
+
<DialogContent className="sm:max-w-md">
|
|
144
|
+
<DialogHeader>
|
|
145
|
+
<DialogTitle className="flex items-center gap-2 text-destructive">
|
|
146
|
+
<AlertTriangle className="h-5 w-5" />
|
|
147
|
+
Destructive Action
|
|
148
|
+
</DialogTitle>
|
|
149
|
+
</DialogHeader>
|
|
150
|
+
<div className="space-y-3 text-sm text-muted-foreground">
|
|
151
|
+
<div>
|
|
152
|
+
This will <strong className="text-foreground">permanently delete all synced data</strong> from the database and re-import from log files currently on disk.
|
|
153
|
+
</div>
|
|
154
|
+
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
|
155
|
+
<strong>Warning:</strong> If your coding agent (Claude Code, Codex, etc.) has purged old log files, that data will be permanently lost. The database may contain sessions that no longer exist on disk.
|
|
156
|
+
</div>
|
|
157
|
+
<div>
|
|
158
|
+
This cannot be undone. Consider backing up <code className="rounded bg-muted px-1 py-0.5 text-xs">agentfit.db</code> first.
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<DialogFooter className="gap-3">
|
|
162
|
+
<Button variant="outline" onClick={() => setResetOpen(false)}>
|
|
163
|
+
Cancel
|
|
164
|
+
</Button>
|
|
165
|
+
<Button
|
|
166
|
+
variant="destructive"
|
|
167
|
+
disabled={resetting}
|
|
168
|
+
onClick={async () => {
|
|
169
|
+
await handleReset()
|
|
170
|
+
setResetOpen(false)
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
I understand, reset everything
|
|
174
|
+
</Button>
|
|
175
|
+
</DialogFooter>
|
|
176
|
+
</DialogContent>
|
|
177
|
+
</Dialog>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,24 @@ 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,
|
|
110
120
|
models,
|
|
121
|
+
skillUsage: (() => {
|
|
122
|
+
const skills: Record<string, number> = { ...a.overview.skillUsage }
|
|
123
|
+
for (const [s, c] of Object.entries(b.overview.skillUsage)) {
|
|
124
|
+
skills[s] = (skills[s] || 0) + c
|
|
125
|
+
}
|
|
126
|
+
return skills
|
|
127
|
+
})(),
|
|
128
|
+
permissionModes: (() => {
|
|
129
|
+
const modes: Record<string, number> = { ...(a.overview.permissionModes || {}) }
|
|
130
|
+
for (const [m, c] of Object.entries(b.overview.permissionModes || {})) {
|
|
131
|
+
modes[m] = (modes[m] || 0) + c
|
|
132
|
+
}
|
|
133
|
+
return modes
|
|
134
|
+
})(),
|
|
111
135
|
},
|
|
112
136
|
sessions,
|
|
113
137
|
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')
|