@aion0/forge 0.4.14 → 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 CHANGED
@@ -1,18 +1,25 @@
1
- # Forge v0.4.14
1
+ # Forge v0.4.15
2
2
 
3
3
  Released: 2026-03-23
4
4
 
5
- ## Changes since v0.4.13
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: bell idle timer 10s + 90s fallback
9
- - fix: bell uses idle detection instead of output pattern matching
10
- - fix: bell resets only on Enter key, not every keystroke
11
- - fix: bell cooldown 2min per tab label, prevents duplicate notifications
12
- - fix: bell requires 2000+ bytes of new output before checking markers
13
- - fix: notification timestamps display in correct timezone
14
- - fix: bell fires once per claude task, suppressed on attach/redraw
15
- - fix: bell detects claude completion markers (Cogitated, tokens, prompt)
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
16
23
 
17
24
 
18
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.13...v0.4.14
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: chunk.toString() })}\n\n`));
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
+ }
@@ -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
+ }
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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('');
@@ -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