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.
package/mcp-server.js ADDED
@@ -0,0 +1,279 @@
1
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
2
+ const { SSEServerTransport } = require('@modelcontextprotocol/sdk/server/sse.js');
3
+ const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
4
+ const { z } = require('zod');
5
+ const crypto = require('crypto');
6
+
7
+ /**
8
+ * Creates an MCP server instance wired to the relay database.
9
+ * Returns { mcpServer, transports } — caller wires SSE endpoints into Express.
10
+ */
11
+ function createMcpServer(getDb) {
12
+ const mcpServer = new McpServer({
13
+ name: 'agentlytics-relay',
14
+ version: '1.0.0',
15
+ });
16
+
17
+ // ── Tool: list_users ──
18
+ mcpServer.tool(
19
+ 'list_users',
20
+ 'List all connected users and their shared projects',
21
+ {},
22
+ async () => {
23
+ const db = getDb();
24
+ if (!db) return { content: [{ type: 'text', text: 'Relay database not initialized' }] };
25
+ const users = db.prepare('SELECT username, last_seen, projects FROM users ORDER BY last_seen DESC').all();
26
+ const result = users.map(u => ({
27
+ username: u.username,
28
+ lastSeen: new Date(u.last_seen).toISOString(),
29
+ projects: JSON.parse(u.projects || '[]'),
30
+ }));
31
+ return {
32
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
33
+ };
34
+ }
35
+ );
36
+
37
+ // ── Tool: search_sessions ──
38
+ mcpServer.tool(
39
+ 'search_sessions',
40
+ 'Search across all users\' chat messages by keyword. Use this to find what someone worked on, or find discussions about a specific file or topic.',
41
+ {
42
+ query: z.string().describe('Search query — keyword, file name, or topic'),
43
+ username: z.string().optional().describe('Filter by specific username'),
44
+ project: z.string().optional().describe('Filter by project folder path (partial match)'),
45
+ limit: z.number().optional().describe('Max results (default 20)'),
46
+ },
47
+ async ({ query, username, project, limit }) => {
48
+ const db = getDb();
49
+ if (!db) return { content: [{ type: 'text', text: 'Relay database not initialized' }] };
50
+
51
+ let sql = `
52
+ SELECT rm.chat_id, rm.username, rm.role, rm.content, rm.model, rm.seq,
53
+ rc.name as chat_name, rc.source, rc.folder, rc.last_updated_at
54
+ FROM relay_messages rm
55
+ JOIN relay_chats rc ON rm.chat_id = rc.id AND rm.username = rc.username
56
+ WHERE rm.content LIKE ?`;
57
+ const params = [`%${query}%`];
58
+
59
+ if (username) { sql += ' AND rm.username = ?'; params.push(username); }
60
+ if (project) { sql += ' AND rc.folder LIKE ?'; params.push(`%${project}%`); }
61
+ sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
62
+ params.push(limit || 20);
63
+
64
+ const rows = db.prepare(sql).all(...params);
65
+
66
+ if (rows.length === 0) {
67
+ return { content: [{ type: 'text', text: `No results found for "${query}"` }] };
68
+ }
69
+
70
+ const results = rows.map(r => ({
71
+ chatId: r.chat_id,
72
+ chatName: r.chat_name,
73
+ username: r.username,
74
+ role: r.role,
75
+ source: r.source,
76
+ folder: r.folder,
77
+ lastUpdated: r.last_updated_at ? new Date(r.last_updated_at).toISOString() : null,
78
+ model: r.model,
79
+ content: r.content.length > 300 ? r.content.substring(0, 300) + '...' : r.content,
80
+ }));
81
+
82
+ return {
83
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
84
+ };
85
+ }
86
+ );
87
+
88
+ // ── Tool: get_user_activity ──
89
+ mcpServer.tool(
90
+ 'get_user_activity',
91
+ 'Get recent activity for a specific user — their recent sessions, what they worked on, which editors and models they used.',
92
+ {
93
+ username: z.string().describe('Username to look up'),
94
+ project: z.string().optional().describe('Filter by project folder (partial match)'),
95
+ file_path: z.string().optional().describe('Filter by file path mentioned in messages'),
96
+ limit: z.number().optional().describe('Max sessions to return (default 20)'),
97
+ },
98
+ async ({ username, project, file_path, limit }) => {
99
+ const db = getDb();
100
+ if (!db) return { content: [{ type: 'text', text: 'Relay database not initialized' }] };
101
+
102
+ // First get sessions
103
+ let sql = `
104
+ SELECT rc.*, rcs.total_messages, rcs.models, rcs.tool_calls,
105
+ rcs.total_input_tokens, rcs.total_output_tokens
106
+ FROM relay_chats rc
107
+ LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
108
+ WHERE rc.username = ?`;
109
+ const params = [username];
110
+
111
+ if (project) { sql += ' AND rc.folder LIKE ?'; params.push(`%${project}%`); }
112
+ sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
113
+ params.push(limit || 20);
114
+
115
+ let sessions = db.prepare(sql).all(...params);
116
+
117
+ // If file_path filter, narrow down to sessions mentioning that file
118
+ if (file_path && sessions.length > 0) {
119
+ const chatIds = sessions.map(s => s.id);
120
+ const placeholders = chatIds.map(() => '?').join(',');
121
+ const fileMatches = db.prepare(`
122
+ SELECT DISTINCT chat_id FROM relay_messages
123
+ WHERE chat_id IN (${placeholders}) AND username = ? AND content LIKE ?
124
+ `).all(...chatIds, username, `%${file_path}%`);
125
+ const matchingIds = new Set(fileMatches.map(m => m.chat_id));
126
+ sessions = sessions.filter(s => matchingIds.has(s.id));
127
+ }
128
+
129
+ if (sessions.length === 0) {
130
+ return { content: [{ type: 'text', text: `No activity found for user "${username}"` }] };
131
+ }
132
+
133
+ const result = sessions.map(s => ({
134
+ id: s.id,
135
+ name: s.name,
136
+ source: s.source,
137
+ mode: s.mode,
138
+ folder: s.folder,
139
+ lastUpdated: s.last_updated_at ? new Date(s.last_updated_at).toISOString() : null,
140
+ totalMessages: s.total_messages,
141
+ models: s.models ? [...new Set(JSON.parse(s.models))].slice(0, 5) : [],
142
+ toolCalls: s.tool_calls ? JSON.parse(s.tool_calls).length : 0,
143
+ totalInputTokens: s.total_input_tokens,
144
+ totalOutputTokens: s.total_output_tokens,
145
+ }));
146
+
147
+ return {
148
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
149
+ };
150
+ }
151
+ );
152
+
153
+ // ── Tool: get_session_detail ──
154
+ mcpServer.tool(
155
+ 'get_session_detail',
156
+ 'Get the full conversation messages for a specific session. Use the session ID from search_sessions or get_user_activity results.',
157
+ {
158
+ session_id: z.string().describe('The chat/session ID'),
159
+ username: z.string().optional().describe('Username who owns the session (optional, auto-detected if unique)'),
160
+ },
161
+ async ({ session_id, username }) => {
162
+ const db = getDb();
163
+ if (!db) return { content: [{ type: 'text', text: 'Relay database not initialized' }] };
164
+
165
+ let chatSql = 'SELECT * FROM relay_chats WHERE id = ?';
166
+ const chatParams = [session_id];
167
+ if (username) { chatSql += ' AND username = ?'; chatParams.push(username); }
168
+ chatSql += ' LIMIT 1';
169
+
170
+ const chat = db.prepare(chatSql).get(...chatParams);
171
+ if (!chat) {
172
+ return { content: [{ type: 'text', text: `Session "${session_id}" not found` }] };
173
+ }
174
+
175
+ const messages = db.prepare(
176
+ 'SELECT seq, role, content, model FROM relay_messages WHERE chat_id = ? AND username = ? ORDER BY seq'
177
+ ).all(chat.id, chat.username);
178
+
179
+ const formatted = messages.map(m => {
180
+ const label = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : m.role;
181
+ const modelTag = m.model ? ` (${m.model})` : '';
182
+ const content = m.content.length > 2000 ? m.content.substring(0, 2000) + '\n... [truncated]' : m.content;
183
+ return `## ${label}${modelTag}\n\n${content}`;
184
+ }).join('\n\n---\n\n');
185
+
186
+ const header = `# ${chat.name || 'Untitled Session'}\n**User:** ${chat.username} | **Editor:** ${chat.source} | **Project:** ${chat.folder || 'N/A'}\n\n---\n\n`;
187
+
188
+ return {
189
+ content: [{ type: 'text', text: header + formatted }],
190
+ };
191
+ }
192
+ );
193
+
194
+ return mcpServer;
195
+ }
196
+
197
+ /**
198
+ * Wire MCP SSE transport into an Express app.
199
+ * GET /mcp → establishes SSE connection
200
+ * POST /mcp → receives messages from MCP client
201
+ */
202
+ function wireMcpToExpress(app, getDb) {
203
+ const sseTransports = {};
204
+ const httpTransports = {};
205
+
206
+ // SSE: GET /mcp establishes SSE stream
207
+ app.get('/mcp', async (req, res) => {
208
+ const sessionId = req.headers['mcp-session-id'];
209
+
210
+ // Streamable HTTP GET for SSE stream resumption
211
+ if (sessionId && httpTransports[sessionId]) {
212
+ const transport = httpTransports[sessionId];
213
+ await transport.handleRequest(req, res);
214
+ return;
215
+ }
216
+
217
+ // Legacy SSE transport
218
+ const transport = new SSEServerTransport('/mcp', res);
219
+ sseTransports[transport.sessionId] = transport;
220
+
221
+ const mcpServer = createMcpServer(getDb);
222
+
223
+ res.on('close', () => {
224
+ delete sseTransports[transport.sessionId];
225
+ mcpServer.close().catch(() => {});
226
+ });
227
+
228
+ await mcpServer.connect(transport);
229
+ });
230
+
231
+ // POST /mcp handles both SSE messages and Streamable HTTP
232
+ app.post('/mcp', async (req, res) => {
233
+ // Check for SSE session first
234
+ const sseSessionId = req.query.sessionId;
235
+ if (sseSessionId && sseTransports[sseSessionId]) {
236
+ await sseTransports[sseSessionId].handlePostMessage(req, res);
237
+ return;
238
+ }
239
+
240
+ // Streamable HTTP: check for existing session
241
+ const sessionId = req.headers['mcp-session-id'];
242
+ if (sessionId && httpTransports[sessionId]) {
243
+ await httpTransports[sessionId].handleRequest(req, res, req.body);
244
+ return;
245
+ }
246
+
247
+ // New Streamable HTTP session (initialization request)
248
+ const transport = new StreamableHTTPServerTransport({
249
+ sessionIdGenerator: () => crypto.randomUUID(),
250
+ onsessioninitialized: (id) => {
251
+ httpTransports[id] = transport;
252
+ },
253
+ });
254
+
255
+ transport.onclose = () => {
256
+ if (transport.sessionId) {
257
+ delete httpTransports[transport.sessionId];
258
+ }
259
+ };
260
+
261
+ const mcpServer = createMcpServer(getDb);
262
+ await mcpServer.connect(transport);
263
+ await transport.handleRequest(req, res, req.body);
264
+ });
265
+
266
+ // DELETE /mcp for session cleanup
267
+ app.delete('/mcp', async (req, res) => {
268
+ const sessionId = req.headers['mcp-session-id'];
269
+ if (sessionId && httpTransports[sessionId]) {
270
+ await httpTransports[sessionId].handleRequest(req, res);
271
+ return;
272
+ }
273
+ res.status(404).end();
274
+ });
275
+
276
+ return { sseTransports, httpTransports };
277
+ }
278
+
279
+ module.exports = { createMcpServer, wireMcpToExpress };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.0.10",
3
+ "version": "0.1.0",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -11,6 +11,9 @@
11
11
  "cache.js",
