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,38 @@
1
+ {
2
+ "name": "ui",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "node ../index.js --ui-dev & vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@tailwindcss/vite": "^4.2.1",
14
+ "chart.js": "^4.5.1",
15
+ "lucide-react": "^0.577.0",
16
+ "react": "^19.2.0",
17
+ "react-chartjs-2": "^5.3.1",
18
+ "react-dom": "^19.2.0",
19
+ "react-markdown": "^10.1.0",
20
+ "react-router-dom": "^7.13.1",
21
+ "remark-gfm": "^4.0.1",
22
+ "tailwindcss": "^4.2.1"
23
+ },
24
+ "devDependencies": {
25
+ "@eslint/js": "^9.39.1",
26
+ "@types/react": "^19.2.7",
27
+ "@types/react-dom": "^19.2.3",
28
+ "@vitejs/plugin-react": "^5.1.1",
29
+ "eslint": "^9.39.1",
30
+ "eslint-plugin-react-hooks": "^7.0.1",
31
+ "eslint-plugin-react-refresh": "^0.4.24",
32
+ "globals": "^16.5.0",
33
+ "vite": "^8.0.0-beta.13"
34
+ },
35
+ "overrides": {
36
+ "vite": "^8.0.0-beta.13"
37
+ }
38
+ }
package/ui/src/App.jsx ADDED
@@ -0,0 +1,364 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
3
+ import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown, Target } from 'lucide-react'
4
+ import { fetchOverview, refetchAgents, fetchMode, getAuthToken, setOnAuthFailure } from './lib/api'
5
+ import { useTheme } from './lib/theme'
6
+ import { useLive } from './hooks/useLive'
7
+ import AnimatedLogo from './components/AnimatedLogo'
8
+ import AnimatedLoader from './components/AnimatedLoader'
9
+ import LoginScreen from './components/LoginScreen'
10
+ import Dashboard from './pages/Dashboard'
11
+ import Sessions from './pages/Sessions'
12
+ import DeepAnalysis from './pages/DeepAnalysis'
13
+ import Compare from './pages/Compare'
14
+ import Projects from './pages/Projects'
15
+ import ProjectDetail from './pages/ProjectDetail'
16
+ import CostAnalysis from './pages/CostAnalysis'
17
+ import SqlViewer from './pages/SqlViewer'
18
+ import Artifacts from './pages/Artifacts'
19
+ import Settings from './pages/Settings'
20
+ import Subscriptions from './pages/Subscriptions'
21
+ import MCPs from './pages/MCPs'
22
+ import RelayDashboard from './pages/RelayDashboard'
23
+ import RelayUserDetail from './pages/RelayUserDetail'
24
+ import GSD from './pages/GSD'
25
+
26
+ function NavDropdown({ icon: Icon, label, items }) {
27
+ const [open, setOpen] = useState(false)
28
+ const location = useLocation()
29
+ const isActive = items.some(i => i.to === location.pathname)
30
+ const timeout = useRef(null)
31
+
32
+ const enter = () => { clearTimeout(timeout.current); setOpen(true) }
33
+ const leave = () => { timeout.current = setTimeout(() => setOpen(false), 150) }
34
+
35
+ return (
36
+ <div className="relative" onMouseEnter={enter} onMouseLeave={leave}>
37
+ <button
38
+ className={`flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
39
+ isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
40
+ }`}
41
+ >
42
+ <Icon size={12} />
43
+ {label}
44
+ <ChevronDown size={10} style={{ opacity: 0.5 }} />
45
+ </button>
46
+ {open && (
47
+ <div
48
+ className="absolute top-full left-0 mt-1 py-1 rounded shadow-lg min-w-[160px] z-[100]"
49
+ style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)' }}
50
+ >
51
+ {items.map(({ to, icon: SubIcon, label: subLabel }) => (
52
+ <NavLink
53
+ key={to}
54
+ to={to}
55
+ onClick={() => setOpen(false)}
56
+ className={({ isActive: a }) =>
57
+ `flex items-center gap-2 px-3 py-1.5 text-[12px] transition ${
58
+ a ? 'bg-[var(--c-bg3)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)] hover:bg-[var(--c-bg3)]'
59
+ }`
60
+ }
61
+ >
62
+ <SubIcon size={12} />
63
+ {subLabel}
64
+ </NavLink>
65
+ ))}
66
+ </div>
67
+ )}
68
+ </div>
69
+ )
70
+ }
71
+
72
+ export default function App() {
73
+ const [overview, setOverview] = useState(null)
74
+ const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
75
+ const { live, toggle: toggleLive } = useLive()
76
+ const [mode, setMode] = useState(null) // 'local' | 'relay'
77
+ const [needsAuth, setNeedsAuth] = useState(false)
78
+ const [authed, setAuthed] = useState(!!getAuthToken())
79
+ const liveRef = useRef(null)
80
+ const { dark, toggle } = useTheme()
81
+ const [mcpOpen, setMcpOpen] = useState(false)
82
+ const [mcpCopied, setMcpCopied] = useState(false)
83
+
84
+ useEffect(() => {
85
+ setOnAuthFailure(() => setAuthed(false))
86
+ }, [])
87
+
88
+ useEffect(() => {
89
+ fetchMode().then(data => {
90
+ setMode(data.mode || 'local')
91
+ setNeedsAuth(!!data.auth)
92
+ })
93
+ }, [])
94
+
95
+ const refreshOverview = useCallback(() => {
96
+ fetchOverview().then(setOverview).catch(() => {})
97
+ }, [])
98
+
99
+ useEffect(() => {
100
+ if (mode === 'local') refreshOverview()
101
+ }, [mode])
102
+
103
+ const rescanAndRefresh = useCallback(async (onProgress) => {
104
+ await refetchAgents(onProgress)
105
+ const data = await fetchOverview()
106
+ setOverview(data)
107
+ }, [])
108
+
109
+ // Live mode: rescan & refresh every 60s. `mode` must be a dependency: it
110
+ // starts as null (detection is async), so without it a persisted live=true
111
+ // never starts the interval once mode resolves to 'local'.
112
+ useEffect(() => {
113
+ if (live && mode === 'local') {
114
+ liveRef.current = setInterval(() => {
115
+ rescanAndRefresh().catch(() => {})
116
+ }, 60000)
117
+ } else {
118
+ if (liveRef.current) clearInterval(liveRef.current)
119
+ liveRef.current = null
120
+ }
121
+ return () => { if (liveRef.current) clearInterval(liveRef.current) }
122
+ }, [live, mode, rescanAndRefresh])
123
+
124
+ const handleRefetch = async () => {
125
+ setRefetchState({ scanned: 0, total: 0 })
126
+ try {
127
+ await rescanAndRefresh((p) => setRefetchState({ scanned: p.scanned, total: p.total }))
128
+ } catch (e) { console.error(e) }
129
+ setRefetchState(null)
130
+ }
131
+
132
+ const isRelay = mode === 'relay'
133
+ const showLogin = isRelay && needsAuth && !authed
134
+
135
+ const location = useLocation()
136
+ const isFullWidth = location.pathname === '/artifacts'
137
+
138
+ const nav = isRelay ? [
139
+ { to: '/', icon: Users, label: 'Team' },
140
+ ] : [
141
+ { to: '/', icon: Activity, label: 'Dashboard' },
142
+ { to: '/sessions', icon: MessageSquare, label: 'Sessions' },
143
+ { to: '/projects', icon: FolderOpen, label: 'Projects' },
144
+ { icon: DollarSign, label: 'Costs', children: [
145
+ { to: '/costs', icon: DollarSign, label: 'Cost Analysis' },
146
+ { to: '/subscriptions', icon: CreditCard, label: 'Subscriptions' },
147
+ ]},
148
+ { icon: BarChart3, label: 'Insights', children: [
149
+ { to: '/analysis', icon: BarChart3, label: 'Deep Analysis' },
150
+ { to: '/compare', icon: GitCompare, label: 'Compare' },
151
+ ]},
152
+ { to: '/artifacts', icon: Package, label: 'Artifacts' },
153
+ { to: '/gsd', icon: Target, label: 'GSD' },
154
+ { to: '/mcps', icon: Plug, label: 'MCPs' },
155
+ { to: '/sql', icon: Database, label: 'SQL' },
156
+ ]
157
+
158
+ if (showLogin) {
159
+ return <LoginScreen onSuccess={() => setAuthed(true)} />
160
+ }
161
+
162
+ return (
163
+ <div className="min-h-screen">
164
+ <header className="border-b px-4 py-1.5 flex items-center gap-3 sticky top-0 z-50 backdrop-blur-xl" style={{ borderColor: 'var(--c-border)', background: 'var(--c-header)' }}>
165
+ <span className="flex items-center gap-1.5 text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
166
+ <AnimatedLogo size={18} />
167
+ Agentlytics{isRelay && <span className="ml-1.5 text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>relay</span>}
168
+ </span>
169
+ <nav className="flex gap-0.5 ml-2">
170
+ {nav.map((item) => item.children ? (
171
+ <NavDropdown key={item.label} icon={item.icon} label={item.label} items={item.children} />
172
+ ) : (
173
+ <NavLink
174
+ key={item.to}
175
+ to={item.to}
176
+ end={item.to === '/'}
177
+ className={({ isActive }) =>
178
+ `flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
179
+ isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
180
+ }`
181
+ }
182
+ >
183
+ <item.icon size={12} />
184
+ {item.label}
185
+ </NavLink>
186
+ ))}
187
+ </nav>
188
+ <div className="ml-auto flex items-center gap-3">
189
+ {!isRelay && (
190
+ <>
191
+ <button
192
+ onClick={toggleLive}
193
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition"
194
+ style={{
195
+ color: live ? '#22c55e' : 'var(--c-text3)',
196
+ border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
197
+ background: live ? 'rgba(34,197,94,0.08)' : 'transparent',
198
+ }}
199
+ title={live ? 'Disable live refresh' : 'Enable live refresh (every 60s)'}
200
+ >
201
+ <span
202
+ className={`inline-block w-1.5 h-1.5 rounded-full ${live ? 'pulse-dot' : ''}`}
203
+ style={{ background: live ? '#22c55e' : 'var(--c-text3)' }}
204
+ />
205
+ Live
206
+ </button>
207
+ <button
208
+ onClick={handleRefetch}
209
+ disabled={!!refetchState}
210
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition hover:bg-[var(--c-card)]"
211
+ style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
212
+ title="Clear cache and rescan all editors"
213
+ >
214
+ <RefreshCw size={10} className={refetchState ? 'animate-spin' : ''} />
215
+ {refetchState
216
+ ? `Refetching (${refetchState.scanned}/${refetchState.total})...`
217
+ : 'Refetch'}
218
+ </button>
219
+ <span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
220
+ {overview ? `${overview.totalChats} sessions` : '...'}
221
+ </span>
222
+ </>
223
+ )}
224
+ {isRelay && (
225
+ <button
226
+ onClick={() => { setMcpOpen(true); setMcpCopied(false) }}
227
+ className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition hover:bg-[var(--c-card)]"
228
+ style={{ color: '#818cf8', border: '1px solid var(--c-border)' }}
229
+ title="MCP Connection"
230
+ >
231
+ <Plug size={10} />
232
+ Connect
233
+ </button>
234
+ )}
235
+ <NavLink
236
+ to="/settings"
237
+ className="p-1 rounded transition hover:bg-[var(--c-card)]"
238
+ style={({ isActive }) => ({ color: isActive ? '#6366f1' : 'var(--c-text2)' })}
239
+ title="Settings"
240
+ >
241
+ <SettingsIcon size={13} />
242
+ </NavLink>
243
+ <button
244
+ onClick={toggle}
245
+ className="p-1 rounded transition hover:bg-[var(--c-card)]"
246
+ style={{ color: 'var(--c-text2)' }}
247
+ title={dark ? 'Light mode' : 'Dark mode'}
248
+ >
249
+ {dark ? <Sun size={13} /> : <Moon size={13} />}
250
+ </button>
251
+ </div>
252
+ </header>
253
+
254
+ {refetchState && (
255
+ <div className="flex items-center gap-2 px-4 py-1.5 text-[12px]" style={{ background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.15)', color: '#ca8a04' }}>
256
+ <AlertTriangle size={12} />
257
+ <span>Devin, Devin Next, and Antigravity require their app to be running during refetch — otherwise their sessions won't be detected.</span>
258
+ </div>
259
+ )}
260
+
261
+ <main className={isRelay ? 'px-0' : isFullWidth ? 'p-0 overflow-hidden' : 'p-4 max-w-[1400px] mx-auto'}>
262
+ {mode === null ? (
263
+ <AnimatedLoader label="Loading..." />
264
+ ) : isRelay ? (
265
+ <Routes>
266
+ <Route path="/" element={<RelayDashboard />} />
267
+ <Route path="/relay" element={<RelayDashboard />} />
268
+ <Route path="/relay/user/:username" element={<RelayUserDetail />} />
269
+ </Routes>
270
+ ) : (
271
+ <Routes>
272
+ <Route path="/" element={<Dashboard overview={overview} />} />
273
+ <Route path="/projects" element={<Projects overview={overview} />} />
274
+ <Route path="/projects/detail" element={<ProjectDetail />} />
275
+ <Route path="/sessions" element={<Sessions overview={overview} />} />
276
+ {/* ChatDetail is now a sidebar in Sessions */}
277
+ <Route path="/costs" element={<CostAnalysis overview={overview} />} />
278
+ <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
279
+ <Route path="/compare" element={<Compare overview={overview} />} />
280
+ <Route path="/subscriptions" element={<Subscriptions />} />
281
+ <Route path="/artifacts" element={<Artifacts />} />
282
+ <Route path="/mcps" element={<MCPs />} />
283
+ <Route path="/sql" element={<SqlViewer />} />
284
+ <Route path="/gsd" element={<GSD />} />
285
+ <Route path="/settings" element={<Settings />} />
286
+ </Routes>
287
+ )}
288
+ </main>
289
+
290
+ <footer className={`border-t mt-8 px-4 py-3 flex items-center justify-between text-[11px]${isFullWidth ? ' hidden' : ''}`} style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
291
+ <div className="flex items-center gap-3">
292
+ <a href="https://github.com/f/agentlytics" target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-[var(--c-text)] transition">
293
+ <Github size={11} />
294
+ <span>GitHub</span>
295
+ </a>
296
+ <span className="flex items-center gap-1">
297
+ <Terminal size={11} />
298
+ <code style={{ fontFamily: 'JetBrains Mono, monospace' }}>npx agentlytics</code>
299
+ </span>
300
+ </div>
301
+ <span>
302
+ built by <a href="https://github.com/f" target="_blank" rel="noopener noreferrer" className="hover:text-[var(--c-text)] transition" style={{ color: 'var(--c-text2)' }}>fkadev</a>
303
+ </span>
304
+ </footer>
305
+
306
+ {/* MCP Config Modal */}
307
+ {mcpOpen && (
308
+ <>
309
+ <div className="fixed inset-0 z-[60]" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={() => setMcpOpen(false)} />
310
+ <div
311
+ className="fixed z-[70] w-[440px] max-w-[90vw] p-5 rounded shadow-2xl"
312
+ style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'var(--c-bg)', border: '1px solid var(--c-border)' }}
313
+ >
314
+ <div className="flex items-center justify-between mb-4">
315
+ <div className="text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
316
+ <Plug size={13} className="inline mr-1.5" style={{ color: '#818cf8' }} />
317
+ Connection Config
318
+ </div>
319
+ <button onClick={() => setMcpOpen(false)} className="text-[18px] leading-none px-1 hover:opacity-70 transition" style={{ color: 'var(--c-text3)' }}>&times;</button>
320
+ </div>
321
+
322
+ <div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
323
+ <div className="flex items-center justify-between mb-1">
324
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
325
+ <button
326
+ onClick={() => {
327
+ // Password-protected relays gate /mcp with the same bearer token the
328
+ // dashboard got at login — include it or the copied config 401s.
329
+ const relayToken = localStorage.getItem('agentlytics_relay_token')
330
+ const server = { url: `${window.location.origin}/mcp` }
331
+ if (relayToken) server.headers = { Authorization: `Bearer ${relayToken}` }
332
+ const json = JSON.stringify({ "mcpServers": { "agentlytics": server } }, null, 2)
333
+ navigator.clipboard.writeText(json)
334
+ setMcpCopied(true)
335
+ setTimeout(() => setMcpCopied(false), 2000)
336
+ }}
337
+ className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition hover:bg-[var(--c-bg3)]"
338
+ style={{ border: '1px solid var(--c-border)', color: mcpCopied ? '#22c55e' : 'var(--c-text2)' }}
339
+ >
340
+ {mcpCopied ? <><Check size={9} /> Copied</> : <><Copy size={9} /> Copy</>}
341
+ </button>
342
+ </div>
343
+ <pre
344
+ className="text-[11px] px-3 py-2 overflow-x-auto mb-4"
345
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
346
+ >{localStorage.getItem('agentlytics_relay_token')
347
+ ? `{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp",\n "headers": { "Authorization": "Bearer ${localStorage.getItem('agentlytics_relay_token')}" }\n }\n }\n}`
348
+ : `{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp"\n }\n }\n}`}</pre>
349
+
350
+ <div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Team Sharing</div>
351
+ <div className="text-[10px] mb-1" style={{ color: 'var(--c-text3)' }}>
352
+ Relay join is not supported in memhouse — team sharing is kernel-based
353
+ (members + grants + row-level security). See mem-house/delivery/kernel-install.md.
354
+ </div>
355
+ <pre
356
+ className="text-[11px] px-3 py-2 overflow-x-auto"
357
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
358
+ >{`# each member ships with their own credential:\nMEMHOUSE_URL=<realm-url> MEMHOUSE_USER=<member> MEMHOUSE_PASSWORD=<credential> \\\n memhouse ship --loop`}</pre>
359
+ </div>
360
+ </>
361
+ )}
362
+ </div>
363
+ )
364
+ }
@@ -0,0 +1,206 @@
1
+ import { useState, useMemo, useRef } from 'react'
2
+ import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from 'chart.js'
3
+ import { Line } from 'react-chartjs-2'
4
+ import { editorColor, editorLabel } from '../lib/constants'
5
+ import { useTheme } from '../lib/theme'
6
+
7
+ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
8
+
9
+ const CELL_SIZE = 11
10
+ const CELL_GAP = 2
11
+ const WEEK_COLS = 53
12
+ const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']
13
+
14
+ function getIntensity(count, maxCount) {
15
+ if (count === 0) return 0
16
+ if (maxCount <= 0) return 1
17
+ const ratio = count / maxCount
18
+ if (ratio > 0.75) return 4
19
+ if (ratio > 0.5) return 3
20
+ if (ratio > 0.25) return 2
21
+ return 1
22
+ }
23
+
24
+ const INTENSITY_COLORS_DARK = ['rgba(255,255,255,0.03)', '#0e4429', '#006d32', '#26a641', '#39d353']
25
+ const INTENSITY_COLORS_LIGHT = ['rgba(0,0,0,0.04)', '#9be9a8', '#40c463', '#30a14e', '#216e39']
26
+
27
+ export default function ActivityHeatmap({ dailyData }) {
28
+ const { dark } = useTheme()
29
+ const [selectedDay, setSelectedDay] = useState(null)
30
+ const containerRef = useRef(null)
31
+
32
+ // Build a full year grid (53 weeks × 7 days)
33
+ const grid = useMemo(() => {
34
+ if (!dailyData || dailyData.length === 0) return { weeks: [], months: [], maxCount: 0 }
35
+
36
+ const dayMap = {}
37
+ let maxCount = 0
38
+ for (const d of dailyData) {
39
+ dayMap[d.day] = d
40
+ if (d.total > maxCount) maxCount = d.total
41
+ }
42
+
43
+ // End on Saturday of the current week, start WEEK_COLS-1 weeks before
44
+ const today = new Date()
45
+ today.setHours(0, 0, 0, 0)
46
+ const start = new Date(today)
47
+ start.setDate(start.getDate() - start.getDay() - (WEEK_COLS - 1) * 7)
48
+
49
+ const weeks = []
50
+ const months = []
51
+ let lastMonth = -1
52
+ const cursor = new Date(start)
53
+
54
+ for (let w = 0; w < WEEK_COLS; w++) {
55
+ const week = []
56
+ for (let d = 0; d < 7; d++) {
57
+ const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
58
+ const data = dayMap[key] || null
59
+ const count = data ? data.total : 0
60
+ const isFuture = cursor > today
61
+
62
+ if (cursor.getMonth() !== lastMonth) {
63
+ lastMonth = cursor.getMonth()
64
+ months.push({ week: w, label: cursor.toLocaleString('default', { month: 'short' }) })
65
+ }
66
+
67
+ week.push({ key, count, data, isFuture, day: d })
68
+ cursor.setDate(cursor.getDate() + 1)
69
+ }
70
+ weeks.push(week)
71
+ }
72
+
73
+ return { weeks, months, maxCount }
74
+ }, [dailyData])
75
+
76
+ // Hourly drill-down for selected day
77
+ const hourlyChart = useMemo(() => {
78
+ if (!selectedDay?.data) return null
79
+ const hours = selectedDay.data.hours || {}
80
+ const editors = Object.keys(hours)
81
+ const labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
82
+
83
+ return {
84
+ labels,
85
+ datasets: editors.map(eid => ({
86
+ label: editorLabel(eid),
87
+ data: hours[eid],
88
+ borderColor: editorColor(eid),
89
+ backgroundColor: editorColor(eid) + '20',
90
+ borderWidth: 1.5,
91
+ tension: 0.3,
92
+ pointRadius: 1,
93
+ pointHoverRadius: 3,
94
+ fill: true,
95
+ })),
96
+ }
97
+ }, [selectedDay])
98
+
99
+ if (!grid.weeks.length) return null
100
+
101
+ const COLORS = dark ? INTENSITY_COLORS_DARK : INTENSITY_COLORS_LIGHT
102
+ const txtDim = dark ? '#555' : '#999'
103
+ const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
104
+ const legendColor = dark ? '#888' : '#555'
105
+ const svgWidth = WEEK_COLS * (CELL_SIZE + CELL_GAP) + 28
106
+ const svgHeight = 7 * (CELL_SIZE + CELL_GAP) + 20
107
+
108
+ return (
109
+ <div>
110
+ <div className="overflow-x-auto scrollbar-thin" ref={containerRef}>
111
+ <svg width={svgWidth} height={svgHeight} className="block">
112
+ {/* Month labels */}
113
+ {grid.months.map((m, i) => (
114
+ <text key={i} x={28 + m.week * (CELL_SIZE + CELL_GAP)} y={8} fill="var(--c-text3)" fontSize={8}>{m.label}</text>
115
+ ))}
116
+ {DAY_LABELS.map((label, i) => (
117
+ <text key={i} x={0} y={14 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 2} fill="var(--c-text3)" fontSize={8}>{label}</text>
118
+ ))}
119
+ {/* Cells */}
120
+ {grid.weeks.map((week, w) =>
121
+ week.map((cell, d) => {
122
+ if (cell.isFuture) return null
123
+ const intensity = getIntensity(cell.count, grid.maxCount)
124
+ const isSelected = selectedDay?.key === cell.key
125
+ return (
126
+ <rect
127
+ key={cell.key}
128
+ x={28 + w * (CELL_SIZE + CELL_GAP)}
129
+ y={12 + d * (CELL_SIZE + CELL_GAP)}
130
+ width={CELL_SIZE}
131
+ height={CELL_SIZE}
132
+ rx={0}
133
+ fill={COLORS[intensity]}
134
+ stroke={isSelected ? '#818cf8' : 'transparent'}
135
+ strokeWidth={isSelected ? 2 : 0}
136
+ className="cursor-pointer transition-all"
137
+ onClick={() => setSelectedDay(cell.count > 0 ? cell : null)}
138
+ >
139
+ <title>{`${cell.key}: ${cell.count} session${cell.count !== 1 ? 's' : ''}`}</title>
140
+ </rect>
141
+ )
142
+ })
143
+ )}
144
+ </svg>
145
+ </div>
146
+
147
+ <div className="flex items-center gap-1.5 mt-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
148
+ <span>less</span>
149
+ {COLORS.map((color, i) => (
150
+ <span key={i} className="inline-block w-[9px] h-[9px] rounded-sm" style={{ background: color }} />
151
+ ))}
152
+ <span>more</span>
153
+ </div>
154
+
155
+ {/* Drill-down: hourly activity for selected day */}
156
+ {selectedDay && selectedDay.data && (
157
+ <div className="mt-2 card p-3 fade-in">
158
+ <div className="flex items-center justify-between mb-2">
159
+ <div>
160
+ <span className="text-xs font-medium" style={{ color: 'var(--c-white)' }}>{selectedDay.key}</span>
161
+ <span className="text-[11px] ml-2" style={{ color: 'var(--c-text2)' }}>
162
+ {selectedDay.count} session{selectedDay.count !== 1 ? 's' : ''}
163
+ {' · '}
164
+ {Object.entries(selectedDay.data.editors || {}).map(([e, c]) => `${editorLabel(e)}: ${c}`).join(', ')}
165
+ </span>
166
+ </div>
167
+ <button onClick={() => setSelectedDay(null)} className="text-[11px] transition" style={{ color: 'var(--c-text2)' }}>close</button>
168
+ </div>
169
+ {hourlyChart && (
170
+ <div style={{ height: 140 }}>
171
+ <Line
172
+ data={hourlyChart}
173
+ options={{
174
+ responsive: true,
175
+ maintainAspectRatio: false,
176
+ interaction: { mode: 'index', intersect: false },
177
+ scales: {
178
+ x: {
179
+ grid: { color: gridColor },
180
+ ticks: { color: txtDim, font: { size: 9, family: 'JetBrains Mono, monospace' }, maxRotation: 0 },
181
+ },
182
+ y: {
183
+ beginAtZero: true,
184
+ grid: { color: gridColor },
185
+ ticks: { color: txtDim, stepSize: 1, font: { size: 9, family: 'JetBrains Mono, monospace' } },
186
+ },
187
+ },
188
+ plugins: {
189
+ legend: {
190
+ position: 'top',
191
+ labels: { color: legendColor, font: { size: 9, family: 'JetBrains Mono, monospace' }, usePointStyle: true, pointStyle: 'circle', padding: 8 },
192
+ },
193
+ tooltip: {
194
+ bodyFont: { family: 'JetBrains Mono, monospace', size: 10 },
195
+ titleFont: { family: 'JetBrains Mono, monospace', size: 10 },
196
+ },
197
+ },
198
+ }}
199
+ />
200
+ </div>
201
+ )}
202
+ </div>
203
+ )}
204
+ </div>
205
+ )
206
+ }