@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
JavaScript
#!/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