memhouse 0.0.1 → 0.3.0

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.
Files changed (96) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +59 -3
  3. package/bin/memhouse.js +487 -0
  4. package/cache.js +850 -0
  5. package/ch-ingest.js +329 -0
  6. package/ch-schema.js +152 -0
  7. package/clickhouse.js +87 -0
  8. package/editors/antigravity.js +1019 -0
  9. package/editors/base.js +321 -0
  10. package/editors/claude.js +386 -0
  11. package/editors/codebuff.js +338 -0
  12. package/editors/codex.js +537 -0
  13. package/editors/commandcode.js +166 -0
  14. package/editors/copilot-jetbrains.js +207 -0
  15. package/editors/copilot.js +260 -0
  16. package/editors/cursor-agent.js +202 -0
  17. package/editors/cursor.js +438 -0
  18. package/editors/gemini.js +231 -0
  19. package/editors/goose.js +358 -0
  20. package/editors/gsd.js +366 -0
  21. package/editors/index.js +179 -0
  22. package/editors/kiro.js +315 -0
  23. package/editors/opencode.js +374 -0
  24. package/editors/vscode.js +413 -0
  25. package/editors/windsurf.js +634 -0
  26. package/editors/zed.js +213 -0
  27. package/index.js +424 -2
  28. package/mcp-server.js +294 -0
  29. package/mem-house/DESIGN.md +119 -0
  30. package/mem-house/delivery/AGENT-INSTALL.md +75 -0
  31. package/mem-house/delivery/PROMPT.md +42 -0
  32. package/mem-house/delivery/install.sh +181 -0
  33. package/mem-house/delivery/kernel-install.md +76 -0
  34. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  35. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  36. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  37. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  38. package/mem-house/rls.sql +17 -0
  39. package/mem-house/schema.sql +145 -0
  40. package/mem-house/server/queries.js +878 -0
  41. package/mem-house/server/server.js +228 -0
  42. package/mem-house/shipper/ship.js +402 -0
  43. package/misc/syntax-check.js +38 -0
  44. package/package.json +56 -7
  45. package/pricing.js +88 -0
  46. package/pricing.json +992 -0
  47. package/public/assets/index-BsgjOSTv.js +75 -0
  48. package/public/assets/index-DV6ONi_F.css +2 -0
  49. package/public/index.html +16 -0
  50. package/relay-client.js +306 -0
  51. package/relay-server.js +577 -0
  52. package/server.js +860 -0
  53. package/share-image.js +367 -0
  54. package/ui/eslint.config.js +29 -0
  55. package/ui/index.html +15 -0
  56. package/ui/package-lock.json +4229 -0
  57. package/ui/package.json +38 -0
  58. package/ui/src/App.jsx +364 -0
  59. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  60. package/ui/src/components/AiAuditCard.jsx +254 -0
  61. package/ui/src/components/AnimatedLoader.jsx +14 -0
  62. package/ui/src/components/AnimatedLogo.jsx +96 -0
  63. package/ui/src/components/ChatSidebar.jsx +226 -0
  64. package/ui/src/components/DateRangePicker.jsx +104 -0
  65. package/ui/src/components/EditorDot.jsx +11 -0
  66. package/ui/src/components/EditorIcon.jsx +117 -0
  67. package/ui/src/components/KpiCard.jsx +9 -0
  68. package/ui/src/components/LiveFeed.jsx +128 -0
  69. package/ui/src/components/LoginScreen.jsx +81 -0
  70. package/ui/src/components/MessageRenderer.jsx +148 -0
  71. package/ui/src/components/PageHeader.jsx +11 -0
  72. package/ui/src/components/SectionTitle.jsx +3 -0
  73. package/ui/src/components/ShareModal.jsx +305 -0
  74. package/ui/src/components/TokenTimeline.jsx +258 -0
  75. package/ui/src/hooks/useLive.jsx +15 -0
  76. package/ui/src/index.css +83 -0
  77. package/ui/src/lib/api.js +343 -0
  78. package/ui/src/lib/constants.js +88 -0
  79. package/ui/src/lib/theme.jsx +23 -0
  80. package/ui/src/main.jsx +16 -0
  81. package/ui/src/pages/Artifacts.jsx +575 -0
  82. package/ui/src/pages/Compare.jsx +232 -0
  83. package/ui/src/pages/CostAnalysis.jsx +354 -0
  84. package/ui/src/pages/Dashboard.jsx +529 -0
  85. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  86. package/ui/src/pages/GSD.jsx +726 -0
  87. package/ui/src/pages/MCPs.jsx +744 -0
  88. package/ui/src/pages/ProjectDetail.jsx +325 -0
  89. package/ui/src/pages/Projects.jsx +249 -0
  90. package/ui/src/pages/RelayDashboard.jsx +590 -0
  91. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  92. package/ui/src/pages/Sessions.jsx +535 -0
  93. package/ui/src/pages/Settings.jsx +241 -0
  94. package/ui/src/pages/SqlViewer.jsx +344 -0
  95. package/ui/src/pages/Subscriptions.jsx +411 -0
  96. package/ui/vite.config.js +18 -0
