memhouse 0.0.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +72 -3
  3. package/bin/memhouse.js +559 -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/adapter-errors.js +52 -0
  9. package/editors/antigravity.js +1048 -0
  10. package/editors/base.js +321 -0
  11. package/editors/claude.js +386 -0
  12. package/editors/codebuff.js +338 -0
  13. package/editors/codex.js +537 -0
  14. package/editors/commandcode.js +166 -0
  15. package/editors/copilot-jetbrains.js +207 -0
  16. package/editors/copilot.js +260 -0
  17. package/editors/cursor-agent.js +202 -0
  18. package/editors/cursor.js +498 -0
  19. package/editors/gemini.js +231 -0
  20. package/editors/goose.js +386 -0
  21. package/editors/gsd.js +366 -0
  22. package/editors/index.js +274 -0
  23. package/editors/kiro.js +315 -0
  24. package/editors/opencode.js +432 -0
  25. package/editors/vscode.js +413 -0
  26. package/editors/windsurf.js +634 -0
  27. package/editors/zed.js +229 -0
  28. package/index.js +424 -2
  29. package/mcp-server.js +294 -0
  30. package/mem-house/DESIGN.md +119 -0
  31. package/mem-house/delivery/AGENT-INSTALL.md +87 -0
  32. package/mem-house/delivery/PROMPT.md +42 -0
  33. package/mem-house/delivery/install.sh +181 -0
  34. package/mem-house/delivery/kernel-install.md +83 -0
  35. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  36. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  37. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  38. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  39. package/mem-house/rls.sql +17 -0
  40. package/mem-house/schema.sql +145 -0
  41. package/mem-house/server/queries.js +878 -0
  42. package/mem-house/server/server.js +228 -0
  43. package/mem-house/shipper/ship.js +461 -0
  44. package/misc/syntax-check.js +38 -0
  45. package/package.json +59 -7
  46. package/pricing.js +88 -0
  47. package/pricing.json +992 -0
  48. package/public/assets/index-CCPh7a2Z.js +75 -0
  49. package/public/assets/index-DV6ONi_F.css +2 -0
  50. package/public/index.html +16 -0
  51. package/relay-client.js +306 -0
  52. package/relay-server.js +577 -0
  53. package/server.js +860 -0
  54. package/share-image.js +367 -0
  55. package/ui/eslint.config.js +29 -0
  56. package/ui/index.html +15 -0
  57. package/ui/package-lock.json +4229 -0
  58. package/ui/package.json +38 -0
  59. package/ui/src/App.jsx +364 -0
  60. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  61. package/ui/src/components/AiAuditCard.jsx +254 -0
  62. package/ui/src/components/AnimatedLoader.jsx +14 -0
  63. package/ui/src/components/AnimatedLogo.jsx +96 -0
  64. package/ui/src/components/ChatSidebar.jsx +226 -0
  65. package/ui/src/components/DateRangePicker.jsx +104 -0
  66. package/ui/src/components/EditorDot.jsx +11 -0
  67. package/ui/src/components/EditorIcon.jsx +117 -0
  68. package/ui/src/components/KpiCard.jsx +9 -0
  69. package/ui/src/components/LiveFeed.jsx +128 -0
  70. package/ui/src/components/LoginScreen.jsx +81 -0
  71. package/ui/src/components/MessageRenderer.jsx +148 -0
  72. package/ui/src/components/PageHeader.jsx +11 -0
  73. package/ui/src/components/SectionTitle.jsx +3 -0
  74. package/ui/src/components/ShareModal.jsx +305 -0
  75. package/ui/src/components/TokenTimeline.jsx +258 -0
  76. package/ui/src/hooks/useLive.jsx +15 -0
  77. package/ui/src/index.css +83 -0
  78. package/ui/src/lib/api.js +343 -0
  79. package/ui/src/lib/constants.js +88 -0
  80. package/ui/src/lib/theme.jsx +23 -0
  81. package/ui/src/main.jsx +16 -0
  82. package/ui/src/pages/Artifacts.jsx +575 -0
  83. package/ui/src/pages/Compare.jsx +232 -0
  84. package/ui/src/pages/CostAnalysis.jsx +354 -0
  85. package/ui/src/pages/Dashboard.jsx +529 -0
  86. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  87. package/ui/src/pages/GSD.jsx +726 -0
  88. package/ui/src/pages/MCPs.jsx +744 -0
  89. package/ui/src/pages/ProjectDetail.jsx +325 -0
  90. package/ui/src/pages/Projects.jsx +249 -0
  91. package/ui/src/pages/RelayDashboard.jsx +590 -0
  92. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  93. package/ui/src/pages/Sessions.jsx +535 -0
  94. package/ui/src/pages/Settings.jsx +241 -0
  95. package/ui/src/pages/SqlViewer.jsx +370 -0
  96. package/ui/src/pages/Subscriptions.jsx +411 -0
  97. package/ui/vite.config.js +18 -0
