Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
index-api.tsβ€’37.6 kB
import { Router, Request, Response } from 'express'; import { FileWatchManager } from '../indexing/FileWatchManager.js'; import { WatchConfigManager } from '../indexing/WatchConfigManager.js'; import neo4j from 'neo4j-driver'; import { validateAndSanitizePath, translateHostToContainer, getHostWorkspaceRoot } from '../utils/path-utils.js'; import { promises as fs } from 'fs'; const router = Router(); /** * Safely convert Neo4j integers to JavaScript numbers. * Handles both Neo4j Integer objects (with .toInt()) and plain numbers. * Works with both Neo4j and NornicDB backends. */ const toInt = (value: any): number => { if (value === null || value === undefined) return 0; if (typeof value === 'number') return value; if (typeof value?.toInt === 'function') return value.toInt(); if (typeof value?.toNumber === 'function') return value.toNumber(); return parseInt(String(value), 10) || 0; }; // Get FileWatchManager instance from global (set in http-server.ts) const getWatchManager = (): FileWatchManager => { const manager = (globalThis as any).fileWatchManager; if (!manager) { throw new Error('FileWatchManager not initialized'); } return manager; }; /** * GET /api/indexed-folders - List indexed folders * @example fetch('/api/indexed-folders').then(r => r.json()); */ router.get('/indexed-folders', async (req: Request, res: Response) => { let driver: neo4j.Driver | null = null; try { driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const configManager = new WatchConfigManager(driver); const watchConfigs = await configManager.listAll(); // Use separate session for each query to avoid transaction conflicts const folders = await Promise.all( watchConfigs.map(async (config) => { const session = driver!.session(); try { // Translate host path to container path for querying files // Files may be stored with different path formats: // 1. Container path: /workspace/ngx-cmk-translate/... // 2. Full host path: /Users/c815719/src/ngx-cmk-translate/... // 3. Tilde host path: ~/src/ngx-cmk-translate/... (legacy) const containerPath = translateHostToContainer(config.path); const containerFolderPath = containerPath.endsWith('/') ? containerPath : containerPath + '/'; // Also check for host path format (config.path might be host or container) const hostFolderPath = config.path.endsWith('/') ? config.path : config.path + '/'; // Check for tilde-based path (legacy format) const hostWorkspaceRoot = getHostWorkspaceRoot(); let tildeFolderPath = null; if (config.path.startsWith(hostWorkspaceRoot)) { // Convert /Users/c815719/src/folder -> ~/src/folder const relativePath = config.path.substring(hostWorkspaceRoot.length); tildeFolderPath = `~/src${relativePath}/`; } const result = await session.run( ` MATCH (f:File) WHERE f.path STARTS WITH $containerFolderPath OR f.path = $containerPath OR f.path STARTS WITH $hostFolderPath OR f.path = $hostPath OR ($tildeFolderPath IS NOT NULL AND (f.path STARTS WITH $tildeFolderPath OR f.path = $tildePath)) WITH DISTINCT f OPTIONAL MATCH (f)-[:HAS_CHUNK]->(c:FileChunk) WITH f, c, CASE WHEN c IS NOT NULL AND c.embedding IS NOT NULL THEN 1 ELSE 0 END as chunkHasEmbedding, CASE WHEN f.embedding IS NOT NULL THEN 1 ELSE 0 END as fileHasEmbedding RETURN COUNT(DISTINCT f) as fileCount, COUNT(DISTINCT c) as chunkCount, SUM(chunkHasEmbedding) + SUM(fileHasEmbedding) as embeddingCount `, { containerFolderPath, containerPath, hostFolderPath, hostPath: config.path, tildeFolderPath, tildePath: tildeFolderPath ? tildeFolderPath.slice(0, -1) : null } ); const record = result.records[0]; const fileCount = record ? toInt(record.get('fileCount')) : 0; const chunkCount = record ? toInt(record.get('chunkCount')) : 0; const embeddingCount = record ? toInt(record.get('embeddingCount')) : 0; // Convert Neo4j DateTime to ISO string const lastSyncDate: any = config.last_indexed || config.added_date; const lastSyncString = lastSyncDate ? (typeof lastSyncDate === 'string' ? lastSyncDate : lastSyncDate.toString()) : new Date().toISOString(); return { id: config.id, path: config.path, hostPath: config.host_path, fileCount, chunkCount, embeddingCount, status: config.status || 'active', error: config.error || null, lastSync: lastSyncString, patterns: config.file_patterns }; } finally { await session.close(); } }) ); res.json({ folders }); } catch (error: any) { console.error('❌ Error fetching indexed folders:', error); res.status(500).json({ error: 'Failed to fetch indexed folders', details: error.message }); } finally { if (driver) { await driver.close(); } } }); /** * POST /api/index-folder - Add folder to watch * @example * fetch('/api/index-folder', { * method: 'POST', * body: JSON.stringify({ path: '/workspace/src', recursive: true }) * }).then(r => r.json()); */ router.post('/index-folder', async (req: Request, res: Response) => { try { const { path: inputPath, recursive, generate_embeddings, file_patterns, ignore_patterns } = req.body; if (!inputPath) { return res.status(400).json({ error: 'Path is required' }); } // Sanitize and validate path input using path utilities (same as MCP tools) let resolvedPath: string; let containerPath: string; try { resolvedPath = validateAndSanitizePath(inputPath); // Translate host path to container path containerPath = translateHostToContainer(resolvedPath); } catch (error) { return res.status(400).json({ error: 'Invalid path', message: error instanceof Error ? error.message : 'Invalid path provided', path: inputPath }); } console.log(`πŸ“ Path translation: ${resolvedPath} -> ${containerPath}`); // Validation: Path exists (using container path) try { await fs.access(containerPath); } catch (error) { return res.status(400).json({ error: 'Path does not exist', message: `Path '${resolvedPath}' (container: '${containerPath}') does not exist on filesystem.`, path: resolvedPath }); } // Check if it's a directory try { const stats = await fs.stat(containerPath); if (!stats.isDirectory()) { return res.status(400).json({ error: 'Path is not a directory', message: `Path '${resolvedPath}' is not a directory.`, path: resolvedPath }); } } catch (error) { return res.status(400).json({ error: 'Cannot stat path', message: error instanceof Error ? error.message : 'Failed to check path', path: resolvedPath }); } console.log(`πŸ“ Adding folder to indexing: ${containerPath}`); console.log(` Resolved path: ${resolvedPath}`); console.log(` Container path: ${containerPath}`); console.log(` Recursive: ${recursive !== false}`); console.log(` Generate embeddings: ${generate_embeddings !== false}`); const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const configManager = new WatchConfigManager(driver); // Check if already watching (use container path) const existing = await configManager.getByPath(containerPath); if (existing) { await driver.close(); return res.status(409).json({ error: 'Folder is already being watched', path: resolvedPath, containerPath: containerPath, existingConfig: existing }); } // Create watch configuration (use host path for consistency) const config = await configManager.createWatch({ path: resolvedPath, // Store host path (for UI/SSE matching) host_path: resolvedPath, // Store resolved host path recursive: recursive !== false, generate_embeddings: generate_embeddings !== false, file_patterns, ignore_patterns, debounce_ms: 500 }); await driver.close(); // Start watching in background (don't await - let it run async) const watchManager = getWatchManager(); watchManager.startWatch(config).catch((error) => { console.error(`❌ Error during background indexing for ${containerPath}:`, error); }); // Return immediately - indexing continues in background res.json({ success: true, message: `Folder added to indexing: ${resolvedPath}. Indexing will continue in the background.`, path: resolvedPath, // Return sanitized path to user containerPath: containerPath, // Also include container path for transparency config }); } catch (error: any) { console.error('❌ Error adding folder to indexing:', error); res.status(500).json({ error: 'Failed to add folder to indexing', message: error.message }); } }); /** * DELETE /api/indexed-folders - Remove folder from watch * @example * fetch('/api/indexed-folders', { * method: 'DELETE', * body: JSON.stringify({ id: 'watch-123' }) * }).then(r => r.json()); */ router.delete('/indexed-folders', async (req: Request, res: Response) => { try { const { id } = req.body; if (!id) { return res.status(400).json({ error: 'Watch config ID is required' }); } console.log(`πŸ—‘οΈ Removing watch config by ID: ${id}`); const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const configManager = new WatchConfigManager(driver); // Get config by ID const config = await configManager.getById(id); if (!config) { await driver.close(); return res.status(404).json({ error: 'Watch configuration not found', id }); } const containerPath = config.path; const hostPath = config.host_path || config.path; console.log(` Container path: ${containerPath}`); console.log(` Host path: ${hostPath}`); // Stop watcher const watchManager = getWatchManager(); await watchManager.stopWatch(containerPath); // Execute hybrid cleanup strategy (relationships + path fallback) const session = driver.session(); try { // Step 1: Try relationship-based deletion (for files indexed with new code) const relResult = await session.run(` MATCH (wc:WatchConfig {id: $watchConfigId})-[:WATCHES]->(f:File) OPTIONAL MATCH (f)-[:HAS_CHUNK]->(c:FileChunk) WITH f, collect(c) AS chunks, count(c) AS chunk_count FOREACH (chunk IN chunks | DETACH DELETE chunk) DETACH DELETE f RETURN count(f) AS files_deleted, sum(chunk_count) AS chunks_deleted `, { watchConfigId: config.id }); let filesDeleted = toInt(relResult.records[0]?.get('files_deleted')); let chunksDeleted = toInt(relResult.records[0]?.get('chunks_deleted')); // Step 2: Fallback to path-based deletion (for orphaned files from old code) const folderPathWithSep = containerPath.endsWith('/') ? containerPath : containerPath + '/'; const pathResult = await session.run(` MATCH (f:File) WHERE (f.path STARTS WITH $folderPathWithSep OR f.path = $exactPath) AND NOT EXISTS { MATCH (f)<-[:WATCHES]-(:WatchConfig) } OPTIONAL MATCH (f)-[:HAS_CHUNK]->(c:FileChunk) WITH f, collect(c) AS chunks, count(c) AS chunk_count FOREACH (chunk IN chunks | DETACH DELETE chunk) DETACH DELETE f RETURN count(f) AS files_deleted, sum(chunk_count) AS chunks_deleted `, { folderPathWithSep, exactPath: containerPath }); const pathFilesDeleted = toInt(pathResult.records[0]?.get('files_deleted')); const pathChunksDeleted = toInt(pathResult.records[0]?.get('chunks_deleted')); filesDeleted += pathFilesDeleted; chunksDeleted += pathChunksDeleted; if (pathFilesDeleted > 0) { console.log(`🧹 Cleaned up ${pathFilesDeleted} orphaned files (no relationships) via path matching`); } // Step 3: Delete the WatchConfig await configManager.delete(config.id); console.log(`πŸ—‘οΈ Deleted ${filesDeleted} files and ${chunksDeleted} file chunks`); res.json({ success: true, message: `Folder removed from indexing: ${hostPath}`, path: hostPath, containerPath: containerPath, files_removed: filesDeleted, chunks_removed: chunksDeleted }); } finally { await session.close(); await driver.close(); } } catch (error: any) { console.error('❌ Error removing folder from indexing:', error); res.status(500).json({ error: 'Failed to remove folder from indexing', details: error.message }); } }); /** * PATCH /api/indexed-folders/reactivate * Reactivates an inactive watch configuration */ router.patch('/indexed-folders/reactivate', async (req: Request, res: Response) => { let driver: neo4j.Driver | null = null; try { const { id } = req.body; if (!id) { return res.status(400).json({ error: 'Watch config ID is required' }); } console.log(`πŸ”„ Reactivating watch config by ID: ${id}`); driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const configManager = new WatchConfigManager(driver); // Get config by ID const config = await configManager.getById(id); if (!config) { return res.status(404).json({ error: 'Watch configuration not found', id }); } // Determine which is the host path and which is the container path // If config.path looks like a host path (e.g., /Users/...), translate it const hostPath = config.host_path || config.path; let containerPath = config.path; // If path stored in DB is a host path (not already translated), translate it now if (!containerPath.startsWith('/workspace') && !containerPath.startsWith('/app')) { containerPath = translateHostToContainer(containerPath); console.log(` Translated path: ${config.path} -> ${containerPath}`); } console.log(` Container path: ${containerPath}`); console.log(` Host path: ${hostPath}`); if (config.status === 'active') { return res.status(400).json({ error: 'Watch is already active', id, path: hostPath }); } // Reactivate the watch configuration in DB await configManager.reactivate(config.id); // Start watching again in background (don't await - let it run async) const watchManager = getWatchManager(); watchManager.startWatch({ id: config.id, path: containerPath, // Use translated container path host_path: hostPath, // Use host path for display recursive: config.recursive, debounce_ms: config.debounce_ms, file_patterns: config.file_patterns, ignore_patterns: config.ignore_patterns || [], generate_embeddings: config.generate_embeddings || false, status: 'active', added_date: config.added_date, last_indexed: config.last_indexed, last_updated: new Date().toISOString(), files_indexed: config.files_indexed || 0, error: undefined }).catch((error) => { console.error(`❌ Background indexing error for ${config.id}:`, error); }); console.log(`βœ… Reactivated watch for: ${hostPath}`); res.json({ success: true, message: `Watch reactivated: ${hostPath}`, id: config.id, path: hostPath, containerPath: containerPath }); } catch (error: any) { console.error('❌ Error reactivating watch:', error); res.status(500).json({ error: 'Failed to reactivate watch', details: error.message }); } finally { if (driver) { await driver.close(); } } }); /** * GET /api/index-config - Get path translation config * @example fetch('/api/index-config').then(r => r.json()); */ router.get('/index-config', async (req: Request, res: Response) => { try { const config = { hostWorkspaceRoot: getHostWorkspaceRoot(), workspaceRoot: process.env.WORKSPACE_ROOT || '', home: process.env.HOME || process.env.USERPROFILE || '', }; res.json(config); } catch (error: any) { console.error('Error getting index config:', error); res.status(500).json({ error: error.message }); } }); /** * GET /api/indexing-status - Get indexing status * @example fetch('/api/indexing-status').then(r => r.json()); */ router.get('/indexing-status', async (req: Request, res: Response) => { try { const watchManager = getWatchManager(); const statuses: { path: string; isIndexing: boolean }[] = []; // Get all watched folders and check their indexing status const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const configManager = new WatchConfigManager(driver); const watchConfigs = await configManager.listAll(); for (const config of watchConfigs) { statuses.push({ path: config.path, isIndexing: watchManager.isIndexing(config.path) }); } await driver.close(); res.json({ statuses }); } catch (error: any) { console.error('Error getting indexing status:', error); res.status(500).json({ error: error.message }); } }); /** * OPTIONS /api/indexing-progress * CORS preflight for SSE endpoint */ router.options('/indexing-progress', (req: Request, res: Response) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key'); res.sendStatus(204); }); /** * GET /api/indexing-progress - Stream indexing progress (SSE) * @example * const es = new EventSource('/api/indexing-progress'); * es.onmessage = e => console.log(JSON.parse(e.data)); */ router.get('/indexing-progress', (req: Request, res: Response) => { // Set CORS headers for webview compatibility res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key'); // Set SSE headers res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering // Ensure response is flushed immediately res.flushHeaders(); const watchManager = getWatchManager(); // Send initial progress for all active jobs const allProgress = watchManager.getAllProgress(); console.log(`[SSE] New client connected. Sending ${allProgress.length} initial progress updates`); for (const progress of allProgress) { const data = JSON.stringify(progress); console.log(`[SSE] Initial progress for path: ${progress.path} (${progress.indexed}/${progress.totalFiles}) status: ${progress.status}`); res.write(`data: ${data}\n\n`); } // Register callback for real-time progress updates (per-file) const unsubscribe = watchManager.onProgress((progress) => { try { const data = JSON.stringify(progress); console.log(`[SSE] Sending progress for path: ${progress.path} (${progress.indexed}/${progress.totalFiles})`); res.write(`data: ${data}\n\n`); } catch (error) { console.error('Error sending SSE progress:', error); } }); // Send heartbeat every 30 seconds to keep connection alive const heartbeatId = setInterval(() => { try { res.write(`: heartbeat\n\n`); } catch (error) { clearInterval(heartbeatId); } }, 30000); // Clean up on client disconnect req.on('close', () => { unsubscribe(); clearInterval(heartbeatId); console.log('πŸ“‘ SSE client disconnected from indexing progress'); }); console.log('πŸ“‘ SSE client connected to indexing progress'); }); /** * GET /api/index-stats - Get indexing statistics * @example fetch('/api/index-stats').then(r => r.json()); */ router.get('/index-stats', async (req: Request, res: Response) => { try { const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const session = driver.session(); try { // Get aggregate stats const statsResult = await session.run(` MATCH (f:File) OPTIONAL MATCH (f)-[:HAS_CHUNK]->(c:FileChunk) WITH f, c, CASE WHEN c IS NOT NULL AND c.embedding IS NOT NULL THEN 1 ELSE 0 END as chunkHasEmbedding, CASE WHEN f.embedding IS NOT NULL THEN 1 ELSE 0 END as fileHasEmbedding WITH COUNT(DISTINCT f) as totalFiles, COUNT(DISTINCT c) as totalChunks, SUM(chunkHasEmbedding) + SUM(fileHasEmbedding) as totalEmbeddings, COLLECT(DISTINCT f.extension) as extensions RETURN totalFiles, totalChunks, totalEmbeddings, extensions `); const statsRecord = statsResult.records[0]; const totalFiles = statsRecord ? toInt(statsRecord.get('totalFiles')) : 0; const totalChunks = statsRecord ? toInt(statsRecord.get('totalChunks')) : 0; const totalEmbeddings = statsRecord ? toInt(statsRecord.get('totalEmbeddings')) : 0; // Get file count by extension const extensionResult = await session.run(` MATCH (f:File) WHERE f.extension IS NOT NULL WITH f.extension as ext, COUNT(f) as count RETURN ext, count ORDER BY count DESC `); const byExtension: Record<string, number> = {}; extensionResult.records.forEach(record => { const ext = record.get('ext'); const count = toInt(record.get('count')); byExtension[ext || '(no extension)'] = count; }); // Get file count by type (node label) const typeResult = await session.run(` MATCH (f:File) WITH f, [label IN labels(f) WHERE label <> 'File'] as filteredLabels UNWIND filteredLabels as label WITH label, COUNT(f) as count RETURN label as type, count ORDER BY count DESC `); const byType: Record<string, number> = {}; typeResult.records.forEach(record => { const type = record.get('type'); const count = toInt(record.get('count')); byType[type] = count; }); // Count watched folders const configManager = new WatchConfigManager(driver); const watchConfigs = await configManager.listAll(); res.json({ totalFolders: watchConfigs.length, totalFiles, totalChunks, totalEmbeddings, byType, byExtension }); } finally { await session.close(); await driver.close(); } } catch (error: any) { console.error('❌ Error fetching index stats:', error); res.status(500).json({ error: 'Failed to fetch index stats', details: error.message }); } }); /** * POST /api/migrate-indexed-folders - Migrate to WatchConfig * @example fetch('/api/migrate-indexed-folders', { method: 'POST' }); */ router.post('/migrate-indexed-folders', async (req: Request, res: Response) => { try { const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const session = driver.session(); const configManager = new WatchConfigManager(driver); try { // Find all unique root folders const foldersResult = await session.run(` MATCH (f:File) WITH SPLIT(f.path, '/')[0..3] as pathParts, f WITH '/' + pathParts[0] + '/' + pathParts[1] + '/' + pathParts[2] + '/' + pathParts[3] as rootFolder, COUNT(DISTINCT f) as fileCount WHERE fileCount > 0 RETURN DISTINCT rootFolder ORDER BY rootFolder `); const migratedFolders = []; const skippedFolders = []; for (const record of foldersResult.records) { const folderPath = record.get('rootFolder'); // Skip null or invalid paths if (!folderPath || typeof folderPath !== 'string') { skippedFolders.push({ path: folderPath, reason: 'Invalid or null path' }); continue; } // Check if WatchConfig already exists const existing = await configManager.getByPath(folderPath); if (existing) { skippedFolders.push({ path: folderPath, reason: 'WatchConfig already exists' }); continue; } // Check if path actually exists on filesystem try { await fs.access(folderPath); } catch { skippedFolders.push({ path: folderPath, reason: 'Path does not exist on filesystem' }); console.log(`⏭️ Skipping non-existent path: ${folderPath}`); continue; } // Create WatchConfig for this folder try { await configManager.createWatch({ path: folderPath, recursive: true, generate_embeddings: false, // Don't re-generate embeddings file_patterns: null, ignore_patterns: [], debounce_ms: 500 }); migratedFolders.push(folderPath); console.log(`βœ… Created WatchConfig for: ${folderPath}`); } catch (error: any) { skippedFolders.push({ path: folderPath, reason: error.message }); console.error(`❌ Failed to create WatchConfig for ${folderPath}:`, error.message); } } res.json({ success: true, migratedCount: migratedFolders.length, skippedCount: skippedFolders.length, migrated: migratedFolders, skipped: skippedFolders }); } finally { await session.close(); await driver.close(); } } catch (error: any) { console.error('❌ Error migrating indexed folders:', error); res.status(500).json({ error: 'Failed to migrate indexed folders', details: error.message }); } }); /** * POST /api/migrate-file-paths - Migrate file path schema * @example fetch('/api/migrate-file-paths', { method: 'POST' }); */ router.post('/api/migrate-file-paths', async (req: Request, res: Response) => { try { const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const session = driver.session(); const workspaceRoot = process.env.WORKSPACE_ROOT || ''; const hostWorkspaceRoot = getHostWorkspaceRoot(); try { // Get all File nodes with old structure const files = await session.run(` MATCH (f:File) WHERE f.absolute_path IS NOT NULL RETURN f.path as oldPath, f.absolute_path as containerPath, id(f) as nodeId LIMIT 1000 `); let migrated = 0; let skipped = 0; for (const record of files.records) { const containerPath = record.get('containerPath'); const nodeId = record.get('nodeId'); if (!containerPath) { skipped++; continue; } // Calculate host path let hostPath = containerPath; if (hostWorkspaceRoot) { // Ensure root ends with separator to avoid false matches const rootWithSep = workspaceRoot.endsWith('/') ? workspaceRoot : `${workspaceRoot}/`; // Check if path starts with root (with separator) or is exact match if (containerPath.startsWith(rootWithSep) || containerPath === workspaceRoot) { hostPath = containerPath.replace(workspaceRoot, hostWorkspaceRoot); } } // Update the node await session.run(` MATCH (f:File) WHERE id(f) = $nodeId SET f.path = $containerPath, f.host_path = $hostPath REMOVE f.absolute_path `, { nodeId, containerPath, hostPath }); migrated++; } res.json({ success: true, migrated, skipped, total: files.records.length, workspaceRoot, hostWorkspaceRoot: hostWorkspaceRoot || '(not set - non-Docker mode)' }); console.log(`βœ… Migrated ${migrated} file paths`); } finally { await session.close(); await driver.close(); } } catch (error: any) { console.error('❌ Error migrating file paths:', error); res.status(500).json({ error: 'Failed to migrate file paths', details: error.message }); } }); /** * GET /api/debug-file-paths - Debug file path issues * @example fetch('/api/debug-file-paths').then(r => r.json()); */ router.get('/debug-file-paths', async (req: Request, res: Response) => { try { const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const session = driver.session(); try { const result = await session.run(` MATCH (f:File) RETURN f.path as path LIMIT 20 `); const paths = result.records.map(r => r.get('path')); // Group by root folder const rootFolders = new Map<string, number>(); paths.forEach(path => { if (path) { const parts = path.split('/').filter(Boolean); if (parts.length >= 2) { const root = `/${parts[0]}/${parts[1]}`; rootFolders.set(root, (rootFolders.get(root) || 0) + 1); } } }); res.json({ samplePaths: paths, rootFolders: Array.from(rootFolders.entries()).map(([path, count]) => ({ path, count })) }); } finally { await session.close(); await driver.close(); } } catch (error: any) { console.error('❌ Error fetching debug paths:', error); res.status(500).json({ error: 'Failed to fetch debug paths', details: error.message }); } }); /** * POST /api/cleanup-invalid-watchconfigs - Clean invalid configs * @example fetch('/api/cleanup-invalid-watchconfigs', { method: 'POST' }); */ router.post('/cleanup-invalid-watchconfigs', async (req: Request, res: Response) => { try { const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const session = driver.session(); try { // Delete WatchConfig nodes with path_not_found error const watchConfigResult = await session.run(` MATCH (w:WatchConfig) WHERE w.error = 'path_not_found' OR w.status = 'inactive' WITH w, w.path as path, w.error as error DELETE w RETURN path, error `); const deletedWatchConfigs = watchConfigResult.records.map(r => ({ path: r.get('path'), error: r.get('error') })); // Delete File nodes with null or invalid paths (and their chunks/embeddings) const fileResult = await session.run(` MATCH (f:File) WHERE f.path IS NULL OR f.path = '' OPTIONAL MATCH (f)-[:HAS_CHUNK]->(c:FileChunk) OPTIONAL MATCH (c)-[:HAS_EMBEDDING]->(e) WITH f, c, e, f.path as path DETACH DELETE f, c, e RETURN count(DISTINCT f) as fileCount, count(DISTINCT c) as chunkCount, count(DISTINCT e) as embeddingCount `); const fileStats = fileResult.records[0]; const deletedFiles = fileStats ? toInt(fileStats.get('fileCount')) : 0; const deletedChunks = fileStats ? toInt(fileStats.get('chunkCount')) : 0; const deletedEmbeddings = fileStats ? toInt(fileStats.get('embeddingCount')) : 0; res.json({ success: true, watchConfigs: { deletedCount: deletedWatchConfigs.length, deleted: deletedWatchConfigs }, files: { deletedFiles, deletedChunks, deletedEmbeddings } }); console.log(`βœ… Cleanup complete:`); console.log(` - Removed ${deletedWatchConfigs.length} invalid WatchConfig nodes`); console.log(` - Deleted ${deletedFiles} File nodes with null paths`); console.log(` - Deleted ${deletedChunks} orphaned chunks`); console.log(` - Deleted ${deletedEmbeddings} orphaned embeddings`); } finally { await session.close(); await driver.close(); } } catch (error: any) { console.error('❌ Error cleaning up invalid data:', error); res.status(500).json({ error: 'Failed to cleanup invalid data', details: error.message }); } }); /** * POST /api/migrate-watchconfig-paths - Migrate WatchConfig paths * @example fetch('/api/migrate-watchconfig-paths', { method: 'POST' }); */ router.post('/migrate-watchconfig-paths', async (req: Request, res: Response) => { try { console.log('πŸ”„ Migrating WatchConfig nodes to include host_path...'); const driver = neo4j.driver( process.env.NEO4J_URI || 'bolt://localhost:7687', neo4j.auth.basic( process.env.NEO4J_USER || 'neo4j', process.env.NEO4J_PASSWORD || 'your_password_here' ) ); const session = driver.session(); try { // Helper function to translate container path to host path const translateToHostPath = (containerPath: string): string => { const containerWorkspaceRoot = process.env.WORKSPACE_ROOT || ''; const hostWorkspaceRoot = getHostWorkspaceRoot(); // Ensure root ends with separator to avoid false matches const rootWithSep = containerWorkspaceRoot.endsWith('/') ? containerWorkspaceRoot : `${containerWorkspaceRoot}/`; // Check if path starts with root (with separator) or is exact match if (containerPath.startsWith(rootWithSep) || containerPath === containerWorkspaceRoot) { return containerPath.replace(containerWorkspaceRoot, hostWorkspaceRoot); } // If it doesn't start with container workspace root, return as-is return containerPath; }; // Get all WatchConfig nodes const watchConfigsResult = await session.run(` MATCH (w:WatchConfig) RETURN w.id as id, w.path as path, w.host_path as hostPath `); const updates = []; for (const record of watchConfigsResult.records) { const id = record.get('id'); const path = record.get('path'); const existingHostPath = record.get('hostPath'); // Only update if host_path is missing if (!existingHostPath && path) { const hostPath = translateToHostPath(path); await session.run(` MATCH (w:WatchConfig {id: $id}) SET w.host_path = $hostPath `, { id, hostPath }); updates.push({ id, path, hostPath }); } } res.json({ success: true, updatedCount: updates.length, updates }); console.log(`βœ… Migration complete: Updated ${updates.length} WatchConfig nodes`); } finally { await session.close(); await driver.close(); } } catch (error: any) { console.error('❌ Error migrating WatchConfig paths:', error); res.status(500).json({ error: 'Failed to migrate WatchConfig paths', details: error.message }); } }); export default router;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/orneryd/Mimir'

If you have feedback or need assistance with the MCP directory API, please join our Discord server