memhouse 0.0.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +72 -3
- package/bin/memhouse.js +559 -0
- package/cache.js +850 -0
- package/ch-ingest.js +329 -0
- package/ch-schema.js +152 -0
- package/clickhouse.js +87 -0
- package/editors/adapter-errors.js +52 -0
- package/editors/antigravity.js +1048 -0
- package/editors/base.js +321 -0
- package/editors/claude.js +386 -0
- package/editors/codebuff.js +338 -0
- package/editors/codex.js +537 -0
- package/editors/commandcode.js +166 -0
- package/editors/copilot-jetbrains.js +207 -0
- package/editors/copilot.js +260 -0
- package/editors/cursor-agent.js +202 -0
- package/editors/cursor.js +498 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +386 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +274 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +432 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +229 -0
- package/index.js +424 -2
- package/mcp-server.js +294 -0
- package/mem-house/DESIGN.md +119 -0
- package/mem-house/delivery/AGENT-INSTALL.md +87 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +83 -0
- package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
- package/mem-house/rls.sql +17 -0
- package/mem-house/schema.sql +145 -0
- package/mem-house/server/queries.js +878 -0
- package/mem-house/server/server.js +228 -0
- package/mem-house/shipper/ship.js +461 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +59 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-CCPh7a2Z.js +75 -0
- package/public/assets/index-DV6ONi_F.css +2 -0
- package/public/index.html +16 -0
- package/relay-client.js +306 -0
- package/relay-server.js +577 -0
- package/server.js +860 -0
- package/share-image.js +367 -0
- package/ui/eslint.config.js +29 -0
- package/ui/index.html +15 -0
- package/ui/package-lock.json +4229 -0
- package/ui/package.json +38 -0
- package/ui/src/App.jsx +364 -0
- package/ui/src/components/ActivityHeatmap.jsx +206 -0
- package/ui/src/components/AiAuditCard.jsx +254 -0
- package/ui/src/components/AnimatedLoader.jsx +14 -0
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +226 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/components/EditorDot.jsx +11 -0
- package/ui/src/components/EditorIcon.jsx +117 -0
- package/ui/src/components/KpiCard.jsx +9 -0
- package/ui/src/components/LiveFeed.jsx +128 -0
- package/ui/src/components/LoginScreen.jsx +81 -0
- package/ui/src/components/MessageRenderer.jsx +148 -0
- package/ui/src/components/PageHeader.jsx +11 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/components/ShareModal.jsx +305 -0
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/hooks/useLive.jsx +15 -0
- package/ui/src/index.css +83 -0
- package/ui/src/lib/api.js +343 -0
- package/ui/src/lib/constants.js +88 -0
- package/ui/src/lib/theme.jsx +23 -0
- package/ui/src/main.jsx +16 -0
- package/ui/src/pages/Artifacts.jsx +575 -0
- package/ui/src/pages/Compare.jsx +232 -0
- package/ui/src/pages/CostAnalysis.jsx +354 -0
- package/ui/src/pages/Dashboard.jsx +529 -0
- package/ui/src/pages/DeepAnalysis.jsx +495 -0
- package/ui/src/pages/GSD.jsx +726 -0
- package/ui/src/pages/MCPs.jsx +744 -0
- package/ui/src/pages/ProjectDetail.jsx +325 -0
- package/ui/src/pages/Projects.jsx +249 -0
- package/ui/src/pages/RelayDashboard.jsx +590 -0
- package/ui/src/pages/RelayUserDetail.jsx +466 -0
- package/ui/src/pages/Sessions.jsx +535 -0
- package/ui/src/pages/Settings.jsx +241 -0
- package/ui/src/pages/SqlViewer.jsx +370 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- 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;
|