@@ -0,0 +1,305 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { X, Download, Share2, BarChart3, DollarSign, Clock, Cpu, Braces, User, Sun, Moon, FolderOpen } from 'lucide-react'
3
+ import { fetchShareImage, fetchProjects } from '../lib/api'
4
+
5
+ const TOGGLE_ITEMS = [
6
+ { key: 'showEditors', label: 'Editors', icon: BarChart3 },
7
+ { key: 'showCosts', label: 'Est. Costs', icon: DollarSign },
8
+ { key: 'showHours', label: 'Peak Hours', icon: Clock },
9
+ { key: 'showModels', label: 'Top Models', icon: Cpu },
10
+ { key: 'showTokens', label: 'Token Footer', icon: Braces },
11
+ ]
12
+
13
+ export default function ShareModal({ open, onClose }) {
14
+ const [opts, setOpts] = useState({
15
+ showEditors: true,
16
+ showCosts: true,
17
+ showHours: true,
18
+ showModels: true,
19
+ showTokens: true,
20
+ username: '',
21
+ theme: 'dark',
22
+ })
23
+ const [svg, setSvg] = useState('')
24
+ const [loading, setLoading] = useState(false)
25
+ const [downloading, setDownloading] = useState(false)
26
+ const [projects, setProjects] = useState([])
27
+ const debounceRef = useRef(null)
28
+ const backdropRef = useRef(null)
29
+
30
+ const loadPreview = useCallback(async (currentOpts) => {
31
+ setLoading(true)
32
+ try {
33
+ const result = await fetchShareImage(currentOpts)
34
+ if (result && !result.startsWith('{')) setSvg(result)
35
+ } catch (e) {
36
+ console.error('Preview failed:', e)
37
+ }
38
+ setLoading(false)
39
+ }, [])
40
+
41
+ useEffect(() => {
42
+ if (!open) return
43
+ loadPreview(opts)
44
+ fetchProjects().then(p => setProjects(p || [])).catch(() => {})
45
+ }, [open])
46
+
47
+ const updateOpt = (key, value) => {
48
+ const next = { ...opts, [key]: value }
49
+ setOpts(next)
50
+ if (debounceRef.current) clearTimeout(debounceRef.current)
51
+ debounceRef.current = setTimeout(() => loadPreview(next), 300)
52
+ }
53
+
54
+ const handleDownloadPng = async () => {
55
+ if (!svg) return
56
+ setDownloading(true)
57
+ try {
58
+ const scale = 2
59
+ const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })
60
+ const url = URL.createObjectURL(svgBlob)
61
+ const img = new Image()
62
+ img.width = 1200 * scale
63
+ img.height = 675 * scale
64
+ await new Promise((resolve, reject) => {
65
+ img.onload = resolve
66
+ img.onerror = reject
67
+ img.src = url
68
+ })
69
+ const canvas = document.createElement('canvas')
70
+ canvas.width = 1200 * scale
71
+ canvas.height = 675 * scale
72
+ const ctx = canvas.getContext('2d')
73
+ ctx.drawImage(img, 0, 0, 1200 * scale, 675 * scale)
74
+ URL.revokeObjectURL(url)
75
+ canvas.toBlob((blob) => {
76
+ if (!blob) return
77
+ const a = document.createElement('a')
78
+ a.href = URL.createObjectURL(blob)
79
+ a.download = 'agentlytics.png'
80
+ a.click()
81
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000)
82
+ }, 'image/png')
83
+ } catch (e) {
84
+ console.error('PNG conversion failed:', e)
85
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
86
+ const a = document.createElement('a')
87
+ a.href = URL.createObjectURL(blob)
88
+ a.download = 'agentlytics.svg'
89
+ a.click()
90
+ URL.revokeObjectURL(a.href)
91
+ }
92
+ setDownloading(false)
93
+ }
94
+
95
+ const handleDownloadSvg = () => {
96
+ if (!svg) return
97
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
98
+ const a = document.createElement('a')
99
+ a.href = URL.createObjectURL(blob)
100
+ a.download = 'agentlytics.svg'
101
+ a.click()
102
+ URL.revokeObjectURL(a.href)
103
+ }
104
+
105
+ const handleShareTwitter = async () => {
106
+ await handleDownloadPng()
107
+ const text = encodeURIComponent("Here's my agentic coding stats using agentlytics.io\n\nrun `npx agentlytics` to get yours ✨\n\ngithub.com/f/agentlytics")
108
+ window.open(`https://x.com/intent/post?text=${text}`, '_blank')
109
+ }
110
+
111
+ if (!open) return null
112
+
113
+ return (
114
+ <div
115
+ ref={backdropRef}
116
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
117
+ style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(8px)' }}
118
+ onClick={(e) => e.target === backdropRef.current && onClose()}
119
+ >
120
+ <div
121
+ className="w-full relative flex flex-col"
122
+ style={{
123
+ maxWidth: 960,
124
+ maxHeight: '90vh',
125
+ background: 'var(--c-bg)',
126
+ border: '1px solid var(--c-border)',
127
+ borderRadius: 12,
128
+ }}
129
+ >
130
+ {/* Header */}
131
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
132
+ <div className="flex items-center gap-2">
133
+ <Share2 size={14} style={{ color: 'var(--c-accent)' }} />
134
+ <span className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>Share Stats</span>
135
+ </div>
136
+ <button onClick={onClose} className="p-1 rounded hover:opacity-70 transition" style={{ color: 'var(--c-text2)' }}>
137
+ <X size={16} />
138
+ </button>
139
+ </div>
140
+
141
+ {/* Body */}
142
+ <div className="flex-1 overflow-auto p-5" style={{ minHeight: 0 }}>
143
+ <div className="flex gap-5" style={{ flexDirection: 'row' }}>
144
+
145
+ {/* Sidebar: toggles */}
146
+ <div className="flex-shrink-0" style={{ width: 200 }}>
147
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
148
+ Customize
149
+ </div>
150
+
151
+ <div className="mb-4">
152
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
153
+ Project
154
+ </div>
155
+ <div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
156
+ <FolderOpen size={11} style={{ color: 'var(--c-text3)' }} />
157
+ <select
158
+ value={opts.folder || ''}
159
+ onChange={(e) => updateOpt('folder', e.target.value || undefined)}
160
+ className="bg-transparent outline-none text-[12px] w-full cursor-pointer"
161
+ style={{ color: 'var(--c-text)', appearance: 'none' }}
162
+ >
163
+ <option value="">All Projects</option>
164
+ {projects.map(p => (
165
+ <option key={p.folder} value={p.folder}>
166
+ {p.name} ({p.totalSessions})
167
+ </option>
168
+ ))}
169
+ </select>
170
+ </div>
171
+ </div>
172
+
173
+ <div className="space-y-1.5">
174
+ {TOGGLE_ITEMS.map(({ key, label, icon: Icon }) => {
175
+ const active = opts[key]
176
+ return (
177
+ <button
178
+ key={key}
179
+ onClick={() => updateOpt(key, !active)}
180
+ className="flex items-center gap-2 w-full px-2.5 py-2 rounded-md text-[12px] transition"
181
+ style={{
182
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
183
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
184
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
185
+ opacity: active ? 1 : 0.6,
186
+ }}
187
+ >
188
+ <Icon size={12} />
189
+ {label}
190
+ </button>
191
+ )
192
+ })}
193
+ </div>
194
+
195
+ <div className="mt-4">
196
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
197
+ Theme
198
+ </div>
199
+ <div className="flex gap-1.5">
200
+ {[{ key: 'dark', icon: Moon, label: 'Dark' }, { key: 'light', icon: Sun, label: 'Light' }].map(({ key, icon: Icon, label }) => {
201
+ const active = opts.theme === key
202
+ return (
203
+ <button
204
+ key={key}
205
+ onClick={() => updateOpt('theme', key)}
206
+ className="flex items-center gap-1.5 flex-1 justify-center px-2 py-1.5 rounded-md text-[11px] transition"
207
+ style={{
208
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
209
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
210
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
211
+ opacity: active ? 1 : 0.6,
212
+ }}
213
+ >
214
+ <Icon size={11} />
215
+ {label}
216
+ </button>
217
+ )
218
+ })}
219
+ </div>
220
+ </div>
221
+
222
+ <div className="mt-4">
223
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
224
+ Username
225
+ </div>
226
+ <div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
227
+ <User size={11} style={{ color: 'var(--c-text3)' }} />
228
+ <input
229
+ type="text"
230
+ placeholder="optional"
231
+ value={opts.username}
232
+ onChange={(e) => updateOpt('username', e.target.value)}
233
+ className="bg-transparent outline-none text-[12px] w-full"
234
+ style={{ color: 'var(--c-text)' }}
235
+ />
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ {/* Preview */}
241
+ <div className="flex-1 min-w-0">
242
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
243
+ Preview
244
+ </div>
245
+ <div
246
+ className="rounded-lg overflow-hidden relative"
247
+ style={{
248
+ border: '1px solid var(--c-border)',
249
+ background: '#09090f',
250
+ minHeight: 200,
251
+ }}
252
+ >
253
+ {loading && (
254
+ <div className="absolute inset-0 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.5)', zIndex: 2 }}>
255
+ <span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Generating...</span>
256
+ </div>
257
+ )}
258
+ {svg && (
259
+ <div
260
+ className="w-full [&>svg]:w-full [&>svg]:h-auto [&>svg]:block"
261
+ dangerouslySetInnerHTML={{ __html: svg }}
262
+ />
263
+ )}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ {/* Footer: actions */}
270
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
271
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
272
+ Tip: Toggle sections to customize your share card
273
+ </span>
274
+ <div className="flex items-center gap-2">
275
+ <button
276
+ onClick={handleDownloadSvg}
277
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
278
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
279
+ >
280
+ <Download size={12} />
281
+ SVG
282
+ </button>
283
+ <button
284
+ onClick={handleDownloadPng}
285
+ disabled={downloading}
286
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
287
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)', opacity: downloading ? 0.5 : 1 }}
288
+ >
289
+ <Download size={12} />
290
+ {downloading ? 'Converting...' : 'PNG'}
291
+ </button>
292
+ <button
293
+ onClick={handleShareTwitter}
294
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
295
+ style={{ background: '#6366f1', color: '#fff' }}
296
+ >
297
+ <Share2 size={12} />
298
+ Share on X
299
+ </button>
300
+ </div>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ )
305
+ }
@@ -0,0 +1,258 @@
1
+ import { useState, useRef, useCallback, useMemo } from 'react'
2
+ import { Activity, ChevronDown, ChevronUp, Clock } from 'lucide-react'
3
+ import { formatNumber } from '../lib/constants'
4
+
5
+ const CHART_H = 56
6
+ const POINT_STEP = 52
7
+ const PAD_X = 20
8
+ const PAD_Y = 6
9
+ const BUCKET_MINUTES = 5
10
+ const BUCKET_MSG_COUNT = 8
11
+
12
+ const AVG_TOKEN_SPEED = 80
13
+
14
+ function formatDuration(ms) {
15
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`
16
+ const mins = Math.floor(ms / 60000)
17
+ if (mins < 60) return `${mins}m`
18
+ const hrs = Math.floor(mins / 60)
19
+ const rem = mins % 60
20
+ return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`
21
+ }
22
+
23
+ function lerp(a, b, t) {
24
+ return a + (b - a) * Math.max(0, Math.min(1, t))
25
+ }
26
+
27
+ function tokenColor(ratio) {
28
+ const r = Math.round(lerp(52, 239, ratio))
29
+ const g = Math.round(lerp(211, 68, ratio))
30
+ const b = Math.round(lerp(153, 68, ratio))
31
+ return `rgb(${r},${g},${b})`
32
+ }
33
+
34
+ function buildTimeBuckets(messages, createdAt, lastUpdatedAt) {
35
+ const start = new Date(createdAt).getTime()
36
+ const end = new Date(lastUpdatedAt).getTime()
37
+ const duration = Math.max(end - start, 1)
38
+ const bucketMs = BUCKET_MINUTES * 60 * 1000
39
+ const bucketCount = Math.max(1, Math.ceil(duration / bucketMs))
40
+
41
+ const buckets = Array.from({ length: bucketCount }, (_, i) => ({
42
+ label: `${i * BUCKET_MINUTES}m`,
43
+ tokens: 0,
44
+ messageIndices: [],
45
+ }))
46
+
47
+ messages.forEach((msg, idx) => {
48
+ const totalTokens = (msg.inputTokens || 0) + (msg.outputTokens || 0)
49
+ const msgTime = start + (idx / Math.max(messages.length - 1, 1)) * duration
50
+ const bucketIdx = Math.min(Math.floor((msgTime - start) / bucketMs), bucketCount - 1)
51
+ buckets[bucketIdx].tokens += totalTokens
52
+ buckets[bucketIdx].messageIndices.push(idx)
53
+ })
54
+
55
+ return buckets
56
+ }
57
+
58
+ function buildSeqBuckets(messages) {
59
+ const bucketCount = Math.max(1, Math.ceil(messages.length / BUCKET_MSG_COUNT))
60
+
61
+ const buckets = Array.from({ length: bucketCount }, () => ({
62
+ label: '',
63
+ tokens: 0,
64
+ messageIndices: [],
65
+ }))
66
+
67
+ messages.forEach((msg, idx) => {
68
+ const chars = typeof msg.content === 'string' ? msg.content.length : 0
69
+ const totalTokens = Math.round(chars / 4)
70
+ const bucketIdx = Math.min(Math.floor(idx / BUCKET_MSG_COUNT), bucketCount - 1)
71
+ buckets[bucketIdx].tokens += totalTokens
72
+ buckets[bucketIdx].messageIndices.push(idx)
73
+ })
74
+
75
+ return buckets
76
+ }
77
+
78
+ export default function TokenTimeline({ messages, createdAt, lastUpdatedAt, onScrollToMessage }) {
79
+ const [open, setOpen] = useState(true)
80
+ const [hoveredIdx, setHoveredIdx] = useState(null)
81
+ const scrollContainerRef = useRef(null)
82
+
83
+ const hasRealTokens = useMemo(() => {
84
+ if (!messages) return false
85
+ return messages.some(m => (m.inputTokens || 0) + (m.outputTokens || 0) > 0)
86
+ }, [messages])
87
+
88
+ const buckets = useMemo(() => {
89
+ if (!messages || messages.length === 0) return []
90
+ if (hasRealTokens) return buildTimeBuckets(messages, createdAt, lastUpdatedAt)
91
+ return buildSeqBuckets(messages)
92
+ }, [messages, createdAt, lastUpdatedAt, hasRealTokens])
93
+
94
+ const maxTokens = useMemo(() => Math.max(...buckets.map(b => b.tokens), 1), [buckets])
95
+
96
+ const totalTokens = useMemo(() => buckets.reduce((s, b) => s + b.tokens, 0), [buckets])
97
+
98
+ const durationLabel = useMemo(() => {
99
+ if (hasRealTokens && createdAt && lastUpdatedAt) {
100
+ const ms = new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime()
101
+ if (ms > 0) return formatDuration(ms)
102
+ }
103
+ if (totalTokens > 0) {
104
+ const estSeconds = totalTokens / AVG_TOKEN_SPEED
105
+ return '~' + formatDuration(estSeconds * 1000)
106
+ }
107
+ return null
108
+ }, [hasRealTokens, createdAt, lastUpdatedAt, totalTokens])
109
+
110
+ const handlePointClick = useCallback((bucket) => {
111
+ if (bucket.messageIndices.length > 0 && onScrollToMessage) {
112
+ onScrollToMessage(bucket.messageIndices[0])
113
+ }
114
+ }, [onScrollToMessage])
115
+
116
+ if (!messages || messages.length === 0 || totalTokens === 0) return null
117
+
118
+ const svgW = PAD_X * 2 + Math.max((buckets.length - 1) * POINT_STEP, 0)
119
+ const svgH = CHART_H + PAD_Y * 2 + (hasRealTokens ? 16 : 4)
120
+
121
+ const points = buckets.map((b, i) => {
122
+ const x = PAD_X + i * POINT_STEP
123
+ const ratio = b.tokens / maxTokens
124
+ const y = PAD_Y + CHART_H - ratio * CHART_H
125
+ return { x, y, ratio, bucket: b }
126
+ })
127
+
128
+ const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
129
+
130
+ const areaPath = points.length > 0
131
+ ? `${linePath} L ${points[points.length - 1].x} ${PAD_Y + CHART_H} L ${points[0].x} ${PAD_Y + CHART_H} Z`
132
+ : ''
133
+
134
+ return (
135
+ <div className="shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
136
+ {/* Toggle button */}
137
+ <button
138
+ onClick={() => setOpen(o => !o)}
139
+ className="flex items-center gap-2 w-full px-4 py-1.5 text-[11px] transition hover:bg-[var(--c-bg3)]"
140
+ style={{ color: 'var(--c-text2)' }}
141
+ >
142
+ <Activity size={12} style={{ color: 'var(--c-accent)' }} />
143
+ <span className="font-medium">Token Timeline</span>
144
+ <span style={{ color: 'var(--c-text3)' }}>
145
+ ({formatNumber(totalTokens)} tokens{!hasRealTokens ? ', est.' : ''})
146
+ </span>
147
+ {durationLabel && (
148
+ <span className="inline-flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
149
+ <Clock size={10} /> {durationLabel}
150
+ </span>
151
+ )}
152
+ <span className="ml-auto">
153
+ {open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
154
+ </span>
155
+ </button>
156
+
157
+ {/* Collapsible chart area */}
158
+ {open && (
159
+ <div className="px-2 pb-2 pt-0">
160
+ <div
161
+ ref={scrollContainerRef}
162
+ className="overflow-x-auto scrollbar-thin"
163
+ style={{ scrollBehavior: 'smooth' }}
164
+ >
165
+ <svg
166
+ width={svgW}
167
+ height={svgH}
168
+ style={{ display: 'block', minWidth: svgW }}
169
+ >
170
+ <defs>
171
+ <linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
172
+ <stop offset="0%" stopColor="var(--c-accent)" stopOpacity="0.15" />
173
+ <stop offset="100%" stopColor="var(--c-accent)" stopOpacity="0.02" />
174
+ </linearGradient>
175
+ </defs>
176
+
177
+ {/* Baseline */}
178
+ <line
179
+ x1={PAD_X} y1={PAD_Y + CHART_H}
180
+ x2={PAD_X + (buckets.length - 1) * POINT_STEP} y2={PAD_Y + CHART_H}
181
+ stroke="var(--c-border)" strokeWidth="1"
182
+ />
183
+
184
+ {/* Area fill */}
185
+ {areaPath && <path d={areaPath} fill="url(#tokenAreaGrad)" />}
186
+
187
+ {/* Line */}
188
+ {points.length > 1 && (
189
+ <path d={linePath} fill="none" stroke="var(--c-accent)" strokeWidth="1.5" strokeLinejoin="round" />
190
+ )}
191
+
192
+ {/* Data points + labels */}
193
+ {points.map((p, i) => {
194
+ const isHovered = hoveredIdx === i
195
+ const color = tokenColor(p.ratio)
196
+ return (
197
+ <g
198
+ key={i}
199
+ style={{ cursor: p.bucket.tokens > 0 ? 'pointer' : 'default' }}
200
+ onMouseEnter={() => setHoveredIdx(i)}
201
+ onMouseLeave={() => setHoveredIdx(null)}
202
+ onClick={() => handlePointClick(p.bucket)}
203
+ >
204
+ <circle cx={p.x} cy={p.y} r={12} fill="transparent" />
205
+
206
+ <circle
207
+ cx={p.x} cy={p.y}
208
+ r={isHovered ? 5 : 3}
209
+ fill={p.bucket.tokens > 0 ? color : 'var(--c-border)'}
210
+ stroke={isHovered ? 'var(--c-white)' : 'none'}
211
+ strokeWidth={1.5}
212
+ style={{ transition: 'r 0.15s' }}
213
+ />
214
+
215
+ {isHovered && (
216
+ <line
217
+ x1={p.x} y1={p.y} x2={p.x} y2={PAD_Y + CHART_H}
218
+ stroke="var(--c-text3)" strokeWidth="0.5" strokeDasharray="2,2"
219
+ />
220
+ )}
221
+
222
+ {isHovered && p.bucket.tokens > 0 && (
223
+ <g>
224
+ <rect
225
+ x={p.x - 32} y={p.y - 22}
226
+ width={64} height={16} rx={3}
227
+ fill="var(--c-bg)" stroke="var(--c-border)" strokeWidth="0.5"
228
+ />
229
+ <text
230
+ x={p.x} y={p.y - 11}
231
+ textAnchor="middle" fontSize="9" fontFamily="monospace"
232
+ fill="var(--c-white)"
233
+ >
234
+ {formatNumber(p.bucket.tokens)} tok
235
+ </text>
236
+ </g>
237
+ )}
238
+
239
+ {/* X label — only for time-based buckets */}
240
+ {hasRealTokens && p.bucket.label && (
241
+ <text
242
+ x={p.x} y={PAD_Y + CHART_H + 12}
243
+ textAnchor="middle" fontSize="9" fontFamily="monospace"
244
+ fill="var(--c-text3)"
245
+ >
246
+ {p.bucket.label}
247
+ </text>
248
+ )}
249
+ </g>
250
+ )
251
+ })}
252
+ </svg>
253
+ </div>
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ }
@@ -0,0 +1,15 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+
3
+ const KEY = 'agentlytics-live'
4
+
5
+ export function useLive() {
6
+ const [live, setLive] = useState(() => localStorage.getItem(KEY) === 'true')
7
+
8
+ useEffect(() => {
9
+ localStorage.setItem(KEY, live)
10
+ }, [live])
11
+
12
+ const toggle = useCallback(() => setLive(l => !l), [])
13
+
14
+ return { live, toggle }
15
+ }
@@ -0,0 +1,83 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --color-bg-900: #0e0e0e;
5
+ --color-bg-800: #161616;
6
+ --color-bg-700: #1e1e1e;
7
+ --color-bg-600: #272727;
8
+ --color-accent: #6366f1;
9
+ --color-accent-light: #818cf8;
10
+ --color-accent-dim: #4f46e5;
11
+ }
12
+
13
+ :root {
14
+ --c-bg: #0e0e0e; --c-bg2: #161616; --c-bg3: #1e1e1e; --c-bg4: #272727;
15
+ --c-text: #a0a0a0; --c-text2: #666; --c-text3: #444;
16
+ --c-white: #e0e0e0; --c-border: rgba(255,255,255,0.06);
17
+ --c-card: rgba(255,255,255,0.025); --c-card-hover: rgba(99,102,241,0.25);
18
+ --c-header: rgba(14,14,14,0.85);
19
+ --c-code-bg: rgba(255,255,255,0.05); --c-pre-bg: rgba(0,0,0,0.4);
20
+ --c-md-strong: #d0d0d0;
21
+ }
22
+
23
+ .light {
24
+ --c-bg: #f5f5f5; --c-bg2: #ebebeb; --c-bg3: #e0e0e0; --c-bg4: #d6d6d6;
25
+ --c-text: #444; --c-text2: #888; --c-text3: #bbb;
26
+ --c-white: #111; --c-border: rgba(0,0,0,0.08);
27
+ --c-card: rgba(0,0,0,0.025); --c-card-hover: rgba(99,102,241,0.2);
28
+ --c-header: rgba(245,245,245,0.9);
29
+ --c-code-bg: rgba(0,0,0,0.05); --c-pre-bg: rgba(0,0,0,0.04);
30
+ --c-md-strong: #111;
31
+ }
32
+
33
+ body {
34
+ margin: 0;
35
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
36
+ background: var(--c-bg);
37
+ color: var(--c-text);
38
+ min-height: 100vh;
39
+ font-size: 13px;
40
+ line-height: 1.5;
41
+ }
42
+
43
+ .card {
44
+ background: var(--c-card);
45
+ border: 1px solid var(--c-border);
46
+ border-radius: 0;
47
+ transition: border-color 0.15s;
48
+ }
49
+
50
+ * { border-radius: 0 !important; }
51
+ .card:hover { border-color: var(--c-card-hover); }
52
+
53
+ .scrollbar-thin { scrollbar-width: thin; scrollbar-color: transparent transparent; }
54
+ .scrollbar-thin:hover { scrollbar-color: var(--c-border) transparent; }
55
+ .scrollbar-thin::-webkit-scrollbar { width: 3px; }
56
+ .scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
57
+ .scrollbar-thin::-webkit-scrollbar-thumb { background: transparent; }
58
+ .scrollbar-thin:hover::-webkit-scrollbar-thumb { background: var(--c-border); }
59
+
60
+ .fade-in { animation: fadeIn 0.2s ease-out; }
61
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
62
+
63
+ .sidebar-slide-in { animation: slideInRight 0.2s ease-out; }
64
+ @keyframes slideInRight { from { transform: translateX(100%); } to { transform: translateX(0); } }
65
+
66
+ .pulse-dot { animation: pulseDot 1.5s ease-in-out infinite; }
67
+ @keyframes pulseDot { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34,197,94,0.5); } 50% { opacity: 0.6; box-shadow: 0 0 0 4px rgba(34,197,94,0); } }
68
+
69
+ /* Markdown */
70
+ .md-body h1,.md-body h2,.md-body h3 { font-weight:600; margin:8px 0 4px; color:var(--c-white); }
71
+ .md-body h1 { font-size:1.15em; } .md-body h2 { font-size:1.05em; } .md-body h3 { font-size:1em; }
72
+ .md-body p { margin:4px 0; line-height:1.6; }
73
+ .md-body ul,.md-body ol { padding-left:1.3em; margin:3px 0; }
74
+ .md-body li { margin:1px 0; }
75
+ .md-body code { background:var(--c-code-bg); padding:1px 4px; border-radius:3px; font-size:0.9em; }
76
+ .md-body pre { background:var(--c-pre-bg); padding:10px; border-radius:6px; overflow-x:auto; margin:6px 0; border:1px solid var(--c-border); }
77
+ .md-body pre code { background:none; padding:0; }
78
+ .md-body strong { color:var(--c-md-strong); }
79
+ .md-body a { color:#818cf8; text-decoration:underline; }
80
+ .md-body blockquote { border-left:2px solid rgba(99,102,241,0.3); padding-left:10px; color:var(--c-text2); margin:6px 0; }
81
+ .md-body table { border-collapse:collapse; margin:6px 0; }
82
+ .md-body th,.md-body td { border:1px solid var(--c-border); padding:4px 8px; font-size:0.9em; }
83
+ .md-body th { background:var(--c-code-bg); }