UNPKG

@endlessblink/like-i-said-v2

Version:

Task Management & Memory for Claude - Track tasks, remember context, and maintain continuity across sessions with 27 powerful tools. Works with Claude Desktop and Claude Code.

1,448 lines (1,248 loc) 109 kB
#!/usr/bin/env node import express from 'express'; import cors from 'cors'; import fs from 'fs'; import { promises as fsPromises } from 'fs'; import path from 'path'; import chokidar from 'chokidar'; import { WebSocketServer } from 'ws'; import http from 'http'; import { spawn } from 'child_process'; import { MemoryFormat } from './lib/memory-format.js'; import { TaskStorage } from './lib/task-storage.js'; import { MemoryStorageWrapper } from './lib/memory-storage-wrapper.js'; import { SystemSafeguards } from './lib/system-safeguards.js'; import { FileSystemMonitor } from './lib/file-system-monitor.js'; import { ContentAnalyzer } from './lib/content-analyzer.js'; import { AutomationConfig } from './lib/automation-config.js'; import { AutomationScheduler } from './lib/automation-scheduler.js'; import { OllamaClient } from './lib/ollama-client.js'; import { McpSecurity } from './lib/mcp-security.js'; import { AuthSystem } from './lib/auth-system.js'; import { settingsManager } from './lib/settings-manager.js'; import { MemoryDeduplicator } from './lib/memory-deduplicator.js'; import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import yaml from 'js-yaml'; import { startServerWithValidation, cleanupPortFile } from './lib/robust-port-finder.js'; import { PathSettings } from './lib/path-settings.js'; import { FolderDiscovery } from './lib/folder-discovery.js'; // Enhanced dashboard server with real-time MCP bridge class DashboardBridge { constructor(port) { this.port = port; this.app = express(); this.server = http.createServer(this.app); this.wss = new WebSocketServer({ server: this.server }); this.clients = new Set(); // Load path settings this.pathSettings = new PathSettings(); const paths = this.pathSettings.getEffectivePaths(); this.memoriesDir = paths.memories; this.tasksDir = paths.tasks; this.memoryStorage = new MemoryStorageWrapper(this.memoriesDir); this.taskStorage = new TaskStorage(this.tasksDir, this.memoryStorage); this.safeguards = new SystemSafeguards(); this.contentAnalyzer = new ContentAnalyzer(); // Initialize authentication system this.authSystem = new AuthSystem(); // Enhancement logging this.enhancementLogs = []; this.maxLogEntries = 200; // Initialize automation configuration this.automationConfig = new AutomationConfig(); this.fileSystemMonitor = new FileSystemMonitor(this.taskStorage, this, this.automationConfig); // Initialize automation scheduler this.automationScheduler = new AutomationScheduler( this.taskStorage, this.automationConfig, this.fileSystemMonitor ); this.setupExpress(); this.setupWebSocket(); this.setupSafeguards(); this.setupAutomation(); } setupExpress() { // Security middleware with CSP this.app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: [ "'self'", "'unsafe-inline'", // Required for React dev tools and dynamic imports "'unsafe-eval'", // Required for development mode "blob:", // Required for Monaco Editor "data:", // Required for inline scripts ], styleSrc: [ "'self'", "'unsafe-inline'", // Required for styled components and CSS-in-JS "fonts.googleapis.com" ], fontSrc: [ "'self'", "fonts.gstatic.com", "data:" ], imgSrc: [ "'self'", "data:", "blob:" ], connectSrc: [ "'self'", "ws:", "wss:", "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*" ], frameSrc: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'none'"] }, reportOnly: process.env.NODE_ENV !== 'production' // Report-only in development }, crossOriginEmbedderPolicy: false, // Disable to allow iframes if needed crossOriginResourcePolicy: { policy: "cross-origin" } })); // Rate limiting - increased limits for development const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 1000, // increased from 100 to 1000 for development message: 'Too many requests from this IP, please try again later.', standardHeaders: true, legacyHeaders: false, skip: (req) => { // Skip rate limiting for localhost in development const isLocalhost = req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1'; // Skip rate limiting for quality standards endpoint (cached and shared) const isQualityEndpoint = req.path === '/api/quality/standards'; return isLocalhost || isQualityEndpoint; } }); this.app.use('/api/', limiter); // Dynamic CORS configuration to allow all local network access const corsOptions = { origin: function (origin, callback) { // Allow requests with no origin (like Postman or direct API calls) if (!origin) return callback(null, true); // In development, allow all origins from local network if (process.env.NODE_ENV !== 'production') { const allowedPatterns = [ /^http:\/\/localhost(:\d+)?$/, // Allow with or without port /^http:\/\/127\.0\.0\.1(:\d+)?$/, // Allow with or without port /^http:\/\/\[::1\](:\d+)?$/, // IPv6 localhost /^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/, // Local network /^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/, // Local network /^http:\/\/172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+(:\d+)?$/ // Docker/VPN/WSL ]; const isAllowed = allowedPatterns.some(pattern => pattern.test(origin)); if (isAllowed) { callback(null, true); } else { console.warn(`CORS blocked origin: ${origin}`); callback(new Error('Not allowed by CORS')); } } else { // Production: be more restrictive callback(new Error('Not allowed by CORS')); } }, credentials: true, optionsSuccessStatus: 200, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'x-requested-with'], exposedHeaders: ['Content-Length', 'X-Foo', 'X-Bar'], preflightContinue: false // Ensure CORS handles preflight completely }; this.app.use(cors(corsOptions)); // Additional middleware to ensure credentials header is set for all responses this.app.use((req, res, next) => { const origin = req.headers.origin; if (origin && process.env.NODE_ENV !== 'production') { const allowedPatterns = [ /^http:\/\/localhost(:\d+)?$/, /^http:\/\/127\.0\.0\.1(:\d+)?$/, /^http:\/\/\[::1\](:\d+)?$/, /^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/, /^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/, /^http:\/\/172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+(:\d+)?$/ ]; const isAllowed = allowedPatterns.some(pattern => pattern.test(origin)); if (isAllowed) { res.header('Access-Control-Allow-Credentials', 'true'); } } next(); }); this.app.use(express.json()); // Health check endpoint for server validation this.app.get('/api/health', (req, res) => { res.status(200).json({ status: 'ok', message: 'Like-I-Said MCP Server Dashboard API', version: '2.6.8', timestamp: new Date().toISOString() }); }); // API port endpoint for frontend discovery this.app.get('/api-port', (req, res) => { res.status(200).json({ port: this.port }); }); // Simple test endpoint that always works this.app.get('/test', (req, res) => { res.send('OK'); }); // Helper function to conditionally apply auth middleware const requireAuth = (options = {}) => { if (settingsManager.isAuthEnabled()) { return this.authSystem.requireAuth(options); } // Return a no-op middleware when auth is disabled return (req, res, next) => { req.user = { id: 'anonymous-user', username: 'anonymous', role: 'admin', sessionId: 'no-auth-session' }; next(); }; }; // Authentication routes (public) this.app.post('/api/auth/login', this.login.bind(this)); this.app.post('/api/auth/logout', requireAuth(), this.logout.bind(this)); this.app.post('/api/auth/refresh', this.refreshToken.bind(this)); this.app.post('/api/auth/change-password', requireAuth(), this.changePassword.bind(this)); this.app.get('/api/auth/me', requireAuth(), this.getCurrentUser.bind(this)); // Admin routes this.app.get('/api/auth/users', requireAuth({ role: 'admin' }), this.listUsers.bind(this)); this.app.post('/api/auth/users', requireAuth({ role: 'admin' }), this.createUser.bind(this)); this.app.delete('/api/auth/users/:username', requireAuth({ role: 'admin' }), this.deleteUser.bind(this)); this.app.put('/api/auth/users/:username/role', requireAuth({ role: 'admin' }), this.updateUserRole.bind(this)); this.app.get('/api/auth/stats', requireAuth({ role: 'admin' }), this.getAuthStats.bind(this)); // Protected API Routes - Memory endpoints this.app.get('/api/memories', requireAuth(), this.getMemories.bind(this)); this.app.get('/api/memories/:id', requireAuth(), this.getMemory.bind(this)); this.app.post('/api/memories', requireAuth(), this.createMemory.bind(this)); this.app.put('/api/memories/:id', requireAuth(), this.updateMemory.bind(this)); this.app.delete('/api/memories/:id', requireAuth(), this.deleteMemory.bind(this)); this.app.post('/api/memories/suggest-for-task', requireAuth(), this.suggestMemoriesForTask.bind(this)); // Memory deduplication endpoints this.app.get('/api/memories/duplicates', requireAuth(), this.getMemoryDuplicates.bind(this)); this.app.post('/api/memories/deduplicate', requireAuth(), this.deduplicateMemories.bind(this)); this.app.get('/api/projects', requireAuth(), this.getProjects.bind(this)); this.app.get('/api/status', this.getStatus.bind(this)); // Keep status public for health checks // Path configuration endpoints - public to allow setup this.app.get('/api/paths', this.getPaths.bind(this)); this.app.post('/api/paths', this.updatePaths.bind(this)); // Category Analysis API Routes this.app.post('/api/analyze/categories', requireAuth(), this.analyzeCategories.bind(this)); this.app.post('/api/analyze/categories/feedback', requireAuth(), this.submitCategoryFeedback.bind(this)); // Protected Task API Routes this.app.get('/api/tasks', requireAuth(), this.getTasks.bind(this)); this.app.get('/api/tasks/:id', requireAuth(), this.getTask.bind(this)); this.app.get('/api/tasks/:id/memories', requireAuth(), this.getTaskMemories.bind(this)); this.app.post('/api/tasks', requireAuth(), this.createTask.bind(this)); this.app.put('/api/tasks/:id', requireAuth(), this.updateTask.bind(this)); this.app.delete('/api/tasks/:id', requireAuth(), this.deleteTask.bind(this)); // Protected System API Routes this.app.get('/api/system/health', requireAuth(), this.getSystemHealth.bind(this)); this.app.post('/api/system/backup', requireAuth({ role: 'admin' }), this.createSystemBackup.bind(this)); this.app.post('/api/system/reload', requireAuth({ role: 'admin' }), this.reloadTasks.bind(this)); // Protected Automation API Routes this.app.get('/api/automation/config', requireAuth(), this.getAutomationConfig.bind(this)); this.app.put('/api/automation/config', requireAuth(), this.updateAutomationConfig.bind(this)); this.app.get('/api/automation/stats', requireAuth(), this.getAutomationStats.bind(this)); this.app.post('/api/automation/check/:taskId', requireAuth(), this.checkTaskAutomation.bind(this)); this.app.get('/api/automation/scheduler/status', requireAuth(), this.getSchedulerStatus.bind(this)); this.app.post('/api/automation/scheduler/force', requireAuth(), this.forceSchedulerCheck.bind(this)); // Protected Ollama endpoints (must come before generic MCP route) this.app.get('/api/ollama/status', requireAuth(), this.getOllamaStatus.bind(this)); this.app.post('/api/mcp/batch_enhance_tasks_ollama', requireAuth(), this.batchEnhanceTasksOllama.bind(this)); this.app.post('/api/mcp-tools/check_ollama_status', requireAuth(), this.checkOllamaStatus.bind(this)); this.app.post('/api/mcp-tools/batch_enhance_memories_ollama', requireAuth(), this.batchEnhanceMemoriesOllama.bind(this)); this.app.post('/api/mcp-tools/batch_enhance_tasks_ollama', requireAuth(), this.batchEnhanceTasksOllama.bind(this)); this.app.post('/api/mcp-tools/batch_link_memories', requireAuth(), this.batchLinkMemories.bind(this)); // Protected Enhancement log endpoints this.app.get('/api/enhancement-logs', requireAuth(), this.getEnhancementLogs.bind(this)); this.app.delete('/api/enhancement-logs', requireAuth({ role: 'admin' }), this.clearEnhancementLogs.bind(this)); // Protected Quality Standards endpoints this.app.get('/api/quality/standards', requireAuth(), this.getQualityStandards.bind(this)); this.app.get('/api/quality/validate/:id', requireAuth(), this.validateMemoryQuality.bind(this)); this.app.post('/api/quality/validate/:id', requireAuth(), this.validateMemoryQuality.bind(this)); // Protected MCP Tool Routes - MUST come after specific routes to avoid conflicts this.app.post('/api/mcp-tools/:toolName', requireAuth(), this.callMcpTool.bind(this)); // Settings Management Routes (partially protected) this.app.get('/api/settings', this.getSettings.bind(this)); // Public - to check auth status this.app.get('/api/settings/auth-status', this.getAuthStatus.bind(this)); // Public - to check if auth is enabled this.app.put('/api/settings', requireAuth({ role: 'admin' }), this.updateSettings.bind(this)); this.app.post('/api/settings/reset', requireAuth({ role: 'admin' }), this.resetSettings.bind(this)); this.app.post('/api/settings/setup-auth', this.setupAuthentication.bind(this)); // Public - for initial setup // Backup management endpoints this.app.get('/api/backups', requireAuth(), this.listBackups.bind(this)); this.app.post('/api/backups', requireAuth({ role: 'admin' }), this.createBackup.bind(this)); this.app.post('/api/backups/:name/restore', requireAuth({ role: 'admin' }), this.restoreBackup.bind(this)); this.app.delete('/api/backups/:name', requireAuth({ role: 'admin' }), this.deleteBackup.bind(this)); this.app.get('/api/system/health', requireAuth(), this.getSystemHealth.bind(this)); // Serve static files this.app.use(express.static('dist')); // Serve React app for non-API routes // IMPORTANT: Only serve HTML for non-API routes to prevent JSON parse errors this.app.get('*', (req, res) => { // Skip API routes - they should return 404 if not found if (req.path.startsWith('/api/')) { res.status(404).json({ error: 'API endpoint not found' }); return; } res.sendFile(path.resolve('dist/index.html')); }); // Global error handler - must be last this.app.use((err, req, res, next) => { // Log the full error for debugging console.error('Express error:', err); // Don't expose sensitive error details in production const isDevelopment = process.env.NODE_ENV !== 'production'; const statusCode = err.statusCode || err.status || 500; const errorResponse = { error: isDevelopment ? err.message : 'Internal Server Error', status: statusCode }; // Include stack trace only in development if (isDevelopment && err.stack) { errorResponse.stack = err.stack; } res.status(statusCode).json(errorResponse); }); } setupWebSocket() { // Track connections by IP - make it an instance property this.connectionsByIP = new Map(); this.wss.on('connection', (ws, req) => { const clientIP = req.socket.remoteAddress; const clientId = `${clientIP}:${req.socket.remotePort}`; const userAgent = req.headers['user-agent'] || 'unknown'; // Basic origin validation const origin = req.headers.origin; const allowedOrigins = process.env.NODE_ENV === 'production' ? ['https://localhost:3001', 'https://127.0.0.1:3001'] : [ // Allow all common development ports ...Array.from({length: 20}, (_, i) => `http://localhost:${3000 + i}`), ...Array.from({length: 20}, (_, i) => `http://127.0.0.1:${3000 + i}`), 'http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:5183', 'http://127.0.0.1:5183', 'http://localhost:8080', 'http://127.0.0.1:8080' ]; if (origin && !allowedOrigins.includes(origin)) { console.warn(`🚫 WebSocket connection rejected from unauthorized origin: ${origin}`); ws.close(1003, 'Unauthorized origin'); return; } // Get current connections for this IP let currentConnections = this.connectionsByIP.get(clientIP); if (!currentConnections) { currentConnections = new Set(); this.connectionsByIP.set(clientIP, currentConnections); } // Clean up any dead connections first const deadConnections = []; currentConnections.forEach(conn => { if (conn.readyState === 2 || conn.readyState === 3) { // CLOSING or CLOSED deadConnections.push(conn); } }); deadConnections.forEach(conn => currentConnections.delete(conn)); // Prevent connection spam - limit to 10 concurrent connections per IP (increased for dev hot reload) if (currentConnections.size >= 10) { console.log(`🚫 Too many connections from ${clientIP}, rejecting (current: ${currentConnections.size})`); ws.close(1008, 'Too many connections'); return; } console.log(`📡 Dashboard client connected (${clientId}) - Total: ${this.clients.size + 1}`); // Store connection with IP tracking ws._clientIP = clientIP; this.clients.add(ws); currentConnections.add(ws); // Send current status ws.send(JSON.stringify({ type: 'status', data: { connected: true, memories: this.countMemories() } })); ws.on('close', (code, reason) => { console.log(`📡 Dashboard client disconnected (${clientId}): ${code} ${reason || ''}`); this.clients.delete(ws); // Remove from IP tracking const ipConnections = this.connectionsByIP.get(clientIP); if (ipConnections) { ipConnections.delete(ws); if (ipConnections.size === 0) { this.connectionsByIP.delete(clientIP); } } }); ws.on('error', (error) => { console.error(`WebSocket error (${clientId}):`, error); this.clients.delete(ws); // Remove from IP tracking const ipConnections = this.connectionsByIP.get(clientIP); if (ipConnections) { ipConnections.delete(ws); if (ipConnections.size === 0) { this.connectionsByIP.delete(clientIP); } } }); // Add ping/pong for connection health ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); }); // Setup ping interval to detect broken connections const pingInterval = setInterval(() => { this.wss.clients.forEach((ws) => { if (ws.isAlive === false) { ws.terminate(); this.clients.delete(ws); // Clean up IP tracking for terminated connections if (ws._clientIP) { const ipConnections = this.connectionsByIP.get(ws._clientIP); if (ipConnections) { ipConnections.delete(ws); if (ipConnections.size === 0) { this.connectionsByIP.delete(ws._clientIP); } } } return; } ws.isAlive = false; ws.ping(); }); }, 30000); // Ping every 30 seconds this.wss.on('close', () => { clearInterval(pingInterval); }); } async setupFileWatcher() { try { await fsPromises.access(this.memoriesDir); } catch { await fsPromises.mkdir(this.memoriesDir, { recursive: true }); } // Watch for markdown file changes this.watcher = chokidar.watch(`${this.memoriesDir}/**/*.md`, { ignored: /[\/\\]\./, persistent: true, ignoreInitial: false }); this.watcher .on('add', (filePath) => { console.log('📄 Memory file added:', path.basename(filePath)); this.broadcastChange('add', filePath); }) .on('change', (filePath) => { console.log('📝 Memory file changed:', path.basename(filePath)); this.broadcastChange('change', filePath); }) .on('unlink', (filePath) => { console.log('🗑️ Memory file deleted:', path.basename(filePath)); this.broadcastChange('delete', filePath); }); console.log('👀 File watcher started for memories directory'); // Watch quality standards config file // IMPORTANT: memory-quality-standards.md is located in docs/ directory // If you move this file, also update lib/standards-config-parser.cjs:13 const standardsPath = path.join(path.dirname(this.memoriesDir), 'docs', 'memory-quality-standards.md'); this.standardsWatcher = chokidar.watch(standardsPath, { persistent: true, ignoreInitial: true }); this.standardsWatcher.on('change', () => { console.log('📋 Quality standards updated'); this.broadcastStandardsUpdate(); }); } broadcastStandardsUpdate() { const message = { type: 'standards-update', data: { timestamp: new Date().toISOString() } }; // Broadcast to all connected WebSocket clients this.clients.forEach(client => { if (client.readyState === 1) { // WebSocket.OPEN client.send(JSON.stringify(message)); } }); } broadcastChange(type, filePath) { const message = { type: 'file_change', data: { action: type, file: path.basename(filePath), project: path.basename(path.dirname(filePath)), timestamp: new Date().toISOString() } }; // Broadcast to all connected WebSocket clients this.clients.forEach(client => { if (client.readyState === 1) { // WebSocket.OPEN client.send(JSON.stringify(message)); } }); } async countMemories() { let count = 0; try { await fsPromises.access(this.memoriesDir); const projects = await fsPromises.readdir(this.memoriesDir); for (const project of projects) { const projectPath = path.join(this.memoriesDir, project); try { const stat = await fsPromises.stat(projectPath); if (stat.isDirectory()) { const files = await fsPromises.readdir(projectPath); count += files.filter(f => f.endsWith('.md')).length; } } catch { // Skip if can't access project directory } } } catch { // Memories directory doesn't exist } return count; } parseMarkdownFile(filePath) { // Use the shared memory format parser return MemoryFormat.parseMemoryFile(filePath); } // Authentication route handlers async login(req, res) { try { // Check if authentication is enabled if (!settingsManager.isAuthEnabled()) { return res.status(400).json({ error: 'Authentication is disabled. All API endpoints are publicly accessible.', authEnabled: false, message: 'No login required - authentication is disabled in settings' }); } const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } const clientInfo = { ip: req.ip, userAgent: req.get('User-Agent'), timestamp: new Date().toISOString() }; const result = await this.authSystem.authenticateUser(username, password, clientInfo); res.json({ success: true, ...result }); } catch (error) { console.error('Login error:', error); res.status(401).json({ error: error.message }); } } async logout(req, res) { try { const sessionId = req.user?.sessionId; if (sessionId) { this.authSystem.logout(sessionId); } res.json({ success: true, message: 'Logged out successfully' }); } catch (error) { console.error('Logout error:', error); res.status(500).json({ error: 'Logout failed' }); } } async refreshToken(req, res) { try { const { refreshToken } = req.body; if (!refreshToken) { return res.status(400).json({ error: 'Refresh token is required' }); } const result = await this.authSystem.refreshAccessToken(refreshToken); res.json({ success: true, ...result }); } catch (error) { console.error('Token refresh error:', error); res.status(401).json({ error: error.message }); } } async changePassword(req, res) { try { const { currentPassword, newPassword } = req.body; const username = req.user?.username; if (!currentPassword || !newPassword) { return res.status(400).json({ error: 'Current password and new password are required' }); } await this.authSystem.changePassword(username, currentPassword, newPassword); res.json({ success: true, message: 'Password changed successfully' }); } catch (error) { console.error('Password change error:', error); res.status(400).json({ error: error.message }); } } async getCurrentUser(req, res) { try { const userInfo = this.authSystem.getUserInfo(req.user.username); if (!userInfo) { return res.status(404).json({ error: 'User not found' }); } res.json({ success: true, user: userInfo }); } catch (error) { console.error('Get current user error:', error); res.status(500).json({ error: 'Failed to get user info' }); } } async listUsers(req, res) { try { const users = this.authSystem.listUsers(); res.json({ success: true, users }); } catch (error) { console.error('List users error:', error); res.status(500).json({ error: 'Failed to list users' }); } } async createUser(req, res) { try { const { username, password, role, displayName } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } const user = await this.authSystem.createUser(username, password, role, displayName); res.status(201).json({ success: true, user }); } catch (error) { console.error('Create user error:', error); res.status(400).json({ error: error.message }); } } async deleteUser(req, res) { try { const { username } = req.params; this.authSystem.deleteUser(username); res.json({ success: true, message: 'User deleted successfully' }); } catch (error) { console.error('Delete user error:', error); res.status(400).json({ error: error.message }); } } async updateUserRole(req, res) { try { const { username } = req.params; const { role } = req.body; if (!role) { return res.status(400).json({ error: 'Role is required' }); } this.authSystem.updateUserRole(username, role); res.json({ success: true, message: 'User role updated successfully' }); } catch (error) { console.error('Update user role error:', error); res.status(400).json({ error: error.message }); } } async getAuthStats(req, res) { try { const stats = this.authSystem.getAuthStats(); res.json({ success: true, stats }); } catch (error) { console.error('Get auth stats error:', error); res.status(500).json({ error: 'Failed to get authentication statistics' }); } } async getMemories(req, res) { try { const memories = []; const { project, page = 1, limit = 50, sort = 'timestamp', order = 'desc' } = req.query; // Validate pagination parameters const pageNum = Math.max(1, parseInt(page) || 1); const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 50)); const offset = (pageNum - 1) * limitNum; try { await fsPromises.access(this.memoriesDir); } catch { return res.json({ data: [], pagination: { page: pageNum, limit: limitNum, total: 0, totalPages: 0, hasNext: false, hasPrev: false } }); } const allItems = await fsPromises.readdir(this.memoriesDir); const projects = []; // Filter directories asynchronously for (const item of allItems) { try { const dirPath = path.join(this.memoriesDir, item); const stat = await fsPromises.stat(dirPath); if (stat.isDirectory()) { projects.push(item); } } catch { // Skip if can't access } } for (const proj of projects) { if (project && proj !== project) continue; const projectPath = path.join(this.memoriesDir, proj); // Recursive async function to find all .md files in nested directories const findMemoryFiles = async (dir, currentProject = proj) => { try { const items = await fsPromises.readdir(dir); for (const item of items) { const itemPath = path.join(dir, item); try { const stat = await fsPromises.stat(itemPath); if (stat.isDirectory()) { // For nested directories, use the subdirectory name as the project const nestedProject = dir === projectPath ? item : currentProject; await findMemoryFiles(itemPath, nestedProject); } else if (item.endsWith('.md')) { const memory = this.parseMarkdownFile(itemPath); if (memory) { // Use the nested directory structure to determine project if (proj === 'default' && currentProject !== 'default') { memory.project = currentProject; } else { memory.project = proj === 'default' ? undefined : proj; } memories.push(memory); } } } catch { // Skip if can't access item } } } catch { // Skip if can't read directory } }; await findMemoryFiles(projectPath); } // Remove duplicates based on memory ID const uniqueMemories = []; const seenIds = new Set(); for (const memory of memories) { if (!seenIds.has(memory.id)) { seenIds.add(memory.id); uniqueMemories.push(memory); } } // Sort by specified field and order uniqueMemories.sort((a, b) => { let aVal, bVal; switch (sort) { case 'timestamp': aVal = new Date(a.timestamp).getTime(); bVal = new Date(b.timestamp).getTime(); break; case 'priority': const priorityOrder = { high: 3, medium: 2, low: 1 }; aVal = priorityOrder[a.priority] || 0; bVal = priorityOrder[b.priority] || 0; break; case 'complexity': aVal = a.complexity || 0; bVal = b.complexity || 0; break; case 'size': aVal = a.metadata?.size || 0; bVal = b.metadata?.size || 0; break; default: aVal = new Date(a.timestamp).getTime(); bVal = new Date(b.timestamp).getTime(); } return order === 'asc' ? aVal - bVal : bVal - aVal; }); // Calculate pagination const total = uniqueMemories.length; const totalPages = Math.ceil(total / limitNum); const paginatedMemories = uniqueMemories.slice(offset, offset + limitNum); res.json({ data: paginatedMemories, pagination: { page: pageNum, limit: limitNum, total, totalPages, hasNext: pageNum < totalPages, hasPrev: pageNum > 1, sort, order } }); } catch (error) { console.error('Error getting memories:', error); res.status(500).json({ error: error.message }); } } async getMemory(req, res) { try { const { id } = req.params; const memories = await this.getAllMemories(); const memory = memories.find(m => m.id === id); if (!memory) { return res.status(404).json({ error: 'Memory not found' }); } res.json(memory); } catch (error) { console.error('Error getting memory:', error); res.status(500).json({ error: error.message }); } } async getAllMemories() { const memories = []; try { await fsPromises.access(this.memoriesDir); } catch { return memories; } const allItems = await fsPromises.readdir(this.memoriesDir); const projects = []; // Filter directories asynchronously for (const item of allItems) { try { const dirPath = path.join(this.memoriesDir, item); const stat = await fsPromises.stat(dirPath); if (stat.isDirectory()) { projects.push(item); } } catch { // Skip if can't access } } for (const proj of projects) { const projectPath = path.join(this.memoriesDir, proj); // Recursive async function to find all .md files in nested directories const findMemoryFiles = async (dir, currentProject = proj) => { try { const items = await fsPromises.readdir(dir); for (const item of items) { const itemPath = path.join(dir, item); try { const stat = await fsPromises.stat(itemPath); if (stat.isDirectory()) { // For nested directories, use the subdirectory name as the project const nestedProject = dir === projectPath ? item : currentProject; await findMemoryFiles(itemPath, nestedProject); } else if (item.endsWith('.md')) { const memory = this.parseMarkdownFile(itemPath); if (memory) { // Use the nested directory structure to determine project if (proj === 'default' && currentProject !== 'default') { memory.project = currentProject; } else { memory.project = proj === 'default' ? undefined : proj; } memories.push(memory); } } } catch { // Skip if can't access item } } } catch { // Skip if can't read directory } }; await findMemoryFiles(projectPath); } // Remove duplicates based on memory ID const uniqueMemories = []; const seenIds = new Set(); for (const memory of memories) { if (!seenIds.has(memory.id)) { seenIds.add(memory.id); uniqueMemories.push(memory); } } return uniqueMemories; } async createMemory(req, res) { try { const { content, tags = [], category, project } = req.body; if (!content || !content.trim()) { return res.status(400).json({ error: 'Content is required' }); } // Generate unique ID const timestamp = Date.now(); const randomStr = Math.random().toString(36).substring(2, 15); const id = `${timestamp}${randomStr}`; // Create filename const titleSlug = content.trim() .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .substring(0, 40); const shortId = timestamp.toString().slice(-6); const filename = `${new Date().toISOString().split('T')[0]}-${titleSlug}-${shortId}.md`; // Determine project directory const projectDir = project || 'default'; const projectPath = path.join(this.memoriesDir, projectDir); // Ensure project directory exists try { await fsPromises.access(projectPath); } catch { await fsPromises.mkdir(projectPath, { recursive: true }); } // Create memory object const memory = { id, content: content.trim(), timestamp: new Date().toISOString(), complexity: 1, category: category || undefined, project: project && project !== 'default' ? project : undefined, tags: tags || [], priority: 'medium', status: 'active', access_count: 0, last_accessed: new Date().toISOString(), metadata: { content_type: 'text', size: content.length, mermaid_diagram: false } }; // Generate standardized markdown content const fileContent = MemoryFormat.generateMarkdownContent(memory); // Write markdown file const filePath = path.join(projectPath, filename); await fsPromises.writeFile(filePath, fileContent, 'utf8'); // Return created memory const createdMemory = this.parseMarkdownFile(filePath); res.status(201).json(createdMemory); } catch (error) { console.error('Error creating memory:', error); res.status(500).json({ error: error.message }); } } async updateMemory(req, res) { try { const { id } = req.params; const { content, tags = [] } = req.body; if (!content || !content.trim()) { return res.status(400).json({ error: 'Content is required' }); } // Find the memory file const memories = await this.getAllMemories(); const memory = memories.find(m => m.id === id); if (!memory) { return res.status(404).json({ error: 'Memory not found' }); } // Update memory object with new content memory.content = content.trim(); memory.tags = tags || []; memory.timestamp = new Date().toISOString(); memory.last_accessed = new Date().toISOString(); memory.metadata.size = content.length; // Generate standardized markdown content const fileContent = MemoryFormat.generateMarkdownContent(memory); // Write updated file await fsPromises.writeFile(memory.filepath, fileContent, 'utf8'); // Return updated memory const updatedMemory = this.parseMarkdownFile(memory.filepath); res.json(updatedMemory); } catch (error) { console.error('Error updating memory:', error); res.status(500).json({ error: error.message }); } } async deleteMemory(req, res) { try { const { id } = req.params; const memories = await this.getAllMemories(); const memory = memories.find(m => m.id === id); if (!memory) { return res.status(404).json({ error: 'Memory not found' }); } // Delete the markdown file fs.unlinkSync(memory.filepath); res.json({ success: true, message: 'Memory deleted successfully' }); } catch (error) { console.error('Error deleting memory:', error); res.status(500).json({ error: error.message }); } } async getMemoryDuplicates(req, res) { try { const deduplicator = new MemoryDeduplicator(this.memoryStorage); const duplicates = await deduplicator.previewDeduplication(); // Format the response with duplicate groups const response = { totalDuplicates: duplicates.reduce((sum, group) => sum + group.removeFiles.length, 0), groups: duplicates.map(group => ({ id: group.id, originalFile: group.keepFile, duplicates: group.removeFiles, duplicateCount: group.removeFiles.length })) }; res.json(response); } catch (error) { console.error('Error finding memory duplicates:', error); res.status(500).json({ error: error.message }); } } async deduplicateMemories(req, res) { try { const { previewOnly = false } = req.body; const deduplicator = new MemoryDeduplicator(this.memoryStorage); if (previewOnly) { const duplicates = await deduplicator.previewDeduplication(); const totalToRemove = duplicates.reduce((sum, group) => sum + group.removeFiles.length, 0); res.json({ preview: true, totalToRemove, groups: duplicates }); } else { const result = await deduplicator.deduplicateMemories(); // Broadcast update to WebSocket clients this.broadcastUpdate({ type: 'deduplication_complete', data: { duplicatesRemoved: result.duplicatesRemoved, message: 'Memory deduplication completed' } }); res.json({ success: true, duplicatesRemoved: result.duplicatesRemoved, message: `Successfully removed ${result.duplicatesRemoved} duplicate memories` }); } } catch (error) { console.error('Error deduplicating memories:', error); res.status(500).json({ error: error.message }); } } async getProjects(req, res) { try { const projects = []; try { await fsPromises.access(this.memoriesDir); const allItems = await fsPromises.readdir(this.memoriesDir); for (const item of allItems) { try { const dirPath = path.join(this.memoriesDir, item); const stat = await fsPromises.stat(dirPath); if (stat.isDirectory()) { const files = await fsPromises.readdir(dirPath); const mdFiles = files.filter(f => f.endsWith('.md')); projects.push({ name: item === 'default' ? 'Default' : item, id: item, count: mdFiles.length }); } } catch { // Skip if can't access directory } } } catch { // Memories directory doesn't exist } res.json(projects); } catch (error) { console.error('Error getting projects:', error); res.status(500).json({ error: error.message }); } } async getStatus(req, res) { try { let projectCount = 0; try { await fsPromises.access(this.memoriesDir); const items = await fsPromises.readdir(this.memoriesDir); projectCount = items.length; } catch { // Directory doesn't exist } const status = { status: 'ok', // Add this for compatibility server: 'Dashboard Bridge', version: '2.0.3', storage: 'markdown', memories: await this.countMemories(), projects: projectCount, websocket_clients: this.clients.size, file_watcher: !!this.watcher }; res.json(status); } catch (error) { console.error('Error getting status:', error); res.status(500).json({ error: error.message }); } } async getPaths(req, res) { try { // Return current paths and whether they exist const checkPath = async (dirPath) => { try { await fsPromises.access(dirPath); const stats = await fsPromises.stat(dirPath); return { exists: true, isDirectory: stats.isDirectory(), absolute: path.resolve(dirPath) }; } catch { return { exists: false, isDirectory: false, absolute: path.resolve(dirPath) }; } }; const memoryInfo = await checkPath(this.memoriesDir); const taskInfo = await checkPath(this.tasksDir); res.json({ memories: { path: this.memoriesDir, ...memoryInfo, fromEnv: !!process.env.MEMORY_DIR }, tasks: { path: this.tasksDir, ...taskInfo, fromEnv: !!process.env.TASK_DIR }, suggestions: await this.getSuggestedPaths() }); } catch (error) { console.error('Error getting paths:', error); res.status(500).json({ error: error.message }); } } async updatePaths(req, res) { try { const { memoryPath, taskPath } = req.body; console.log('📁 Path update request:', { memoryPath, taskPath }); // Validate paths if (!memoryPath || !taskPath) { return res.status(400).json({ error: 'Both memory and task paths are required' }); } // Use robust path update with validation const updateResult = await this.pathSettings.safeUpdatePaths(memoryPath, taskPath); // Update runtime paths this.memoriesDir = updateResult.memories.path; this.tasksDir = updateResult.tasks.path; // Update storage instances this.memoryStorage = new MemoryStorageWrapper(this.memoriesDir); this.taskStorage = new TaskStorage(this.tasksDir, this.memoryStorage); // Log the storage status const memories = await this.memoryStorage.listMemories(); console.log('📊 Storage reload status:', { memoriesCount: memories.length, tasksCount: this.taskStorage.getAllTasks().length, memoriesHasData: updateResult.memories.hasData, tasksHasData: updateResult.tasks.hasData }); // Restart file watchers await this.setupFileWatcher(); // Update environment variables for backward compatibility process.env.MEMORY_DIR = this.memoriesDir; process.env.TASK_DIR = this.tasksDir; res.json({ success: true, paths: { memories: updateResult.memories, tasks: updateResult.tasks }, backup: updateResult.backup, message: 'Paths updated successfully with validation and backup' }); } catch (error) { console.error('Error updating paths:', error); res.status(500).json({ error: error.message, type: 'path_update_error' }); } } async getSuggestedPaths() { const suggestions = []; const home = process.env.HOME || process.env.USERPROFILE; // First, add discovered folders try { const discovery = new FolderDiscovery(); const discovered = await discovery.discoverFolders(); discovered.forEach(folder => { suggestions.push({ name: `📁 ${folder.name} (${folder.memoryCount} memories, ${folder.taskCount} tasks)`, memories: folder.memoriesPath, tasks: folder.tasksPath, discovered: true, lastModified: folder.lastModified, stats: { memoryCount: folder.memoryCount, taskCount: folder.taskCount, projectCount: folder.projectCount } }); }); } catch (error) { console.warn('Failed to discover folders:', error); } // Then add standard suggestions if (home) { // Common Claude Desktop paths suggestions.push({ name: 'Claude Desktop Default', memories: path.join(home, 'memories'), tasks: path.join(home, 'tasks'), discovered: false }); suggestions.push({ name: 'Like-I-Said Directory', memories: path.join(home, 'like-i-said-mcp', 'memories'), tasks: path.join(home, 'like-i-said-mcp', 'tasks'), discovered: false }); suggestions.push({ name: 'Documents Folder', memories: path.join(home, 'Documents', 'like-i-said', 'memories'), tasks: path.join(home, 'Documents', 'like-i-said', 'tasks'), discovered: false }); } return suggestions; } /** * Sanitize data to remove invalid Unicode characters * Prevents JSON.stringify errors from lone surrogates */ sanitizeUnicode(obj) { if (typeof obj === 'string') { // Replace lone surrogates and other invalid Unicode sequences return obj.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD'); } if (Array.isArray(obj)) { return obj.map(item => this.sanitizeUn