agentlytics 0.0.10 → 0.1.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.
@@ -0,0 +1,552 @@
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
+ function requireAuth(req, res, next) {
95
+ if (!AUTH_TOKEN) return next();
96
+ const header = req.headers.authorization || '';
97
+ const token = header.startsWith('Bearer ') ? header.slice(7) : null;
98
+ if (token === AUTH_TOKEN) return next();
99
+ res.status(401).json({ error: 'Unauthorized' });
100
+ }
101
+
102
+ // ── Login endpoint ──
103
+ app.post('/api/login', (req, res) => {
104
+ if (!AUTH_TOKEN) return res.json({ token: null });
105
+ const { password } = req.body || {};
106
+ if (!password) return res.status(400).json({ error: 'Password required' });
107
+ const attempt = crypto.createHmac('sha256', 'agentlytics-relay').update(password).digest('hex');
108
+ if (attempt === AUTH_TOKEN) return res.json({ token: AUTH_TOKEN });
109
+ res.status(401).json({ error: 'Invalid password' });
110
+ });
111
+
112
+ // ── Mode detection for UI ──
113
+ app.get('/api/mode', (req, res) => {
114
+ res.json({ mode: 'relay', auth: !!AUTH_TOKEN });
115
+ });
116
+
117
+ // ── Config for UI ──
118
+ app.get('/relay/config', requireAuth, (req, res) => {
119
+ res.json({ relayPassword: RELAY_PASSWORD || '' });
120
+ });
121
+
122
+ // ── Team stats (aggregate across all users) ──
123
+ app.get('/relay/team-stats', requireAuth, (req, res) => {
124
+ try {
125
+ const users = db.prepare('SELECT username, last_seen, projects FROM users ORDER BY last_seen DESC').all();
126
+ const totalUsers = users.length;
127
+
128
+ const chatStats = db.prepare(`
129
+ SELECT COUNT(*) as totalSessions,
130
+ COUNT(DISTINCT rc.username) as activeUsers,
131
+ COUNT(DISTINCT rc.folder) as totalProjects
132
+ FROM relay_chats rc
133
+ `).get();
134
+
135
+ const editorBreakdown = db.prepare(`
136
+ SELECT source, COUNT(*) as count, COUNT(DISTINCT username) as users
137
+ FROM relay_chats WHERE source IS NOT NULL
138
+ GROUP BY source ORDER BY count DESC
139
+ `).all();
140
+
141
+ const perUser = db.prepare(`
142
+ SELECT rc.username,
143
+ COUNT(*) as sessions,
144
+ COUNT(DISTINCT rc.source) as editors,
145
+ COUNT(DISTINCT rc.folder) as projects,
146
+ MAX(rc.last_updated_at) as lastActive,
147
+ COALESCE(SUM(rcs.total_messages), 0) as totalMessages,
148
+ COALESCE(SUM(rcs.total_input_tokens), 0) as totalInputTokens,
149
+ COALESCE(SUM(rcs.total_output_tokens), 0) as totalOutputTokens
150
+ FROM relay_chats rc
151
+ LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
152
+ GROUP BY rc.username
153
+ ORDER BY sessions DESC
154
+ `).all();
155
+
156
+ const perUserEditors = db.prepare(`
157
+ SELECT username, source, COUNT(*) as count
158
+ FROM relay_chats WHERE source IS NOT NULL
159
+ GROUP BY username, source
160
+ `).all();
161
+ const userEditorMap = {};
162
+ for (const r of perUserEditors) {
163
+ if (!userEditorMap[r.username]) userEditorMap[r.username] = {};
164
+ userEditorMap[r.username][r.source] = r.count;
165
+ }
166
+
167
+ const perUserModels = db.prepare(`
168
+ SELECT rcs.username, rcs.models
169
+ FROM relay_chat_stats rcs
170
+ `).all();
171
+ const userModelMap = {};
172
+ for (const r of perUserModels) {
173
+ if (!userModelMap[r.username]) userModelMap[r.username] = {};
174
+ try {
175
+ for (const m of JSON.parse(r.models || '[]')) {
176
+ userModelMap[r.username][m] = (userModelMap[r.username][m] || 0) + 1;
177
+ }
178
+ } catch {}
179
+ }
180
+
181
+ const totalTokens = db.prepare(`
182
+ SELECT COALESCE(SUM(total_messages), 0) as messages,
183
+ COALESCE(SUM(total_input_tokens), 0) as inputTokens,
184
+ COALESCE(SUM(total_output_tokens), 0) as outputTokens
185
+ FROM relay_chat_stats
186
+ `).get();
187
+
188
+ const modelBreakdown = db.prepare('SELECT models FROM relay_chat_stats').all();
189
+ const modelFreq = {};
190
+ for (const r of modelBreakdown) {
191
+ try { for (const m of JSON.parse(r.models || '[]')) modelFreq[m] = (modelFreq[m] || 0) + 1; } catch {}
192
+ }
193
+
194
+ res.json({
195
+ totalUsers,
196
+ totalSessions: chatStats.totalSessions,
197
+ activeUsers: chatStats.activeUsers,
198
+ totalProjects: chatStats.totalProjects,
199
+ totalMessages: totalTokens.messages,
200
+ totalInputTokens: totalTokens.inputTokens,
201
+ totalOutputTokens: totalTokens.outputTokens,
202
+ editors: editorBreakdown.map(e => ({ source: e.source, count: e.count, users: e.users })),
203
+ topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 15).map(([name, count]) => ({ name, count })),
204
+ users: perUser.map(u => ({
205
+ username: u.username,
206
+ sessions: u.sessions,
207
+ editors: userEditorMap[u.username] || {},
208
+ projects: u.projects,
209
+ lastActive: u.lastActive,
210
+ totalMessages: u.totalMessages,
211
+ totalInputTokens: u.totalInputTokens,
212
+ totalOutputTokens: u.totalOutputTokens,
213
+ topModels: Object.entries(userModelMap[u.username] || {}).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, count]) => ({ name, count })),
214
+ sharedProjects: JSON.parse((users.find(x => x.username === u.username) || {}).projects || '[]'),
215
+ })),
216
+ });
217
+ } catch (err) {
218
+ res.status(500).json({ error: err.message });
219
+ }
220
+ });
221
+
222
+ // ── Health check ──
223
+ app.get('/relay/health', requireAuth, (req, res) => {
224
+ res.json({ ok: true, users: db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt });
225
+ });
226
+
227
+ // ── List connected users ──
228
+ app.get('/relay/users', requireAuth, (req, res) => {
229
+ try {
230
+ const users = db.prepare('SELECT username, last_seen, projects FROM users ORDER BY last_seen DESC').all();
231
+ res.json(users.map(u => ({
232
+ username: u.username,
233
+ lastSeen: u.last_seen,
234
+ projects: JSON.parse(u.projects || '[]'),
235
+ })));
236
+ } catch (err) {
237
+ res.status(500).json({ error: err.message });
238
+ }
239
+ });
240
+
241
+ // ── Sync endpoint — receives data from join clients ──
242
+ app.post('/relay/sync', requireAuth, (req, res) => {
243
+ try {
244
+ const { username, projects, chats, messages, stats } = req.body;
245
+ if (!username) return res.status(400).json({ error: 'username required' });
246
+
247
+ // Upsert user
248
+ db.prepare(`
249
+ INSERT INTO users (username, last_seen, projects)
250
+ VALUES (?, ?, ?)
251
+ ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, projects = excluded.projects
252
+ `).run(username, Date.now(), JSON.stringify(projects || []));
253
+
254
+ let syncedChats = 0;
255
+ let syncedMessages = 0;
256
+ let syncedStats = 0;
257
+
258
+ // Upsert chats
259
+ if (chats && chats.length > 0) {
260
+ const insChat = db.prepare(`
261
+ INSERT INTO relay_chats (id, username, source, name, mode, folder, created_at, last_updated_at, bubble_count)
262
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
263
+ ON CONFLICT(id, username) DO UPDATE SET
264
+ name = excluded.name, mode = excluded.mode, folder = excluded.folder,
265
+ last_updated_at = excluded.last_updated_at, bubble_count = excluded.bubble_count
266
+ `);
267
+ const insertChats = db.transaction((chatList) => {
268
+ for (const c of chatList) {
269
+ insChat.run(c.id, username, c.source, c.name, c.mode, c.folder, c.created_at, c.last_updated_at, c.bubble_count || 0);
270
+ syncedChats++;
271
+ }
272
+ });
273
+ insertChats(chats);
274
+ }
275
+
276
+ // Upsert messages (delete + reinsert per chat)
277
+ if (messages && messages.length > 0) {
278
+ const chatIds = [...new Set(messages.map(m => m.chat_id))];
279
+ const delMsgs = db.prepare('DELETE FROM relay_messages WHERE chat_id = ? AND username = ?');
280
+ const insMsg = db.prepare('INSERT INTO relay_messages (chat_id, username, seq, role, content, model) VALUES (?, ?, ?, ?, ?, ?)');
281
+ const insertMessages = db.transaction((msgList) => {
282
+ for (const cid of chatIds) {
283
+ delMsgs.run(cid, username);
284
+ }
285
+ for (const m of msgList) {
286
+ insMsg.run(m.chat_id, username, m.seq, m.role, m.content, m.model);
287
+ syncedMessages++;
288
+ }
289
+ });
290
+ insertMessages(messages);
291
+ }
292
+
293
+ // Upsert stats
294
+ if (stats && stats.length > 0) {
295
+ const insStat = db.prepare(`
296
+ INSERT INTO relay_chat_stats (chat_id, username, total_messages, user_messages, assistant_messages, tool_calls, models, total_input_tokens, total_output_tokens)
297
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
298
+ ON CONFLICT(chat_id, username) DO UPDATE SET
299
+ total_messages = excluded.total_messages, user_messages = excluded.user_messages,
300
+ assistant_messages = excluded.assistant_messages, tool_calls = excluded.tool_calls,
301
+ models = excluded.models, total_input_tokens = excluded.total_input_tokens,
302
+ total_output_tokens = excluded.total_output_tokens
303
+ `);
304
+ const insertStats = db.transaction((statList) => {
305
+ for (const s of statList) {
306
+ insStat.run(s.chat_id, username, s.total_messages, s.user_messages, s.assistant_messages,
307
+ JSON.stringify(s.tool_calls || []), JSON.stringify(s.models || []),
308
+ s.total_input_tokens, s.total_output_tokens);
309
+ syncedStats++;
310
+ }
311
+ });
312
+ insertStats(stats);
313
+ }
314
+
315
+ res.json({ ok: true, synced: { chats: syncedChats, messages: syncedMessages, stats: syncedStats } });
316
+ } catch (err) {
317
+ res.status(500).json({ error: err.message });
318
+ }
319
+ });
320
+
321
+ // ── Search messages across all users ──
322
+ app.get('/relay/search', requireAuth, (req, res) => {
323
+ try {
324
+ const { q, username, folder, limit } = req.query;
325
+ if (!q) return res.status(400).json({ error: 'q (query) required' });
326
+
327
+ let sql = `
328
+ SELECT rm.chat_id, rm.username, rm.role, rm.content, rm.model, rm.seq,
329
+ rc.name as chat_name, rc.source, rc.folder
330
+ FROM relay_messages rm
331
+ JOIN relay_chats rc ON rm.chat_id = rc.id AND rm.username = rc.username
332
+ WHERE rm.content LIKE ?`;
333
+ const params = [`%${q}%`];
334
+
335
+ if (username) { sql += ' AND rm.username = ?'; params.push(username); }
336
+ if (folder) { sql += ' AND rc.folder LIKE ?'; params.push(`%${folder}%`); }
337
+ sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
338
+ params.push(parseInt(limit) || 50);
339
+
340
+ const rows = db.prepare(sql).all(...params);
341
+ res.json(rows.map(r => ({
342
+ chatId: r.chat_id,
343
+ username: r.username,
344
+ role: r.role,
345
+ content: r.content.length > 500 ? r.content.substring(0, 500) + '...' : r.content,
346
+ model: r.model,
347
+ seq: r.seq,
348
+ chatName: r.chat_name,
349
+ source: r.source,
350
+ folder: r.folder,
351
+ })));
352
+ } catch (err) {
353
+ res.status(500).json({ error: err.message });
354
+ }
355
+ });
356
+
357
+ // ── Get user activity ──
358
+ app.get('/relay/activity/:username', requireAuth, (req, res) => {
359
+ try {
360
+ const { username } = req.params;
361
+ const { folder, limit } = req.query;
362
+
363
+ let sql = `
364
+ SELECT rc.*, rcs.total_messages, rcs.models, rcs.tool_calls,
365
+ rcs.total_input_tokens, rcs.total_output_tokens
366
+ FROM relay_chats rc
367
+ LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
368
+ WHERE rc.username = ?`;
369
+ const params = [username];
370
+
371
+ if (folder) { sql += ' AND rc.folder LIKE ?'; params.push(`%${folder}%`); }
372
+ sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
373
+ params.push(parseInt(limit) || 50);
374
+
375
+ const rows = db.prepare(sql).all(...params);
376
+ res.json(rows.map(r => ({
377
+ id: r.id,
378
+ username: r.username,
379
+ source: r.source,
380
+ name: r.name,
381
+ mode: r.mode,
382
+ folder: r.folder,
383
+ createdAt: r.created_at,
384
+ lastUpdatedAt: r.last_updated_at,
385
+ totalMessages: r.total_messages,
386
+ models: r.models ? JSON.parse(r.models) : [],
387
+ toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : [],
388
+ totalInputTokens: r.total_input_tokens,
389
+ totalOutputTokens: r.total_output_tokens,
390
+ })));
391
+ } catch (err) {
392
+ res.status(500).json({ error: err.message });
393
+ }
394
+ });
395
+
396
+ // ── Get session detail ──
397
+ app.get('/relay/session/:chatId', requireAuth, (req, res) => {
398
+ try {
399
+ const { chatId } = req.params;
400
+ const { username } = req.query;
401
+
402
+ let chatSql = 'SELECT * FROM relay_chats WHERE id = ?';
403
+ const chatParams = [chatId];
404
+ if (username) { chatSql += ' AND username = ?'; chatParams.push(username); }
405
+ chatSql += ' LIMIT 1';
406
+
407
+ const chat = db.prepare(chatSql).get(...chatParams);
408
+ if (!chat) return res.status(404).json({ error: 'Session not found' });
409
+
410
+ const messages = db.prepare(
411
+ 'SELECT seq, role, content, model FROM relay_messages WHERE chat_id = ? AND username = ? ORDER BY seq'
412
+ ).all(chat.id, chat.username);
413
+
414
+ const stats = db.prepare(
415
+ 'SELECT * FROM relay_chat_stats WHERE chat_id = ? AND username = ?'
416
+ ).get(chat.id, chat.username);
417
+
418
+ res.json({
419
+ id: chat.id,
420
+ username: chat.username,
421
+ source: chat.source,
422
+ name: chat.name,
423
+ mode: chat.mode,
424
+ folder: chat.folder,
425
+ createdAt: chat.created_at,
426
+ lastUpdatedAt: chat.last_updated_at,
427
+ messages,
428
+ stats: stats ? {
429
+ totalMessages: stats.total_messages,
430
+ models: JSON.parse(stats.models || '[]'),
431
+ toolCalls: JSON.parse(stats.tool_calls || '[]'),
432
+ totalInputTokens: stats.total_input_tokens,
433
+ totalOutputTokens: stats.total_output_tokens,
434
+ } : null,
435
+ });
436
+ } catch (err) {
437
+ res.status(500).json({ error: err.message });
438
+ }
439
+ });
440
+
441
+ // ── Live feed — recent activity timeline ──
442
+ app.get('/relay/feed', requireAuth, (req, res) => {
443
+ try {
444
+ const limit = parseInt(req.query.limit) || 60;
445
+ const since = parseInt(req.query.since) || 0;
446
+
447
+ let sql = `
448
+ SELECT rc.id, rc.username, rc.source, rc.name, rc.mode, rc.folder,
449
+ rc.last_updated_at, rc.created_at,
450
+ rcs.total_messages, rcs.models, rcs.total_input_tokens, rcs.total_output_tokens
451
+ FROM relay_chats rc
452
+ LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
453
+ WHERE rc.last_updated_at > ?
454
+ ORDER BY rc.last_updated_at DESC
455
+ LIMIT ?
456
+ `;
457
+ const rows = db.prepare(sql).all(since, limit);
458
+
459
+ res.json(rows.map(r => ({
460
+ id: r.id,
461
+ username: r.username,
462
+ source: r.source,
463
+ name: r.name,
464
+ mode: r.mode,
465
+ folder: r.folder,
466
+ lastUpdatedAt: r.last_updated_at,
467
+ createdAt: r.created_at,
468
+ totalMessages: r.total_messages || 0,
469
+ models: r.models ? JSON.parse(r.models) : [],
470
+ totalInputTokens: r.total_input_tokens || 0,
471
+ totalOutputTokens: r.total_output_tokens || 0,
472
+ })));
473
+ } catch (err) {
474
+ res.status(500).json({ error: err.message });
475
+ }
476
+ });
477
+
478
+ // ── Merge users ──────────────────────────────────────────────
479
+ app.post('/relay/merge-users', requireAuth, (req, res) => {
480
+ try {
481
+ const { from, to } = req.body;
482
+ if (!from || !to) return res.status(400).json({ error: 'Both "from" and "to" usernames are required.' });
483
+ if (from === to) return res.status(400).json({ error: '"from" and "to" cannot be the same.' });
484
+
485
+ const result = db.transaction(() => {
486
+ // 1. Find conflicting chat IDs (exist for both users)
487
+ const conflicts = db.prepare(`
488
+ SELECT a.id FROM relay_chats a
489
+ INNER JOIN relay_chats b ON a.id = b.id
490
+ WHERE a.username = ? AND b.username = ?
491
+ `).all(from, to).map(r => r.id);
492
+
493
+ // 2. For conflicting chats: keep the "to" user's row, delete the "from" row
494
+ if (conflicts.length > 0) {
495
+ const placeholders = conflicts.map(() => '?').join(',');
496
+
497
+ // Delete conflicting messages from "from" user
498
+ db.prepare(`DELETE FROM relay_messages WHERE username = ? AND chat_id IN (${placeholders})`).run(from, ...conflicts);
499
+
500
+ // Delete conflicting chat stats from "from" user
501
+ db.prepare(`DELETE FROM relay_chat_stats WHERE username = ? AND chat_id IN (${placeholders})`).run(from, ...conflicts);
502
+
503
+ // Delete conflicting chats from "from" user
504
+ db.prepare(`DELETE FROM relay_chats WHERE username = ? AND id IN (${placeholders})`).run(from, ...conflicts);
505
+ }
506
+
507
+ // 3. Move remaining non-conflicting data from "from" → "to"
508
+ const movedChats = db.prepare(`UPDATE relay_chats SET username = ? WHERE username = ?`).run(to, from).changes;
509
+ const movedMessages = db.prepare(`UPDATE relay_messages SET username = ? WHERE username = ?`).run(to, from).changes;
510
+ const movedStats = db.prepare(`UPDATE relay_chat_stats SET username = ? WHERE username = ?`).run(to, from).changes;
511
+
512
+ // 4. Merge user record: update projects, remove old user
513
+ const fromUser = db.prepare(`SELECT projects FROM users WHERE username = ?`).get(from);
514
+ const toUser = db.prepare(`SELECT projects FROM users WHERE username = ?`).get(to);
515
+
516
+ if (fromUser && toUser) {
517
+ const fromProjects = JSON.parse(fromUser.projects || '[]');
518
+ const toProjects = JSON.parse(toUser.projects || '[]');
519
+ const merged = [...new Set([...toProjects, ...fromProjects])];
520
+ db.prepare(`UPDATE users SET projects = ? WHERE username = ?`).run(JSON.stringify(merged), to);
521
+ }
522
+
523
+ db.prepare(`DELETE FROM users WHERE username = ?`).run(from);
524
+
525
+ return { movedChats, movedMessages, movedStats, conflicts: conflicts.length };
526
+ })();
527
+
528
+ res.json({
529
+ ok: true,
530
+ merged: { from, to },
531
+ moved: { chats: result.movedChats, messages: result.movedMessages, stats: result.movedStats },
532
+ duplicatesSkipped: result.conflicts,
533
+ });
534
+ } catch (err) {
535
+ res.status(500).json({ error: err.message });
536
+ }
537
+ });
538
+
539
+ // SPA fallback
540
+ app.get('*', (req, res) => {
541
+ const index = path.join(__dirname, 'public', 'index.html');
542
+ if (fs.existsSync(index)) {
543
+ res.sendFile(index);
544
+ } else {
545
+ res.status(404).send('UI not built. Run: cd ui && npm install && npm run build');
546
+ }
547
+ });
548
+
549
+ return app;
550
+ }
551
+
552
+ module.exports = { initRelayDb, getRelayDb, createRelayApp };
package/server.js CHANGED
@@ -19,6 +19,10 @@ function parseDateOpts(query) {
19
19
  return opts;
20
20
  }
21
21
 
22
+ app.get('/api/mode', (req, res) => {
23
+ res.json({ mode: 'local' });
24
+ });
25
+
22
26
  app.get('/api/overview', (req, res) => {
23
27
  try {
24
28
  const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };