UNPKG

cntx-ui

Version:

Autonomous Repository Intelligence engine with web UI and MCP server. Unified semantic code understanding, local RAG, and agent working memory.

511 lines (510 loc) 22.7 kB
/** * API Router for cntx-ui * Handles all HTTP API endpoints and request routing */ import { readFileSync, writeFileSync, existsSync } from 'fs'; import path, { join } from 'path'; export default class APIRouter { cntxServer; configManager; bundleManager; fileSystemManager; semanticAnalysisManager; vectorStore; activityManager; constructor(cntxServer, configManager, bundleManager, fileSystemManager, semanticAnalysisManager, vectorStore, activityManager) { this.cntxServer = cntxServer; this.configManager = configManager; this.bundleManager = bundleManager; this.fileSystemManager = fileSystemManager; this.semanticAnalysisManager = semanticAnalysisManager; this.vectorStore = vectorStore; this.activityManager = activityManager; } async handleRequest(req, res, url) { const { pathname } = url; const method = req.method; try { // === Bundle Endpoints === if (pathname === '/api/bundles' && method === 'GET') { return await this.handleGetBundles(req, res, url); } if (pathname === '/api/bundles' && method === 'POST') { return await this.handlePostBundles(req, res); } if (pathname.startsWith('/api/bundles/') && method === 'GET') { const bundleName = pathname.split('/')[3]; return await this.handleGetBundle(req, res, bundleName); } if (pathname.startsWith('/api/regenerate/') && (method === 'GET' || method === 'POST')) { const bundleName = pathname.split('/')[3]; return await this.handleRegenerateBundle(req, res, bundleName); } // === File Endpoints === if (pathname === '/api/files' && method === 'GET') { return await this.handleGetFiles(req, res); } if (pathname.startsWith('/api/files/') && method === 'GET') { const filePath = pathname.substring(11); // Remove /api/files/ return await this.handleGetFile(req, res, filePath); } if (pathname === '/api/open-file' && method === 'POST') { return await this.handlePostOpenFile(req, res); } // === Configuration Endpoints === if (pathname === '/api/config' && method === 'GET') { return await this.handleGetConfig(req, res); } if (pathname === '/api/config' && method === 'POST') { return await this.handlePostConfig(req, res); } if (pathname === '/api/cntxignore' && method === 'GET') { return await this.handleGetCntxignore(req, res); } if (pathname === '/api/cntxignore' && method === 'POST') { return await this.handlePostCntxignore(req, res); } // === Semantic Analysis Endpoints === if (pathname === '/api/semantic-chunks' && method === 'GET') { return await this.handleGetSemanticChunks(req, res, url); } if (pathname === '/api/semantic-search' && method === 'POST') { return await this.handlePostSemanticSearch(req, res); } // === Vector DB Endpoints === if (pathname === '/api/vector-db/status' && method === 'GET') { return await this.handleGetVectorDbStatus(req, res); } if (pathname === '/api/vector-db/rebuild' && method === 'POST') { return await this.handlePostVectorDbRebuild(req, res); } if (pathname === '/api/vector-db/search' && method === 'POST') { return await this.handlePostVectorDbSearch(req, res); } if (pathname === '/api/vector-db/projection' && method === 'GET') { return await this.handleGetVectorDbProjection(req, res); } if (pathname === '/api/vector-db/network' && method === 'GET') { return await this.handleGetVectorDbNetwork(req, res); } // === Database Endpoints === if (pathname === '/api/database/info' && method === 'GET') { return await this.handleGetDatabaseInfo(req, res); } if (pathname === '/api/database/query' && method === 'POST') { return await this.handlePostDatabaseQuery(req, res); } // === Activity Endpoints === if (pathname === '/api/activities' && method === 'GET') { return await this.handleGetActivities(req, res); } if (pathname.startsWith('/api/activities/') && pathname.endsWith('/reasoning') && method === 'GET') { const activityId = pathname.split('/')[3]; return await this.handleGetActivityReasoning(req, res, activityId); } // === Status & MCP === if (pathname === '/api/status' && method === 'GET') { return await this.handleGetStatus(req, res); } if (pathname === '/api/mcp-status' && method === 'GET') { return await this.handleGetMcpStatus(req, res); } // === Artifact Endpoints === if (pathname === '/api/artifacts' && method === 'GET') { return await this.handleGetArtifacts(req, res); } if (pathname === '/api/artifacts/openapi' && method === 'GET') { return await this.handleGetArtifact(req, res, 'openapi'); } if (pathname === '/api/artifacts/navigation' && method === 'GET') { return await this.handleGetArtifact(req, res, 'navigation'); } // === Rule Management === if (pathname === '/api/cursor-rules' && method === 'GET') { return await this.handleGetCursorRules(req, res); } if (pathname === '/api/cursor-rules' && method === 'POST') { return await this.handlePostCursorRules(req, res); } if (pathname === '/api/claude-md' && method === 'GET') { return await this.handleGetClaudeMd(req, res); } if (pathname === '/api/claude-md' && method === 'POST') { return await this.handlePostClaudeMd(req, res); } // 404 for unknown API routes this.sendError(res, 404, `API endpoint not found: ${method} ${pathname}`); } catch (error) { console.error(`API Error: ${error.message}`); this.sendError(res, 500, error.message); } } // === Helper Methods === sendResponse(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } sendError(res, status, message) { this.sendResponse(res, status, { error: message }); } async getRequestBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { resolve(body); }); req.on('error', reject); }); } // === Handlers === async handleGetBundles(req, res, url) { try { const bundleInfo = this.bundleManager.getAllBundleInfo(); this.sendResponse(res, 200, bundleInfo); } catch (error) { this.sendError(res, 500, error.message); } } async handlePostBundles(req, res) { const body = await this.getRequestBody(req); const { action, bundleName, fileName, fileNames } = JSON.parse(body); if (!action || !bundleName) { return this.sendError(res, 400, 'Missing required fields: action and bundleName'); } const bundles = this.configManager.getBundles(); const bundle = bundles.get(bundleName); if (!bundle) { return this.sendError(res, 404, `Bundle not found: ${bundleName}`); } switch (action) { case 'add-file': if (!fileName) return this.sendError(res, 400, 'Missing fileName'); const relAdd = fileName.startsWith('/') ? path.relative(this.configManager.CWD, fileName) : fileName; if (!bundle.files.includes(relAdd)) { bundle.files.push(relAdd); bundle.changed = true; this.configManager.saveBundleStates(); } break; case 'remove-file': if (!fileName) return this.sendError(res, 400, 'Missing fileName'); const relRem = fileName.startsWith('/') ? path.relative(this.configManager.CWD, fileName) : fileName; const idx = bundle.files.indexOf(relRem); if (idx > -1) { bundle.files.splice(idx, 1); bundle.changed = true; this.configManager.saveBundleStates(); } break; } this.sendResponse(res, 200, { success: true }); } async handleGetBundle(req, res, bundleName) { const content = await this.bundleManager.getBundleContent(bundleName); if (content === null) return this.sendError(res, 404, 'Bundle not found'); res.writeHead(200, { 'Content-Type': 'application/xml' }); res.end(content); } async handleRegenerateBundle(req, res, bundleName) { await this.bundleManager.regenerateBundle(bundleName); this.sendResponse(res, 200, { success: true }); } async handleGetFiles(req, res) { const files = this.fileSystemManager.getFileTree(); this.sendResponse(res, 200, files); } async handleGetFile(req, res, filePath) { const fullPath = this.fileSystemManager.fullPath(filePath); if (!existsSync(fullPath)) return this.sendError(res, 404, 'File not found'); const content = readFileSync(fullPath, 'utf8'); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end(content); } async handlePostOpenFile(req, res) { const body = await this.getRequestBody(req); const { filePath, line } = JSON.parse(body); // Simple mock for opening in editor console.log(`Editor requested for ${filePath}:${line || 1}`); this.sendResponse(res, 200, { success: true }); } async handleGetConfig(req, res) { const config = this.configManager.loadConfig(); this.sendResponse(res, 200, config); } async handlePostConfig(req, res) { const body = await this.getRequestBody(req); this.configManager.saveConfig(JSON.parse(body)); this.sendResponse(res, 200, { success: true }); } async handleGetCntxignore(req, res) { if (existsSync(this.configManager.IGNORE_FILE)) { const content = readFileSync(this.configManager.IGNORE_FILE, 'utf8'); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end(content); } else { res.end(''); } } async handlePostCntxignore(req, res) { const body = await this.getRequestBody(req); const { content } = JSON.parse(body); const success = this.configManager.saveCntxignore(content); if (!success) return this.sendError(res, 500, 'Failed to save .cntxignore'); this.fileSystemManager.setIgnorePatterns(this.configManager.ignorePatterns); this.sendResponse(res, 200, { success: true }); } async handleGetSemanticChunks(req, res, url) { const refresh = url.query?.refresh === 'true'; let analysis; if (refresh) { analysis = await this.cntxServer.refreshSemanticAnalysis(); } else { analysis = await this.cntxServer.getSemanticAnalysis(); } const chunks = analysis.chunks.map((chunk) => ({ id: chunk.id || chunk.name, name: chunk.name, code: chunk.code, semanticType: chunk.subtype || chunk.type, businessDomain: chunk.businessDomain || [], technicalPatterns: chunk.technicalPatterns || [], purpose: chunk.purpose || '', filePath: chunk.filePath, complexity: chunk.complexity || { score: 0, level: 'low' }, tags: chunk.tags || [], startLine: chunk.startLine })); this.sendResponse(res, 200, { summary: analysis.summary, chunks }); } async handlePostSemanticSearch(req, res) { const body = await this.getRequestBody(req); const { query, question, limit = 20 } = JSON.parse(body); const searchTerm = query || question; if (!searchTerm) { return this.sendError(res, 400, 'Missing search term (query or question)'); } const results = await this.vectorStore.search(searchTerm, { limit }); this.sendResponse(res, 200, { results }); } async handleGetVectorDbStatus(req, res) { const info = this.configManager.dbManager.getInfo(); this.sendResponse(res, 200, { stats: { totalChunks: info.chunkCount, embeddingCount: info.embeddingCount, modelName: this.vectorStore.modelName } }); } async handlePostVectorDbRebuild(req, res) { const analysis = await this.cntxServer.getSemanticAnalysis(); for (const chunk of analysis.chunks) { await this.vectorStore.upsertChunk(chunk); } const info = this.configManager.dbManager.getInfo(); this.sendResponse(res, 200, { success: true, embeddingCount: info.embeddingCount }); } async handlePostVectorDbSearch(req, res) { const body = await this.getRequestBody(req); const { query, limit = 10 } = JSON.parse(body); const results = await this.vectorStore.search(query, { limit }); this.sendResponse(res, 200, results); } async handleGetVectorDbProjection(req, res) { const dbManager = this.configManager.dbManager; const embeddingCountRow = dbManager.db.prepare('SELECT COUNT(*) as count FROM vector_embeddings').get(); const currentEmbeddingCount = embeddingCountRow.count; if (currentEmbeddingCount === 0) { return this.sendResponse(res, 200, { points: [], meta: { totalPoints: 0, embeddingCount: 0, computedAt: null, cached: false } }); } // Check cache freshness const cachedCount = dbManager.getProjectionEmbeddingCount(); if (cachedCount === currentEmbeddingCount) { const cached = dbManager.getProjections(); if (cached) { // Join with chunk metadata const chunkMap = new Map(); const chunks = dbManager.db.prepare('SELECT * FROM semantic_chunks').all(); for (const c of chunks) { const mapped = dbManager.mapChunkRow(c); chunkMap.set(mapped.id, mapped); } const points = cached.map(p => { const chunk = chunkMap.get(p.chunkId); return { id: p.chunkId, x: p.x, y: p.y, name: chunk?.name || 'unknown', filePath: chunk?.filePath || '', purpose: chunk?.purpose || 'unknown', semanticType: chunk?.subtype || chunk?.type || 'unknown', complexity: chunk?.complexity?.score || 0, directory: chunk?.filePath ? chunk.filePath.split('/').slice(0, -1).join('/') || '.' : '.' }; }); return this.sendResponse(res, 200, { points, meta: { totalPoints: points.length, embeddingCount: currentEmbeddingCount, computedAt: new Date().toISOString(), cached: true } }); } } // Compute fresh UMAP projection const rows = dbManager.db.prepare(` SELECT ve.chunk_id, ve.embedding, sc.name, sc.file_path, sc.type, sc.subtype, sc.complexity_score, sc.purpose FROM vector_embeddings ve JOIN semantic_chunks sc ON ve.chunk_id = sc.id `).all(); if (rows.length < 2) { return this.sendResponse(res, 200, { points: rows.map((r) => ({ id: r.chunk_id, x: 0, y: 0, name: r.name, filePath: r.file_path, purpose: r.purpose || 'unknown', semanticType: r.subtype || r.type || 'unknown', complexity: r.complexity_score || 0, directory: r.file_path ? r.file_path.split('/').slice(0, -1).join('/') || '.' : '.' })), meta: { totalPoints: rows.length, embeddingCount: currentEmbeddingCount, computedAt: new Date().toISOString(), cached: false } }); } // Extract embeddings as number[][] const embeddings = rows.map((r) => { const buf = r.embedding; const floats = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); return Array.from(floats); }); // Run UMAP const { UMAP } = await import('umap-js'); const umap = new UMAP({ nNeighbors: Math.min(15, rows.length - 1), minDist: 0.1, nComponents: 2 }); const projected = umap.fit(embeddings); // Save to cache const projections = rows.map((r, i) => ({ chunkId: r.chunk_id, x: projected[i][0], y: projected[i][1] })); dbManager.saveProjections(projections, currentEmbeddingCount); // Build response const points = rows.map((r, i) => ({ id: r.chunk_id, x: projected[i][0], y: projected[i][1], name: r.name, filePath: r.file_path, purpose: r.purpose || 'unknown', semanticType: r.subtype || r.type || 'unknown', complexity: r.complexity_score || 0, directory: r.file_path ? r.file_path.split('/').slice(0, -1).join('/') || '.' : '.' })); this.sendResponse(res, 200, { points, meta: { totalPoints: points.length, embeddingCount: currentEmbeddingCount, computedAt: new Date().toISOString(), cached: false } }); } async handleGetVectorDbNetwork(req, res) { const chunks = this.configManager.dbManager.db.prepare('SELECT * FROM semantic_chunks').all(); const embeddings = this.configManager.dbManager.db.prepare('SELECT * FROM vector_embeddings').all(); const nodes = chunks.map(c => this.configManager.dbManager.mapChunkRow(c)).slice(0, 100); const edges = []; // Simple pairwise similarity this.sendResponse(res, 200, { nodes, edges }); } async handleGetDatabaseInfo(req, res) { const info = this.configManager.dbManager.getInfo(); this.sendResponse(res, 200, info); } async handlePostDatabaseQuery(req, res) { const body = await this.getRequestBody(req); const { query } = JSON.parse(body); const results = this.configManager.dbManager.query(query); this.sendResponse(res, 200, { results }); } async handleGetActivities(req, res) { const activities = await this.activityManager.loadActivities(); this.sendResponse(res, 200, activities); } async handleGetActivityReasoning(req, res, activityId) { const history = this.configManager.dbManager.getSessionHistory(activityId); this.sendResponse(res, 200, { history }); } async handleGetStatus(req, res) { const bundles = this.bundleManager.getAllBundleInfo(); this.sendResponse(res, 200, { uptime: process.uptime(), memory: process.memoryUsage(), bundles, scanning: this.bundleManager.isScanning, totalFiles: this.fileSystemManager.getAllFiles().length, mcp: { enabled: this.cntxServer.mcpServerStarted, available: true } }); } async handleGetMcpStatus(req, res) { const isRunning = this.cntxServer.mcpServerStarted; this.sendResponse(res, 200, { enabled: isRunning, running: isRunning, available: true, message: isRunning ? 'MCP server is running' : 'MCP server integration available' }); } async handleGetArtifacts(req, res) { const artifacts = this.cntxServer.artifactManager.refresh(); this.sendResponse(res, 200, { artifacts }); } async handleGetArtifact(req, res, type) { this.cntxServer.artifactManager.refresh(); const payload = this.cntxServer.artifactManager.getPayload(type); this.sendResponse(res, 200, payload); } async handleGetCursorRules(req, res) { const filePath = join(this.configManager.CWD, '.cursorrules'); if (existsSync(filePath)) { const content = readFileSync(filePath, 'utf8'); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end(content); } else { res.end(''); } } async handlePostCursorRules(req, res) { const body = await this.getRequestBody(req); const { content } = JSON.parse(body); writeFileSync(join(this.configManager.CWD, '.cursorrules'), content, 'utf8'); this.sendResponse(res, 200, { success: true }); } async handleGetClaudeMd(req, res) { const filePath = join(this.configManager.CWD, 'CLAUDE.md'); if (existsSync(filePath)) { const content = readFileSync(filePath, 'utf8'); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end(content); } else { res.end(''); } } async handlePostClaudeMd(req, res) { const body = await this.getRequestBody(req); const { content } = JSON.parse(body); writeFileSync(join(this.configManager.CWD, 'CLAUDE.md'), content, 'utf8'); this.sendResponse(res, 200, { success: true }); } }