memhouse 0.0.1 → 0.3.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.
Files changed (96) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +59 -3
  3. package/bin/memhouse.js +487 -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/antigravity.js +1019 -0
  9. package/editors/base.js +321 -0
  10. package/editors/claude.js +386 -0
  11. package/editors/codebuff.js +338 -0
  12. package/editors/codex.js +537 -0
  13. package/editors/commandcode.js +166 -0
  14. package/editors/copilot-jetbrains.js +207 -0
  15. package/editors/copilot.js +260 -0
  16. package/editors/cursor-agent.js +202 -0
  17. package/editors/cursor.js +438 -0
  18. package/editors/gemini.js +231 -0
  19. package/editors/goose.js +358 -0
  20. package/editors/gsd.js +366 -0
  21. package/editors/index.js +179 -0
  22. package/editors/kiro.js +315 -0
  23. package/editors/opencode.js +374 -0
  24. package/editors/vscode.js +413 -0
  25. package/editors/windsurf.js +634 -0
  26. package/editors/zed.js +213 -0
  27. package/index.js +424 -2
  28. package/mcp-server.js +294 -0
  29. package/mem-house/DESIGN.md +119 -0
  30. package/mem-house/delivery/AGENT-INSTALL.md +75 -0
  31. package/mem-house/delivery/PROMPT.md +42 -0
  32. package/mem-house/delivery/install.sh +181 -0
  33. package/mem-house/delivery/kernel-install.md +76 -0
  34. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  35. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  36. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  37. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  38. package/mem-house/rls.sql +17 -0
  39. package/mem-house/schema.sql +145 -0
  40. package/mem-house/server/queries.js +878 -0
  41. package/mem-house/server/server.js +228 -0
  42. package/mem-house/shipper/ship.js +402 -0
  43. package/misc/syntax-check.js +38 -0
  44. package/package.json +56 -7
  45. package/pricing.js +88 -0
  46. package/pricing.json +992 -0
  47. package/public/assets/index-BsgjOSTv.js +75 -0
  48. package/public/assets/index-DV6ONi_F.css +2 -0
  49. package/public/index.html +16 -0
  50. package/relay-client.js +306 -0
  51. package/relay-server.js +577 -0
  52. package/server.js +860 -0
  53. package/share-image.js +367 -0
  54. package/ui/eslint.config.js +29 -0
  55. package/ui/index.html +15 -0
  56. package/ui/package-lock.json +4229 -0
  57. package/ui/package.json +38 -0
  58. package/ui/src/App.jsx +364 -0
  59. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  60. package/ui/src/components/AiAuditCard.jsx +254 -0
  61. package/ui/src/components/AnimatedLoader.jsx +14 -0
  62. package/ui/src/components/AnimatedLogo.jsx +96 -0
  63. package/ui/src/components/ChatSidebar.jsx +226 -0
  64. package/ui/src/components/DateRangePicker.jsx +104 -0
  65. package/ui/src/components/EditorDot.jsx +11 -0
  66. package/ui/src/components/EditorIcon.jsx +117 -0
  67. package/ui/src/components/KpiCard.jsx +9 -0
  68. package/ui/src/components/LiveFeed.jsx +128 -0
  69. package/ui/src/components/LoginScreen.jsx +81 -0
  70. package/ui/src/components/MessageRenderer.jsx +148 -0
  71. package/ui/src/components/PageHeader.jsx +11 -0
  72. package/ui/src/components/SectionTitle.jsx +3 -0
  73. package/ui/src/components/ShareModal.jsx +305 -0
  74. package/ui/src/components/TokenTimeline.jsx +258 -0
  75. package/ui/src/hooks/useLive.jsx +15 -0
  76. package/ui/src/index.css +83 -0
  77. package/ui/src/lib/api.js +343 -0
  78. package/ui/src/lib/constants.js +88 -0
  79. package/ui/src/lib/theme.jsx +23 -0
  80. package/ui/src/main.jsx +16 -0
  81. package/ui/src/pages/Artifacts.jsx +575 -0
  82. package/ui/src/pages/Compare.jsx +232 -0
  83. package/ui/src/pages/CostAnalysis.jsx +354 -0
  84. package/ui/src/pages/Dashboard.jsx +529 -0
  85. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  86. package/ui/src/pages/GSD.jsx +726 -0
  87. package/ui/src/pages/MCPs.jsx +744 -0
  88. package/ui/src/pages/ProjectDetail.jsx +325 -0
  89. package/ui/src/pages/Projects.jsx +249 -0
  90. package/ui/src/pages/RelayDashboard.jsx +590 -0
  91. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  92. package/ui/src/pages/Sessions.jsx +535 -0
  93. package/ui/src/pages/Settings.jsx +241 -0
  94. package/ui/src/pages/SqlViewer.jsx +344 -0
  95. package/ui/src/pages/Subscriptions.jsx +411 -0
  96. package/ui/vite.config.js +18 -0