@@ -0,0 +1,232 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { Loader2, ArrowLeftRight } from 'lucide-react'
3
+ import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from 'chart.js'
4
+ import { Bar } from 'react-chartjs-2'
5
+ import { fetchDeepAnalytics, fetchChats } from '../lib/api'
6
+ import { editorColor, editorLabel, formatNumber } from '../lib/constants'
7
+ import { useTheme } from '../lib/theme'
8
+ import PageHeader from '../components/PageHeader'
9
+
10
+ ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
11
+
12
+ const MONO = 'JetBrains Mono, monospace'
13
+
14
+ function MetricRow({ label, a, b, colorA, colorB }) {
15
+ const numA = parseFloat(a) || 0
16
+ const numB = parseFloat(b) || 0
17
+ const max = Math.max(numA, numB, 1)
18
+ return (
19
+ <div className="grid grid-cols-[1fr_100px_100px] gap-x-3 items-center py-0.5" style={{ borderBottom: '1px solid var(--c-border)' }}>
20
+ <div className="text-[11px]" style={{ color: 'var(--c-text2)' }}>{label}</div>
21
+ <div className="text-right">
22
+ <span className="text-[12px] font-mono font-medium" style={{ color: 'var(--c-white)' }}>
23
+ {typeof a === 'number' ? formatNumber(a) : a}
24
+ </span>
25
+ <div className="h-1 rounded-full mt-0.5 ml-auto" style={{ background: colorA, width: `${(numA / max * 100).toFixed(0)}%` }} />
26
+ </div>
27
+ <div className="text-right">
28
+ <span className="text-[12px] font-mono font-medium" style={{ color: 'var(--c-white)' }}>
29
+ {typeof b === 'number' ? formatNumber(b) : b}
30
+ </span>
31
+ <div className="h-1 rounded-full mt-0.5 ml-auto" style={{ background: colorB, width: `${(numB / max * 100).toFixed(0)}%` }} />
32
+ </div>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ function ListCompare({ titleA, titleB, colorA, colorB, itemsA, itemsB, limit = 8 }) {
38
+ return (
39
+ <div className="grid grid-cols-2 gap-2">
40
+ <div>
41
+ <h4 className="text-[11px] font-medium uppercase tracking-wider mb-1" style={{ color: colorA }}>{titleA}</h4>
42
+ <div className="space-y-0.5">
43
+ {itemsA.slice(0, limit).map(t => (
44
+ <div key={t.name} className="flex justify-between text-[11px] py-0.5">
45
+ <span className="truncate" style={{ color: 'var(--c-text)' }}>{t.name}</span>
46
+ <span className="font-mono ml-2" style={{ color: 'var(--c-text3)' }}>{t.count}</span>
47
+ </div>
48
+ ))}
49
+ {itemsA.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>none</div>}
50
+ </div>
51
+ </div>
52
+ <div>
53
+ <h4 className="text-[11px] font-medium uppercase tracking-wider mb-1" style={{ color: colorB }}>{titleB}</h4>
54
+ <div className="space-y-0.5">
55
+ {itemsB.slice(0, limit).map(t => (
56
+ <div key={t.name} className="flex justify-between text-[11px] py-0.5">
57
+ <span className="truncate" style={{ color: 'var(--c-text)' }}>{t.name}</span>
58
+ <span className="font-mono ml-2" style={{ color: 'var(--c-text3)' }}>{t.count}</span>
59
+ </div>
60
+ ))}
61
+ {itemsB.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>none</div>}
62
+ </div>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ export default function Compare({ overview }) {
69
+ const editors = overview?.editors || []
70
+ const [editorA, setEditorA] = useState(editors[0]?.id || '')
71
+ const [editorB, setEditorB] = useState(editors[1]?.id || '')
72
+ const [result, setResult] = useState(null)
73
+ const [loading, setLoading] = useState(false)
74
+ const { dark } = useTheme()
75
+ const legendColor = dark ? '#888' : '#555'
76
+
77
+ async function run() {
78
+ if (!editorA || !editorB) return
79
+ setLoading(true)
80
+ const [deepA, deepB, chatsA, chatsB] = await Promise.all([
81
+ fetchDeepAnalytics({ editor: editorA, limit: 500 }),
82
+ fetchDeepAnalytics({ editor: editorB, limit: 500 }),
83
+ fetchChats({ editor: editorA, limit: 1000 }),
84
+ fetchChats({ editor: editorB, limit: 1000 }),
85
+ ])
86
+ setResult({ deepA, deepB, chatsA, chatsB })
87
+ setLoading(false)
88
+ }
89
+
90
+ useEffect(() => { if (editorA && editorB) run() }, [editorA, editorB])
91
+
92
+ const colorA = editorColor(editorA)
93
+ const colorB = editorColor(editorB)
94
+ const nameA = editorLabel(editorA)
95
+ const nameB = editorLabel(editorB)
96
+
97
+ // Derived metrics
98
+ const avg = (total, count) => count ? (total / count).toFixed(1) : '0'
99
+ const pct = (part, whole) => whole ? ((part / whole) * 100).toFixed(0) + '%' : '0%'
100
+
101
+ const metrics = result ? [
102
+ { label: 'Sessions', a: result.chatsA.total, b: result.chatsB.total },
103
+ { label: 'Messages', a: result.deepA.totalMessages, b: result.deepB.totalMessages },
104
+ { label: 'Tool Calls', a: result.deepA.totalToolCalls, b: result.deepB.totalToolCalls },
105
+ { label: 'Input Tokens', a: result.deepA.totalInputTokens, b: result.deepB.totalInputTokens },
106
+ { label: 'Output Tokens', a: result.deepA.totalOutputTokens, b: result.deepB.totalOutputTokens },
107
+ { label: 'Cache Read', a: result.deepA.totalCacheRead, b: result.deepB.totalCacheRead },
108
+ ] : []
109
+
110
+ const ratios = result ? [
111
+ { label: 'Avg Msgs / Session', a: avg(result.deepA.totalMessages, result.deepA.analyzedChats), b: avg(result.deepB.totalMessages, result.deepB.analyzedChats) },
112
+ { label: 'Avg Tools / Session', a: avg(result.deepA.totalToolCalls, result.deepA.analyzedChats), b: avg(result.deepB.totalToolCalls, result.deepB.analyzedChats) },
113
+ { label: 'Avg Tokens / Session', a: avg(result.deepA.totalInputTokens + result.deepA.totalOutputTokens, result.deepA.analyzedChats), b: avg(result.deepB.totalInputTokens + result.deepB.totalOutputTokens, result.deepB.analyzedChats) },
114
+ { label: 'Output / Input Ratio', a: avg(result.deepA.totalOutputTokens, result.deepA.totalInputTokens || 1), b: avg(result.deepB.totalOutputTokens, result.deepB.totalInputTokens || 1) },
115
+ { label: 'Tools / Message', a: avg(result.deepA.totalToolCalls, result.deepA.totalMessages || 1), b: avg(result.deepB.totalToolCalls, result.deepB.totalMessages || 1) },
116
+ { label: 'Cache Hit Rate', a: pct(result.deepA.totalCacheRead, result.deepA.totalInputTokens), b: pct(result.deepB.totalCacheRead, result.deepB.totalInputTokens) },
117
+ ] : []
118
+
119
+ // Bar chart: side-by-side comparison of key metrics
120
+ const barLabels = ['Sessions', 'Messages', 'Tool Calls']
121
+ const barDataA = result ? [result.chatsA.total, result.deepA.totalMessages, result.deepA.totalToolCalls] : []
122
+ const barDataB = result ? [result.chatsB.total, result.deepB.totalMessages, result.deepB.totalToolCalls] : []
123
+
124
+ const barChart = {
125
+ labels: barLabels,
126
+ datasets: [
127
+ { label: nameA, data: barDataA, backgroundColor: colorA + '99', borderRadius: 2 },
128
+ { label: nameB, data: barDataB, backgroundColor: colorB + '99', borderRadius: 2 },
129
+ ],
130
+ }
131
+
132
+ // Token breakdown bar chart
133
+ const tokenLabels = ['Input', 'Output', 'Cache Read']
134
+ const tokenChart = result ? {
135
+ labels: tokenLabels,
136
+ datasets: [
137
+ { label: nameA, data: [result.deepA.totalInputTokens, result.deepA.totalOutputTokens, result.deepA.totalCacheRead], backgroundColor: colorA + '99', borderRadius: 2 },
138
+ { label: nameB, data: [result.deepB.totalInputTokens, result.deepB.totalOutputTokens, result.deepB.totalCacheRead], backgroundColor: colorB + '99', borderRadius: 2 },
139
+ ],
140
+ } : null
141
+
142
+ const barOpts = {
143
+ responsive: true, maintainAspectRatio: false,
144
+ plugins: {
145
+ legend: { labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 8 } },
146
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
147
+ },
148
+ scales: {
149
+ x: { grid: { display: false }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
150
+ y: { grid: { color: dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)' }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
151
+ },
152
+ }
153
+
154
+ return (
155
+ <div className="fade-in space-y-3">
156
+ <PageHeader icon={ArrowLeftRight} title="Compare">
157
+ <select
158
+ value={editorA}
159
+ onChange={e => setEditorA(e.target.value)}
160
+ className="px-2 py-1 text-[12px] outline-none"
161
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
162
+ >
163
+ {editors.map(e => <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>)}
164
+ </select>
165
+ <span style={{ color: 'var(--c-text3)' }}>vs</span>
166
+ <select
167
+ value={editorB}
168
+ onChange={e => setEditorB(e.target.value)}
169
+ className="px-2 py-1 text-[12px] outline-none"
170
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
171
+ >
172
+ {editors.map(e => <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>)}
173
+ </select>
174
+ {loading && <Loader2 size={11} className="animate-spin" style={{ color: 'var(--c-text3)' }} />}
175
+ </PageHeader>
176
+
177
+ {result && (
178
+ <div className="space-y-3">
179
+ {/* Metrics + Ratios side by side */}
180
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
181
+ <div className="card p-3">
182
+ <div className="flex items-center justify-between mb-1.5">
183
+ <h3 className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>totals</h3>
184
+ <div className="flex items-center gap-3 text-[10px]">
185
+ <span style={{ color: colorA }}>● {nameA}</span>
186
+ <span style={{ color: colorB }}>● {nameB}</span>
187
+ </div>
188
+ </div>
189
+ {metrics.map(m => <MetricRow key={m.label} label={m.label} a={m.a} b={m.b} colorA={colorA} colorB={colorB} />)}
190
+ </div>
191
+ <div className="card p-3">
192
+ <h3 className="text-[11px] font-medium uppercase tracking-wider mb-1.5" style={{ color: 'var(--c-text2)' }}>efficiency</h3>
193
+ {ratios.map(m => <MetricRow key={m.label} label={m.label} a={m.a} b={m.b} colorA={colorA} colorB={colorB} />)}
194
+ </div>
195
+ </div>
196
+
197
+ {/* Charts row */}
198
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
199
+ <div className="card p-3">
200
+ <h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>usage</h3>
201
+ <div style={{ height: 160 }}>
202
+ <Bar data={barChart} options={barOpts} />
203
+ </div>
204
+ </div>
205
+ {tokenChart && (
206
+ <div className="card p-3">
207
+ <h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>tokens</h3>
208
+ <div style={{ height: 160 }}>
209
+ <Bar data={tokenChart} options={barOpts} />
210
+ </div>
211
+ </div>
212
+ )}
213
+ </div>
214
+
215
+ {/* Tools + Models side-by-side */}
216
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
217
+ <div className="card p-3">
218
+ <h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>top tools</h3>
219
+ <ListCompare titleA={nameA} titleB={nameB} colorA={colorA} colorB={colorB}
220
+ itemsA={result.deepA.topTools} itemsB={result.deepB.topTools} limit={10} />
221
+ </div>
222
+ <div className="card p-3">
223
+ <h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>models</h3>
224
+ <ListCompare titleA={nameA} titleB={nameB} colorA={colorA} colorB={colorB}
225
+ itemsA={result.deepA.topModels} itemsB={result.deepB.topModels} limit={8} />
226
+ </div>
227
+ </div>
228
+ </div>
229
+ )}
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1,354 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { DollarSign, TrendingUp, Cpu, FolderOpen, AlertTriangle } from 'lucide-react'
4
+ import { Chart as ChartJS, ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler } from 'chart.js'
5
+ import { Line, Doughnut, Bar } from 'react-chartjs-2'
6
+ import { fetchCostAnalytics, fetchOverview } from '../lib/api'
7
+ import { editorColor, editorLabel, formatNumber, formatCost, formatDate, dateRangeToApiParams } from '../lib/constants'
8
+ import { useTheme } from '../lib/theme'
9
+ import KpiCard from '../components/KpiCard'
10
+ import EditorIcon from '../components/EditorIcon'
11
+ import AnimatedLoader from '../components/AnimatedLoader'
12
+ import SectionTitle from '../components/SectionTitle'
13
+ import DateRangePicker from '../components/DateRangePicker'
14
+ import ChatSidebar from '../components/ChatSidebar'
15
+ import PageHeader from '../components/PageHeader'
16
+
17
+ ChartJS.register(ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler)
18
+
19
+ const MONO = 'JetBrains Mono, monospace'
20
+ const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399', '#2dd4bf', '#38bdf8', '#60a5fa', '#a3e635']
21
+
22
+ export default function CostAnalysis({ overview }) {
23
+ const { dark } = useTheme()
24
+ const navigate = useNavigate()
25
+ const txtDim = dark ? '#555' : '#999'
26
+ const legendColor = dark ? '#777' : '#555'
27
+ const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
28
+
29
+ const [data, setData] = useState(null)
30
+ const [loading, setLoading] = useState(true)
31
+ const [editor, setEditor] = useState('')
32
+ const [apiDateRange, setApiDateRange] = useState(null)
33
+ const [selectedChatId, setSelectedChatId] = useState(null)
34
+
35
+ const editors = overview?.editors || []
36
+
37
+ useEffect(() => {
38
+ setLoading(true)
39
+ fetchCostAnalytics({ editor, ...dateRangeToApiParams(apiDateRange) }).then(d => {
40
+ setData(d)
41
+ setLoading(false)
42
+ }).catch(() => setLoading(false))
43
+ }, [editor, apiDateRange])
44
+
45
+ if (!data) {
46
+ return <AnimatedLoader label="Loading cost data..." />
47
+ }
48
+
49
+ const { totalCost, byModel, byEditor, byProject, monthly, topSessions, summary, unknownModels } = data
50
+
51
+ // Charts
52
+ const modelChartData = byModel.length > 0 ? {
53
+ labels: byModel.slice(0, 10).map(m => m.model),
54
+ datasets: [{
55
+ data: byModel.slice(0, 10).map(m => Math.round(m.cost * 100) / 100),
56
+ backgroundColor: MODEL_COLORS,
57
+ borderWidth: 0,
58
+ }],
59
+ } : null
60
+
61
+ const editorChartData = byEditor.length > 0 ? {
62
+ labels: byEditor.map(e => editorLabel(e.editor)),
63
+ datasets: [{
64
+ data: byEditor.map(e => Math.round(e.cost * 100) / 100),
65
+ backgroundColor: byEditor.map(e => editorColor(e.editor)),
66
+ borderWidth: 0,
67
+ }],
68
+ } : null
69
+
70
+ const monthlyChartData = monthly.length > 1 ? {
71
+ labels: monthly.map(m => m.month),
72
+ datasets: [{
73
+ label: 'Cost ($)',
74
+ data: monthly.map(m => m.cost),
75
+ borderColor: '#6366f1',
76
+ backgroundColor: 'rgba(99,102,241,0.1)',
77
+ borderWidth: 2,
78
+ tension: 0.3,
79
+ pointRadius: 3,
80
+ pointHoverRadius: 5,
81
+ fill: true,
82
+ }],
83
+ } : null
84
+
85
+ const projectChartData = byProject.length > 0 ? {
86
+ labels: byProject.slice(0, 10).map(p => p.name),
87
+ datasets: [{
88
+ data: byProject.slice(0, 10).map(p => Math.round(p.cost * 100) / 100),
89
+ backgroundColor: '#818cf8',
90
+ borderRadius: 3,
91
+ }],
92
+ } : null
93
+
94
+ const doughnutOpts = {
95
+ responsive: true, maintainAspectRatio: false, cutout: '55%',
96
+ plugins: {
97
+ legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
98
+ tooltip: {
99
+ bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
100
+ callbacks: { label: ctx => ` ${ctx.label}: $${ctx.raw.toFixed(2)}` },
101
+ },
102
+ },
103
+ }
104
+
105
+ return (
106
+ <div className="fade-in space-y-3">
107
+ {/* Filters */}
108
+ <PageHeader icon={DollarSign} title="Cost Analysis">
109
+ <select
110
+ value={editor}
111
+ onChange={e => setEditor(e.target.value)}
112
+ className="px-2 py-1 text-[12px] outline-none"
113
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
114
+ >
115
+ <option value="">All Editors</option>
116
+ {editors.map(e => (
117
+ <option key={e.id} value={e.id}>{editorLabel(e.id)} ({e.count})</option>
118
+ ))}
119
+ </select>
120
+ <div className="ml-auto"><DateRangePicker value={apiDateRange} onChange={setApiDateRange} /></div>
121
+ </PageHeader>
122
+
123
+ {/* Disclaimer */}
124
+ <div className="flex items-start gap-2 px-3 py-2 text-[11px] rounded" style={{ background: 'rgba(234,179,8,0.06)', border: '1px solid rgba(234,179,8,0.15)', color: '#ca8a04' }}>
125
+ <AlertTriangle size={12} className="mt-0.5 shrink-0" />
126
+ <span>These are <strong>estimates</strong> based on public API list prices. Actual costs may be lower if you use discounted plans, commitments, prompt caching, batching, or provider credits. Token counts for some editors are approximated from character counts (~4 chars/token).</span>
127
+ </div>
128
+
129
+ {/* KPIs */}
130
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
131
+ <KpiCard label="total est. cost" value={formatCost(totalCost)} sub="all models" />
132
+ <KpiCard label="avg / session" value={formatCost(summary.avgPerSession)} sub={`${formatNumber(summary.totalSessions)} sessions`} />
133
+ <KpiCard label="avg / day" value={formatCost(summary.avgPerDay)} sub={`${summary.totalDays} days`} />
134
+ <KpiCard label="models" value={byModel.length} sub={unknownModels.length > 0 ? `${unknownModels.length} unknown` : 'all priced'} />
135
+ <KpiCard label="top model" value={byModel[0]?.model || '—'} sub={byModel[0] ? formatCost(byModel[0].cost) : ''} />
136
+ <KpiCard label="top editor" value={byEditor[0] ? editorLabel(byEditor[0].editor) : '—'} sub={byEditor[0] ? formatCost(byEditor[0].cost) : ''} />
137
+ </div>
138
+
139
+ {/* Charts row 1: Model + Editor doughnuts */}
140
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
141
+ <div className="card p-3 lg:col-span-2">
142
+ <SectionTitle>cost by model</SectionTitle>
143
+ {modelChartData ? (
144
+ <div style={{ height: 220 }}>
145
+ <Bar
146
+ data={{
147
+ labels: byModel.slice(0, 12).map(m => m.model),
148
+ datasets: [{
149
+ data: byModel.slice(0, 12).map(m => Math.round(m.cost * 100) / 100),
150
+ backgroundColor: MODEL_COLORS,
151
+ borderRadius: 3,
152
+ }],
153
+ }}
154
+ options={{
155
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
156
+ scales: {
157
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO }, callback: v => '$' + v } },
158
+ y: { grid: { display: false }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
159
+ },
160
+ plugins: {
161
+ legend: { display: false },
162
+ tooltip: {
163
+ bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
164
+ callbacks: { label: ctx => ` $${ctx.raw.toFixed(2)}` },
165
+ },
166
+ },
167
+ }}
168
+ />
169
+ </div>
170
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no cost data</div>}
171
+ </div>
172
+ <div className="card p-3">
173
+ <SectionTitle>cost by editor</SectionTitle>
174
+ {editorChartData ? (
175
+ <div style={{ height: 220 }}>
176
+ <Doughnut data={editorChartData} options={doughnutOpts} />
177
+ </div>
178
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no cost data</div>}
179
+ </div>
180
+ </div>
181
+
182
+ {/* Monthly trend */}
183
+ {monthlyChartData && (
184
+ <div className="card p-3">
185
+ <SectionTitle>monthly cost trend</SectionTitle>
186
+ <div style={{ height: 180 }}>
187
+ <Line
188
+ data={monthlyChartData}
189
+ options={{
190
+ responsive: true, maintainAspectRatio: false,
191
+ interaction: { mode: 'index', intersect: false },
192
+ scales: {
193
+ x: { grid: { display: false }, ticks: { color: txtDim, font: { size: 9, family: MONO }, maxRotation: 0, maxTicksLimit: 12 } },
194
+ y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO }, callback: v => '$' + v } },
195
+ },
196
+ plugins: {
197
+ legend: { display: false },
198
+ tooltip: {
199
+ bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
200
+ callbacks: { label: ctx => ` $${ctx.raw.toFixed(2)} (${monthly[ctx.dataIndex]?.sessions || 0} sessions)` },
201
+ },
202
+ },
203
+ }}
204
+ />
205
+ </div>
206
+ </div>
207
+ )}
208
+
209
+ {/* Charts row 2: Projects */}
210
+ {projectChartData && (
211
+ <div className="card p-3">
212
+ <SectionTitle>cost by project (top 10)</SectionTitle>
213
+ <div style={{ height: 220 }}>
214
+ <Bar
215
+ data={projectChartData}
216
+ options={{
217
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
218
+ scales: {
219
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO }, callback: v => '$' + v } },
220
+ y: { grid: { display: false }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
221
+ },
222
+ plugins: {
223
+ legend: { display: false },
224
+ tooltip: {
225
+ bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
226
+ callbacks: { label: ctx => ` $${ctx.raw.toFixed(2)}` },
227
+ },
228
+ },
229
+ }}
230
+ />
231
+ </div>
232
+ </div>
233
+ )}
234
+
235
+ {/* Model breakdown table */}
236
+ <div className="card overflow-hidden">
237
+ <div className="px-3 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
238
+ <SectionTitle>model breakdown</SectionTitle>
239
+ </div>
240
+ <table className="w-full text-[12px]">
241
+ <thead>
242
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
243
+ <th className="text-left py-2 px-3 font-medium">model</th>
244
+ <th className="text-right py-2 px-3 font-medium">input tokens</th>
245
+ <th className="text-right py-2 px-3 font-medium">output tokens</th>
246
+ <th className="text-right py-2 px-3 font-medium">cache read</th>
247
+ <th className="text-right py-2 px-3 font-medium">cache write</th>
248
+ <th className="text-right py-2 px-3 font-medium">est. cost</th>
249
+ <th className="text-right py-2 px-3 font-medium">% of total</th>
250
+ </tr>
251
+ </thead>
252
+ <tbody>
253
+ {byModel.map((m, i) => (
254
+ <tr key={m.model} style={{ borderBottom: '1px solid var(--c-border)' }}>
255
+ <td className="py-2 px-3 font-mono font-medium" style={{ color: 'var(--c-white)' }}>
256
+ <span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: MODEL_COLORS[i % MODEL_COLORS.length] }} />
257
+ {m.model}
258
+ </td>
259
+ <td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text2)' }}>{formatNumber(m.inputTokens)}</td>
260
+ <td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text2)' }}>{formatNumber(m.outputTokens)}</td>
261
+ <td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text3)' }}>{m.cacheRead > 0 ? formatNumber(m.cacheRead) : '—'}</td>
262
+ <td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text3)' }}>{m.cacheWrite > 0 ? formatNumber(m.cacheWrite) : '—'}</td>
263
+ <td className="py-2 px-3 text-right font-mono font-medium" style={{ color: 'var(--c-white)' }}>{formatCost(m.cost)}</td>
264
+ <td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text3)' }}>
265
+ {totalCost > 0 ? ((m.cost / totalCost) * 100).toFixed(1) + '%' : '—'}
266
+ </td>
267
+ </tr>
268
+ ))}
269
+ </tbody>
270
+ </table>
271
+ {byModel.length === 0 && (
272
+ <div className="text-center py-8 text-sm" style={{ color: 'var(--c-text3)' }}>no model cost data</div>
273
+ )}
274
+ </div>
275
+
276
+ {/* Top expensive sessions */}
277
+ {topSessions.length > 0 && (
278
+ <div className="card overflow-hidden">
279
+ <div className="px-3 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
280
+ <SectionTitle>most expensive sessions</SectionTitle>
281
+ </div>
282
+ <table className="w-full text-[12px]">
283
+ <thead>
284
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
285
+ <th className="text-left py-2 px-3 font-medium">editor</th>
286
+ <th className="text-left py-2 px-3 font-medium">name</th>
287
+ <th className="text-left py-2 px-3 font-medium">project</th>
288
+ <th className="text-left py-2 px-3 font-medium">model</th>
289
+ <th className="text-right py-2 px-3 font-medium">msgs</th>
290
+ <th className="text-right py-2 px-3 font-medium">est. cost</th>
291
+ <th className="text-left py-2 px-3 font-medium">date</th>
292
+ </tr>
293
+ </thead>
294
+ <tbody>
295
+ {topSessions.slice(0, 30).map(s => (
296
+ <tr
297
+ key={s.id}
298
+ className="cursor-pointer transition"
299
+ style={{ borderBottom: '1px solid var(--c-border)' }}
300
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
301
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
302
+ onClick={() => setSelectedChatId(s.id)}
303
+ >
304
+ <td className="py-2 px-3">
305
+ <span className="inline-flex items-center gap-1.5">
306
+ <EditorIcon source={s.source} size={12} />
307
+ <span style={{ color: 'var(--c-text2)' }}>{editorLabel(s.source)}</span>
308
+ </span>
309
+ </td>
310
+ <td className="py-2 px-3 font-medium truncate max-w-[200px]" style={{ color: 'var(--c-white)' }}>
311
+ {s.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
312
+ </td>
313
+ <td className="py-2 px-3 truncate max-w-[140px]" style={{ color: 'var(--c-text2)' }} title={s.folder}>
314
+ {s.folder ? (
315
+ <span
316
+ className="cursor-pointer hover:underline"
317
+ style={{ color: 'var(--c-accent)' }}
318
+ onClick={e => { e.stopPropagation(); navigate(`/projects/detail?folder=${encodeURIComponent(s.folder)}`) }}
319
+ >{s.folder.split('/').pop()}</span>
320
+ ) : ''}
321
+ </td>
322
+ <td className="py-2 px-3 font-mono truncate max-w-[140px]" style={{ color: 'var(--c-text2)' }} title={s.model}>{s.model}</td>
323
+ <td className="py-2 px-3 text-right" style={{ color: 'var(--c-text3)' }}>{s.messages}</td>
324
+ <td className="py-2 px-3 text-right font-mono font-medium" style={{ color: 'var(--c-white)' }}>{formatCost(s.cost)}</td>
325
+ <td className="py-2 px-3 whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{formatDate(s.lastUpdatedAt)}</td>
326
+ </tr>
327
+ ))}
328
+ </tbody>
329
+ </table>
330
+ </div>
331
+ )}
332
+
333
+ {/* Unknown models */}
334
+ {unknownModels.length > 0 && (
335
+ <div className="card p-3">
336
+ <SectionTitle>
337
+ <AlertTriangle size={11} className="inline mr-1" style={{ color: '#f59e0b' }} />
338
+ unpriced models ({unknownModels.length})
339
+ </SectionTitle>
340
+ <div className="flex flex-wrap gap-1.5 mt-2">
341
+ {unknownModels.map(m => (
342
+ <span key={m} className="text-[11px] px-2 py-0.5 font-mono" style={{ background: 'rgba(245,158,11,0.08)', color: '#f59e0b', border: '1px solid rgba(245,158,11,0.2)' }}>
343
+ {m}
344
+ </span>
345
+ ))}
346
+ </div>
347
+ </div>
348
+ )}
349
+
350
+ {/* Chat sidebar */}
351
+ <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
352
+ </div>
353
+ )
354
+ }