@aion0/forge 0.4.13 → 0.4.15
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/RELEASE_NOTES.md +18 -4
- package/app/api/mobile-chat/route.ts +23 -1
- package/app/api/usage/route.ts +20 -0
- package/components/Dashboard.tsx +17 -1
- package/components/SkillsPanel.tsx +6 -0
- package/components/UsagePanel.tsx +207 -0
- package/components/WebTerminal.tsx +33 -16
- package/forge-logo.svg +106 -0
- package/lib/init.ts +7 -0
- package/lib/notifications.ts +1 -1
- package/lib/task-manager.ts +26 -1
- package/lib/usage-scanner.ts +249 -0
- package/package.json +1 -1
- package/scripts/verify-usage.ts +178 -0
- package/src/core/db/database.ts +28 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.15
|
|
2
2
|
|
|
3
3
|
Released: 2026-03-23
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.14
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
- feat: record token usage from task, pipeline, and mobile sources
|
|
9
|
+
- feat: Usage dashboard — token cost by project, model, day, source
|
|
10
|
+
- feat: token usage tracking — scanner, DB, API
|
|
6
11
|
|
|
7
12
|
### Bug Fixes
|
|
8
|
-
- fix:
|
|
13
|
+
- fix: exclude cache tokens from cost estimate
|
|
14
|
+
- fix: usage stored per day+model for accurate daily breakdown
|
|
15
|
+
- fix: usage query uses local timezone for daily grouping
|
|
16
|
+
|
|
17
|
+
### Performance
|
|
18
|
+
- perf: usage scan interval from 5min to 1 hour
|
|
19
|
+
|
|
20
|
+
### Other
|
|
21
|
+
- ui: show author and source URL in skills detail view
|
|
22
|
+
- ui: move Usage button next to Browser in header right section
|
|
9
23
|
|
|
10
24
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.
|
|
25
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.14...v0.4.15
|
|
@@ -19,6 +19,7 @@ export async function POST(req: Request) {
|
|
|
19
19
|
|
|
20
20
|
const settings = loadSettings();
|
|
21
21
|
const claudePath = settings.claudePath || 'claude';
|
|
22
|
+
const projectName = projectPath.split('/').pop() || projectPath;
|
|
22
23
|
|
|
23
24
|
const args = ['-p', '--dangerously-skip-permissions', '--output-format', 'json'];
|
|
24
25
|
if (resume) args.push('-c');
|
|
@@ -34,13 +35,16 @@ export async function POST(req: Request) {
|
|
|
34
35
|
|
|
35
36
|
const encoder = new TextEncoder();
|
|
36
37
|
let closed = false;
|
|
38
|
+
let fullOutput = '';
|
|
37
39
|
|
|
38
40
|
const stream = new ReadableStream({
|
|
39
41
|
start(controller) {
|
|
40
42
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
41
43
|
if (closed) return;
|
|
44
|
+
const text = chunk.toString();
|
|
45
|
+
fullOutput += text;
|
|
42
46
|
try {
|
|
43
|
-
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text
|
|
47
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text })}\n\n`));
|
|
44
48
|
} catch {}
|
|
45
49
|
});
|
|
46
50
|
|
|
@@ -56,6 +60,24 @@ export async function POST(req: Request) {
|
|
|
56
60
|
child.on('exit', (code) => {
|
|
57
61
|
if (closed) return;
|
|
58
62
|
closed = true;
|
|
63
|
+
|
|
64
|
+
// Record usage from the JSON output
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(fullOutput);
|
|
67
|
+
if (parsed.session_id) {
|
|
68
|
+
const { recordUsage } = require('@/lib/usage-scanner');
|
|
69
|
+
recordUsage({
|
|
70
|
+
sessionId: parsed.session_id,
|
|
71
|
+
source: 'mobile',
|
|
72
|
+
projectPath,
|
|
73
|
+
projectName,
|
|
74
|
+
model: parsed.model || 'unknown',
|
|
75
|
+
inputTokens: parsed.usage?.input_tokens || parsed.total_input_tokens || 0,
|
|
76
|
+
outputTokens: parsed.usage?.output_tokens || parsed.total_output_tokens || 0,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
|
|
59
81
|
try {
|
|
60
82
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', code })}\n\n`));
|
|
61
83
|
controller.close();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { scanUsage, queryUsage } from '@/lib/usage-scanner';
|
|
3
|
+
|
|
4
|
+
// GET /api/usage?days=7&project=forge&source=task&model=claude-opus-4
|
|
5
|
+
export async function GET(req: Request) {
|
|
6
|
+
const { searchParams } = new URL(req.url);
|
|
7
|
+
const days = searchParams.get('days') ? parseInt(searchParams.get('days')!) : undefined;
|
|
8
|
+
const projectName = searchParams.get('project') || undefined;
|
|
9
|
+
const source = searchParams.get('source') || undefined;
|
|
10
|
+
const model = searchParams.get('model') || undefined;
|
|
11
|
+
|
|
12
|
+
const data = queryUsage({ days, projectName, source, model });
|
|
13
|
+
return NextResponse.json(data);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// POST /api/usage — trigger scan
|
|
17
|
+
export async function POST() {
|
|
18
|
+
const result = scanUsage();
|
|
19
|
+
return NextResponse.json(result);
|
|
20
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -21,6 +21,7 @@ const PipelineView = lazy(() => import('./PipelineView'));
|
|
|
21
21
|
const HelpDialog = lazy(() => import('./HelpDialog'));
|
|
22
22
|
const LogViewer = lazy(() => import('./LogViewer'));
|
|
23
23
|
const SkillsPanel = lazy(() => import('./SkillsPanel'));
|
|
24
|
+
const UsagePanel = lazy(() => import('./UsagePanel'));
|
|
24
25
|
|
|
25
26
|
interface UsageSummary {
|
|
26
27
|
provider: string;
|
|
@@ -94,7 +95,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
export default function Dashboard({ user }: { user: any }) {
|
|
97
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'skills' | 'logs'>('terminal');
|
|
98
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'skills' | 'logs' | 'usage'>('terminal');
|
|
98
99
|
const [browserMode, setBrowserMode] = useState<'none' | 'float' | 'right' | 'left'>('none');
|
|
99
100
|
const [showBrowserMenu, setShowBrowserMenu] = useState(false);
|
|
100
101
|
const [browserWidth, setBrowserWidth] = useState(600);
|
|
@@ -409,6 +410,14 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
409
410
|
</>
|
|
410
411
|
)}
|
|
411
412
|
</div>
|
|
413
|
+
<button
|
|
414
|
+
onClick={() => setViewMode('usage')}
|
|
415
|
+
className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
|
|
416
|
+
viewMode === 'usage'
|
|
417
|
+
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
418
|
+
: 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
|
|
419
|
+
}`}
|
|
420
|
+
>Usage</button>
|
|
412
421
|
<TunnelToggle />
|
|
413
422
|
{onlineCount.total > 0 && (
|
|
414
423
|
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
|
|
@@ -708,6 +717,13 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
708
717
|
</Suspense>
|
|
709
718
|
)}
|
|
710
719
|
|
|
720
|
+
{/* Usage */}
|
|
721
|
+
{viewMode === 'usage' && (
|
|
722
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
723
|
+
<UsagePanel />
|
|
724
|
+
</Suspense>
|
|
725
|
+
)}
|
|
726
|
+
|
|
711
727
|
{/* Logs */}
|
|
712
728
|
{viewMode === 'logs' && (
|
|
713
729
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
@@ -579,6 +579,12 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
579
579
|
</div>}
|
|
580
580
|
</div>
|
|
581
581
|
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5">{skill?.description || ''}</p>
|
|
582
|
+
{skill?.author && (
|
|
583
|
+
<div className="text-[9px] text-[var(--text-secondary)] mt-1">By {skill.author}</div>
|
|
584
|
+
)}
|
|
585
|
+
{skill?.sourceUrl && (
|
|
586
|
+
<a href={skill.sourceUrl} target="_blank" rel="noopener noreferrer" className="text-[9px] text-[var(--accent)] hover:underline mt-0.5 block truncate">{skill.sourceUrl.replace(/^https?:\/\//, '').slice(0, 60)}</a>
|
|
587
|
+
)}
|
|
582
588
|
{/* Installed indicators */}
|
|
583
589
|
{skill && isInstalled && (
|
|
584
590
|
<div className="flex items-center gap-2 mt-1">
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface UsageData {
|
|
6
|
+
total: { input: number; output: number; cost: number; sessions: number; messages: number };
|
|
7
|
+
byProject: { name: string; input: number; output: number; cost: number; sessions: number }[];
|
|
8
|
+
byModel: { model: string; input: number; output: number; cost: number; messages: number }[];
|
|
9
|
+
byDay: { date: string; input: number; output: number; cost: number }[];
|
|
10
|
+
bySource: { source: string; input: number; output: number; cost: number; messages: number }[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatTokens(n: number): string {
|
|
14
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
15
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
16
|
+
return String(n);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatCost(n: number): string {
|
|
20
|
+
return `$${n.toFixed(2)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Simple bar component
|
|
24
|
+
function Bar({ value, max, color }: { value: number; max: number; color: string }) {
|
|
25
|
+
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
26
|
+
return (
|
|
27
|
+
<div className="h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden flex-1">
|
|
28
|
+
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function UsagePanel() {
|
|
34
|
+
const [data, setData] = useState<UsageData | null>(null);
|
|
35
|
+
const [days, setDays] = useState(7);
|
|
36
|
+
const [scanning, setScanning] = useState(false);
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
|
|
39
|
+
const fetchData = useCallback(async () => {
|
|
40
|
+
setLoading(true);
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`/api/usage${days ? `?days=${days}` : ''}`);
|
|
43
|
+
const d = await res.json();
|
|
44
|
+
setData(d);
|
|
45
|
+
} catch {}
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}, [days]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => { fetchData(); }, [fetchData]);
|
|
50
|
+
|
|
51
|
+
const triggerScan = async () => {
|
|
52
|
+
setScanning(true);
|
|
53
|
+
try {
|
|
54
|
+
await fetch('/api/usage', { method: 'POST' });
|
|
55
|
+
await fetchData();
|
|
56
|
+
} catch {}
|
|
57
|
+
setScanning(false);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (loading && !data) {
|
|
61
|
+
return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading usage data...</div>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!data) {
|
|
65
|
+
return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Failed to load usage data</div>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const maxProjectCost = data.byProject.length > 0 ? data.byProject[0].cost : 1;
|
|
69
|
+
const maxDayCost = data.byDay.length > 0 ? Math.max(...data.byDay.map(d => d.cost)) : 1;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
|
73
|
+
{/* Header */}
|
|
74
|
+
<div className="px-4 py-3 border-b border-[var(--border)] shrink-0 flex items-center gap-3">
|
|
75
|
+
<h2 className="text-sm font-semibold text-[var(--text-primary)]">Token Usage</h2>
|
|
76
|
+
<div className="flex items-center gap-1 ml-auto">
|
|
77
|
+
{[7, 30, 90, 0].map(d => (
|
|
78
|
+
<button
|
|
79
|
+
key={d}
|
|
80
|
+
onClick={() => setDays(d)}
|
|
81
|
+
className={`text-[10px] px-2 py-0.5 rounded ${days === d ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
82
|
+
>
|
|
83
|
+
{d === 0 ? 'All' : `${d}d`}
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
<button
|
|
88
|
+
onClick={triggerScan}
|
|
89
|
+
disabled={scanning}
|
|
90
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
91
|
+
>
|
|
92
|
+
{scanning ? 'Scanning...' : 'Scan Now'}
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="p-4 space-y-6">
|
|
97
|
+
{/* Summary cards */}
|
|
98
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
99
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
100
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Total Cost</div>
|
|
101
|
+
<div className="text-lg font-bold text-[var(--text-primary)]">{formatCost(data.total.cost)}</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
104
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Input Tokens</div>
|
|
105
|
+
<div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.input)}</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
108
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Output Tokens</div>
|
|
109
|
+
<div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.output)}</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
112
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Sessions</div>
|
|
113
|
+
<div className="text-lg font-bold text-[var(--text-primary)]">{data.total.sessions}</div>
|
|
114
|
+
<div className="text-[9px] text-[var(--text-secondary)]">{data.total.messages} messages</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* By Day — bar chart */}
|
|
119
|
+
{data.byDay.length > 0 && (
|
|
120
|
+
<div>
|
|
121
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Daily Cost</h3>
|
|
122
|
+
<div className="space-y-1">
|
|
123
|
+
{data.byDay.slice(0, 14).reverse().map(d => (
|
|
124
|
+
<div key={d.date} className="flex items-center gap-2 text-[10px]">
|
|
125
|
+
<span className="text-[var(--text-secondary)] w-16 shrink-0">{d.date.slice(5)}</span>
|
|
126
|
+
<Bar value={d.cost} max={maxDayCost} color="bg-[var(--accent)]" />
|
|
127
|
+
<span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(d.cost)}</span>
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* By Project */}
|
|
135
|
+
{data.byProject.length > 0 && (
|
|
136
|
+
<div>
|
|
137
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Project</h3>
|
|
138
|
+
<div className="space-y-1.5">
|
|
139
|
+
{data.byProject.map(p => (
|
|
140
|
+
<div key={p.name} className="flex items-center gap-2 text-[10px]">
|
|
141
|
+
<span className="text-[var(--text-primary)] w-28 truncate shrink-0" title={p.name}>{p.name}</span>
|
|
142
|
+
<Bar value={p.cost} max={maxProjectCost} color="bg-blue-500" />
|
|
143
|
+
<span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(p.cost)}</span>
|
|
144
|
+
<span className="text-[var(--text-secondary)] w-12 text-right shrink-0">{p.sessions}s</span>
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* By Model */}
|
|
152
|
+
{data.byModel.length > 0 && (
|
|
153
|
+
<div>
|
|
154
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Model</h3>
|
|
155
|
+
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
156
|
+
<table className="w-full text-[10px]">
|
|
157
|
+
<thead>
|
|
158
|
+
<tr className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
|
159
|
+
<th className="text-left px-3 py-1.5">Model</th>
|
|
160
|
+
<th className="text-right px-3 py-1.5">Input</th>
|
|
161
|
+
<th className="text-right px-3 py-1.5">Output</th>
|
|
162
|
+
<th className="text-right px-3 py-1.5">Cost</th>
|
|
163
|
+
<th className="text-right px-3 py-1.5">Msgs</th>
|
|
164
|
+
</tr>
|
|
165
|
+
</thead>
|
|
166
|
+
<tbody>
|
|
167
|
+
{data.byModel.map(m => (
|
|
168
|
+
<tr key={m.model} className="border-t border-[var(--border)]/30">
|
|
169
|
+
<td className="px-3 py-1.5 text-[var(--text-primary)]">{m.model}</td>
|
|
170
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.input)}</td>
|
|
171
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.output)}</td>
|
|
172
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-primary)] font-medium">{formatCost(m.cost)}</td>
|
|
173
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{m.messages}</td>
|
|
174
|
+
</tr>
|
|
175
|
+
))}
|
|
176
|
+
</tbody>
|
|
177
|
+
</table>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* By Source */}
|
|
183
|
+
{data.bySource.length > 0 && (
|
|
184
|
+
<div>
|
|
185
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Source</h3>
|
|
186
|
+
<div className="flex gap-3 flex-wrap">
|
|
187
|
+
{data.bySource.map(s => (
|
|
188
|
+
<div key={s.source} className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-3 py-2 min-w-[120px]">
|
|
189
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">{s.source}</div>
|
|
190
|
+
<div className="text-sm font-bold text-[var(--text-primary)]">{formatCost(s.cost)}</div>
|
|
191
|
+
<div className="text-[9px] text-[var(--text-secondary)]">{s.messages} msgs</div>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{/* Note */}
|
|
199
|
+
<div className="text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] pt-3">
|
|
200
|
+
Cost estimates based on API pricing (Opus: $15/$75 per M tokens, Sonnet: $3/$15).
|
|
201
|
+
Actual cost may differ with Claude Max/Pro subscription.
|
|
202
|
+
Daily breakdown groups by session completion date.
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -168,16 +168,15 @@ const pendingCommands = new Map<number, string>();
|
|
|
168
168
|
|
|
169
169
|
const bellEnabledPanes = new Set<number>();
|
|
170
170
|
const bellPaneLabels = new Map<number, string>();
|
|
171
|
-
const bellLastFired = new Map<
|
|
172
|
-
const BELL_COOLDOWN =
|
|
171
|
+
const bellLastFired = new Map<string, number>(); // tabLabel -> timestamp
|
|
172
|
+
const BELL_COOLDOWN = 120000; // 2min cooldown between bells
|
|
173
173
|
|
|
174
174
|
function fireBellNotification(paneId: number) {
|
|
175
|
+
const label = bellPaneLabels.get(paneId) || 'Terminal';
|
|
175
176
|
const now = Date.now();
|
|
176
|
-
const last = bellLastFired.get(
|
|
177
|
+
const last = bellLastFired.get(label) || 0;
|
|
177
178
|
if (now - last < BELL_COOLDOWN) return;
|
|
178
|
-
bellLastFired.set(
|
|
179
|
-
|
|
180
|
-
const label = bellPaneLabels.get(paneId) || 'Terminal';
|
|
179
|
+
bellLastFired.set(label, now);
|
|
181
180
|
|
|
182
181
|
// Browser notification
|
|
183
182
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
@@ -1120,7 +1119,10 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1120
1119
|
if (!containerRef.current) return;
|
|
1121
1120
|
|
|
1122
1121
|
let disposed = false; // guard against post-cleanup writes (React Strict Mode)
|
|
1123
|
-
let
|
|
1122
|
+
let bellArmed = false; // armed after user presses Enter
|
|
1123
|
+
let bellNewBytes = 0;
|
|
1124
|
+
let bellIdleTimer = 0;
|
|
1125
|
+
let bellArmedAt = 0; // timestamp when armed
|
|
1124
1126
|
|
|
1125
1127
|
// Read terminal theme from CSS variables
|
|
1126
1128
|
const cs = getComputedStyle(document.documentElement);
|
|
@@ -1257,15 +1259,23 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1257
1259
|
const msg = JSON.parse(event.data);
|
|
1258
1260
|
if (msg.type === 'output') {
|
|
1259
1261
|
try { term.write(msg.data); } catch {};
|
|
1260
|
-
// Bell: detect claude completion
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1262
|
+
// Bell: detect claude completion
|
|
1263
|
+
// Bell: idle detection after user submits prompt
|
|
1264
|
+
if (bellEnabledPanes.has(id) && bellArmed) {
|
|
1265
|
+
bellNewBytes += (msg.data as string).length;
|
|
1266
|
+
clearTimeout(bellIdleTimer);
|
|
1267
|
+
if (bellNewBytes > 2000) {
|
|
1268
|
+
// 10s idle = claude finished
|
|
1269
|
+
bellIdleTimer = window.setTimeout(() => {
|
|
1270
|
+
bellArmed = false;
|
|
1271
|
+
fireBellNotification(id);
|
|
1272
|
+
}, 10000);
|
|
1273
|
+
// Fallback: if 90s since armed with activity, force fire
|
|
1274
|
+
if (Date.now() - bellArmedAt > 90000) {
|
|
1275
|
+
bellArmed = false;
|
|
1276
|
+
clearTimeout(bellIdleTimer);
|
|
1277
|
+
fireBellNotification(id);
|
|
1278
|
+
}
|
|
1269
1279
|
}
|
|
1270
1280
|
}
|
|
1271
1281
|
} else if (msg.type === 'connected') {
|
|
@@ -1358,6 +1368,13 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1358
1368
|
|
|
1359
1369
|
term.onData((data) => {
|
|
1360
1370
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
|
|
1371
|
+
// Arm bell on Enter (user submitted a new prompt)
|
|
1372
|
+
if (data === '\r' || data === '\n') {
|
|
1373
|
+
bellArmed = true;
|
|
1374
|
+
bellNewBytes = 0;
|
|
1375
|
+
bellArmedAt = Date.now();
|
|
1376
|
+
clearTimeout(bellIdleTimer);
|
|
1377
|
+
}
|
|
1361
1378
|
});
|
|
1362
1379
|
|
|
1363
1380
|
// ── Resize handling ──
|
package/forge-logo.svg
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<?xml version="1.0" standalone="no"?>
|
|
2
|
+
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
|
3
|
+
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
|
4
|
+
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
|
5
|
+
width="1050.000000pt" height="1102.000000pt" viewBox="0 0 1050.000000 1102.000000"
|
|
6
|
+
preserveAspectRatio="xMidYMid meet">
|
|
7
|
+
|
|
8
|
+
<g transform="translate(0.000000,1102.000000) scale(0.100000,-0.100000)"
|
|
9
|
+
fill="#000000" stroke="none">
|
|
10
|
+
<path d="M0 5510 l0 -5510 5250 0 5250 0 0 5510 0 5510 -5250 0 -5250 0 0
|
|
11
|
+
-5510z m2410 3369 c37 -18 164 -120 395 -319 584 -503 1025 -883 1101 -946 39
|
|
12
|
+
-34 111 -96 159 -138 52 -45 96 -76 109 -76 12 0 233 45 492 99 258 55 503
|
|
13
|
+
106 544 115 67 13 83 14 137 1 74 -17 147 -33 398 -85 105 -21 233 -48 285
|
|
14
|
+
-60 237 -52 327 -70 350 -70 25 0 127 84 655 541 61 52 162 139 225 193 63 54
|
|
15
|
+
171 147 240 207 475 410 597 512 643 536 74 38 198 44 279 13 66 -24 145 -92
|
|
16
|
+
181 -153 60 -101 57 -27 57 -1656 l0 -1492 54 -217 c57 -224 150 -590 185
|
|
17
|
+
-722 10 -41 38 -147 62 -235 23 -88 47 -185 54 -215 7 -30 16 -67 20 -82 6
|
|
18
|
+
-25 5 -28 -19 -28 -15 0 -29 6 -32 14 -4 11 -13 12 -35 6 -16 -5 -68 -12 -114
|
|
19
|
+
-15 l-84 -7 -6 29 c-4 15 -9 35 -12 43 -2 8 -29 110 -58 225 -30 116 -75 289
|
|
20
|
+
-100 385 -25 96 -81 316 -125 488 l-80 313 0 1495 c0 1664 5 1554 -65 1554
|
|
21
|
+
-29 0 -49 -13 -123 -77 -88 -77 -479 -412 -617 -528 -40 -33 -213 -181 -385
|
|
22
|
+
-330 -173 -148 -399 -343 -504 -432 -121 -105 -197 -163 -211 -163 -21 0 -115
|
|
23
|
+
19 -525 106 -204 43 -302 64 -500 105 l-155 32 -115 -23 c-63 -12 -155 -31
|
|
24
|
+
-205 -42 -49 -11 -133 -28 -185 -39 -52 -11 -124 -27 -160 -34 -259 -56 -505
|
|
25
|
+
-105 -526 -105 -17 0 -66 36 -162 119 -341 296 -862 743 -1486 1275 -166 142
|
|
26
|
+
-194 156 -230 120 -9 -8 -17 -16 -18 -17 -2 -1 -5 -688 -8 -1527 l-5 -1525
|
|
27
|
+
-53 -195 c-28 -107 -79 -298 -111 -425 -33 -126 -72 -275 -86 -330 -15 -55
|
|
28
|
+
-46 -177 -70 -271 -64 -248 -50 -226 -142 -219 -129 10 -144 11 -149 3 -8 -13
|
|
29
|
+
-54 -9 -54 5 0 6 5 28 11 47 6 19 17 60 24 90 11 50 115 454 165 645 12 44 56
|
|
30
|
+
213 98 375 l77 295 5 1540 5 1540 33 67 c40 81 117 155 193 186 78 31 202 27
|
|
31
|
+
279 -9z m560 -1324 c0 -85 0 -85 -26 -85 -36 0 -81 -29 -94 -59 -6 -14 -14
|
|
32
|
+
-112 -19 -217 -9 -212 -21 -257 -83 -309 l-30 -25 40 -44 c58 -63 73 -120 78
|
|
33
|
+
-301 2 -82 4 -160 4 -173 0 -37 52 -82 94 -82 l36 0 0 -85 0 -85 -47 0 c-107
|
|
34
|
+
1 -190 39 -237 111 -42 63 -56 137 -56 294 0 77 -3 157 -6 177 -9 50 -43 85
|
|
35
|
+
-98 100 l-46 12 0 78 c0 77 0 78 28 84 111 24 115 34 123 268 6 204 13 236 58
|
|
36
|
+
307 45 72 133 113 254 118 l27 1 0 -85z m4810 42 c117 -58 150 -147 150 -406
|
|
37
|
+
0 -192 17 -229 113 -246 l38 -7 -3 -76 -3 -75 -49 -17 c-84 -28 -89 -41 -97
|
|
38
|
+
-254 -4 -101 -10 -199 -14 -218 -17 -79 -79 -154 -153 -185 -19 -8 -66 -15
|
|
39
|
+
-106 -16 l-71 -2 0 80 0 79 45 8 c81 13 86 27 93 239 7 208 18 258 73 316 19
|
|
40
|
+
20 34 37 34 38 0 1 -16 19 -35 40 -52 57 -65 109 -66 254 0 196 -10 259 -43
|
|
41
|
+
287 -15 13 -45 27 -67 30 l-39 7 0 77 c0 54 4 80 13 83 32 12 126 -6 187 -36z
|
|
42
|
+
m-3638 -2314 c233 -236 490 -501 596 -615 72 -77 72 -77 72 -133 l0 -57 -177
|
|
43
|
+
-181 c-455 -467 -688 -697 -710 -697 -23 0 -198 181 -198 206 0 7 159 173 354
|
|
44
|
+
369 195 195 353 357 350 360 -17 18 -186 194 -424 438 -154 159 -282 293 -284
|
|
45
|
+
299 -3 10 197 218 210 218 3 0 98 -93 211 -207z m2473 -1633 l0 -145 -95 -6
|
|
46
|
+
c-121 -9 -1077 -8 -1320 0 l-185 6 -3 134 c-2 90 1 138 9 147 10 12 136 14
|
|
47
|
+
803 12 l791 -3 0 -145z m-4912 -141 c21 -35 42 -73 48 -84 22 -47 71 -129 86
|
|
48
|
+
-143 14 -14 16 -14 25 1 5 9 14 17 20 17 7 0 6 -5 -2 -15 -6 -8 -9 -19 -5 -26
|
|
49
|
+
4 -6 2 -15 -5 -19 -9 -5 -5 -20 13 -56 13 -27 28 -59 32 -72 4 -13 25 -55 47
|
|
50
|
+
-93 38 -65 44 -106 16 -97 -11 4 -40 53 -160 278 -66 123 -173 337 -188 374
|
|
51
|
+
-10 28 -10 29 12 14 13 -9 40 -44 61 -79z m7221 54 c-4 -16 -28 -68 -54 -118
|
|
52
|
+
-26 -49 -80 -153 -120 -230 -40 -77 -90 -170 -112 -207 -21 -36 -38 -71 -38
|
|
53
|
+
-77 0 -13 -27 -15 -34 -2 -3 5 8 41 24 79 17 39 30 78 30 87 0 9 5 13 11 9 8
|
|
54
|
+
-4 9 1 4 17 -4 13 -4 21 0 17 5 -4 14 0 21 10 9 13 10 20 1 31 -10 12 -9 14 4
|
|
55
|
+
9 17 -7 40 30 132 212 74 146 149 240 131 163z m-284 -35 c-7 -18 -33 -75 -57
|
|
56
|
+
-126 -24 -51 -43 -99 -43 -106 0 -7 -7 -19 -15 -26 -9 -7 -13 -20 -10 -30 3
|
|
57
|
+
-10 -5 -26 -21 -41 -14 -13 -46 -59 -71 -101 -30 -53 -49 -75 -59 -71 -8 3
|
|
58
|
+
-14 9 -14 13 0 28 233 461 252 468 4 2 8 8 8 13 0 11 33 47 39 43 2 -2 -2 -18
|
|
59
|
+
-9 -36z m-6543 -260 c99 -190 119 -235 106 -241 -18 -11 -8 -24 -87 113 -87
|
|
60
|
+
148 -108 179 -124 180 -14 0 -36 49 -27 58 3 4 8 -4 12 -16 3 -12 10 -22 15
|
|
61
|
+
-22 5 0 0 17 -11 38 -28 52 -51 111 -51 132 0 10 -5 20 -12 22 -7 3 -8 9 -3
|
|
62
|
+
18 15 24 52 -34 182 -282z m-231 54 c-18 -18 -29 -12 -20 11 3 9 12 13 21 10
|
|
63
|
+
13 -5 13 -8 -1 -21z m6779 -162 c3 -5 1 -10 -4 -10 -6 0 -11 5 -11 10 0 6 2
|
|
64
|
+
10 4 10 3 0 8 -4 11 -10z m-751 -568 c-22 -19 -66 -52 -99 -75 -33 -22 -99
|
|
65
|
+
-69 -146 -103 -48 -35 -89 -64 -93 -64 -3 0 -14 -7 -23 -16 -17 -14 -69 -51
|
|
66
|
+
-248 -174 -36 -25 -107 -74 -158 -110 -51 -36 -100 -69 -109 -75 -8 -5 -43
|
|
67
|
+
-30 -78 -55 -34 -25 -75 -53 -92 -62 -17 -9 -34 -21 -37 -25 -15 -20 -72 -53
|
|
68
|
+
-82 -47 -12 7 48 63 69 64 6 0 12 4 12 8 0 5 33 31 72 58 40 27 77 53 83 57
|
|
69
|
+
16 12 252 181 414 295 73 52 180 126 205 142 27 17 67 46 204 147 62 45 106
|
|
70
|
+
71 121 70 22 -1 21 -4 -15 -35z m-5149 -18 c5 -5 30 -23 54 -39 25 -15 50 -32
|
|
71
|
+
55 -37 6 -5 65 -47 131 -93 66 -46 125 -88 130 -92 6 -4 30 -21 55 -37 25 -16
|
|
72
|
+
72 -50 105 -75 58 -45 100 -75 134 -96 9 -5 32 -22 51 -37 20 -14 71 -50 115
|
|
73
|
+
-79 44 -29 97 -71 119 -93 21 -21 36 -34 32 -28 -12 20 10 13 36 -10 14 -13
|
|
74
|
+
35 -30 47 -38 11 -8 21 -20 21 -28 0 -12 -27 2 -75 40 -5 5 -53 37 -105 71
|
|
75
|
+
-134 89 -261 177 -285 197 -11 10 -74 54 -140 99 -66 45 -147 102 -180 126
|
|
76
|
+
-70 52 -161 116 -183 130 -12 7 -140 100 -181 131 -1 1 0 7 3 13 7 11 39 -2
|
|
77
|
+
61 -25z m5388 -81 c-5 -12 -3 -14 8 -8 68 39 -178 -142 -435 -320 -154 -106
|
|
78
|
+
-291 -203 -384 -270 -53 -39 -135 -96 -182 -127 -102 -69 -351 -246 -383 -273
|
|
79
|
+
-52 -44 -168 -104 -167 -88 1 11 75 67 195 148 96 65 153 106 288 206 34 25
|
|
80
|
+
107 76 162 114 93 63 384 273 467 337 20 15 39 28 43 28 3 0 45 29 93 64 78
|
|
81
|
+
57 294 206 299 206 1 0 -1 -7 -4 -17z m-5615 -47 c71 -47 139 -94 159 -111 6
|
|
82
|
+
-5 49 -36 95 -68 46 -32 162 -114 258 -183 96 -69 231 -164 300 -211 122 -85
|
|
83
|
+
260 -185 290 -210 8 -7 24 -19 35 -27 11 -8 17 -17 13 -21 -7 -8 -42 10 -84
|
|
84
|
+
42 -16 12 -76 54 -134 93 -58 39 -143 98 -190 131 -47 33 -96 68 -110 77 -26
|
|
85
|
+
17 -177 125 -195 139 -5 4 -30 21 -55 38 -25 16 -95 66 -156 111 -61 45 -122
|
|
86
|
+
87 -135 94 -25 14 -113 79 -140 105 -9 8 -20 15 -26 15 -5 0 -10 7 -10 15 0
|
|
87
|
+
23 13 19 85 -29z m1347 -667 c11 -17 -1 -21 -15 -4 -8 9 -8 15 -2 15 6 0 14
|
|
88
|
+
-5 17 -11z m2850 1 c10 -16 -258 -206 -276 -195 -15 10 0 25 19 18 9 -3 14 -3
|
|
89
|
+
10 2 -9 9 26 42 53 50 11 4 19 11 19 17 0 6 3 8 7 5 3 -4 16 5 29 18 13 14 27
|
|
90
|
+
25 31 26 4 0 28 15 53 34 53 40 47 37 55 25z m-2795 -49 c-1 -15 -2 -15 -13 0
|
|
91
|
+
-7 9 -19 14 -27 12 -13 -4 -13 -3 -1 5 19 13 41 4 41 -17z m178 -117 c34 -27
|
|
92
|
+
37 -39 8 -28 -9 3 -14 10 -11 14 3 4 -8 10 -23 13 -15 4 -40 19 -57 34 -16 15
|
|
93
|
+
-43 35 -58 43 -16 8 -24 16 -18 18 6 2 8 8 5 14 -7 11 99 -63 154 -108z m-190
|
|
94
|
+
-152 c62 -43 76 -62 47 -62 -8 0 -15 3 -15 8 0 4 -15 13 -32 21 -18 8 -35 17
|
|
95
|
+
-38 20 -3 3 -27 22 -55 40 -27 19 -52 37 -55 41 -3 3 -24 18 -47 33 -25 18
|
|
96
|
+
-40 34 -36 43 5 13 36 -6 231 -144z m452 28 c0 -5 -15 -10 -34 -10 -19 0 -38
|
|
97
|
+
5 -41 10 -4 6 10 10 34 10 23 0 41 -4 41 -10z m685 5 c-25 -8 -675 -12 -675
|
|
98
|
+
-4 0 5 141 9 343 8 188 0 338 -2 332 -4z m705 -5 c-96 -3 -249 -3 -340 0 -113
|
|
99
|
+
4 -58 6 175 6 246 0 291 -2 165 -6z m263 3 c-7 -2 -21 -2 -30 0 -10 3 -4 5 12
|
|
100
|
+
5 17 0 24 -2 18 -5z m165 0 c-21 -2 -57 -2 -80 0 -24 2 -7 4 37 4 44 0 63 -2
|
|
101
|
+
43 -4z m401 -119 c-6 -7 -15 -12 -19 -9 -5 3 -11 1 -15 -5 -4 -6 -11 -7 -17
|
|
102
|
+
-4 -5 4 2 14 18 25 30 20 52 15 33 -7z m-134 -84 c3 -5 -3 -10 -15 -10 -12 0
|
|
103
|
+
-18 5 -15 10 3 6 10 10 15 10 5 0 12 -4 15 -10z m-71 -39 c3 -5 -11 -7 -32 -4
|
|
104
|
+
-49 7 -54 13 -9 13 20 0 38 -4 41 -9z"/>
|
|
105
|
+
</g>
|
|
106
|
+
</svg>
|
package/lib/init.ts
CHANGED
|
@@ -106,6 +106,13 @@ export function ensureInitialized() {
|
|
|
106
106
|
setInterval(() => { syncSkills().catch(() => {}); }, 60 * 60 * 1000);
|
|
107
107
|
} catch {}
|
|
108
108
|
|
|
109
|
+
// Usage scanner — scan JSONL files for token usage on startup + every hour
|
|
110
|
+
try {
|
|
111
|
+
const { scanUsage } = require('./usage-scanner');
|
|
112
|
+
scanUsage();
|
|
113
|
+
setInterval(() => { try { scanUsage(); } catch {} }, 60 * 60 * 1000);
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
109
116
|
// Task runner is safe in every worker (DB-level coordination)
|
|
110
117
|
ensureRunnerStarted();
|
|
111
118
|
|
package/lib/notifications.ts
CHANGED
|
@@ -40,7 +40,7 @@ export function getNotifications(limit = 50, offset = 0): Notification[] {
|
|
|
40
40
|
body: r.body,
|
|
41
41
|
read: !!r.read,
|
|
42
42
|
taskId: r.task_id,
|
|
43
|
-
createdAt: r.created_at,
|
|
43
|
+
createdAt: r.created_at ? (r.created_at.endsWith('Z') ? r.created_at : r.created_at.replace(' ', 'T') + 'Z') : r.created_at,
|
|
44
44
|
}));
|
|
45
45
|
}
|
|
46
46
|
|
package/lib/task-manager.ts
CHANGED
|
@@ -322,6 +322,8 @@ function executeTask(task: Task): Promise<void> {
|
|
|
322
322
|
let totalCost = 0;
|
|
323
323
|
let sessionId = '';
|
|
324
324
|
let modelUsed = '';
|
|
325
|
+
let totalInputTokens = 0;
|
|
326
|
+
let totalOutputTokens = 0;
|
|
325
327
|
|
|
326
328
|
child.on('error', (err) => {
|
|
327
329
|
console.error(`[task-runner] Spawn error:`, err.message);
|
|
@@ -355,9 +357,16 @@ function executeTask(task: Task): Promise<void> {
|
|
|
355
357
|
if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.model) {
|
|
356
358
|
modelUsed = parsed.model;
|
|
357
359
|
}
|
|
360
|
+
// Accumulate token usage from assistant messages
|
|
361
|
+
if (parsed.type === 'assistant' && parsed.message?.usage) {
|
|
362
|
+
totalInputTokens += parsed.message.usage.input_tokens || 0;
|
|
363
|
+
totalOutputTokens += parsed.message.usage.output_tokens || 0;
|
|
364
|
+
}
|
|
358
365
|
if (parsed.type === 'result') {
|
|
359
366
|
resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
|
|
360
367
|
totalCost = parsed.total_cost_usd || 0;
|
|
368
|
+
if (parsed.total_input_tokens) totalInputTokens = parsed.total_input_tokens;
|
|
369
|
+
if (parsed.total_output_tokens) totalOutputTokens = parsed.total_output_tokens;
|
|
361
370
|
}
|
|
362
371
|
} catch {}
|
|
363
372
|
}
|
|
@@ -412,7 +421,23 @@ function executeTask(task: Task): Promise<void> {
|
|
|
412
421
|
WHERE id = ?
|
|
413
422
|
`).run(resultText, totalCost, task.id);
|
|
414
423
|
emit(task.id, 'status', 'done');
|
|
415
|
-
console.log(`[task] Done: ${task.id} ${task.projectName} (cost: $${totalCost?.toFixed(4) || '0'})`);
|
|
424
|
+
console.log(`[task] Done: ${task.id} ${task.projectName} (cost: $${totalCost?.toFixed(4) || '0'}, ${totalInputTokens}in/${totalOutputTokens}out)`);
|
|
425
|
+
// Record usage
|
|
426
|
+
try {
|
|
427
|
+
const { recordUsage } = require('./usage-scanner');
|
|
428
|
+
let isPipeline = false;
|
|
429
|
+
try { const { pipelineTaskIds: ptids } = require('./pipeline'); isPipeline = ptids.has(task.id); } catch {}
|
|
430
|
+
recordUsage({
|
|
431
|
+
sessionId: sessionId || task.id,
|
|
432
|
+
source: isPipeline ? 'pipeline' : 'task',
|
|
433
|
+
projectPath: task.projectPath,
|
|
434
|
+
projectName: task.projectName,
|
|
435
|
+
model: modelUsed || 'unknown',
|
|
436
|
+
inputTokens: totalInputTokens,
|
|
437
|
+
outputTokens: totalOutputTokens,
|
|
438
|
+
taskId: task.id,
|
|
439
|
+
});
|
|
440
|
+
} catch {}
|
|
416
441
|
const doneTask = getTask(task.id);
|
|
417
442
|
if (doneTask) notifyTaskComplete(doneTask).catch(() => {});
|
|
418
443
|
notifyTerminalSession(task, 'done', sessionId);
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Scanner — scans Claude Code JSONL session files for token usage data.
|
|
3
|
+
* Stores per-day aggregated results in SQLite for accurate daily breakdown.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, basename } from 'node:path';
|
|
8
|
+
import { getDb } from '@/src/core/db/database';
|
|
9
|
+
import { getDbPath } from '@/src/config';
|
|
10
|
+
import { getClaudeDir } from './dirs';
|
|
11
|
+
|
|
12
|
+
function db() { return getDb(getDbPath()); }
|
|
13
|
+
|
|
14
|
+
const PRICING: Record<string, { input: number; output: number }> = {
|
|
15
|
+
'claude-opus-4': { input: 15, output: 75 },
|
|
16
|
+
'claude-sonnet-4': { input: 3, output: 15 },
|
|
17
|
+
'claude-haiku-4': { input: 0.80, output: 4 },
|
|
18
|
+
'default': { input: 3, output: 15 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function getModelFamily(model: string): string {
|
|
22
|
+
if (!model) return 'unknown';
|
|
23
|
+
if (model.includes('opus')) return 'claude-opus-4';
|
|
24
|
+
if (model.includes('haiku')) return 'claude-haiku-4';
|
|
25
|
+
if (model.includes('sonnet')) return 'claude-sonnet-4';
|
|
26
|
+
return 'unknown';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function calcCost(family: string, input: number, output: number): number {
|
|
30
|
+
const p = PRICING[family] || PRICING['default'];
|
|
31
|
+
// Only count input + output tokens. Cache tokens excluded from cost estimate
|
|
32
|
+
// because subscriptions (Max/Pro) don't charge per-token for cache.
|
|
33
|
+
return (input * p.input / 1_000_000) + (output * p.output / 1_000_000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function dirToProjectPath(dirName: string): string {
|
|
37
|
+
return dirName.replace(/^-/, '/').replace(/-/g, '/');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dirToProjectName(dirName: string): string {
|
|
41
|
+
return dirToProjectPath(dirName).split('/').pop() || dirName;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get local date string from UTC timestamp */
|
|
45
|
+
function toLocalDate(ts: string): string {
|
|
46
|
+
if (!ts) return 'unknown';
|
|
47
|
+
try {
|
|
48
|
+
const d = new Date(ts);
|
|
49
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
50
|
+
} catch {
|
|
51
|
+
return ts.slice(0, 10) || 'unknown';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DayModelBucket {
|
|
56
|
+
input: number; output: number; cacheRead: number; cacheCreate: number; count: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse JSONL and aggregate by day + model */
|
|
60
|
+
function parseByDayModel(content: string): Map<string, DayModelBucket> {
|
|
61
|
+
// key: "day|model"
|
|
62
|
+
const buckets = new Map<string, DayModelBucket>();
|
|
63
|
+
|
|
64
|
+
for (const line of content.split('\n')) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
try {
|
|
67
|
+
const obj = JSON.parse(line);
|
|
68
|
+
if (obj.type === 'assistant' && obj.message?.usage) {
|
|
69
|
+
const u = obj.message.usage;
|
|
70
|
+
const model = getModelFamily(obj.message.model || '');
|
|
71
|
+
const day = toLocalDate(obj.timestamp || '');
|
|
72
|
+
const key = `${day}|${model}`;
|
|
73
|
+
|
|
74
|
+
let b = buckets.get(key);
|
|
75
|
+
if (!b) { b = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, count: 0 }; buckets.set(key, b); }
|
|
76
|
+
b.input += u.input_tokens || 0;
|
|
77
|
+
b.output += u.output_tokens || 0;
|
|
78
|
+
b.cacheRead += u.cache_read_input_tokens || 0;
|
|
79
|
+
b.cacheCreate += u.cache_creation_input_tokens || 0;
|
|
80
|
+
b.count++;
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
return buckets;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Scan all JSONL files */
|
|
88
|
+
export function scanUsage(): { scanned: number; updated: number; errors: number } {
|
|
89
|
+
const projectsDir = join(getClaudeDir(), 'projects');
|
|
90
|
+
let scanned = 0, updated = 0, errors = 0;
|
|
91
|
+
|
|
92
|
+
let projectDirs: string[];
|
|
93
|
+
try { projectDirs = readdirSync(projectsDir); } catch { return { scanned: 0, updated: 0, errors: 0 }; }
|
|
94
|
+
|
|
95
|
+
const upsert = db().prepare(`
|
|
96
|
+
INSERT INTO token_usage (session_id, source, project_path, project_name, model, day, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, cost_usd, message_count)
|
|
97
|
+
VALUES (?, 'terminal', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
98
|
+
ON CONFLICT(session_id, source, model, day) DO UPDATE SET
|
|
99
|
+
input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens,
|
|
100
|
+
cache_read_tokens = excluded.cache_read_tokens, cache_create_tokens = excluded.cache_create_tokens,
|
|
101
|
+
cost_usd = excluded.cost_usd, message_count = excluded.message_count
|
|
102
|
+
`);
|
|
103
|
+
|
|
104
|
+
const getScanState = db().prepare('SELECT last_size FROM usage_scan_state WHERE file_path = ?');
|
|
105
|
+
const setScanState = db().prepare(`
|
|
106
|
+
INSERT INTO usage_scan_state (file_path, last_size) VALUES (?, ?)
|
|
107
|
+
ON CONFLICT(file_path) DO UPDATE SET last_size = excluded.last_size, last_scan = datetime('now')
|
|
108
|
+
`);
|
|
109
|
+
|
|
110
|
+
for (const projDir of projectDirs) {
|
|
111
|
+
const projPath = join(projectsDir, projDir);
|
|
112
|
+
try { if (!statSync(projPath).isDirectory()) continue; } catch { continue; }
|
|
113
|
+
|
|
114
|
+
const projectPath = dirToProjectPath(projDir);
|
|
115
|
+
const projectName = dirToProjectName(projDir);
|
|
116
|
+
let files: string[];
|
|
117
|
+
try { files = readdirSync(projPath).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')); } catch { continue; }
|
|
118
|
+
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
const filePath = join(projPath, file);
|
|
121
|
+
const sessionId = basename(file, '.jsonl');
|
|
122
|
+
scanned++;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const currentSize = statSync(filePath).size;
|
|
126
|
+
const scanState = getScanState.get(filePath) as { last_size: number } | undefined;
|
|
127
|
+
if (currentSize === (scanState?.last_size || 0)) continue;
|
|
128
|
+
|
|
129
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
130
|
+
const buckets = parseByDayModel(content);
|
|
131
|
+
|
|
132
|
+
if (buckets.size === 0) { setScanState.run(filePath, currentSize); continue; }
|
|
133
|
+
|
|
134
|
+
for (const [key, b] of buckets) {
|
|
135
|
+
const [day, model] = key.split('|');
|
|
136
|
+
const cost = calcCost(model, b.input, b.output);
|
|
137
|
+
upsert.run(sessionId, projectPath, projectName, model, day, b.input, b.output, b.cacheRead, b.cacheCreate, cost, b.count);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setScanState.run(filePath, currentSize);
|
|
141
|
+
updated++;
|
|
142
|
+
} catch { errors++; }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { scanned, updated, errors };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Record usage from task/mobile/pipeline */
|
|
149
|
+
export function recordUsage(opts: {
|
|
150
|
+
sessionId: string;
|
|
151
|
+
source: 'task' | 'mobile' | 'pipeline';
|
|
152
|
+
projectPath: string;
|
|
153
|
+
projectName: string;
|
|
154
|
+
model: string;
|
|
155
|
+
inputTokens: number;
|
|
156
|
+
outputTokens: number;
|
|
157
|
+
cacheReadTokens?: number;
|
|
158
|
+
cacheCreateTokens?: number;
|
|
159
|
+
taskId?: string;
|
|
160
|
+
}): void {
|
|
161
|
+
const family = getModelFamily(opts.model);
|
|
162
|
+
const cost = calcCost(family, opts.inputTokens, opts.outputTokens);
|
|
163
|
+
const day = toLocalDate(new Date().toISOString());
|
|
164
|
+
|
|
165
|
+
db().prepare(`
|
|
166
|
+
INSERT INTO token_usage (session_id, source, project_path, project_name, model, day, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, cost_usd, message_count, task_id)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
|
168
|
+
ON CONFLICT(session_id, source, model, day) DO UPDATE SET
|
|
169
|
+
input_tokens = token_usage.input_tokens + excluded.input_tokens,
|
|
170
|
+
output_tokens = token_usage.output_tokens + excluded.output_tokens,
|
|
171
|
+
cost_usd = token_usage.cost_usd + excluded.cost_usd,
|
|
172
|
+
message_count = token_usage.message_count + 1
|
|
173
|
+
`).run(opts.sessionId, opts.source, opts.projectPath, opts.projectName, family, day, opts.inputTokens, opts.outputTokens, opts.cacheReadTokens || 0, opts.cacheCreateTokens || 0, cost, opts.taskId || null);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Query usage data */
|
|
177
|
+
export function queryUsage(opts: {
|
|
178
|
+
days?: number;
|
|
179
|
+
projectName?: string;
|
|
180
|
+
source?: string;
|
|
181
|
+
model?: string;
|
|
182
|
+
}): {
|
|
183
|
+
total: { input: number; output: number; cost: number; sessions: number; messages: number };
|
|
184
|
+
byProject: { name: string; input: number; output: number; cost: number; sessions: number }[];
|
|
185
|
+
byModel: { model: string; input: number; output: number; cost: number; messages: number }[];
|
|
186
|
+
byDay: { date: string; input: number; output: number; cost: number }[];
|
|
187
|
+
bySource: { source: string; input: number; output: number; cost: number; messages: number }[];
|
|
188
|
+
} {
|
|
189
|
+
let where = '1=1';
|
|
190
|
+
const params: any[] = [];
|
|
191
|
+
|
|
192
|
+
if (opts.days) {
|
|
193
|
+
const cutoff = new Date();
|
|
194
|
+
cutoff.setDate(cutoff.getDate() - opts.days);
|
|
195
|
+
const cutoffDay = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}-${String(cutoff.getDate()).padStart(2, '0')}`;
|
|
196
|
+
where += ' AND day >= ?';
|
|
197
|
+
params.push(cutoffDay);
|
|
198
|
+
}
|
|
199
|
+
if (opts.projectName) { where += ' AND project_name = ?'; params.push(opts.projectName); }
|
|
200
|
+
if (opts.source) { where += ' AND source = ?'; params.push(opts.source); }
|
|
201
|
+
if (opts.model) { where += ' AND model = ?'; params.push(opts.model); }
|
|
202
|
+
|
|
203
|
+
const totalRow = db().prepare(`
|
|
204
|
+
SELECT COALESCE(SUM(input_tokens), 0) as input, COALESCE(SUM(output_tokens), 0) as output,
|
|
205
|
+
COALESCE(SUM(cost_usd), 0) as cost, COUNT(DISTINCT session_id) as sessions,
|
|
206
|
+
COALESCE(SUM(message_count), 0) as messages
|
|
207
|
+
FROM token_usage WHERE ${where}
|
|
208
|
+
`).get(...params) as any;
|
|
209
|
+
|
|
210
|
+
const byProject = (db().prepare(`
|
|
211
|
+
SELECT project_name as name, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
212
|
+
SUM(cost_usd) as cost, COUNT(DISTINCT session_id) as sessions
|
|
213
|
+
FROM token_usage WHERE ${where}
|
|
214
|
+
GROUP BY project_name ORDER BY cost DESC LIMIT 20
|
|
215
|
+
`).all(...params) as any[]).map(r => ({
|
|
216
|
+
name: r.name, input: r.input, output: r.output, cost: +r.cost.toFixed(4), sessions: r.sessions,
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
const byModel = (db().prepare(`
|
|
220
|
+
SELECT model, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
221
|
+
SUM(cost_usd) as cost, SUM(message_count) as messages
|
|
222
|
+
FROM token_usage WHERE ${where}
|
|
223
|
+
GROUP BY model ORDER BY cost DESC
|
|
224
|
+
`).all(...params) as any[]).map(r => ({
|
|
225
|
+
model: r.model, input: r.input, output: r.output, cost: +r.cost.toFixed(4), messages: r.messages,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const byDay = (db().prepare(`
|
|
229
|
+
SELECT day as date, SUM(input_tokens) as input, SUM(output_tokens) as output, SUM(cost_usd) as cost
|
|
230
|
+
FROM token_usage WHERE ${where} AND day != 'unknown'
|
|
231
|
+
GROUP BY day ORDER BY day DESC LIMIT 30
|
|
232
|
+
`).all(...params) as any[]).map(r => ({
|
|
233
|
+
date: r.date, input: r.input, output: r.output, cost: +r.cost.toFixed(4),
|
|
234
|
+
}));
|
|
235
|
+
|
|
236
|
+
const bySource = (db().prepare(`
|
|
237
|
+
SELECT source, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
238
|
+
SUM(cost_usd) as cost, SUM(message_count) as messages
|
|
239
|
+
FROM token_usage WHERE ${where}
|
|
240
|
+
GROUP BY source ORDER BY cost DESC
|
|
241
|
+
`).all(...params) as any[]).map(r => ({
|
|
242
|
+
source: r.source, input: r.input, output: r.output, cost: +r.cost.toFixed(4), messages: r.messages,
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
total: { input: totalRow.input, output: totalRow.output, cost: +totalRow.cost.toFixed(4), sessions: totalRow.sessions, messages: totalRow.messages },
|
|
247
|
+
byProject, byModel, byDay, bySource,
|
|
248
|
+
};
|
|
249
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification script — compares direct JSONL scanning with DB scanner results.
|
|
3
|
+
* Run: npx tsx scripts/verify-usage.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
7
|
+
import { join, basename } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
|
|
11
|
+
|
|
12
|
+
const PRICING: Record<string, { input: number; output: number }> = {
|
|
13
|
+
'claude-opus-4': { input: 15, output: 75 },
|
|
14
|
+
'claude-sonnet-4': { input: 3, output: 15 },
|
|
15
|
+
'claude-haiku-4': { input: 0.80, output: 4 },
|
|
16
|
+
'default': { input: 3, output: 15 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function getModelFamily(model: string): string {
|
|
20
|
+
if (!model) return 'unknown';
|
|
21
|
+
if (model.includes('opus')) return 'claude-opus-4';
|
|
22
|
+
if (model.includes('haiku')) return 'claude-haiku-4';
|
|
23
|
+
if (model.includes('sonnet')) return 'claude-sonnet-4';
|
|
24
|
+
return 'unknown';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function calcCost(family: string, input: number, output: number, cacheRead: number, cacheCreate: number): number {
|
|
28
|
+
const p = PRICING[family] || PRICING['default'];
|
|
29
|
+
return (
|
|
30
|
+
(input * p.input / 1_000_000) +
|
|
31
|
+
(output * p.output / 1_000_000) +
|
|
32
|
+
(cacheRead * p.input * 0.1 / 1_000_000) +
|
|
33
|
+
(cacheCreate * p.input * 0.25 / 1_000_000)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ProjectStats {
|
|
38
|
+
input: number; output: number; cost: number; sessions: number; messages: number;
|
|
39
|
+
cacheRead: number; cacheCreate: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ModelStats {
|
|
43
|
+
input: number; output: number; cost: number; messages: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface DayStats {
|
|
47
|
+
input: number; output: number; cost: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const byProject: Record<string, ProjectStats> = {};
|
|
51
|
+
const byModel: Record<string, ModelStats> = {};
|
|
52
|
+
const byDay: Record<string, DayStats> = {};
|
|
53
|
+
let totalInput = 0, totalOutput = 0, totalCost = 0, totalSessions = 0, totalMessages = 0;
|
|
54
|
+
|
|
55
|
+
console.log('Scanning JSONL files...\n');
|
|
56
|
+
|
|
57
|
+
const projectDirs = readdirSync(CLAUDE_DIR);
|
|
58
|
+
let fileCount = 0;
|
|
59
|
+
|
|
60
|
+
for (const projDir of projectDirs) {
|
|
61
|
+
const projPath = join(CLAUDE_DIR, projDir);
|
|
62
|
+
try { if (!statSync(projPath).isDirectory()) continue; } catch { continue; }
|
|
63
|
+
|
|
64
|
+
const projectName = projDir.replace(/^-/, '/').replace(/-/g, '/').split('/').pop() || projDir;
|
|
65
|
+
const files = readdirSync(projPath).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
|
|
66
|
+
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const filePath = join(projPath, file);
|
|
69
|
+
fileCount++;
|
|
70
|
+
let sessionInput = 0, sessionOutput = 0, sessionCost = 0, sessionMsgs = 0;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
74
|
+
for (const line of content.split('\n')) {
|
|
75
|
+
if (!line.trim()) continue;
|
|
76
|
+
try {
|
|
77
|
+
const obj = JSON.parse(line);
|
|
78
|
+
if (obj.type === 'assistant' && obj.message?.usage) {
|
|
79
|
+
const u = obj.message.usage;
|
|
80
|
+
const model = obj.message.model || '';
|
|
81
|
+
const family = getModelFamily(model);
|
|
82
|
+
const input = u.input_tokens || 0;
|
|
83
|
+
const output = u.output_tokens || 0;
|
|
84
|
+
const cacheRead = u.cache_read_input_tokens || 0;
|
|
85
|
+
const cacheCreate = u.cache_creation_input_tokens || 0;
|
|
86
|
+
const cost = calcCost(family, input, output, cacheRead, cacheCreate);
|
|
87
|
+
|
|
88
|
+
sessionInput += input;
|
|
89
|
+
sessionOutput += output;
|
|
90
|
+
sessionCost += cost;
|
|
91
|
+
sessionMsgs++;
|
|
92
|
+
|
|
93
|
+
if (!byModel[family]) byModel[family] = { input: 0, output: 0, cost: 0, messages: 0 };
|
|
94
|
+
byModel[family].input += input;
|
|
95
|
+
byModel[family].output += output;
|
|
96
|
+
byModel[family].cost += cost;
|
|
97
|
+
byModel[family].messages++;
|
|
98
|
+
|
|
99
|
+
const day = (obj.timestamp || '').slice(0, 10) || 'unknown';
|
|
100
|
+
if (!byDay[day]) byDay[day] = { input: 0, output: 0, cost: 0 };
|
|
101
|
+
byDay[day].input += input;
|
|
102
|
+
byDay[day].output += output;
|
|
103
|
+
byDay[day].cost += cost;
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
} catch { continue; }
|
|
108
|
+
|
|
109
|
+
if (sessionMsgs > 0) {
|
|
110
|
+
totalSessions++;
|
|
111
|
+
totalMessages += sessionMsgs;
|
|
112
|
+
totalInput += sessionInput;
|
|
113
|
+
totalOutput += sessionOutput;
|
|
114
|
+
totalCost += sessionCost;
|
|
115
|
+
|
|
116
|
+
if (!byProject[projectName]) byProject[projectName] = { input: 0, output: 0, cost: 0, sessions: 0, messages: 0, cacheRead: 0, cacheCreate: 0 };
|
|
117
|
+
byProject[projectName].input += sessionInput;
|
|
118
|
+
byProject[projectName].output += sessionOutput;
|
|
119
|
+
byProject[projectName].cost += sessionCost;
|
|
120
|
+
byProject[projectName].sessions++;
|
|
121
|
+
byProject[projectName].messages += sessionMsgs;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Now run the DB scanner and compare
|
|
127
|
+
console.log('Running DB scanner...\n');
|
|
128
|
+
|
|
129
|
+
// Set up environment for the scanner
|
|
130
|
+
process.env.FORGE_DATA_DIR = process.env.FORGE_DATA_DIR || join(homedir(), '.forge', 'data');
|
|
131
|
+
|
|
132
|
+
// Dynamic import to use the actual scanner
|
|
133
|
+
const { scanUsage, queryUsage } = await import('../lib/usage-scanner');
|
|
134
|
+
|
|
135
|
+
const scanResult = scanUsage();
|
|
136
|
+
console.log(`Scan result: ${scanResult.scanned} files scanned, ${scanResult.updated} updated, ${scanResult.errors} errors\n`);
|
|
137
|
+
|
|
138
|
+
const dbData = queryUsage({});
|
|
139
|
+
|
|
140
|
+
// Compare
|
|
141
|
+
console.log('=== COMPARISON ===\n');
|
|
142
|
+
|
|
143
|
+
console.log('TOTAL:');
|
|
144
|
+
console.log(` Direct: ${(totalInput/1000).toFixed(0)}K in, ${(totalOutput/1000).toFixed(0)}K out, $${totalCost.toFixed(2)}, ${totalSessions} sessions, ${totalMessages} msgs`);
|
|
145
|
+
console.log(` DB: ${(dbData.total.input/1000).toFixed(0)}K in, ${(dbData.total.output/1000).toFixed(0)}K out, $${dbData.total.cost.toFixed(2)}, ${dbData.total.sessions} sessions, ${dbData.total.messages} msgs`);
|
|
146
|
+
|
|
147
|
+
const costDiff = Math.abs(totalCost - dbData.total.cost);
|
|
148
|
+
const costMatch = costDiff < 0.1;
|
|
149
|
+
console.log(` Match: ${costMatch ? '✅' : '❌'} (diff: $${costDiff.toFixed(2)})\n`);
|
|
150
|
+
|
|
151
|
+
console.log('BY MODEL:');
|
|
152
|
+
for (const [model, d] of Object.entries(byModel).sort((a, b) => b[1].cost - a[1].cost)) {
|
|
153
|
+
const dbModel = dbData.byModel.find(m => m.model === model);
|
|
154
|
+
const dbCost = dbModel?.cost || 0;
|
|
155
|
+
const match = Math.abs(d.cost - dbCost) < 0.1;
|
|
156
|
+
console.log(` ${model.padEnd(20)} Direct: $${d.cost.toFixed(2).padStart(8)} DB: $${dbCost.toFixed(2).padStart(8)} ${match ? '✅' : '❌'}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log('\nBY PROJECT (top 10):');
|
|
160
|
+
const sortedProjects = Object.entries(byProject).sort((a, b) => b[1].cost - a[1].cost).slice(0, 10);
|
|
161
|
+
for (const [name, d] of sortedProjects) {
|
|
162
|
+
const dbProj = dbData.byProject.find(p => p.name === name);
|
|
163
|
+
const dbCost = dbProj?.cost || 0;
|
|
164
|
+
const match = Math.abs(d.cost - dbCost) < 0.1;
|
|
165
|
+
console.log(` ${name.padEnd(25)} Direct: $${d.cost.toFixed(2).padStart(8)} DB: $${dbCost.toFixed(2).padStart(8)} ${match ? '✅' : '❌'}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log('\nBY DAY (last 7):');
|
|
169
|
+
const sortedDays = Object.entries(byDay).filter(([d]) => d !== 'unknown').sort((a, b) => b[0].localeCompare(a[0])).slice(0, 7);
|
|
170
|
+
for (const [day, d] of sortedDays) {
|
|
171
|
+
const dbDay = dbData.byDay.find(dd => dd.date === day);
|
|
172
|
+
const dbCost = dbDay?.cost || 0;
|
|
173
|
+
const match = Math.abs(d.cost - dbCost) < 0.1;
|
|
174
|
+
console.log(` ${day} Direct: $${d.cost.toFixed(2).padStart(8)} DB: $${dbCost.toFixed(2).padStart(8)} ${match ? '✅' : '❌'}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`\nFiles scanned: ${fileCount}`);
|
|
178
|
+
console.log('');
|
package/src/core/db/database.ts
CHANGED
|
@@ -36,6 +36,8 @@ function initSchema(db: Database.Database) {
|
|
|
36
36
|
migrate('ALTER TABLE skills ADD COLUMN deleted_remotely INTEGER NOT NULL DEFAULT 0');
|
|
37
37
|
migrate('ALTER TABLE project_pipelines ADD COLUMN last_run_at TEXT');
|
|
38
38
|
migrate('ALTER TABLE pipeline_runs ADD COLUMN dedup_key TEXT');
|
|
39
|
+
// Recreate token_usage with day column (drop old version if schema changed)
|
|
40
|
+
try { db.exec("SELECT day FROM token_usage LIMIT 1"); } catch { try { db.exec("DROP TABLE IF EXISTS token_usage"); db.exec("DROP TABLE IF EXISTS usage_scan_state"); } catch {} }
|
|
39
41
|
// Unique index for dedup (only applies when dedup_key is NOT NULL)
|
|
40
42
|
try { db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_runs_dedup ON pipeline_runs(project_path, workflow_name, dedup_key)'); } catch {}
|
|
41
43
|
// Migrate old issue_autofix_processed → pipeline_runs
|
|
@@ -219,6 +221,32 @@ function initSchema(db: Database.Database) {
|
|
|
219
221
|
active INTEGER NOT NULL DEFAULT 1,
|
|
220
222
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
221
223
|
);
|
|
224
|
+
|
|
225
|
+
-- Token usage tracking (per session + day + model for accurate daily breakdown)
|
|
226
|
+
CREATE TABLE IF NOT EXISTS token_usage (
|
|
227
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
228
|
+
session_id TEXT NOT NULL,
|
|
229
|
+
source TEXT NOT NULL DEFAULT 'terminal',
|
|
230
|
+
project_path TEXT NOT NULL,
|
|
231
|
+
project_name TEXT NOT NULL,
|
|
232
|
+
model TEXT NOT NULL DEFAULT 'unknown',
|
|
233
|
+
day TEXT NOT NULL,
|
|
234
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
235
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
236
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
237
|
+
cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
238
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
239
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
240
|
+
task_id TEXT,
|
|
241
|
+
UNIQUE(session_id, source, model, day)
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
-- Track scan progress for incremental JSONL scanning
|
|
245
|
+
CREATE TABLE IF NOT EXISTS usage_scan_state (
|
|
246
|
+
file_path TEXT PRIMARY KEY,
|
|
247
|
+
last_size INTEGER NOT NULL DEFAULT 0,
|
|
248
|
+
last_scan TEXT NOT NULL DEFAULT (datetime('now'))
|
|
249
|
+
);
|
|
222
250
|
`);
|
|
223
251
|
}
|
|
224
252
|
|