package/server.js ADDED
@@ -0,0 +1,860 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const cache = require('./cache');
6
+ const { generateShareSvg } = require('./share-image');
7
+
8
+ const app = express();
9
+ app.use(express.json());
10
+ app.use(express.static(path.join(__dirname, 'public')));
11
+
12
+ // ============================================================
13
+ // Config: ~/.agentlytics/config.json
14
+ // ============================================================
15
+
16
+ const CONFIG_PATH = path.join(os.homedir(), '.agentlytics', 'config.json');
17
+
18
+ function readConfig() {
19
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return {}; }
20
+ }
21
+
22
+ function writeConfig(config) {
23
+ const dir = path.dirname(CONFIG_PATH);
24
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
25
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
26
+ }
27
+
28
+ function getHiddenFolders() {
29
+ return readConfig().hiddenProjects || [];
30
+ }
31
+
32
+ // ============================================================
33
+ // API endpoints — all reads from SQLite cache
34
+ // ============================================================
35
+
36
+ // Helper: parse date query params into Unix ms timestamps
37
+ function parseDateOpts(query) {
38
+ const opts = {};
39
+ if (query.dateFrom) opts.dateFrom = parseInt(query.dateFrom) || null;
40
+ if (query.dateTo) opts.dateTo = parseInt(query.dateTo) || null;
41
+ return opts;
42
+ }
43
+
44
+ app.get('/api/ping', async (req, res) => {
45
+ res.json({ app: 'agentlytics', pid: process.pid });
46
+ });
47
+
48
+ app.get('/api/mode', async (req, res) => {
49
+ res.json({ mode: 'local' });
50
+ });
51
+
52
+ app.get('/api/overview', async (req, res) => {
53
+ try {
54
+ const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
55
+ res.json(await cache.getCachedOverview(opts));
56
+ } catch (err) {
57
+ res.status(500).json({ error: err.message });
58
+ }
59
+ });
60
+
61
+ app.get('/api/daily-activity', async (req, res) => {
62
+ try {
63
+ const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
64
+ res.json(await cache.getCachedDailyActivity(opts));
65
+ } catch (err) {
66
+ res.status(500).json({ error: err.message });
67
+ }
68
+ });
69
+
70
+ app.get('/api/chats', async (req, res) => {
71
+ try {
72
+ const opts = {
73
+ editor: req.query.editor || null,
74
+ folder: req.query.folder || null,
75
+ named: req.query.named !== 'false',
76
+ limit: req.query.limit ? parseInt(req.query.limit) : 200,
77
+ offset: req.query.offset ? parseInt(req.query.offset) : 0,
78
+ ...parseDateOpts(req.query),
79
+ hiddenFolders: getHiddenFolders(),
80
+ };
81
+ const total = await cache.countCachedChats(opts);
82
+ const rows = await cache.getCachedChats(opts);
83
+ res.json({
84
+ total,
85
+ chats: rows.map(c => ({
86
+ id: c.id,
87
+ source: c.source,
88
+ name: c.name,
89
+ mode: c.mode,
90
+ folder: c.folder,
91
+ createdAt: c.created_at,
92
+ lastUpdatedAt: c.last_updated_at,
93
+ encrypted: !!c.encrypted,
94
+ bubbleCount: c.bubble_count,
95
+ topModel: c.top_model || null,
96
+ cost: c.cost || 0,
97
+ })),
98
+ });
99
+ } catch (err) {
100
+ res.status(500).json({ error: err.message });
101
+ }
102
+ });
103
+
104
+ app.get('/api/chats/:id', async (req, res) => {
105
+ try {
106
+ const result = await cache.getCachedChat(req.params.id);
107
+ if (!result) return res.status(404).json({ error: 'Chat not found' });
108
+ res.json(result);
109
+ } catch (err) {
110
+ res.status(500).json({ error: err.message });
111
+ }
112
+ });
113
+
114
+ app.get('/api/chats/:id/markdown', async (req, res) => {
115
+ try {
116
+ const result = await cache.getCachedChat(req.params.id);
117
+ if (!result) return res.status(404).json({ error: 'Chat not found' });
118
+
119
+ const lines = [];
120
+ const title = result.name || 'Untitled Session';
121
+ lines.push(`# ${title}\n`);
122
+
123
+ // Metadata
124
+ const meta = [];
125
+ if (result.source) meta.push(`**Editor:** ${result.source}`);
126
+ if (result.mode) meta.push(`**Mode:** ${result.mode}`);
127
+ if (result.folder) meta.push(`**Project:** ${result.folder}`);
128
+ if (result.createdAt) meta.push(`**Created:** ${new Date(result.createdAt).toISOString()}`);
129
+ if (result.lastUpdatedAt) meta.push(`**Updated:** ${new Date(result.lastUpdatedAt).toISOString()}`);
130
+ if (result.stats) {
131
+ meta.push(`**Messages:** ${result.stats.totalMessages}`);
132
+ if (result.stats.totalInputTokens) meta.push(`**Input Tokens:** ${result.stats.totalInputTokens}`);
133
+ if (result.stats.totalOutputTokens) meta.push(`**Output Tokens:** ${result.stats.totalOutputTokens}`);
134
+ const models = [...new Set(result.stats.models || [])];
135
+ if (models.length > 0) meta.push(`**Models:** ${models.join(', ')}`);
136
+ }
137
+ if (meta.length > 0) lines.push(meta.join(' \n') + '\n');
138
+
139
+ lines.push('---\n');
140
+
141
+ // Messages
142
+ for (const msg of result.messages) {
143
+ const label = msg.role === 'user' ? '## User' : msg.role === 'assistant' ? '## Assistant' : `## ${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}`;
144
+ const modelTag = msg.model ? ` *(${msg.model})*` : '';
145
+ lines.push(`${label}${modelTag}\n`);
146
+ lines.push(msg.content + '\n');
147
+ }
148
+
149
+ const md = lines.join('\n');
150
+ const filename = title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 80) + '.md';
151
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
152
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
153
+ res.send(md);
154
+ } catch (err) {
155
+ res.status(500).json({ error: err.message });
156
+ }
157
+ });
158
+
159
+ app.get('/api/projects', async (req, res) => {
160
+ try {
161
+ res.json(await cache.getCachedProjects({ ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() }));
162
+ } catch (err) {
163
+ res.status(500).json({ error: err.message });
164
+ }
165
+ });
166
+
167
+ app.get('/api/deep-analytics', async (req, res) => {
168
+ try {
169
+ const opts = {
170
+ editor: req.query.editor || null,
171
+ folder: req.query.folder || null,
172
+ limit: Math.min(parseInt(req.query.limit) || 500, 5000),
173
+ ...parseDateOpts(req.query),
174
+ hiddenFolders: getHiddenFolders(),
175
+ };
176
+ res.json(await cache.getCachedDeepAnalytics(opts));
177
+ } catch (err) {
178
+ res.status(500).json({ error: err.message });
179
+ }
180
+ });
181
+
182
+ app.get('/api/dashboard-stats', async (req, res) => {
183
+ try {
184
+ const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
185
+ res.json(await cache.getCachedDashboardStats(opts));
186
+ } catch (err) {
187
+ res.status(500).json({ error: err.message });
188
+ }
189
+ });
190
+
191
+ app.get('/api/cost-analytics', async (req, res) => {
192
+ try {
193
+ const opts = {
194
+ editor: req.query.editor || null,
195
+ ...parseDateOpts(req.query),
196
+ hiddenFolders: getHiddenFolders(),
197
+ };
198
+ res.json(await cache.getCostAnalytics(opts));
199
+ } catch (err) {
200
+ res.status(500).json({ error: err.message });
201
+ }
202
+ });
203
+
204
+ app.get('/api/costs', async (req, res) => {
205
+ try {
206
+ const opts = {
207
+ editor: req.query.editor || null,
208
+ folder: req.query.folder || null,
209
+ chatId: req.query.chatId || null,
210
+ ...parseDateOpts(req.query),
211
+ hiddenFolders: getHiddenFolders(),
212
+ };
213
+ res.json(await cache.getCostBreakdown(opts));
214
+ } catch (err) {
215
+ res.status(500).json({ error: err.message });
216
+ }
217
+ });
218
+
219
+ app.get('/api/tool-calls', async (req, res) => {
220
+ try {
221
+ const name = req.query.name;
222
+ if (!name) return res.status(400).json({ error: 'name query param required' });
223
+ const opts = {
224
+ limit: Math.min(parseInt(req.query.limit) || 200, 1000),
225
+ folder: req.query.folder || null,
226
+ editor: req.query.editor || null,
227
+ ...parseDateOpts(req.query),
228
+ hiddenFolders: getHiddenFolders(),
229
+ };
230
+ res.json(await cache.getCachedToolCalls(name, opts));
231
+ } catch (err) {
232
+ res.status(500).json({ error: err.message });
233
+ }
234
+ });
235
+
236
+ app.post('/api/query', async (req, res) => {
237
+ try {
238
+ const { sql } = req.body;
239
+ if (!sql || typeof sql !== 'string') return res.status(400).json({ error: 'sql string required' });
240
+ // Only allow SELECT / PRAGMA / EXPLAIN / WITH statements
241
+ const trimmed = sql.trim().replace(/^--.*$/gm, '').trim();
242
+ const first = trimmed.split(/\s+/)[0].toUpperCase();
243
+ if (!['SELECT', 'PRAGMA', 'EXPLAIN', 'WITH'].includes(first)) {
244
+ return res.status(403).json({ error: 'Only SELECT queries are allowed' });
245
+ }
246
+ const { columns, rows, count } = await cache.rawQuery(sql);
247
+ res.json({ columns, rows, count });
248
+ } catch (err) {
249
+ res.status(400).json({ error: err.message });
250
+ }
251
+ });
252
+
253
+ app.get('/api/schema', async (req, res) => {
254
+ try {
255
+ res.json(await cache.schema());
256
+ } catch (err) {
257
+ res.status(500).json({ error: err.message });
258
+ }
259
+ });
260
+
261
+ app.get('/api/share-image', async (req, res) => {
262
+ try {
263
+ const filterOpts = { hiddenFolders: getHiddenFolders() };
264
+ if (req.query.folder) filterOpts.folder = req.query.folder;
265
+ const overview = await cache.getCachedOverview(filterOpts);
266
+ const stats = await cache.getCachedDashboardStats(filterOpts);
267
+ const costs = await cache.getCostAnalytics(filterOpts);
268
+ const opts = {};
269
+ if (req.query.showEditors !== undefined) opts.showEditors = req.query.showEditors !== 'false';
270
+ if (req.query.showModels !== undefined) opts.showModels = req.query.showModels !== 'false';
271
+ if (req.query.showCosts !== undefined) opts.showCosts = req.query.showCosts !== 'false';
272
+ if (req.query.showTokens !== undefined) opts.showTokens = req.query.showTokens !== 'false';
273
+ if (req.query.showHours !== undefined) opts.showHours = req.query.showHours !== 'false';
274
+ if (req.query.username) opts.username = req.query.username;
275
+ if (req.query.theme) opts.theme = req.query.theme;
276
+ if (req.query.folder) opts.folder = req.query.folder;
277
+ const svg = generateShareSvg(overview, stats, costs, opts);
278
+ res.setHeader('Content-Type', 'image/svg+xml');
279
+ res.send(svg);
280
+ } catch (err) {
281
+ console.error('Share image error:', err);
282
+ res.status(500).json({ error: err.message, stack: err.stack });
283
+ }
284
+ });
285
+
286
+ app.get('/api/usage', async (req, res) => {
287
+ try {
288
+ const { getAllUsage } = require('./editors');
289
+ const usage = await getAllUsage();
290
+ res.json(usage);
291
+ } catch (err) {
292
+ res.status(500).json({ error: err.message });
293
+ }
294
+ });
295
+
296
+ app.get('/api/refetch', async (req, res) => {
297
+ res.writeHead(200, {
298
+ 'Content-Type': 'text/event-stream',
299
+ 'Cache-Control': 'no-cache',
300
+ 'Connection': 'keep-alive',
301
+ });
302
+ try {
303
+ const result = await cache.resetAndRescanAsync((progress) => {
304
+ res.write(`data: ${JSON.stringify(progress)}\n\n`);
305
+ });
306
+ res.write(`data: ${JSON.stringify({ done: true, total: result.total, analyzed: result.analyzed })}\n\n`);
307
+ } catch (err) {
308
+ res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
309
+ }
310
+ res.end();
311
+ });
312
+
313
+ // ============================================================
314
+ // Config endpoints
315
+ // ============================================================
316
+
317
+ app.get('/api/config', async (req, res) => {
318
+ res.json(readConfig());
319
+ });
320
+
321
+ app.put('/api/config', async (req, res) => {
322
+ try {
323
+ const config = readConfig();
324
+ Object.assign(config, req.body);
325
+ writeConfig(config);
326
+ res.json(config);
327
+ } catch (err) {
328
+ res.status(500).json({ error: err.message });
329
+ }
330
+ });
331
+
332
+ app.get('/api/check-ai', async (req, res) => {
333
+ const folder = req.query.folder;
334
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
335
+ try {
336
+ const { execFile } = require('child_process');
337
+ const isWindows = process.platform === 'win32';
338
+ // On Windows, use npx.cmd with shell; on Unix, use npx directly
339
+ const cmd = isWindows ? 'npx.cmd' : 'npx';
340
+ const result = await new Promise((resolve, reject) => {
341
+ execFile(cmd, ['-y', 'check-ai', '--json', folder], {
342
+ timeout: 60000,
343
+ maxBuffer: 1024 * 1024,
344
+ shell: isWindows
345
+ }, (err, stdout) => {
346
+ try {
347
+ const json = JSON.parse(stdout);
348
+ resolve(json);
349
+ } catch (e) {
350
+ reject(new Error(err ? err.message : 'Failed to parse check-ai output'));
351
+ }
352
+ });
353
+ });
354
+ res.json(result);
355
+ } catch (err) {
356
+ res.status(500).json({ error: err.message });
357
+ }
358
+ });
359
+
360
+ // ============================================================
361
+ // Artifacts — delegates to editors/index.js getAllArtifacts
362
+ // ============================================================
363
+
364
+ app.get('/api/artifacts', async (req, res) => {
365
+ try {
366
+ const { getAllArtifacts } = require('./editors');
367
+ const projects = await cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
368
+ const result = [];
369
+
370
+ for (const project of projects) {
371
+ const folder = project.folder;
372
+ if (!folder) continue;
373
+
374
+ const artifacts = getAllArtifacts(folder);
375
+ if (artifacts.length === 0) continue;
376
+
377
+ // Group by editor
378
+ const byEditor = {};
379
+ for (const a of artifacts) {
380
+ if (!byEditor[a.editor]) byEditor[a.editor] = { editor: a.editor, label: a.editorLabel, files: [] };
381
+ byEditor[a.editor].files.push(a);
382
+ }
383
+
384
+ result.push({
385
+ folder,
386
+ name: project.name || path.basename(folder),
387
+ totalArtifacts: artifacts.length,
388
+ editors: Object.values(byEditor),
389
+ });
390
+ }
391
+
392
+ // Sort by total artifacts descending
393
+ result.sort((a, b) => b.totalArtifacts - a.totalArtifacts);
394
+ res.json(result);
395
+ } catch (err) {
396
+ res.status(500).json({ error: err.message });
397
+ }
398
+ });
399
+
400
+ app.get('/api/artifact-content', async (req, res) => {
401
+ try {
402
+ const filePath = req.query.path;
403
+ if (!filePath) return res.status(400).json({ error: 'path query param required' });
404
+
405
+ // Security: validate file exists in known artifact results for at least one project
406
+ const { getAllArtifacts } = require('./editors');
407
+ const projects = await cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
408
+ let allowed = false;
409
+ for (const project of projects) {
410
+ if (!project.folder) continue;
411
+ const artifacts = getAllArtifacts(project.folder);
412
+ if (artifacts.some(a => a.path === filePath)) { allowed = true; break; }
413
+ }
414
+ if (!allowed) return res.status(403).json({ error: 'Not an artifact file' });
415
+
416
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
417
+
418
+ const content = fs.readFileSync(filePath, 'utf-8');
419
+ const stat = fs.statSync(filePath);
420
+ res.json({ path: filePath, name: path.basename(filePath), content, size: stat.size, modifiedAt: stat.mtime.getTime() });
421
+ } catch (err) {
422
+ res.status(500).json({ error: err.message });
423
+ }
424
+ });
425
+
426
+ // ============================================================
427
+ // MCPs — collect MCP servers from all editors + match tool calls
428
+ // ============================================================
429
+
430
+ // Cache: MCP server tool lists are queried once at startup via initMcpToolsCache()
431
+ let _mcpToolsCache = null; // { servers, serverToolResults, toolToServer, serverToolPatterns }
432
+
433
+ // Live tool discovery EXECUTES each configured server (stdio discovery spawns
434
+ // the configured command; HTTP discovery calls its URL). Any project's .mcp.json
435
+ // or editor MCP config would otherwise run arbitrary commands just by opening
436
+ // the dashboard — so discovery is opt-in: config files are always read and
437
+ // listed, but servers are only queried when AGENTLYTICS_MCP_DISCOVERY=1.
438
+ const MCP_DISCOVERY = process.env.AGENTLYTICS_MCP_DISCOVERY === '1'
439
+ || process.env.AGENTLYTICS_MCP_DISCOVERY === 'true';
440
+
441
+ async function initMcpToolsCache() {
442
+ const { getAllMCPServers } = require('./editors');
443
+ const { queryMcpServerTools } = require('./editors/base');
444
+ const projects = await cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
445
+ const projectFolders = projects.map(p => p.folder).filter(Boolean);
446
+
447
+ const servers = getAllMCPServers(projectFolders);
448
+
449
+ const queryPromises = servers.map(async (server) => {
450
+ if (server.disabled || !MCP_DISCOVERY) return { server, tools: [] };
451
+ try {
452
+ const tools = await queryMcpServerTools(server);
453
+ return { server, tools };
454
+ } catch {
455
+ return { server, tools: [] };
456
+ }
457
+ });
458
+
459
+ const serverToolResults = await Promise.all(queryPromises);
460
+
461
+ const toolToServer = {};
462
+ for (const { server, tools } of serverToolResults) {
463
+ server.tools = tools;
464
+ for (const toolName of tools) {
465
+ toolToServer[toolName] = server.name;
466
+ }
467
+ }
468
+
469
+ const serverToolPatterns = {};
470
+ for (const { server, tools } of serverToolResults) {
471
+ if (tools.length === 0) continue;
472
+ serverToolPatterns[server.name] = new Set(tools.map(t => t.toLowerCase()));
473
+ }
474
+
475
+ _mcpToolsCache = { servers, serverToolResults, toolToServer, serverToolPatterns };
476
+ return _mcpToolsCache;
477
+ }
478
+
479
+ app.initMcpToolsCache = initMcpToolsCache;
480
+
481
+ app.get('/api/mcps', async (req, res) => {
482
+ try {
483
+ // Use cached MCP tool data (queried once at startup)
484
+ if (!_mcpToolsCache) await initMcpToolsCache();
485
+ const { servers, serverToolResults, toolToServer, serverToolPatterns } = _mcpToolsCache;
486
+
487
+ // 3. Get tool call stats from the ClickHouse cache
488
+ const toolRows = await cache.getAllToolCallRows();
489
+
490
+ const toolCallMap = {}; // toolName -> { count, editors: Set, sessions: Set, folders: Set }
491
+ const sessionMap = {}; // chatId -> { ... }
492
+
493
+ for (const row of toolRows) {
494
+ const name = row.tool_name;
495
+ if (!toolCallMap[name]) toolCallMap[name] = { count: 0, editors: new Set(), sessions: new Set(), folders: new Set() };
496
+ toolCallMap[name].count++;
497
+ toolCallMap[name].editors.add(row.source);
498
+ toolCallMap[name].sessions.add(row.chat_id);
499
+ if (row.folder) toolCallMap[name].folders.add(row.folder);
500
+
501
+ if (!sessionMap[row.chat_id]) {
502
+ sessionMap[row.chat_id] = {
503
+ composerId: row.chat_id,
504
+ source: row.source,
505
+ name: row.chat_name,
506
+ folder: row.folder,
507
+ createdAt: row.timestamp,
508
+ totalToolCalls: 0,
509
+ tools: {},
510
+ };
511
+ }
512
+ sessionMap[row.chat_id].totalToolCalls++;
513
+ sessionMap[row.chat_id].tools[name] = (sessionMap[row.chat_id].tools[name] || 0) + 1;
514
+ }
515
+
516
+ // 4. Build tool call summary
517
+ const toolCalls = Object.entries(toolCallMap)
518
+ .map(([name, data]) => ({
519
+ name,
520
+ count: data.count,
521
+ editors: [...data.editors],
522
+ sessionCount: data.sessions.size,
523
+ folders: [...data.folders],
524
+ }))
525
+ .sort((a, b) => b.count - a.count);
526
+
527
+ // 5. Match tool calls to MCP servers using actual queried tool names.
528
+ // Editors prefix MCP tool names in various ways:
529
+ // - Devin: mcp{N}_{toolName} (e.g. mcp1_query-docs)
530
+ // - Cursor: mcp_{ServerName}_{toolName} (e.g. mcp_Figma_get_figma_data)
531
+ // - VS Code: mcp_{sanitizedId}_{toolName} (e.g. mcp_io_github_byt_execute_sql)
532
+ // - Others: {server}_{sep}_{toolName} (e.g. prompts_chat__search_prompts)
533
+ //
534
+ // IMPORTANT: Only match tool calls that have an explicit MCP prefix.
535
+ // Tool calls without a prefix (e.g. "read_file", "edit_file") are built-in
536
+ // editor tools even if an MCP server happens to expose a tool with the same name.
537
+ const matchedTools = {};
538
+
539
+ for (const tc of toolCalls) {
540
+ const tcName = tc.name;
541
+ let serverName = null;
542
+
543
+ // Pattern 1: Devin — mcp{N}_{toolName}
544
+ const windsurfMatch = tcName.match(/^mcp(\d+)_(.+)$/);
545
+ if (windsurfMatch) {
546
+ const stripped = windsurfMatch[2];
547
+ serverName = toolToServer[stripped];
548
+ if (!serverName) {
549
+ // Fallback: search all server tool sets
550
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
551
+ if (toolSet.has(stripped.toLowerCase())) { serverName = sn; break; }
552
+ }
553
+ }
554
+ }
555
+
556
+ // Pattern 2: Cursor — mcp_{ServerName}_{toolName}
557
+ if (!serverName) {
558
+ const cursorMatch = tcName.match(/^mcp_([^_]+)_(.+)$/);
559
+ if (cursorMatch) {
560
+ const sName = cursorMatch[1];
561
+ const tName = cursorMatch[2];
562
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
563
+ if (sn.toLowerCase() === sName.toLowerCase() && toolSet.has(tName.toLowerCase())) {
564
+ serverName = sn; break;
565
+ }
566
+ }
567
+ // Even if we can't verify the tool, the prefix confirms it's an MCP call
568
+ if (!serverName) {
569
+ for (const s of servers) {
570
+ if (s.name.toLowerCase() === sName.toLowerCase()) { serverName = s.name; break; }
571
+ }
572
+ }
573
+ }
574
+ }
575
+
576
+ // Pattern 3: VS Code / generic — mcp_{sanitizedServerId}_{toolName}
577
+ // Server IDs like "io.github.f/prompts.chat-mcp" become "io_github_f_prompts_chat_mcp"
578
+ if (!serverName && tcName.startsWith('mcp_') && !tcName.match(/^mcp\d/)) {
579
+ const suffix = tcName.slice(4).toLowerCase(); // after "mcp_"
580
+ // First try matching via queried tool sets
581
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
582
+ for (const tool of toolSet) {
583
+ if (suffix.endsWith('_' + tool) || suffix.endsWith(tool)) {
584
+ serverName = sn; break;
585
+ }
586
+ }
587
+ if (serverName) break;
588
+ }
589
+ // Fallback: match by sanitized server name (for servers whose tools couldn't be queried)
590
+ // VS Code may truncate sanitized server IDs, so find the best (longest) prefix match
591
+ if (!serverName) {
592
+ let bestLen = 0;
593
+ let bestServer = null;
594
+ for (const s of servers) {
595
+ const sanitized = s.name.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_');
596
+ // Full sanitized name match
597
+ if (suffix.startsWith(sanitized + '_') && sanitized.length > bestLen) {
598
+ bestLen = sanitized.length; bestServer = s.name;
599
+ }
600
+ // Truncated prefix: suffix must start with at least 60% of sanitized name
601
+ const minLen = Math.max(6, Math.ceil(sanitized.length * 0.6));
602
+ for (let len = sanitized.length; len >= minLen; len--) {
603
+ const prefix = sanitized.slice(0, len);
604
+ if (suffix.startsWith(prefix + '_') && len > bestLen) {
605
+ bestLen = len; bestServer = s.name; break;
606
+ }
607
+ }
608
+ }
609
+ if (bestServer) serverName = bestServer;
610
+ }
611
+ }
612
+
613
+ // Pattern 4: Double-underscore separator — {server_name}__{toolName}
614
+ if (!serverName) {
615
+ const sepMatch = tcName.match(/^(.+?)__(.+)$/);
616
+ if (sepMatch) {
617
+ const tName = sepMatch[2];
618
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
619
+ if (toolSet.has(tName.toLowerCase())) { serverName = sn; break; }
620
+ }
621
+ }
622
+ }
623
+
624
+ if (serverName) {
625
+ if (!matchedTools[serverName]) matchedTools[serverName] = [];
626
+ matchedTools[serverName].push(tc);
627
+ }
628
+ }
629
+
630
+ // 6. Top sessions by tool calls
631
+ const topSessions = Object.values(sessionMap)
632
+ .sort((a, b) => b.totalToolCalls - a.totalToolCalls)
633
+ .slice(0, 50);
634
+
635
+ // 7. Per-project MCP stats
636
+ const projects = await cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
637
+ const projectMcpConfigs = [
638
+ { file: '.mcp.json', editor: 'claude-code', label: 'Claude Code' },
639
+ { file: '.cursor/mcp.json', editor: 'cursor', label: 'Cursor' },
640
+ { file: '.vscode/mcp.json', editor: 'vscode', label: 'VS Code' },
641
+ { file: '.gemini/settings.json', editor: 'gemini-cli', label: 'Gemini CLI' },
642
+ { file: '.kiro/settings/mcp.json', editor: 'kiro', label: 'Kiro' },
643
+ ];
644
+
645
+ const projectMcps = [];
646
+ for (const proj of projects) {
647
+ if (!proj.folder) continue;
648
+ const configs = [];
649
+ for (const pc of projectMcpConfigs) {
650
+ const configPath = path.join(proj.folder, pc.file);
651
+ if (!fs.existsSync(configPath)) continue;
652
+ try {
653
+ const raw = fs.readFileSync(configPath, 'utf-8');
654
+ const parsed = JSON.parse(raw);
655
+ const mcpServers = parsed.mcpServers || parsed.mcp_servers || parsed.servers || {};
656
+ configs.push({
657
+ file: pc.file,
658
+ editor: pc.editor,
659
+ editorLabel: pc.label,
660
+ serverCount: Object.keys(mcpServers).length,
661
+ serverNames: Object.keys(mcpServers),
662
+ });
663
+ } catch { /* skip invalid configs */ }
664
+ }
665
+ if (configs.length === 0) continue;
666
+
667
+ // Count MCP tool calls from this project's sessions
668
+ const projToolCalls = toolRows.filter(r => r.folder === proj.folder);
669
+ const mcpToolCallCount = projToolCalls.filter(r => {
670
+ const n = r.tool_name;
671
+ return n.startsWith('mcp') || n.includes('__');
672
+ }).length;
673
+
674
+ // Which configured servers are used (matched to tool calls)
675
+ const configuredServerNames = new Set(configs.flatMap(c => c.serverNames));
676
+ const matchedServerNames = [];
677
+ for (const sn of configuredServerNames) {
678
+ if (matchedTools[sn]) matchedServerNames.push(sn);
679
+ }
680
+
681
+ projectMcps.push({
682
+ folder: proj.folder,
683
+ name: proj.name,
684
+ configs,
685
+ totalServers: [...configuredServerNames].length,
686
+ matchedServers: matchedServerNames.length,
687
+ mcpToolCalls: mcpToolCallCount,
688
+ totalSessions: proj.totalSessions || 0,
689
+ });
690
+ }
691
+
692
+ projectMcps.sort((a, b) => b.totalServers - a.totalServers || b.mcpToolCalls - a.mcpToolCalls);
693
+
694
+ // Strip _env from response (security)
695
+ const safeServers = servers.map(({ _env, ...rest }) => rest);
696
+
697
+ res.json({
698
+ servers: safeServers,
699
+ toolCalls,
700
+ matchedTools,
701
+ topSessions,
702
+ projectMcps,
703
+ summary: {
704
+ totalServers: servers.length,
705
+ totalToolCalls: toolRows.length,
706
+ uniqueTools: toolCalls.length,
707
+ sessionsWithTools: Object.keys(sessionMap).length,
708
+ editorsWithServers: [...new Set(servers.map(s => s.editor))],
709
+ },
710
+ });
711
+ } catch (err) {
712
+ res.status(500).json({ error: err.message });
713
+ }
714
+ });
715
+
716
+ app.get('/api/all-projects', async (req, res) => {
717
+ try {
718
+ res.json(await cache.getCachedProjects({ ...parseDateOpts(req.query), includeHidden: true }));
719
+ } catch (err) {
720
+ res.status(500).json({ error: err.message });
721
+ }
722
+ });
723
+
724
+ // ============================================================
725
+ // GSD endpoints
726
+ // ============================================================
727
+
728
+ app.get('/api/gsd/projects', async (req, res) => {
729
+ try {
730
+ res.json(await cache.getCachedGSDProjects());
731
+ } catch (err) {
732
+ res.status(500).json({ error: err.message });
733
+ }
734
+ });
735
+
736
+ app.get('/api/gsd/phases', async (req, res) => {
737
+ try {
738
+ const { folder } = req.query;
739
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
740
+ res.json(await cache.getCachedGSDPhases(folder));
741
+ } catch (err) {
742
+ res.status(500).json({ error: err.message });
743
+ }
744
+ });
745
+
746
+ app.get('/api/gsd/plan', async (req, res) => {
747
+ try {
748
+ const { folder, phase } = req.query;
749
+ if (!folder || !phase) return res.status(400).json({ error: 'folder and phase query params required' });
750
+ // Same containment as /api/gsd/file: indexed project + no ../ escape.
751
+ // Anchor at .planning/phases, not .planning — '../backlog' resolves inside
752
+ // .planning and would pass a planningDir-prefix check while leaving phases/.
753
+ const planningDir = await authorizedPlanningDir(folder);
754
+ if (!planningDir) return res.status(403).json({ error: 'Not an indexed GSD project' });
755
+ const phasesDir = path.resolve(planningDir, 'phases');
756
+ const phaseFullDir = path.resolve(phasesDir, String(phase));
757
+ if (!phaseFullDir.startsWith(phasesDir + path.sep)) {
758
+ return res.status(403).json({ error: 'phase escapes the phases directory' });
759
+ }
760
+ const gsd = require('./editors/gsd');
761
+ const detail = gsd.getGSDPlanDetail(folder, phase);
762
+ if (!detail) return res.status(404).json({ error: 'Plan not found' });
763
+ res.json(detail);
764
+ } catch (err) {
765
+ res.status(500).json({ error: err.message });
766
+ }
767
+ });
768
+
769
+ app.get('/api/gsd/overview', async (req, res) => {
770
+ try {
771
+ res.json(await cache.getCachedGSDOverview());
772
+ } catch (err) {
773
+ res.status(500).json({ error: err.message });
774
+ }
775
+ });
776
+
777
+ app.get('/api/gsd/config', async (req, res) => {
778
+ try {
779
+ const { folder } = req.query;
780
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
781
+ const planningDir = await authorizedPlanningDir(folder);
782
+ if (!planningDir) return res.status(403).json({ error: 'Not an indexed GSD project' });
783
+ const configPath = path.join(planningDir, 'config.json');
784
+ if (!fs.existsSync(configPath)) return res.json(null);
785
+ res.json(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
786
+ } catch (err) {
787
+ res.status(500).json({ error: err.message });
788
+ }
789
+ });
790
+
791
+ app.get('/api/gsd/phase-tokens', async (req, res) => {
792
+ try {
793
+ const { folder } = req.query;
794
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
795
+ res.json(await cache.getGSDPhaseTokens(folder));
796
+ } catch (err) {
797
+ res.status(500).json({ error: err.message });
798
+ }
799
+ });
800
+
801
+ // GSD routes read the local filesystem from caller-supplied params. Two rules,
802
+ // mirroring /api/artifact-content: the folder must be one of the INDEXED GSD
803
+ // projects (never an arbitrary path), and phase paths must resolve INSIDE that
804
+ // project's .planning directory (no ../ escapes).
805
+ async function authorizedPlanningDir(folder) {
806
+ const projects = await cache.getCachedGSDProjects();
807
+ const resolved = path.resolve(String(folder));
808
+ const known = projects.some(p => p.folder && path.resolve(p.folder) === resolved);
809
+ return known ? path.join(resolved, '.planning') : null;
810
+ }
811
+
812
+ // Generic .planning file reader
813
+ // type: 'state' (project-level STATE.md) | 'research' | 'verification' | 'summary' (phase-level, requires phase param)
814
+ app.get('/api/gsd/file', async (req, res) => {
815
+ try {
816
+ const { folder, phase: phaseDir, type } = req.query;
817
+ if (!folder || !type) return res.status(400).json({ error: 'folder and type required' });
818
+
819
+ const planningDir = await authorizedPlanningDir(folder);
820
+ if (!planningDir) return res.status(403).json({ error: 'Not an indexed GSD project' });
821
+ let content = null;
822
+
823
+ if (type === 'state') {
824
+ const filePath = path.join(planningDir, 'STATE.md');
825
+ if (fs.existsSync(filePath)) content = fs.readFileSync(filePath, 'utf-8');
826
+ } else if (phaseDir) {
827
+ // Anchor at .planning/phases, not .planning (see /api/gsd/plan).
828
+ const phasesDir = path.resolve(planningDir, 'phases');
829
+ const phaseFullDir = path.resolve(phasesDir, String(phaseDir));
830
+ if (!phaseFullDir.startsWith(phasesDir + path.sep)) {
831
+ return res.status(403).json({ error: 'phase escapes the phases directory' });
832
+ }
833
+ const pattern = type === 'research' ? /RESEARCH\.md$/i
834
+ : type === 'verification' ? /VERIFICATION\.md$/i
835
+ : type === 'summary' ? /SUMMARY\.md$/i
836
+ : null;
837
+ if (pattern && fs.existsSync(phaseFullDir)) {
838
+ const files = fs.readdirSync(phaseFullDir).filter(f => pattern.test(f)).sort();
839
+ if (files.length > 0) {
840
+ const sections = files.map(f => {
841
+ const c = fs.readFileSync(path.join(phaseFullDir, f), 'utf-8');
842
+ return files.length > 1 ? `## ${f}\n\n${c}` : c;
843
+ });
844
+ content = sections.join('\n\n---\n\n');
845
+ }
846
+ }
847
+ }
848
+
849
+ res.json({ content });
850
+ } catch (err) {
851
+ res.status(500).json({ error: err.message });
852
+ }
853
+ });
854
+
855
+ // SPA fallback
856
+ app.get('*', async (req, res) => {
857
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
858
+ });
859
+
860
+ module.exports = app;