12
12
  "server.js",
13
13
  "share-image.js",
14
+ "relay-server.js",
15
+ "relay-client.js",
16
+ "mcp-server.js",
14
17
  "editors/",
15
18
  "ui/src/",
16
19
  "ui/index.html",
@@ -46,10 +49,12 @@
46
49
  "url": "https://github.com/f/agentlytics"
47
50
  },
48
51
  "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.27.1",
49
53
  "better-sqlite3": "^12.6.2",
50
54
  "chalk": "^4.1.2",
51
55
  "commander": "^14.0.3",
52
56
  "express": "^4.22.1",
57
+ "inquirer": "^13.3.0",
53
58
  "open": "^8.4.2"
54
59
  }
55
60
  }
@@ -0,0 +1,307 @@
1
+ const chalk = require('chalk');
2
+ const http = require('http');
3
+ const https = require('https');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const fs = require('fs');
7
+ const readline = require('readline');
8
+ const crypto = require('crypto');
9
+
10
+ const cache = require('./cache');
11
+
12
+ const SYNC_INTERVAL_MS = 30000; // 30 seconds
13
+
14
+ const EDITOR_LABELS = {
15
+ 'cursor': 'Cursor',
16
+ 'windsurf': 'Windsurf',
17
+ 'windsurf-next': 'Windsurf Next',
18
+ 'antigravity': 'Antigravity',
19
+ 'claude-code': 'Claude Code',
20
+ 'claude': 'Claude Code',
21
+ 'vscode': 'VS Code',
22
+ 'vscode-insiders': 'VS Code Insiders',
23
+ 'zed': 'Zed',
24
+ 'opencode': 'OpenCode',
25
+ 'codex': 'Codex CLI',
26
+ 'gemini-cli': 'Gemini CLI',
27
+ 'copilot-cli': 'Copilot CLI',
28
+ 'cursor-agent': 'Cursor (Background Agent)',
29
+ 'commandcode': 'CommandCode',
30
+ };
31
+
32
+ /**
33
+ * Interactive project picker using readline (no external deps beyond Node built-ins).
34
+ * Returns an array of selected folder paths.
35
+ */
36
+ async function pickProjects() {
37
+ cache.initDb();
38
+
39
+ // Scan to populate cache
40
+ console.log(chalk.dim(' Scanning local sessions...'));
41
+ cache.scanAll(() => {});
42
+
43
+ const db = cache.getDb();
44
+ const projects = db.prepare(`
45
+ SELECT folder, COUNT(*) as count
46
+ FROM chats WHERE folder IS NOT NULL
47
+ GROUP BY folder ORDER BY count DESC
48
+ `).all();
49
+
50
+ if (projects.length === 0) {
51
+ console.log(chalk.yellow(' No projects found in local cache.'));
52
+ process.exit(1);
53
+ }
54
+
55
+ const cwd = process.cwd();
56
+ const cwdMatch = projects.find(p => p.folder === cwd);
57
+
58
+ // If cwd is a known project, offer quick share
59
+ if (cwdMatch) {
60
+ const name = cwdMatch.folder.split('/').pop();
61
+ const editors = db.prepare(`
62
+ SELECT source, COUNT(*) as count FROM chats
63
+ WHERE folder = ? AND source IS NOT NULL
64
+ GROUP BY source ORDER BY count DESC
65
+ `).all(cwdMatch.folder);
66
+ console.log('');
67
+ console.log(chalk.cyan(` ${name}`) + chalk.dim(` — ${cwdMatch.count} sessions`));
68
+ for (const e of editors) {
69
+ console.log(chalk.yellow(` • ${EDITOR_LABELS[e.source] || e.source}`) + chalk.dim(` (${e.count} sessions)`));
70
+ }
71
+ console.log('');
72
+
73
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
74
+ const answer = await new Promise(r => {
75
+ rl.question(chalk.bold(' Share your sessions with your team? ') + chalk.dim('(Y/n) '), r);
76
+ });
77
+ rl.close();
78
+
79
+ const trimmed = answer.trim().toLowerCase();
80
+ if (trimmed === '' || trimmed === 'y' || trimmed === 'yes') {
81
+ return [cwdMatch.folder];
82
+ }
83
+ // Fall through to full picker
84
+ }
85
+
86
+ console.log('');
87
+ console.log(chalk.bold(' Select projects to share (comma-separated numbers, or "all"):'));
88
+ console.log('');
89
+ projects.forEach((p, i) => {
90
+ const name = p.folder.split('/').pop();
91
+ console.log(chalk.cyan(` ${i + 1}.`) + ` ${name} ${chalk.dim(`(${p.count} sessions) — ${p.folder}`)}`);
92
+ });
93
+ console.log('');
94
+
95
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
96
+
97
+ return new Promise((resolve) => {
98
+ rl2.question(chalk.bold(' > '), (answer) => {
99
+ rl2.close();
100
+ const trimmed = answer.trim().toLowerCase();
101
+ if (trimmed === 'all' || trimmed === '*') {
102
+ resolve(projects.map(p => p.folder));
103
+ return;
104
+ }
105
+ const indices = trimmed.split(/[,\s]+/).map(s => parseInt(s.trim()) - 1).filter(i => i >= 0 && i < projects.length);
106
+ if (indices.length === 0) {
107
+ console.log(chalk.red(' No valid selection. Exiting.'));
108
+ process.exit(1);
109
+ }
110
+ resolve(indices.map(i => projects[i].folder));
111
+ });
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Collect data for selected projects from local cache DB.
117
+ */
118
+ function collectProjectData(selectedFolders) {
119
+ const db = cache.getDb();
120
+ if (!db) return { chats: [], messages: [], stats: [] };
121
+
122
+ const allChats = [];
123
+ const allMessages = [];
124
+ const allStats = [];
125
+
126
+ for (const folder of selectedFolders) {
127
+ // Get chats for this project
128
+ const chats = db.prepare(`
129
+ SELECT id, source, name, mode, folder, created_at, last_updated_at, bubble_count
130
+ FROM chats WHERE folder = ?
131
+ `).all(folder);
132
+
133
+ for (const chat of chats) {
134
+ allChats.push({
135
+ id: chat.id,
136
+ source: chat.source,
137
+ name: chat.name,
138
+ mode: chat.mode,
139
+ folder: chat.folder,
140
+ created_at: chat.created_at,
141
+ last_updated_at: chat.last_updated_at,
142
+ bubble_count: chat.bubble_count,
143
+ });
144
+
145
+ // Get messages
146
+ const messages = db.prepare(
147
+ 'SELECT chat_id, seq, role, content, model FROM messages WHERE chat_id = ? ORDER BY seq'
148
+ ).all(chat.id);
149
+ for (const m of messages) {
150
+ allMessages.push({
151
+ chat_id: m.chat_id,
152
+ seq: m.seq,
153
+ role: m.role,
154
+ content: m.content,
155
+ model: m.model,
156
+ });
157
+ }
158
+
159
+ // Get stats
160
+ const stat = db.prepare(
161
+ 'SELECT * FROM chat_stats WHERE chat_id = ?'
162
+ ).get(chat.id);
163
+ if (stat) {
164
+ allStats.push({
165
+ chat_id: stat.chat_id,
166
+ total_messages: stat.total_messages,
167
+ user_messages: stat.user_messages,
168
+ assistant_messages: stat.assistant_messages,
169
+ tool_calls: JSON.parse(stat.tool_calls || '[]'),
170
+ models: JSON.parse(stat.models || '[]'),
171
+ total_input_tokens: stat.total_input_tokens,
172
+ total_output_tokens: stat.total_output_tokens,
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ return { chats: allChats, messages: allMessages, stats: allStats };
179
+ }
180
+
181
+ /**
182
+ * POST data to relay server.
183
+ */
184
+ function postToRelay(host, port, username, data, authToken) {
185
+ return new Promise((resolve, reject) => {
186
+ const payload = JSON.stringify({
187
+ username,
188
+ projects: data.projects,
189
+ chats: data.chats,
190
+ messages: data.messages,
191
+ stats: data.stats,
192
+ });
193
+
194
+ const headers = {
195
+ 'Content-Type': 'application/json',
196
+ 'Content-Length': Buffer.byteLength(payload),
197
+ };
198
+ if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
199
+
200
+ const options = {
201
+ hostname: host,
202
+ port: port,
203
+ path: '/relay/sync',
204
+ method: 'POST',
205
+ headers,
206
+ };
207
+
208
+ const req = http.request(options, (res) => {
209
+ let body = '';
210
+ res.on('data', (chunk) => { body += chunk; });
211
+ res.on('end', () => {
212
+ try {
213
+ resolve(JSON.parse(body));
214
+ } catch {
215
+ resolve({ raw: body });
216
+ }
217
+ });
218
+ });
219
+
220
+ req.on('error', reject);
221
+ req.setTimeout(15000, () => {
222
+ req.destroy(new Error('Request timed out'));
223
+ });
224
+ req.write(payload);
225
+ req.end();
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Main join client entry point.
231
+ */
232
+ async function startJoinClient(relayAddress, username) {
233
+ console.log('');
234
+ console.log(chalk.bold(' ⚡ Agentlytics Relay — Join'));
235
+ console.log(chalk.dim(` Connecting to relay at ${relayAddress}`));
236
+ console.log(chalk.dim(` Username: ${username}`));
237
+ console.log('');
238
+
239
+ // Parse host:port
240
+ const parts = relayAddress.replace(/^https?:\/\//, '').split(':');
241
+ const host = parts[0] || 'localhost';
242
+ const port = parseInt(parts[1]) || 4638;
243
+
244
+ // Auth token from RELAY_PASSWORD env
245
+ const relayPassword = process.env.RELAY_PASSWORD || null;
246
+ const authToken = relayPassword
247
+ ? crypto.createHmac('sha256', 'agentlytics-relay').update(relayPassword).digest('hex')
248
+ : null;
249
+
250
+ // Test connection
251
+ try {
252
+ const testResult = await postToRelay(host, port, username, { projects: [], chats: [], messages: [], stats: [] }, authToken);
253
+ if (!testResult.ok) {
254
+ const msg = testResult.error || 'unknown error';
255
+ if (msg === 'Unauthorized') {
256
+ console.log(chalk.red(' ✗ Relay requires a password. Set RELAY_PASSWORD env variable.'));
257
+ } else {
258
+ console.log(chalk.red(` ✗ Failed to connect: ${msg}`));
259
+ }
260
+ process.exit(1);
261
+ }
262
+ console.log(chalk.green(' ✓ Connected to relay'));
263
+ } catch (err) {
264
+ console.log(chalk.red(` ✗ Cannot reach relay at ${host}:${port}`));
265
+ console.log(chalk.dim(` ${err.message}`));
266
+ process.exit(1);
267
+ }
268
+
269
+ // Pick projects
270
+ const selectedFolders = await pickProjects();
271
+ console.log('');
272
+ console.log(chalk.green(` ✓ Sharing ${selectedFolders.length} project(s):`));
273
+ for (const f of selectedFolders) {
274
+ console.log(chalk.dim(` • ${f.split('/').pop()} — ${f}`));
275
+ }
276
+ console.log('');
277
+
278
+ // Initial sync
279
+ async function sync() {
280
+ try {
281
+ // Rescan editors to pick up new/updated sessions (reset caches so fresh LS data is obtained)
282
+ cache.scanAll(() => {}, { resetCaches: true });
283
+ const data = collectProjectData(selectedFolders);
284
+ data.projects = selectedFolders;
285
+ const result = await postToRelay(host, port, username, data, authToken);
286
+ if (result.ok) {
287
+ const s = result.synced || {};
288
+ process.stdout.write(chalk.dim(`\r ⟳ Synced: ${s.chats || 0} chats, ${s.messages || 0} messages — ${new Date().toLocaleTimeString()} `));
289
+ } else {
290
+ process.stdout.write(chalk.yellow(`\r ⚠ Sync issue: ${result.error || 'unknown'} `));
291
+ }
292
+ } catch (err) {
293
+ process.stdout.write(chalk.red(`\r ✗ Sync failed: ${err.message} `));
294
+ }
295
+ }
296
+
297
+ console.log(chalk.cyan(` ⟳ Syncing every ${SYNC_INTERVAL_MS / 1000}s (Ctrl+C to stop)`));
298
+ console.log('');
299
+
300
+ // Do first sync immediately
301
+ await sync();
302
+
303
+ // Then sync periodically
304
+ setInterval(sync, SYNC_INTERVAL_MS);
305
+ }
306
+
307
+ module.exports = { startJoinClient };