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,577 @@
1
+ const express = require('express');
2
+ const Database = require('better-sqlite3');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const fs = require('fs');
6
+ const crypto = require('crypto');
7
+
8
+ const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
9
+ const RELAY_DB_PATH = path.join(CACHE_DIR, 'relay.db');
10
+
11
+ let db = null;
12
+
13
+ // ============================================================
14
+ // Schema
15
+ // ============================================================
16
+
17
+ function initRelayDb() {
18
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
19
+ db = new Database(RELAY_DB_PATH);
20
+ db.pragma('journal_mode = WAL');
21
+ db.pragma('synchronous = NORMAL');
22
+
23
+ db.exec(`
24
+ CREATE TABLE IF NOT EXISTS users (
25
+ username TEXT PRIMARY KEY,
26
+ last_seen INTEGER,
27
+ projects TEXT DEFAULT '[]'
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS relay_chats (
31
+ id TEXT NOT NULL,
32
+ username TEXT NOT NULL,
33
+ source TEXT,
34
+ name TEXT,
35
+ mode TEXT,
36
+ folder TEXT,
37
+ created_at INTEGER,
38
+ last_updated_at INTEGER,
39
+ bubble_count INTEGER DEFAULT 0,
40
+ PRIMARY KEY (id, username)
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS relay_messages (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ chat_id TEXT NOT NULL,
46
+ username TEXT NOT NULL,
47
+ seq INTEGER,
48
+ role TEXT,
49
+ content TEXT,
50
+ model TEXT
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS relay_chat_stats (
54
+ chat_id TEXT NOT NULL,
55
+ username TEXT NOT NULL,
56
+ total_messages INTEGER DEFAULT 0,
57
+ user_messages INTEGER DEFAULT 0,
58
+ assistant_messages INTEGER DEFAULT 0,
59
+ tool_calls TEXT DEFAULT '[]',
60
+ models TEXT DEFAULT '[]',
61
+ total_input_tokens INTEGER DEFAULT 0,
62
+ total_output_tokens INTEGER DEFAULT 0,
63
+ PRIMARY KEY (chat_id, username)
64
+ );
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_relay_chats_username ON relay_chats(username);
67
+ CREATE INDEX IF NOT EXISTS idx_relay_chats_folder ON relay_chats(folder);
68
+ CREATE INDEX IF NOT EXISTS idx_relay_messages_chat ON relay_messages(chat_id, username);
69
+ CREATE INDEX IF NOT EXISTS idx_relay_messages_content ON relay_messages(content);
70
+ `);
71
+
72
+ return db;
73
+ }
74
+
75
+ function getRelayDb() {
76
+ return db;
77
+ }
78
+
79
+ // ============================================================
80
+ // Express app
81
+ // ============================================================
82
+
83
+ function createRelayApp() {
84
+ const app = express();
85
+ app.use(express.json({ limit: '50mb' }));
86
+ app.use(express.static(path.join(__dirname, 'public')));
87
+
88
+ // ── Password-based auth ──
89
+ const RELAY_PASSWORD = process.env.RELAY_PASSWORD || null;
90
+ const AUTH_TOKEN = RELAY_PASSWORD
91
+ ? crypto.createHmac('sha256', 'agentlytics-relay').update(RELAY_PASSWORD).digest('hex')
92
+ : null;
93
+
94
+ // Constant-time comparison — string === leaks match length through timing.
95
+ function tokenMatches(candidate) {
96
+ if (!AUTH_TOKEN || typeof candidate !== 'string') return false;
97
+ const a = Buffer.from(candidate);
98
+ const b = Buffer.from(AUTH_TOKEN);
99
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
100
+ }
101
+
102
+ function requireAuth(req, res, next) {
103
+ if (!AUTH_TOKEN) return next();
104
+ const header = req.headers.authorization || '';
105
+ const token = header.startsWith('Bearer ') ? header.slice(7) : null;
106
+ if (tokenMatches(token)) return next();
107
+ res.status(401).json({ error: 'Unauthorized' });
108
+ }
109
+
110
+ // ── Login endpoint ──
111
+ // Rate-limited: 10 failures per IP per 15 minutes, then locked out for the
112
+ // remainder of the window. In-memory — resets on restart, which is fine for
113
+ // slowing online guessing.
114
+ const loginFailures = new Map(); // ip -> { count, windowStart }
115
+ const LOGIN_WINDOW_MS = 15 * 60 * 1000;
116
+ const LOGIN_MAX_FAILURES = 10;
117
+
118
+ app.post('/api/login', (req, res) => {
119
+ if (!AUTH_TOKEN) return res.json({ token: null });
120
+ const ip = req.ip || 'unknown';
121
+ const now = Date.now();
122
+ let fail = loginFailures.get(ip);
123
+ if (fail && now - fail.windowStart > LOGIN_WINDOW_MS) fail = null;
124
+ if (fail && fail.count >= LOGIN_MAX_FAILURES) {
125
+ return res.status(429).json({ error: 'Too many attempts — try again later' });
126
+ }
127
+ const { password } = req.body || {};
128
+ if (!password) return res.status(400).json({ error: 'Password required' });
129
+ const attempt = crypto.createHmac('sha256', 'agentlytics-relay').update(String(password)).digest('hex');
130
+ if (tokenMatches(attempt)) { loginFailures.delete(ip); return res.json({ token: AUTH_TOKEN }); }
131
+ loginFailures.set(ip, fail ? { count: fail.count + 1, windowStart: fail.windowStart } : { count: 1, windowStart: now });
132
+ res.status(401).json({ error: 'Invalid password' });
133
+ });
134
+
135
+ // ── Mode detection for UI ──
136
+ app.get('/api/mode', (req, res) => {
137
+ res.json({ mode: 'relay', auth: !!AUTH_TOKEN });
138
+ });
139
+
140
+ // ── Config for UI ──
141
+ // Never return the raw password — a leaked token must not escalate to the
142
+ // reusable credential. The UI only needs to know whether auth is on.
143
+ app.get('/relay/config', requireAuth, (req, res) => {
144
+ res.json({ auth: !!AUTH_TOKEN });
145
+ });
146
+
147
+ // ── Team stats (aggregate across all users) ──
148
+ app.get('/relay/team-stats', requireAuth, (req, res) => {
149
+ try {
150
+ const users = db.prepare('SELECT username, last_seen, projects FROM users ORDER BY last_seen DESC').all();
151
+ const totalUsers = users.length;
152
+
153
+ const chatStats = db.prepare(`
154
+ SELECT COUNT(*) as totalSessions,
155
+ COUNT(DISTINCT rc.username) as activeUsers,
156
+ COUNT(DISTINCT rc.folder) as totalProjects
157
+ FROM relay_chats rc
158
+ `).get();
159
+
160
+ const editorBreakdown = db.prepare(`
161
+ SELECT source, COUNT(*) as count, COUNT(DISTINCT username) as users
162
+ FROM relay_chats WHERE source IS NOT NULL
163
+ GROUP BY source ORDER BY count DESC
164
+ `).all();
165
+
166
+ const perUser = db.prepare(`
167
+ SELECT rc.username,
168
+ COUNT(*) as sessions,
169
+ COUNT(DISTINCT rc.source) as editors,
170
+ COUNT(DISTINCT rc.folder) as projects,
171
+ MAX(rc.last_updated_at) as lastActive,
172
+ COALESCE(SUM(rcs.total_messages), 0) as totalMessages,
173
+ COALESCE(SUM(rcs.total_input_tokens), 0) as totalInputTokens,
174
+ COALESCE(SUM(rcs.total_output_tokens), 0) as totalOutputTokens
175
+ FROM relay_chats rc
176
+ LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
177
+ GROUP BY rc.username
178
+ ORDER BY sessions DESC
179
+ `).all();
180
+
181
+ const perUserEditors = db.prepare(`
182
+ SELECT username, source, COUNT(*) as count
183
+ FROM relay_chats WHERE source IS NOT NULL
184
+ GROUP BY username, source
185
+ `).all();
186
+ const userEditorMap = {};
187
+ for (const r of perUserEditors) {
188
+ if (!userEditorMap[r.username]) userEditorMap[r.username] = {};
189
+ userEditorMap[r.username][r.source] = r.count;
190
+ }
191
+
192
+ const perUserModels = db.prepare(`
193
+ SELECT rcs.username, rcs.models
194
+ FROM relay_chat_stats rcs
195
+ `).all();
196
+ const userModelMap = {};
197
+ for (const r of perUserModels) {
198
+ if (!userModelMap[r.username]) userModelMap[r.username] = {};
199
+ try {
200
+ for (const m of JSON.parse(r.models || '[]')) {
201
+ userModelMap[r.username][m] = (userModelMap[r.username][m] || 0) + 1;
202
+ }
203
+ } catch {}
204
+ }
205
+
206
+ const totalTokens = db.prepare(`
207
+ SELECT COALESCE(SUM(total_messages), 0) as messages,
208
+ COALESCE(SUM(total_input_tokens), 0) as inputTokens,
209
+ COALESCE(SUM(total_output_tokens), 0) as outputTokens
210
+ FROM relay_chat_stats
211
+ `).get();
212
+
213
+ const modelBreakdown = db.prepare('SELECT models FROM relay_chat_stats').all();
214
+ const modelFreq = {};
215
+ for (const r of modelBreakdown) {
216
+ try { for (const m of JSON.parse(r.models || '[]')) modelFreq[m] = (modelFreq[m] || 0) + 1; } catch {}
217
+ }
218
+
219
+ res.json({
220
+ totalUsers,
221
+ totalSessions: chatStats.totalSessions,
222
+ activeUsers: chatStats.activeUsers,
223
+ totalProjects: chatStats.totalProjects,
224
+ totalMessages: totalTokens.messages,
225
+ totalInputTokens: totalTokens.inputTokens,
226
+ totalOutputTokens: totalTokens.outputTokens,
227
+ editors: editorBreakdown.map(e => ({ source: e.source, count: e.count, users: e.users })),
228
+ topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 15).map(([name, count]) => ({ name, count })),
229
+ users: perUser.map(u => ({
230
+ username: u.username,
231
+ sessions: u.sessions,
232
+ editors: userEditorMap[u.username] || {},
233
+ projects: u.projects,
234
+ lastActive: u.lastActive,
235
+ totalMessages: u.totalMessages,
236
+ totalInputTokens: u.totalInputTokens,
237
+ totalOutputTokens: u.totalOutputTokens,
238
+ topModels: Object.entries(userModelMap[u.username] || {}).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, count]) => ({ name, count })),
239
+ sharedProjects: JSON.parse((users.find(x => x.username === u.username) || {}).projects || '[]'),
240
+ })),
241
+ });
242
+ } catch (err) {
243
+ res.status(500).json({ error: err.message });
244
+ }
245
+ });
246
+
247
+ // ── Health check ──
248
+ app.get('/relay/health', requireAuth, (req, res) => {
249
+ res.json({ ok: true, users: db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt });
250
+ });
251
+
252
+ // ── List connected users ──
253
+ app.get('/relay/users', requireAuth, (req, res) => {
254
+ try {
255
+ const users = db.prepare('SELECT username, last_seen, projects FROM users ORDER BY last_seen DESC').all();
256
+ res.json(users.map(u => ({
257
+ username: u.username,
258
+ lastSeen: u.last_seen,
259
+ projects: JSON.parse(u.projects || '[]'),
260
+ })));
261
+ } catch (err) {
262
+ res.status(500).json({ error: err.message });
263
+ }
264
+ });
265
+
266
+ // ── Sync endpoint — receives data from join clients ──
267
+ app.post('/relay/sync', requireAuth, (req, res) => {
268
+ try {
269
+ const { username, projects, chats, messages, stats } = req.body;
270
+ if (!username) return res.status(400).json({ error: 'username required' });
271
+
272
+ // Upsert user
273
+ db.prepare(`
274
+ INSERT INTO users (username, last_seen, projects)
275
+ VALUES (?, ?, ?)
276
+ ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, projects = excluded.projects
277
+ `).run(username, Date.now(), JSON.stringify(projects || []));
278
+
279
+ let syncedChats = 0;
280
+ let syncedMessages = 0;
281
+ let syncedStats = 0;
282
+
283
+ // Upsert chats
284
+ if (chats && chats.length > 0) {
285
+ const insChat = db.prepare(`
286
+ INSERT INTO relay_chats (id, username, source, name, mode, folder, created_at, last_updated_at, bubble_count)
287
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
288
+ ON CONFLICT(id, username) DO UPDATE SET
289
+ name = excluded.name, mode = excluded.mode, folder = excluded.folder,
290
+ last_updated_at = excluded.last_updated_at, bubble_count = excluded.bubble_count
291
+ `);
292
+ const insertChats = db.transaction((chatList) => {
293
+ for (const c of chatList) {
294
+ insChat.run(c.id, username, c.source, c.name, c.mode, c.folder, c.created_at, c.last_updated_at, c.bubble_count || 0);
295
+ syncedChats++;
296
+ }
297
+ });
298
+ insertChats(chats);
299
+ }
300
+
301
+ // Upsert messages (delete + reinsert per chat)
302
+ if (messages && messages.length > 0) {
303
+ const chatIds = [...new Set(messages.map(m => m.chat_id))];
304
+ const delMsgs = db.prepare('DELETE FROM relay_messages WHERE chat_id = ? AND username = ?');
305
+ const insMsg = db.prepare('INSERT INTO relay_messages (chat_id, username, seq, role, content, model) VALUES (?, ?, ?, ?, ?, ?)');
306
+ const insertMessages = db.transaction((msgList) => {
307
+ for (const cid of chatIds) {
308
+ delMsgs.run(cid, username);
309
+ }
310
+ for (const m of msgList) {
311
+ insMsg.run(m.chat_id, username, m.seq, m.role, m.content, m.model);
312
+ syncedMessages++;
313
+ }
314
+ });
315
+ insertMessages(messages);
316
+ }
317
+
318
+ // Upsert stats
319
+ if (stats && stats.length > 0) {
320
+ const insStat = db.prepare(`
321
+ INSERT INTO relay_chat_stats (chat_id, username, total_messages, user_messages, assistant_messages, tool_calls, models, total_input_tokens, total_output_tokens)
322
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
323
+ ON CONFLICT(chat_id, username) DO UPDATE SET
324
+ total_messages = excluded.total_messages, user_messages = excluded.user_messages,
325
+ assistant_messages = excluded.assistant_messages, tool_calls = excluded.tool_calls,
326
+ models = excluded.models, total_input_tokens = excluded.total_input_tokens,
327
+ total_output_tokens = excluded.total_output_tokens
328
+ `);
329
+ const insertStats = db.transaction((statList) => {
330
+ for (const s of statList) {
331
+ insStat.run(s.chat_id, username, s.total_messages, s.user_messages, s.assistant_messages,
332
+ JSON.stringify(s.tool_calls || []), JSON.stringify(s.models || []),
333
+ s.total_input_tokens, s.total_output_tokens);
334
+ syncedStats++;
335
+ }
336
+ });
337
+ insertStats(stats);
338
+ }
339
+
340
+ res.json({ ok: true, synced: { chats: syncedChats, messages: syncedMessages, stats: syncedStats } });
341
+ } catch (err) {
342
+ res.status(500).json({ error: err.message });
343
+ }
344
+ });
345
+
346
+ // ── Search messages across all users ──
347
+ app.get('/relay/search', requireAuth, (req, res) => {
348
+ try {
349
+ const { q, username, folder, limit } = req.query;
350
+ if (!q) return res.status(400).json({ error: 'q (query) required' });
351
+
352
+ let sql = `
353
+ SELECT rm.chat_id, rm.username, rm.role, rm.content, rm.model, rm.seq,
354
+ rc.name as chat_name, rc.source, rc.folder
355
+ FROM relay_messages rm
356
+ JOIN relay_chats rc ON rm.chat_id = rc.id AND rm.username = rc.username
357
+ WHERE rm.content LIKE ?`;
358
+ const params = [`%${q}%`];
359
+
360
+ if (username) { sql += ' AND rm.username = ?'; params.push(username); }
361
+ if (folder) { sql += ' AND rc.folder LIKE ?'; params.push(`%${folder}%`); }
362
+ sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
363
+ params.push(parseInt(limit) || 50);
364
+
365
+ const rows = db.prepare(sql).all(...params);
366
+ res.json(rows.map(r => ({
367
+ chatId: r.chat_id,
368
+ username: r.username,
369
+ role: r.role,
370
+ content: r.content.length > 500 ? r.content.substring(0, 500) + '...' : r.content,
371
+ model: r.model,
372
+ seq: r.seq,
373
+ chatName: r.chat_name,
374
+ source: r.source,
375
+ folder: r.folder,
376
+ })));
377
+ } catch (err) {
378
+ res.status(500).json({ error: err.message });
379
+ }
380
+ });
381
+
382
+ // ── Get user activity ──
383
+ app.get('/relay/activity/:username', requireAuth, (req, res) => {
384
+ try {
385
+ const { username } = req.params;
386
+ const { folder, limit } = req.query;
387
+
388
+ let sql = `
389
+ SELECT rc.*, rcs.total_messages, rcs.models, rcs.tool_calls,
390
+ rcs.total_input_tokens, rcs.total_output_tokens
391
+ FROM relay_chats rc
392
+ LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
393
+ WHERE rc.username = ?`;
394
+ const params = [username];
395
+
396
+ if (folder) { sql += ' AND rc.folder LIKE ?'; params.push(`%${folder}%`); }
397
+ sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
398
+ params.push(parseInt(limit) || 50);
399
+
400
+ const rows = db.prepare(sql).all(...params);
401
+ res.json(rows.map(r => ({
402
+ id: r.id,
403
+ username: r.username,
404
+ source: r.source,
405
+ name: r.name,
406
+ mode: r.mode,
407
+ folder: r.folder,
408
+ createdAt: r.created_at,
409
+ lastUpdatedAt: r.last_updated_at,
410
+ totalMessages: r.total_messages,
411
+ models: r.models ? JSON.parse(r.models) : [],
412
+ toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : [],
413
+ totalInputTokens: r.total_input_tokens,
414
+ totalOutputTokens: r.total_output_tokens,
415
+ })));
416
+ } catch (err) {
417
+ res.status(500).json({ error: err.message });
418
+ }
419
+ });
420
+
421
+ // ── Get session detail ──
422
+ app.get('/relay/session/:chatId', requireAuth, (req, res) => {
423
+ try {
424
+ const { chatId } = req.params;
425
+ const { username } = req.query;
426
+
427
+ let chatSql = 'SELECT * FROM relay_chats WHERE id = ?';
428
+ const chatParams = [chatId];
429
+ if (username) { chatSql += ' AND username = ?'; chatParams.push(username); }
430
+ chatSql += ' LIMIT 1';
431
+
432
+ const chat = db.prepare(chatSql).get(...chatParams);
433
+ if (!chat) return res.status(404).json({ error: 'Session not found' });
434
+
435
+ const messages = db.prepare(
436
+ 'SELECT seq, role, content, model FROM relay_messages WHERE chat_id = ? AND username = ? ORDER BY seq'
437
+ ).all(chat.id, chat.username);
438
+
439
+ const stats = db.prepare(
440
+ 'SELECT * FROM relay_chat_stats WHERE chat_id = ? AND username = ?'
441
+ ).get(chat.id, chat.username);
442
+
443
+ res.json({
444
+ id: chat.id,
445
+ username: chat.username,
446
+ source: chat.source,
447
+ name: chat.name,
448
+ mode: chat.mode,
449
+ folder: chat.folder,
450
+ createdAt: chat.created_at,
451
+ lastUpdatedAt: chat.last_updated_at,
452
+ messages,
453
+ stats: stats ? {
454
+ totalMessages: stats.total_messages,
455
+ models: JSON.parse(stats.models || '[]'),
456
+ toolCalls: JSON.parse(stats.tool_calls || '[]'),
457
+ totalInputTokens: stats.total_input_tokens,
458
+ totalOutputTokens: stats.total_output_tokens,
459
+ } : null,
460
+ });
461
+ } catch (err) {
462
+ res.status(500).json({ error: err.message });
463
+ }
464
+ });
465
+
466
+ // ── Live feed — recent activity timeline ──
467
+ app.get('/relay/feed', requireAuth, (req, res) => {
468
+ try {
469
+ const limit = parseInt(req.query.limit) || 60;
470
+ const since = parseInt(req.query.since) || 0;
471
+
472
+ let sql = `
473
+ SELECT rc.id, rc.username, rc.source, rc.name, rc.mode, rc.folder,
474
+ rc.last_updated_at, rc.created_at,
475
+ rcs.total_messages, rcs.models, rcs.total_input_tokens, rcs.total_output_tokens
476
+ FROM relay_chats rc
477
+ LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
478
+ WHERE rc.last_updated_at > ?
479
+ ORDER BY rc.last_updated_at DESC
480
+ LIMIT ?
481
+ `;
482
+ const rows = db.prepare(sql).all(since, limit);
483
+
484
+ res.json(rows.map(r => ({
485
+ id: r.id,
486
+ username: r.username,
487
+ source: r.source,
488
+ name: r.name,
489
+ mode: r.mode,
490
+ folder: r.folder,
491
+ lastUpdatedAt: r.last_updated_at,
492
+ createdAt: r.created_at,
493
+ totalMessages: r.total_messages || 0,
494
+ models: r.models ? JSON.parse(r.models) : [],
495
+ totalInputTokens: r.total_input_tokens || 0,
496
+ totalOutputTokens: r.total_output_tokens || 0,
497
+ })));
498
+ } catch (err) {
499
+ res.status(500).json({ error: err.message });
500
+ }
501
+ });
502
+
503
+ // ── Merge users ──────────────────────────────────────────────
504
+ app.post('/relay/merge-users', requireAuth, (req, res) => {
505
+ try {
506
+ const { from, to } = req.body;
507
+ if (!from || !to) return res.status(400).json({ error: 'Both "from" and "to" usernames are required.' });
508
+ if (from === to) return res.status(400).json({ error: '"from" and "to" cannot be the same.' });
509
+
510
+ const result = db.transaction(() => {
511
+ // 1. Find conflicting chat IDs (exist for both users)
512
+ const conflicts = db.prepare(`
513
+ SELECT a.id FROM relay_chats a
514
+ INNER JOIN relay_chats b ON a.id = b.id
515
+ WHERE a.username = ? AND b.username = ?
516
+ `).all(from, to).map(r => r.id);
517
+
518
+ // 2. For conflicting chats: keep the "to" user's row, delete the "from" row
519
+ if (conflicts.length > 0) {
520
+ const placeholders = conflicts.map(() => '?').join(',');
521
+
522
+ // Delete conflicting messages from "from" user
523
+ db.prepare(`DELETE FROM relay_messages WHERE username = ? AND chat_id IN (${placeholders})`).run(from, ...conflicts);
524
+
525
+ // Delete conflicting chat stats from "from" user
526
+ db.prepare(`DELETE FROM relay_chat_stats WHERE username = ? AND chat_id IN (${placeholders})`).run(from, ...conflicts);
527
+
528
+ // Delete conflicting chats from "from" user
529
+ db.prepare(`DELETE FROM relay_chats WHERE username = ? AND id IN (${placeholders})`).run(from, ...conflicts);
530
+ }
531
+
532
+ // 3. Move remaining non-conflicting data from "from" → "to"
533
+ const movedChats = db.prepare(`UPDATE relay_chats SET username = ? WHERE username = ?`).run(to, from).changes;
534
+ const movedMessages = db.prepare(`UPDATE relay_messages SET username = ? WHERE username = ?`).run(to, from).changes;
535
+ const movedStats = db.prepare(`UPDATE relay_chat_stats SET username = ? WHERE username = ?`).run(to, from).changes;
536
+
537
+ // 4. Merge user record: update projects, remove old user
538
+ const fromUser = db.prepare(`SELECT projects FROM users WHERE username = ?`).get(from);
539
+ const toUser = db.prepare(`SELECT projects FROM users WHERE username = ?`).get(to);
540
+
541
+ if (fromUser && toUser) {
542
+ const fromProjects = JSON.parse(fromUser.projects || '[]');
543
+ const toProjects = JSON.parse(toUser.projects || '[]');
544
+ const merged = [...new Set([...toProjects, ...fromProjects])];
545
+ db.prepare(`UPDATE users SET projects = ? WHERE username = ?`).run(JSON.stringify(merged), to);
546
+ }
547
+
548
+ db.prepare(`DELETE FROM users WHERE username = ?`).run(from);
549
+
550
+ return { movedChats, movedMessages, movedStats, conflicts: conflicts.length };
551
+ })();
552
+
553
+ res.json({
554
+ ok: true,
555
+ merged: { from, to },
556
+ moved: { chats: result.movedChats, messages: result.movedMessages, stats: result.movedStats },
557
+ duplicatesSkipped: result.conflicts,
558
+ });
559
+ } catch (err) {
560
+ res.status(500).json({ error: err.message });
561
+ }
562
+ });
563
+
564
+ // SPA fallback
565
+ app.get('*', (req, res) => {
566
+ const index = path.join(__dirname, 'public', 'index.html');
567
+ if (fs.existsSync(index)) {
568
+ res.sendFile(index);
569
+ } else {
570
+ res.status(404).send('UI not built. Run: cd ui && npm install && npm run build (or use your preferred package manager)');
571
+ }
572
+ });
573
+
574
+ return app;
575
+ }
576
+
577
+ module.exports = { initRelayDb, getRelayDb, createRelayApp };