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/README.md +84 -1
- package/cache.js +12 -7
- package/editors/index.js +7 -1
- package/editors/windsurf.js +199 -47
- package/index.js +125 -3
- package/mcp-server.js +279 -0
- package/package.json +6 -1
- package/relay-client.js +307 -0
- package/relay-server.js +552 -0
- package/server.js +4 -0
- package/ui/src/App.jsx +154 -45
- package/ui/src/components/ChatSidebar.jsx +27 -155
- package/ui/src/components/EditorBreakdown.jsx +22 -0
- package/ui/src/components/LiveFeed.jsx +138 -0
- package/ui/src/components/LoginScreen.jsx +79 -0
- package/ui/src/components/MessageRenderer.jsx +167 -0
- package/ui/src/components/ModelBreakdown.jsx +23 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/lib/api.js +115 -0
- package/ui/src/pages/ChatDetail.jsx +5 -164
- package/ui/src/pages/Dashboard.jsx +1 -4
- package/ui/src/pages/RelayDashboard.jsx +380 -0
- package/ui/src/pages/RelaySessionDetail.jsx +32 -0
- package/ui/src/pages/RelayUserDetail.jsx +204 -0
- package/ui/src/pages/Sessions.jsx +14 -1
- package/ui/vite.config.js +2 -1
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
|
|
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
|
}
|
package/relay-client.js
ADDED
|
@@ -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 };
|