diff --git a/ai/core/AIService.js b/ai/core/AIService.js index e3ec061c..7d25258e 100644 --- a/ai/core/AIService.js +++ b/ai/core/AIService.js @@ -38,17 +38,12 @@ class AIService { try { log.info('Initializing AI Service...'); - // Dynamic require of index.js bootstrap to initialize the agent const { initializeAISystem } = require('../index.js'); const result = await initializeAISystem(appDataDir, workspaceRoot, llmProvider, embeddingConfig); const { getAIAgent } = require('../index.js'); this.agent = getAIAgent(); - const AIFlow = require('./AIFlow'); - this.aiFlow = new AIFlow(this.agent); - this.agent.aiFlow = this.aiFlow; - - log.info('AI Service & AIFlow Orchestrator successfully initialized'); + log.info('AI Service successfully initialized (embeddings + graph ready)'); return result; } catch (error) { log.error('Failed to initialize AI Service:', error.message); @@ -120,14 +115,12 @@ class AIService { const { shutdownAISystem } = require('../index.js'); shutdownAISystem(); this.agent = null; - this.aiFlow = null; } shutdown() { const { shutdownAISystem } = require('../index.js'); shutdownAISystem(); this.agent = null; - this.aiFlow = null; log.info('AI Service shut down'); } @@ -227,78 +220,6 @@ class AIService { } } - async chat(message, context = {}) { - if (!this.enabled || !this.agent) { - throw new Error('AI is currently disabled or uninitialized.'); - } - if (!this.aiFlow) { - const AIFlow = require('./AIFlow'); - this.aiFlow = new AIFlow(this.agent); - this.agent.aiFlow = this.aiFlow; - } - return this.aiFlow.execute(message, context); - } - - /** - * Main chat query streaming wrapper - */ - async stream(message, context = {}, onChunk, abortSignal) { - if (!this.enabled || !this.agent) { - throw new Error('AI is currently disabled or uninitialized.'); - } - if (!this.aiFlow) { - const AIFlow = require('./AIFlow'); - this.aiFlow = new AIFlow(this.agent); - this.agent.aiFlow = this.aiFlow; - } - return this.aiFlow.stream(message, context, onChunk, abortSignal); - } - - // --- Facade API Methods for Subsystem Modules --- - - getGraphStatus() { - return this.agent?.graphDb ? this.agent.graphDb.getStatus() : null; - } - - getGraphData() { - return this.agent?.graphDb ? this.agent.graphDb.getAll() : null; - } - - clearGraphData() { - if (this.agent?.graphDb) { - this.agent.graphDb.clearAllData(); - } - } - - async buildGraph(onProgress) { - return this.agent ? this.agent.buildRelationshipGraph(onProgress) : { success: false, error: 'Agent not initialized' }; - } - - getEmbeddingStats() { - return this.agent?.embeddingDb ? this.agent.embeddingDb.getStats() : null; - } - - clearEmbeddingData() { - if (this.agent?.embeddingDb) { - this.agent.embeddingDb.clearAllData(); - } - } - - async generateEmbeddings(forceRefresh = false) { - return this.agent ? this.agent.generateEmbeddings(forceRefresh) : { success: false, error: 'Agent not initialized' }; - } - - detectPatterns() { - return this.agent ? this.agent.detectPatterns() : { success: false, error: 'Agent not initialized' }; - } - - getConversationStore() { - return this.agent?.conversationStore || null; - } - - getPersonaManager() { - return this.agent?.personaManager || null; - } } const aiServiceInstance = new AIService(); diff --git a/ai/core/Agent.js b/ai/core/Agent.js index 56018389..684033c2 100644 --- a/ai/core/Agent.js +++ b/ai/core/Agent.js @@ -117,39 +117,9 @@ class Agent { } } - /** - * Process a query via AIFlow orchestrator - */ - async query(userQuery, context = {}) { - if (!this.isInitialized) { - throw new Error('Agent not initialized'); - } - if (this.aiFlow) { - return this.aiFlow.execute(userQuery, context); - } + // query() and stream() removed — chat moved to MCP layer - const AIFlow = require('./AIFlow'); - this.aiFlow = new AIFlow(this); - return this.aiFlow.execute(userQuery, context); - } - - /** - * Process a query with streaming output via AIFlow orchestrator - */ - async stream(userQuery, context = {}, onChunk, abortSignal) { - if (!this.isInitialized) { - throw new Error('Agent not initialized'); - } - - if (this.aiFlow) { - return this.aiFlow.stream(userQuery, context, onChunk, abortSignal); - } - - const AIFlow = require('./AIFlow'); - this.aiFlow = new AIFlow(this); - return this.aiFlow.stream(userQuery, context, onChunk, abortSignal); - } /** * Generate embeddings for workspace diff --git a/ai/diagnostics/AIHealth.js b/ai/diagnostics/AIHealth.js index 46066682..db636e7b 100644 --- a/ai/diagnostics/AIHealth.js +++ b/ai/diagnostics/AIHealth.js @@ -1,136 +1,175 @@ /** - * AIHealth - Diagnostics and health check metrics aggregator for the AI subsystem + * ai/diagnostics/AIHealth.js + * + * Diagnostics and health check metrics aggregator for the Notely MCP subsystem. */ const { aiService } = require('../core/AIService'); +const path = require('path'); +const fs = require('fs'); + +function getEmbeddingStats(db) { + let chunks = 0; + let notes = 0; + if (!db) return { chunks, notes }; + try { + const cRes = db.prepare("SELECT COUNT(*) as count FROM chunks").get(); + chunks = cRes ? Number(cRes.count || 0) : 0; + } catch { /* ignore */ } + + try { + const nRes1 = db.prepare("SELECT COUNT(DISTINCT note_path) as count FROM chunks WHERE note_path IS NOT NULL AND note_path != ''").get(); + notes = nRes1 ? Number(nRes1.count || 0) : 0; + } catch { /* ignore */ } + + if (notes === 0) { + try { + const nRes2 = db.prepare("SELECT COUNT(*) as count FROM note_hashes").get(); + notes = nRes2 ? Number(nRes2.count || 0) : 0; + } catch { /* ignore */ } + } + + return { chunks, notes }; +} + +function getGraphStats(db) { + let nodes = 0; + let edges = 0; + if (!db) return { nodes, edges }; + + try { + const nRes1 = db.prepare("SELECT COUNT(*) as count FROM entities").get(); + nodes = nRes1 ? Number(nRes1.count || 0) : 0; + } catch { /* ignore */ } + + try { + const eRes = db.prepare("SELECT COUNT(*) as count FROM relationships").get(); + edges = eRes ? Number(eRes.count || 0) : 0; + } catch { /* ignore */ } + + if (nodes === 0 && edges > 0) { + try { + const nRes2 = db.prepare(` + SELECT COUNT(*) as count FROM ( + SELECT source_id AS id FROM relationships + UNION + SELECT target_id AS id FROM relationships + ) + `).get(); + nodes = nRes2 ? Number(nRes2.count || 0) : 0; + } catch { /* ignore */ } + } + + return { nodes, edges }; +} function getSubsystemHealth() { const isEnabled = aiService.isEnabled(); const agent = aiService.agent; const isInitialized = Boolean(agent?.isInitialized); + const workspaceRoot = aiService.workspaceRoot || agent?.workspaceRoot || null; - // DB file checks - let dbStatus = 'uninitialized'; - let memoryDBPath = 'none'; - let personaDBPath = 'none'; - let embeddingDBPath = 'none'; - let graphDBPath = 'none'; - let logDBPath = 'none'; - let telemetryDBPath = 'none'; - let totalPersonas = 0; - let totalConversations = 0; + let dbStatus = workspaceRoot ? 'connected' : 'uninitialized'; + let embeddingDBPath = workspaceRoot ? path.join(workspaceRoot, '.notes-app', 'ai-embeddings.db') : 'none'; + let graphDBPath = workspaceRoot ? path.join(workspaceRoot, '.notes-app', 'ai-graph.db') : 'none'; + let telemetryDBPath = workspaceRoot ? path.join(workspaceRoot, '.notes-app', 'ai-telemetry.db') : 'none'; let totalChunks = 0; + let indexedNotes = 0; + let totalEntities = 0; let totalRelations = 0; - let totalLogs = 0; - let totalTelemetry = 0; - let requestsCount = 0; - let tokensUsed = 0; - if (isInitialized) { - dbStatus = 'connected'; + // 1. Primary: Use active Agent DB instances if initialized + if (isInitialized && agent) { try { - if (agent.conversationStore) { - memoryDBPath = agent.conversationStore.memoryDB?.dbPath || agent.conversationStore.dbPath || 'none'; - const convs = agent.conversationStore.listConversations(); - totalConversations = convs ? convs.length : 0; - } - if (agent.personaDB) { - personaDBPath = agent.personaDB.dbPath || 'none'; - const personas = agent.personaDB.list(); - totalPersonas = personas ? personas.length : 0; - } if (agent.embeddingDb && agent.embeddingDb.db) { - embeddingDBPath = agent.embeddingDb.dbPath || 'none'; - const countRes = agent.embeddingDb.db.prepare("SELECT COUNT(*) as count FROM chunks").get(); - totalChunks = countRes ? countRes.count : 0; + embeddingDBPath = agent.embeddingDb.dbPath || embeddingDBPath; + const eStats = getEmbeddingStats(agent.embeddingDb.db); + totalChunks = eStats.chunks; + indexedNotes = eStats.notes; } if (agent.graphDb && agent.graphDb.db) { - graphDBPath = agent.graphDb.dbPath || 'none'; - const relsRes = agent.graphDb.db.prepare("SELECT COUNT(*) as count FROM relationships").get(); - totalRelations = relsRes ? relsRes.count : 0; - } - if (agent.logDb && agent.logDb.db) { - logDBPath = agent.logDb.dbPath || 'none'; - const logCountRes = agent.logDb.db.prepare("SELECT COUNT(*) as count FROM logs").get(); - totalLogs = logCountRes ? logCountRes.count : 0; - - const statsRes = agent.logDb.db.prepare(` - SELECT - COUNT(*) as reqCount, - SUM(CAST(json_extract(metadata, '$.tokensUsed') AS INTEGER)) as tokSum - FROM logs - WHERE subsystem IN ('FlowTracker', 'PromptTracker') - `).get(); - if (statsRes) { - requestsCount += statsRes.reqCount || 0; - tokensUsed += statsRes.tokSum || 0; - } + graphDBPath = agent.graphDb.dbPath || graphDBPath; + const gStats = getGraphStats(agent.graphDb.db); + totalEntities = gStats.nodes; + totalRelations = gStats.edges; } if (agent.telemetryDb && agent.telemetryDb.db) { - telemetryDBPath = agent.telemetryDb.dbPath || 'none'; - const telStats = agent.telemetryDb.db.prepare("SELECT COUNT(*) as cnt, SUM(tokens_used) as tokSum FROM telemetry_logs").get(); - if (telStats) { - totalTelemetry = telStats.cnt || 0; - requestsCount += telStats.cnt || 0; - tokensUsed += telStats.tokSum || 0; - } + telemetryDBPath = agent.telemetryDb.dbPath || telemetryDBPath; } } catch (err) { - console.error('[AI Health] Failed to gather detailed database stats:', err); + console.error('[MCP Health] Failed to gather agent database stats:', err); dbStatus = 'degraded'; } - - let providerStats; + } else if (workspaceRoot) { + // 2. Fallback: Use direct SQLite connection to workspace disk DB files try { - providerStats = agent.llmRegistry?.activeProvider ? agent.llmRegistry.getActiveProvider()?.getUsageStats() : null; - } catch { - providerStats = null; - } - if (providerStats) { - if ((providerStats.requestsTotal || 0) > requestsCount) requestsCount = providerStats.requestsTotal; - if ((providerStats.tokensUsedTotal || 0) > tokensUsed) tokensUsed = providerStats.tokensUsedTotal; + const { DatabaseSync } = require('node:sqlite'); + if (fs.existsSync(embeddingDBPath)) { + try { + const embDb = new DatabaseSync(embeddingDBPath); + const eStats = getEmbeddingStats(embDb); + totalChunks = eStats.chunks; + indexedNotes = eStats.notes; + embDb.close(); + } catch { /* ignore */ } + } + if (fs.existsSync(graphDBPath)) { + try { + const gDb = new DatabaseSync(graphDBPath); + const gStats = getGraphStats(gDb); + totalEntities = gStats.nodes; + totalRelations = gStats.edges; + gDb.close(); + } catch { /* ignore */ } + } + } catch (err) { + console.error('[MCP Health] Fallback workspace DB check failed:', err); } } - const activeProvider = isInitialized && agent.llmRegistry?.activeProvider ? (agent.llmRegistry.getActiveProvider()?.name || 'none') : 'none'; + // Get MCP Server & Telemetry Stats + let mcpStats = { + totalSessions: 0, + activeConnections: 0, + totalToolCalls: 0, + successfulCalls: 0, + failedCalls: 0, + successRate: 100, + mostUsedTools: [] + }; - let isPaused = true; - let isIndexing = false; try { - const workerManager = require('../../electron/ai/workerManager.cjs'); - isPaused = workerManager.isPaused === true; - isIndexing = workerManager.isWorking === true; + if (agent?.telemetryDb && typeof agent.telemetryDb.getMcpStats === 'function') { + mcpStats = agent.telemetryDb.getMcpStats(); + } else if (workspaceRoot && fs.existsSync(telemetryDBPath)) { + const { TelemetryDB } = require('../telemetry'); + const tempTelDb = new TelemetryDB(workspaceRoot); + tempTelDb.initialize(); + mcpStats = tempTelDb.getMcpStats(); + tempTelDb.close(); + } } catch (err) { - console.error('[AI Health] Failed to load workerManager:', err.message); + console.error('[MCP Health] Failed to gather MCP stats:', err.message); } return { enabled: isEnabled, initialized: isInitialized, - activeProvider, - isPaused, - isIndexing, + serverStatus: isEnabled ? 'Running' : 'Stopped', + mcp: mcpStats, database: { status: dbStatus, - memoryDBPath, - personaDBPath, embeddingDBPath, graphDBPath, - logDBPath, telemetryDBPath, - totalPersonas, - totalConversations, totalChunks, - totalRelations, - totalLogs, - totalTelemetry - }, - systemStats: { - requestsCount, - tokensUsed + indexedNotes, + totalEntities, + totalRelations } }; } module.exports = { getSubsystemHealth }; + diff --git a/ai/telemetry/TelemetryDB.js b/ai/telemetry/TelemetryDB.js index 20573b34..30cc6e7b 100644 --- a/ai/telemetry/TelemetryDB.js +++ b/ai/telemetry/TelemetryDB.js @@ -1,7 +1,7 @@ /** * ai/telemetry/TelemetryDB.js * - * Dedicated, isolated SQLite database for AI execution telemetry. + * Dedicated, isolated SQLite database for MCP execution telemetry and system observability. * Stored inside {workspace}/.notes-app/ai-telemetry.db */ @@ -13,25 +13,34 @@ const { createLogger } = require('../core/logger'); const log = createLogger('TelemetryDB'); /** - * Security payload redaction utility for API keys and auth tokens + * Security payload redaction utility for API keys, passwords, and auth tokens. + * Enforces payload truncation at maxBytes to prevent database bloat. */ -function sanitizePayload(data) { +function sanitizePayload(data, maxBytes = 32768) { if (!data) return data; if (typeof data === 'string') { - return data + let sanitized = data .replace(/gsk_[A-Za-z0-9_-]+/gi, 'gsk_***REDACTED***') .replace(/sk-[A-Za-z0-9_-]+/gi, 'sk-***REDACTED***') .replace(/AIzaSy[A-Za-z0-9_-]+/gi, 'AIzaSy***REDACTED***') - .replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, 'Bearer ***REDACTED***'); + .replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, 'Bearer ***REDACTED***') + .replace(/("password"|"secret"|"token"|"apiKey"|"api_key"|"authorization")\s*:\s*"[^"]+"/gi, '$1: "***REDACTED***"'); + if (sanitized.length > maxBytes) { + sanitized = sanitized.slice(0, maxBytes) + `\n... [truncated ${sanitized.length - maxBytes} bytes]`; + } + return sanitized; } if (typeof data === 'object') { try { const copy = Array.isArray(data) ? [...data] : { ...data }; + const sensitiveKeys = new Set(['password', 'secret', 'token', 'apikey', 'api_key', 'authorization', 'credential', 'auth']); for (const k in copy) { - if (typeof copy[k] === 'string') { - copy[k] = sanitizePayload(copy[k]); + if (sensitiveKeys.has(k.toLowerCase())) { + copy[k] = '***REDACTED***'; + } else if (typeof copy[k] === 'string') { + copy[k] = sanitizePayload(copy[k], maxBytes); } else if (typeof copy[k] === 'object' && copy[k] !== null) { - copy[k] = sanitizePayload(copy[k]); + copy[k] = sanitizePayload(copy[k], maxBytes); } } return copy; @@ -62,60 +71,75 @@ class TelemetryDB { this.db.exec('PRAGMA journal_mode = WAL'); this.db.exec('PRAGMA synchronous = NORMAL'); - // Create telemetry_logs table this.db.exec(` - CREATE TABLE IF NOT EXISTS telemetry_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - flow_id TEXT UNIQUE NOT NULL, - trace_id TEXT, - conversation_id TEXT NOT NULL, - query TEXT NOT NULL, - persona TEXT, - duration_ms INTEGER, - tokens_used INTEGER, - tokens_detail TEXT, - system_prompt TEXT, - stages TEXT, - events TEXT, - created_at TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS telemetry_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id TEXT NOT NULL, span_id TEXT NOT NULL, parent_span_id TEXT, - conversation_id TEXT NOT NULL, + conversation_id TEXT DEFAULT 'mcp-session', event_type TEXT NOT NULL, category TEXT NOT NULL, status TEXT NOT NULL, severity TEXT DEFAULT 'info', - caller_type TEXT DEFAULT 'system', + caller_type TEXT DEFAULT 'external_client', label TEXT, duration_ms INTEGER DEFAULT 0, payload TEXT, created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS mcp_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + client_name TEXT DEFAULT 'Unknown Client', + client_version TEXT DEFAULT '1.0.0', + connected_at TEXT NOT NULL, + disconnected_at TEXT, + client_info TEXT, + tool_calls_count INTEGER DEFAULT 0, + successful_calls INTEGER DEFAULT 0, + errors_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'active' + ); + + CREATE TABLE IF NOT EXISTS mcp_tool_calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + call_id TEXT UNIQUE, + session_id TEXT NOT NULL, + client_name TEXT DEFAULT 'Unknown Client', + tool_name TEXT NOT NULL, + input_payload TEXT, + output_payload TEXT, + duration_ms INTEGER DEFAULT 0, + success INTEGER DEFAULT 1, + error TEXT, + called_at TEXT NOT NULL + ); `); - // Add trace_id column if upgrading existing database + // Migrate missing columns if tables pre-existed from older schema version try { - this.db.exec(`ALTER TABLE telemetry_logs ADD COLUMN trace_id TEXT;`); - } catch { - /* column already exists */ + const eventCols = this.db.prepare("PRAGMA table_info(telemetry_events)").all().map(c => c.name); + if (eventCols.length > 0 && !eventCols.includes('status')) { + this.db.exec("ALTER TABLE telemetry_events ADD COLUMN status TEXT DEFAULT 'SUCCESS'"); + } + const sessionCols = this.db.prepare("PRAGMA table_info(mcp_sessions)").all().map(c => c.name); + if (sessionCols.length > 0 && !sessionCols.includes('status')) { + this.db.exec("ALTER TABLE mcp_sessions ADD COLUMN status TEXT DEFAULT 'active'"); + } + } catch (migErr) { + log.warn('TelemetryDB column migration warning:', migErr.message); } - // Create indexes after ensuring columns exist this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_telemetry_conv_id ON telemetry_logs(conversation_id); - CREATE INDEX IF NOT EXISTS idx_telemetry_created_at ON telemetry_logs(created_at); - CREATE INDEX IF NOT EXISTS idx_telemetry_flow_id ON telemetry_logs(flow_id); - CREATE INDEX IF NOT EXISTS idx_telemetry_trace_id ON telemetry_logs(trace_id); CREATE INDEX IF NOT EXISTS idx_events_trace_id ON telemetry_events(trace_id); - CREATE INDEX IF NOT EXISTS idx_events_conv_id ON telemetry_events(conversation_id); CREATE INDEX IF NOT EXISTS idx_events_type ON telemetry_events(event_type); CREATE INDEX IF NOT EXISTS idx_events_status ON telemetry_events(status); - CREATE INDEX IF NOT EXISTS idx_events_severity ON telemetry_events(severity); + CREATE INDEX IF NOT EXISTS idx_mcp_tool_calls_session ON mcp_tool_calls(session_id); + CREATE INDEX IF NOT EXISTS idx_mcp_tool_calls_tool ON mcp_tool_calls(tool_name); + CREATE INDEX IF NOT EXISTS idx_mcp_tool_calls_called_at ON mcp_tool_calls(called_at); + CREATE INDEX IF NOT EXISTS idx_mcp_sessions_status ON mcp_sessions(status); `); this.isInitialized = true; @@ -127,74 +151,324 @@ class TelemetryDB { } } + recordMcpSession(session) { + if (!this.db || !this.isInitialized || !session?.id) return; + try { + const stmt = this.db.prepare(` + INSERT INTO mcp_sessions (session_id, client_name, client_version, connected_at, disconnected_at, client_info, tool_calls_count, successful_calls, errors_count, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + disconnected_at = excluded.disconnected_at, + tool_calls_count = excluded.tool_calls_count, + successful_calls = excluded.successful_calls, + errors_count = excluded.errors_count, + status = excluded.status + `); + stmt.run( + session.id, + session.clientName || session.clientInfo?.name || 'Unknown Client', + session.clientVersion || session.clientInfo?.version || '1.0.0', + session.connectedAt || new Date().toISOString(), + session.disconnectedAt || null, + JSON.stringify(sanitizePayload(session.clientInfo || {})), + session.toolCallsCount || 0, + session.successfulCalls || 0, + session.errorsCount || 0, + session.status || (session.disconnectedAt ? 'disconnected' : 'active') + ); + } catch (err) { + log.error('Failed to record MCP session in TelemetryDB:', err.message); + } + } + + recordMcpToolCall({ callId, sessionId, clientName, toolName, input, output, durationMs, success, error }) { + if (!this.db || !this.isInitialized) return; + try { + const sanitizedInput = input ? JSON.stringify(sanitizePayload(input)) : null; + const sanitizedOutput = output ? JSON.stringify(sanitizePayload(output)) : null; + const generatedCallId = callId || `call_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + + const stmt = this.db.prepare(` + INSERT INTO mcp_tool_calls (call_id, session_id, client_name, tool_name, input_payload, output_payload, duration_ms, success, error, called_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + generatedCallId, + sessionId || 'anonymous', + clientName || 'Unknown Client', + toolName || 'unknown_tool', + sanitizedInput, + sanitizedOutput, + durationMs || 0, + success ? 1 : 0, + error || null, + new Date().toISOString() + ); + + // Update session statistics + if (sessionId) { + this.db.prepare(` + UPDATE mcp_sessions + SET tool_calls_count = tool_calls_count + 1, + successful_calls = successful_calls + (CASE WHEN ? = 1 THEN 1 ELSE 0 END), + errors_count = errors_count + (CASE WHEN ? = 0 THEN 1 ELSE 0 END) + WHERE session_id = ? + `).run(success ? 1 : 0, success ? 1 : 0, sessionId); + } + } catch (err) { + log.error('Failed to record MCP tool call in TelemetryDB:', err.message); + } + } + + recordEvent(evt) { + if (!this.db || !this.isInitialized) return; + try { + const stmt = this.db.prepare(` + INSERT INTO telemetry_events + (trace_id, span_id, parent_span_id, conversation_id, event_type, category, status, severity, caller_type, label, duration_ms, payload, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + evt.traceId || `trc_${Date.now()}`, + evt.spanId || `spn_${Date.now()}`, + evt.parentSpanId || null, + evt.conversationId || 'mcp-session', + evt.eventType || evt.type || 'MCP_EVENT', + evt.category || 'MCP', + evt.status || 'SUCCESS', + evt.severity || 'info', + evt.callerType || 'external_client', + evt.label || evt.type || 'MCP Event', + Number(evt.durationMs || 0), + JSON.stringify(sanitizePayload(evt.payload || evt.input || {})), + evt.createdAt || new Date().toISOString() + ); + } catch (err) { + log.error('Failed to record telemetry event:', err.message); + } + } + + getMcpStats() { + if (!this.db || !this.isInitialized) { + return { totalSessions: 0, activeConnections: 0, totalToolCalls: 0, successfulCalls: 0, failedCalls: 0, successRate: 100, mostUsedTools: [] }; + } + try { + const sessionsRow = this.db.prepare(` + SELECT + COUNT(*) as totalSessions, + SUM(CASE WHEN status = 'active' AND disconnected_at IS NULL THEN 1 ELSE 0 END) as activeConnections + FROM mcp_sessions + `).get(); + + const callsRow = this.db.prepare(` + SELECT + COUNT(*) as totalCalls, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successfulCalls, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failedCalls + FROM mcp_tool_calls + `).get(); + + const topTools = this.db.prepare(` + SELECT tool_name as toolName, COUNT(*) as count + FROM mcp_tool_calls + GROUP BY tool_name + ORDER BY count DESC + LIMIT 5 + `).all(); + + const totalCalls = callsRow?.totalCalls || 0; + const successfulCalls = callsRow?.successfulCalls || 0; + const failedCalls = callsRow?.failedCalls || 0; + const successRate = totalCalls > 0 ? Math.round((successfulCalls / totalCalls) * 1000) / 10 : 100; + + return { + totalSessions: sessionsRow?.totalSessions || 0, + activeConnections: sessionsRow?.activeConnections || 0, + totalToolCalls: totalCalls, + successfulCalls, + failedCalls, + successRate, + mostUsedTools: topTools || [] + }; + } catch (err) { + log.error('Failed to get MCP stats:', err.message); + return { totalSessions: 0, activeConnections: 0, totalToolCalls: 0, successfulCalls: 0, failedCalls: 0, successRate: 100, mostUsedTools: [] }; + } + } + + getMcpToolCalls(filters = {}) { + if (!this.db || !this.isInitialized) return []; + try { + const conditions = []; + const params = []; + + if (filters.sessionId) { + conditions.push('session_id = ?'); + params.push(filters.sessionId); + } + if (filters.clientName) { + conditions.push('client_name LIKE ?'); + params.push(`%${filters.clientName}%`); + } + if (filters.toolName) { + conditions.push('tool_name = ?'); + params.push(filters.toolName); + } + if (filters.status) { + if (filters.status === 'SUCCESS' || filters.status === 'success') { + conditions.push('success = 1'); + } else if (filters.status === 'FAILED' || filters.status === 'failed') { + conditions.push('success = 0'); + } + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const limit = Number(filters.limit) || 100; + params.push(limit); + + const stmt = this.db.prepare(` + SELECT * FROM mcp_tool_calls + ${whereClause} + ORDER BY id DESC + LIMIT ? + `); + const rows = stmt.all(...params); + return rows.map(r => { + let input = null; + let output = null; + try { if (r.input_payload) input = JSON.parse(r.input_payload); } catch { input = r.input_payload; } + try { if (r.output_payload) output = JSON.parse(r.output_payload); } catch { output = r.output_payload; } + + return { + id: r.id, + callId: r.call_id, + sessionId: r.session_id, + clientName: r.client_name, + toolName: r.tool_name, + input, + output, + durationMs: r.duration_ms, + status: r.success === 1 ? 'SUCCESS' : 'FAILED', + error: r.error, + calledAt: r.called_at + }; + }); + } catch (err) { + log.error('Failed to get MCP tool calls:', err.message); + return []; + } + } + + getMcpSessions(limit = 50) { + if (!this.db || !this.isInitialized) return []; + try { + const stmt = this.db.prepare(` + SELECT * FROM mcp_sessions + ORDER BY id DESC + LIMIT ? + `); + const rows = stmt.all(limit); + return rows.map(r => { + let clientInfo = null; + try { if (r.client_info) clientInfo = JSON.parse(r.client_info); } catch { /* ignore */ } + return { + id: r.id, + sessionId: r.session_id, + clientName: r.client_name, + clientVersion: r.client_version, + connectedAt: r.connected_at, + disconnectedAt: r.disconnected_at, + clientInfo, + toolCallsCount: r.tool_calls_count, + successfulCalls: r.successful_calls, + errorsCount: r.errors_count, + status: r.status + }; + }); + } catch (err) { + log.error('Failed to get MCP sessions:', err.message); + return []; + } + } + + clearTelemetry() { + if (!this.db || !this.isInitialized) return; + try { + this.db.prepare('DELETE FROM mcp_tool_calls').run(); + this.db.prepare('DELETE FROM mcp_sessions').run(); + this.db.prepare('DELETE FROM telemetry_events').run(); + log.info('Cleared MCP telemetry logs and sessions'); + } catch (err) { + log.error('Failed to clear telemetry logs:', err.message); + } + } + addTelemetry(payload) { - if (!this.db) return; + if (!this.db || !this.isInitialized) return; try { const now = payload.startedAt || new Date().toISOString(); const flowId = payload.flowId || `flow-${Date.now()}`; const traceId = payload.traceId || flowId; const conversationId = payload.conversationId || 'default'; - const query = String(payload.query || ''); - const persona = String(payload.persona || 'general'); - const durationMs = Number(payload.totalDurationMs || 0); - const tokensUsed = typeof payload.tokensUsed === 'number' ? payload.tokensUsed : (payload.tokensUsed?.totalTokens || 0); - const tokensDetailStr = payload.tokensDetail ? JSON.stringify(payload.tokensDetail) : (typeof payload.tokensUsed === 'object' ? JSON.stringify(payload.tokensUsed) : null); - const systemPrompt = String(sanitizePayload(payload.systemPrompt || '')); - const stagesStr = JSON.stringify(sanitizePayload(payload.stages || [])); - const eventsStr = JSON.stringify(sanitizePayload(payload.events || [])); + const query = String(payload.query || payload.toolName || 'telemetry_event'); + const durationMs = Number(payload.totalDurationMs || payload.durationMs || 0); - const stmt = this.db.prepare(` - INSERT OR REPLACE INTO telemetry_logs - (flow_id, trace_id, conversation_id, query, persona, duration_ms, tokens_used, tokens_detail, system_prompt, stages, events, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - stmt.run(flowId, traceId, conversationId, query, persona, durationMs, tokensUsed, tokensDetailStr, systemPrompt, stagesStr, eventsStr, now); + // Record as generic MCP tool call / event + this.recordMcpToolCall({ + callId: flowId, + sessionId: conversationId, + clientName: payload.persona || 'System', + toolName: query, + input: payload.input || { query }, + output: payload.output || { stages: payload.stages }, + durationMs, + success: payload.status !== 'failed' && payload.status !== 'error', + error: payload.error || null + }); - // Optionally populate telemetry_events table if events exist if (Array.isArray(payload.events)) { - const evtStmt = this.db.prepare(` - INSERT INTO telemetry_events - (trace_id, span_id, parent_span_id, conversation_id, event_type, category, status, severity, caller_type, label, duration_ms, payload, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); for (const evt of payload.events) { - try { - evtStmt.run( - traceId, - evt.spanId || `spn_${Date.now()}`, - evt.parentSpanId || null, - conversationId, - evt.eventType || evt.type || 'event', - evt.category || 'System', - evt.status || 'completed', - evt.severity || 'info', - evt.callerType || 'system', - evt.label || evt.type || 'Event', - Number(evt.durationMs || 0), - JSON.stringify(sanitizePayload(evt.payload || evt.input || {})), - evt.startedAt || now - ); - } catch { - /* ignore individual event insert errors */ - } + this.recordEvent({ + traceId, + spanId: evt.spanId || `spn_${Date.now()}`, + parentSpanId: evt.parentSpanId || null, + conversationId, + eventType: evt.eventType || evt.type || 'event', + category: evt.category || 'System', + status: evt.status || 'completed', + severity: evt.severity || 'info', + callerType: evt.callerType || 'system', + label: evt.label || evt.type || 'Event', + durationMs: Number(evt.durationMs || 0), + payload: evt.payload || evt.input || {}, + createdAt: evt.startedAt || now + }); } } } catch (err) { - log.error('Failed to add telemetry log:', err.message); + log.error('Failed to add telemetry in TelemetryDB:', err.message); } } getTelemetryByConversation(conversationId, limit = 50) { - if (!this.db) return []; + if (!this.db || !this.isInitialized) return []; try { - const stmt = this.db.prepare(` - SELECT * FROM telemetry_logs - WHERE conversation_id = ? - ORDER BY id DESC - LIMIT ? - `); - const rows = stmt.all(conversationId, limit); - return rows.map(r => this._parseRow(r)); + const calls = this.getMcpToolCalls({ sessionId: conversationId, limit }); + return calls.map(c => ({ + id: c.id, + subsystem: 'FlowTracker', + message: `Telemetry for ${c.toolName}`, + timestamp: c.calledAt, + metadata: { + flowId: c.callId, + traceId: c.callId, + conversationId: c.sessionId, + query: c.toolName, + totalDurationMs: c.durationMs, + input: c.input, + output: c.output + } + })); } catch (err) { log.error('Failed to fetch telemetry by conversation:', err.message); return []; @@ -202,15 +476,24 @@ class TelemetryDB { } getTelemetryByTrace(traceId) { - if (!this.db) return null; + if (!this.db || !this.isInitialized) return null; try { - const stmt = this.db.prepare(` - SELECT * FROM telemetry_logs - WHERE trace_id = ? OR flow_id = ? - LIMIT 1 - `); - const row = stmt.get(traceId, traceId); - return row ? this._parseRow(row) : null; + const calls = this.getMcpToolCalls({ limit: 100 }); + const found = calls.find(c => c.callId === traceId); + if (!found) return null; + return { + id: found.id, + subsystem: 'FlowTracker', + message: `Telemetry for ${found.toolName}`, + timestamp: found.calledAt, + metadata: { + flowId: found.callId, + traceId: found.callId, + conversationId: found.sessionId, + query: found.toolName, + totalDurationMs: found.durationMs + } + }; } catch (err) { log.error('Failed to fetch telemetry by trace:', err.message); return null; @@ -218,7 +501,7 @@ class TelemetryDB { } queryEvents(filters = {}) { - if (!this.db) return []; + if (!this.db || !this.isInitialized) return []; try { const conditions = []; const params = []; @@ -285,72 +568,6 @@ class TelemetryDB { } } - getLatestTelemetry(limit = 100) { - if (!this.db) return []; - try { - const stmt = this.db.prepare(` - SELECT * FROM telemetry_logs - ORDER BY id DESC - LIMIT ? - `); - const rows = stmt.all(limit); - return rows.map(r => this._parseRow(r)); - } catch (err) { - log.error('Failed to fetch latest telemetry:', err.message); - return []; - } - } - - clearTelemetry(conversationId = null, beforeTimestamp = null) { - if (!this.db) return; - try { - if (conversationId) { - const stmt = this.db.prepare('DELETE FROM telemetry_logs WHERE conversation_id = ?'); - stmt.run(conversationId); - const stmtEvt = this.db.prepare('DELETE FROM telemetry_events WHERE conversation_id = ?'); - stmtEvt.run(conversationId); - } else if (beforeTimestamp) { - this.db.prepare('DELETE FROM telemetry_logs WHERE created_at <= ?').run(beforeTimestamp); - this.db.prepare('DELETE FROM telemetry_events WHERE created_at <= ?').run(beforeTimestamp); - } else { - this.db.prepare('DELETE FROM telemetry_logs').run(); - this.db.prepare('DELETE FROM telemetry_events').run(); - } - } catch (err) { - log.error('Failed to clear telemetry logs:', err.message); - } - } - - _parseRow(r) { - let stages = []; - let events = []; - let tokensDetail = null; - - try { if (r.stages) stages = JSON.parse(r.stages); } catch { /* ignore */ } - try { if (r.events) events = JSON.parse(r.events); } catch { /* ignore */ } - try { if (r.tokens_detail) tokensDetail = JSON.parse(r.tokens_detail); } catch { /* ignore */ } - - return { - id: r.id, - subsystem: 'FlowTracker', - message: `Flow execution telemetry recorded for query: "${r.query.slice(0, 60)}"`, - timestamp: r.created_at, - metadata: { - flowId: r.flow_id, - traceId: r.trace_id || r.flow_id, - conversationId: r.conversation_id, - query: r.query, - persona: r.persona, - totalDurationMs: r.duration_ms, - tokensUsed: r.tokens_used, - tokensDetail, - systemPrompt: r.system_prompt, - stages, - events - } - }; - } - close() { if (this.db) { try { @@ -365,3 +582,4 @@ class TelemetryDB { } module.exports = TelemetryDB; + diff --git a/docs-site/.vitepress/config.mts b/docs-site/.vitepress/config.mts index 33e20869..eda95dcd 100644 --- a/docs-site/.vitepress/config.mts +++ b/docs-site/.vitepress/config.mts @@ -95,6 +95,7 @@ export default withMermaid( { text: "Tasks", link: "/workspace/tasks" }, { text: "Calendar", link: "/workspace/calendar" }, { text: "Media", link: "/workspace/media" }, + { text: "Embedded Terminal", link: "/workspace/terminal" }, { text: "Screen Capture & Recording", link: "/workspace/screen-capture" }, { text: "Workspace Graph", link: "/workspace/graph" }, { text: "Downloads & History", link: "/workspace/downloads" }, @@ -123,13 +124,10 @@ export default withMermaid( ], }, { - text: "AI Features", + text: "AI Features & Graph", collapsed: false, items: [ - { text: "AI Overview", link: "/ai/" }, - { text: "AI Setup", link: "/ai/setup" }, - { text: "AI Features", link: "/ai/features" }, - { text: "AI Architecture", link: "/ai/architecture" }, + { text: "AI & Graph Overview", link: "/ai/" }, { text: "Knowledge Graph Engine", link: "/ai/knowledge-graph" }, ], }, @@ -157,10 +155,11 @@ export default withMermaid( ], }, { - text: "Developer", + text: "Developer & MCP", collapsed: true, items: [ { text: "Developer Docs", link: "/developer/" }, + { text: "MCP Protocol Integration", link: "/developer/mcp" }, { text: "Application Architecture", link: "/architecture" }, { text: "License", link: "/license" }, ], diff --git a/docs/architecture.md b/docs/architecture.md index ef45d690..b7e734de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,16 +108,29 @@ The Electron main process (`electron/main.cjs` & `electron/lib/`) coordinates ap * **Security**: AES-256 encrypted bundle (not readable by generic ZIP tools). Each file is SHA-256 hashed and verified on import to reject tampered packages. Optional password protection stores a salted SHA-256 signature in the manifest. * **Import**: Decrypts and verifies bundle integrity, resolves asset path conflicts, and places all files into the active workspace. See [Export & Import Reference](/export-reference) for full user-facing documentation. -### E. AI & Context Engine Subsystem (`aiService.cjs`) +### E. Model Context Protocol (MCP) Subsystem (`McpServer.cjs`) +* **HTTP SSE Server**: Embedded SSE server listening by default on port `3700` exposing workspace capabilities to external AI clients (Claude Desktop, Cursor, IDE agents). +* **Capability Suites**: Registers **129 tools across 15 suites** (`notes`, `index`, `workspace`, `diagrams`, `drawio`, `excalidraw`, `media`, `tasks`, `search`, `knowledge`, `git`, `diagnostics`, `web`, `personas`, `export`). See [`docs/mcp-tools-reference.md`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/docs/mcp-tools-reference.md). +* **Permission Control**: Enforces `allowWriteTools` configuration toggle; rejects unauthorized write operations (`[W]`) automatically. +* **Telemetry Flight Log**: Records tool execution events in SQLite database and streams updates via IPC to `AIHealthPage.jsx`. + +### F. Knowledge Graph & Vector Embedding Subsystem * **Vector Embeddings Engine (`EmbeddingDB.js`)**: Stores 384-dimensional `BGE-small` vector chunks in `{workspace}/.notes-app/ai-embeddings.db`. Features physical vector dimension validation (`verifyModelDimensions`) to prevent dimension mismatches. * **Knowledge Graph Subsystem (`GraphService.js`, `GraphDB.js`)**: Maps note relations, tags, mentions, Wikilinks, Images, Local Documents, and External URLs in `{workspace}/.notes-app/ai-graph.db`. Executes relation traversals via SQLite **Recursive Common Table Expressions (CTEs)**. -* **Agent & Tool Orchestration**: Integrates with a local embedding runtime and cloud LLMs (Gemini, Groq, OpenAI) using the Vercel AI SDK. -* **Local ONNX Neural Models**: Vector embeddings (`BGE-small-en-v1.5`) and Knowledge Graph entity/relationship extraction (`gliner2-multi-v1-onnx`) run 100% on-device and offline using `onnxruntime-node`. +* **Local ONNX Embedder**: Vector embeddings (`BGE-small-en-v1.5`) and Knowledge Graph entity/relationship extraction run on-device and offline using `onnxruntime-node`. +* **MCP Integration**: Inbuilt LLM chat was replaced by the embedded **Model Context Protocol (MCP)** server, allowing external AI clients (Claude Desktop, Cursor, AI agents) to query the knowledge graph and search vector indices. #### AI Layer Architecture The following diagram shows the full request path from the React UI through each layer to inference and storage: +### F. Model Context Protocol (MCP) Server Subsystem (`electron/mcp/`) +* **HTTP & SSE JSON-RPC Server (`McpServer.cjs`)**: Embedded MCP server (default port `3700`) exposing Notely workspace capabilities over SSE at `/sse` and `/messages` with Bearer token authentication support. +* **Lifecycle Controller (`McpLifecycle.cjs`)**: Manages MCP server lifecycle, port settings (`mcp-config.json`), IPC status broadcasting (`mcp:status-changed`), and graceful app shutdown. +* **Session Manager (`McpSessionManager.cjs`)**: Tracks active client connections, remote User-Agents, request durations, and tool invocation stats. +* **Application Tool Registry (`ApplicationToolRegistry.cjs`)**: Exposes 129 typed tools for note operations, workspace full-text search, knowledge graph queries, task management, Excalidraw canvas diagrams, media scanning, git operations, diagnostics, and workspace metadata. +* **Capabilities & Diagnostics UI**: Integrated React views `MCPToolsPage.jsx` (Interactive capabilities catalog & terminal test runner) and `AIHealthPage.jsx` (MCP server telemetry & SSE session diagnostics). See [Developer MCP Guide](/developer/mcp) for detailed API schemas. + ```mermaid flowchart TD subgraph Renderer["Renderer Process (React / Vite)"] diff --git a/docs/developer/index.md b/docs/developer/index.md index d4fba8cb..ace9dfc4 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -86,8 +86,6 @@ npm run test:p2p - `electron/lib/ipc/codeExecutorIpc.test.js`: Code execution runner tests. - `electron/p2p/p2pLive.test.js`: Peer-to-peer discovery and encrypted handshake tests. ---- - ## 5. Build & Packaging Scripts For generating standalone distribution packages: @@ -96,3 +94,10 @@ For generating standalone distribution packages: - **Release Packaging Script (`release.sh`)**: Automates version stamping, package archive creation, and release checksum generation. - **Icon Generation (`scripts/generate-icon.cjs`)**: Generates app icons from source image assets (`process.env.NOTELY_ICON_SOURCE`). +--- + +## 6. Related Developer Documentation + +- [Model Context Protocol (MCP) Integration](/developer/mcp) — Comprehensive guide for Notely's embedded MCP server, SSE transport, JSON-RPC tools, and UI diagnostics. +- [Application Architecture](/architecture) — Deep dive into Electron process model, subsystems, LogDB, and SQLite storage layers. + diff --git a/docs/developer/mcp.md b/docs/developer/mcp.md new file mode 100644 index 00000000..e08ae57e --- /dev/null +++ b/docs/developer/mcp.md @@ -0,0 +1,109 @@ +--- +title: Model Context Protocol (MCP) Integration +description: Comprehensive architecture and developer guide for Notely's Model Context Protocol (MCP) Server, Application Tool Registry, SSE transport, session management, and UI capabilities catalog. +keywords: MCP, Model Context Protocol, McpServer, McpLifecycle, McpSessionManager, ApplicationToolRegistry, SSE, JSON-RPC, Claude Desktop, Cursor, AI agents +category: Developer +--- + +# Notely Model Context Protocol (MCP) Architecture + +Notely features a first-class, local-first **Model Context Protocol (MCP)** server embedded directly into the Electron main process. This allows external AI clients (such as Claude Desktop, Cursor, IDE AI agents, or custom scripts) to connect to Notely via SSE (Server-Sent Events) and discover, read, search, and edit workspace notes using Notely's application capabilities. + +--- + +## 1. Subsystem Components Architecture + +```mermaid +flowchart TD + subgraph Clients ["External MCP Clients"] + CD["Claude Desktop"] & CR["Cursor / IDE Agents"] & EXT["Custom AI Scripts"] + end + + subgraph Transport ["Transport Layer (electron/mcp/McpServer.cjs)"] + HTTP["HTTP Server (127.0.0.1:3700)"] + AUTH["Bearer Token Authenticator"] + SSE["SSEServerTransport (/sse)"] + POST["JSON-RPC Message Endpoint (/messages?sessionId=...)"] + end + + subgraph Management ["Lifecycle & Session Controller"] + LC["McpLifecycle.cjs (IPC Controller)"] + CFG["McpConfig.cjs (mcp-config.json)"] + SM["McpSessionManager.cjs (Session & Stats Tracker)"] + end + + subgraph Registry ["Application Tool Registry (electron/tools/ApplicationToolRegistry.cjs)"] + ATR["ApplicationToolRegistry"] + NTS["NoteApplicationService"] + KNS["KnowledgeApplicationService"] + WSS["WorkspaceApplicationService"] + WBS["WebToolService"] + end + + subgraph UILayer ["Renderer UI Views (src/components/)"] + MTP["MCPToolsPage.jsx (Capabilities Catalog & Test Runner)"] + AHP["AIHealthPage.jsx (MCP Server Telemetry & Sessions)"] + MCS["MCPSettings.jsx (Port & Auth Settings)"] + end + + Clients -->|"HTTP GET /sse (Bearer Token)"| AUTH + AUTH --> SSE + SSE --> SM + Clients -->|"HTTP POST /messages"| POST + POST --> ATR + ATR --> NTS & KNS & WSS & WBS + LC --> CFG & SM & HTTP + UILayer -->|"IPC mcp:get-status / mcp:set-config"| LC +``` + +--- + +## 2. Server & Transport Specifications + +* **Protocol Version**: Model Context Protocol JSON-RPC 2.0. +* **Default Endpoint**: `http://127.0.0.1:3700/sse` +* **Message Endpoint**: `http://127.0.0.1:3700/messages?sessionId=` +* **Health Check**: `GET /health` or `GET /status` returns JSON server state, version (`0.1.41`), and registered tool count. +* **Authentication**: Optional HTTP Authorization Header `Bearer `. +* **Session Lifecycle**: Connections managed via `SSEServerTransport`. Disconnections gracefully purge active session state from `McpSessionManager`. + +--- + +## 3. Registered Tool Capabilities + +External clients can call `tools/list` to inspect Notely's available tool schema. Tools are provided via `ApplicationToolRegistry.cjs`: + +| Tool Name | Service Domain | Description | +| :--- | :--- | :--- | +| `note.create` | Note Service | Create a new Markdown document with initial content and title. | +| `note.read` | Note Service | Read document content and metadata by relative path. | +| `note.update` | Note Service | Edit or append to existing Markdown document. | +| `note.delete` | Note Service | Delete note file in workspace. | +| `workspace.search` | Workspace Service | Perform full-text search across all workspace Markdown files. | +| `workspace.list_files` | Workspace Service | Recursively list workspace files and folder structure. | +| `graph.query` | Knowledge Service | Query knowledge graph nodes, wikilinks, and cross-references. | +| `graph.get_stats` | Knowledge Service | Get node, link, and graph density statistics. | +| `tasks.list` | Workspace Service | Parse GFM task lists (`- [ ]`) across notes. | +| `system.get_info` | App Service | Get workspace path, version info, and server health metrics. | + +--- + +## 4. UI Capabilities Catalog & Diagnostics + +Notely provides two specialized React UI views for managing and inspecting the MCP subsystem: + +1. **MCP Tools & Capabilities (`src/components/MCPToolsPage.jsx`)**: + - **Live Status Header**: Real-time status badge (Running / Stopped / Port Conflict), active session counter, and total invocation counter. + - **Category Filters**: Categorized by *All*, *Notes & Docs*, *Search & Graph*, *Workspace & Files*, and *System & AI*. + - **Parameters Schema Inspector**: Type-coded parameter tags (`string`, `number`, `boolean`, `object`, `array`) and `REQUIRED` badges. + - **Interactive Console Test Runner**: Dark syntax-highlighted IDE console (`#0f172a`), 1-click **Auto-Fill JSON** sample generator, and **Copy Output** button. + - **Manifest Exporter**: 1-click **Export Manifest** button to copy full JSON-RPC tool schema manifest to clipboard for external integration. + +2. **MCP Diagnostics & Health (`src/components/AIHealthPage.jsx`)**: + - Live telemetry feed for active SSE sessions, remote client User-Agents, request durations, and error diagnostics. + +--- + +## 5. Security & IPC Control + +All IPC handlers (`mcp:get-status`, `mcp:set-config`, `mcp:start`, `mcp:stop`, `mcp:restart`, `mcp:get-sessions`) enforce trusted sender verification via `assertTrustedIpcSender`. diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md new file mode 100644 index 00000000..13b78748 --- /dev/null +++ b/docs/mcp-tools-reference.md @@ -0,0 +1,223 @@ +--- +title: MCP Tools & Capabilities Reference +description: Comprehensive reference documentation for Notely Model Context Protocol (MCP) server capabilities, tool suites, write permission controls, and SSE transport integration. +keywords: MCP, Model Context Protocol, SSE, AI, Claude Desktop, tools, capabilities, permissions +category: Developer +--- + +# Notely MCP Tools & Capabilities Reference + +Notely embeds an **HTTP SSE (Server-Sent Events) Model Context Protocol (MCP)** server enabling external AI clients (such as Claude Desktop, Cursor, IDE agents, and LLMs) to query, search, analyze, and manipulate workspace content safely. + +--- + +## 1. Server Architecture & Permission Control + +- **Transport Protocol**: HTTP SSE listening by default on `http://127.0.0.1:3700/sse` (messages accepted at `/messages`). +- **Security Guard (`allowWriteTools`)**: Configurable toggle in MCP Settings. When set to `false`, all write operations (`[W]`) are automatically hidden from MCP capability advertisement (`tools/list`) and blocked with a `WRITE_DISABLED` error envelope. +- **Flight Log Telemetry**: All incoming tool call executions are recorded in the local SQLite telemetry database and broadcast via IPC to the **MCP Diagnostics** flight log viewer (`AIHealthPage`). +- **Total Capabilities**: **133 Tools** across 14 specialized suites. + +--- + +## 2. Complete Tool Suites Reference (133 Tools) + +### Suite 1: Notes & Document Management (`notes.*`) — 33 Tools + +- `notes.read`: Read content of a specific note file in the workspace. +- `notes.create` **[W]**: Create a new markdown note in the workspace. +- `notes.update` **[W]**: Update, append, or overwrite content in an existing note. +- `notes.delete` **[W]**: Delete a note file from the workspace. +- `notes.move` **[W]**: Move or rename a note file within the workspace. +- `notes.read_frontmatter`: Extract and parse YAML frontmatter metadata from a note file. +- `notes.extract_toc`: Extract heading outline (Table of Contents) from a note file. +- `notes.backlinks`: Find incoming and outgoing wiki-style links for a given note. +- `notes.search_replace` **[W]**: Bulk search and replace string or regex across workspace notes. +- `notes.history`: Retrieve revision history and commit logs for a note file. +- `notes.list`: List all markdown notes in the workspace with their paths, sizes, and last modified timestamps. +- `notes.rename` **[W]**: Rename a note file, preserving its folder location. Updates the filename on disk. +- `notes.append` **[W]**: Append text content to the end of an existing note without overwriting existing content. +- `notes.duplicate` **[W]**: Duplicate an existing note to a new path, creating an independent copy. +- `notes.extract_headings`: Extract all headings (H1–H6) from a note file with their levels and line numbers. +- `notes.find_broken_links`: Scan the workspace for [[wikilinks]] that point to notes which do not exist. +- `notes.frontmatter_update` **[W]**: Add or update specific YAML frontmatter fields in a note without touching the body content. +- `notes.count`: Count notes in the workspace, optionally grouped by top-level folder. +- `notes.get_links`: Extract all outgoing wikilinks and markdown links from a note. +- `notes.insert_at` **[W]**: Insert content at a specific line number or directly after a named heading in a note. +- `notes.stats`: Get detailed stats for a single note: word count, line count, heading count, link count, task count, and file size. +- `notes.bulk_tag` **[W]**: Add or remove frontmatter tags from multiple notes matching a folder or name pattern. +- `notes.read_section`: Read only the content under a specific heading in a note, without reading the entire file. +- `notes.delete_lines` **[W]**: Delete a range of lines from a note file by start and end line number. +- `notes.replace_line` **[W]**: Replace the content of a specific line in a note by line number. +- `notes.extract_code`: Extract all fenced code blocks from a note with their language labels and content. +- `notes.table_of_contents`: Generate a Markdown Table of Contents from the headings in a note and optionally insert it. +- `notes.convert_to_checklist` **[W]**: Convert plain bullet list items (- item) to checklist items (- [ ] item) in a note. +- `notes.merge` **[W]**: Merge content from a source note into a target note with optional separator, and optionally delete source. +- `notes.archive` **[W]**: Move a note file into an Archive/ subfolder within the workspace. +- `notes.set_title` **[W]**: Update or insert the primary top-level heading (# Title) in a markdown note. +- `notes.prepend` **[W]**: Prepend text content to the beginning of a note (after frontmatter if present). +- `notes.template_apply` **[W]**: Instantiate a new note by applying variables ({{title}}, {{date}}, {{time}}, etc.) to a template string or existing template note. + +### Suite 2: Workspace Index (`index.*`) — 4 Tools + +- `index.build_index`: Generate multi-level index of workspace documents, folder trees, headers, code blocks, tasks, and tag map. +- `index.search_hierarchical`: Multi-level section block & header deep search across documents, headers, tasks, and tags. +- `index.get_tags`: Retrieve tag map and list of documents grouped by tag across the workspace. +- `index.list_notes`: Return a flat list of all notes in the workspace index with titles and relative paths. + +### Suite 3: Workspace Metadata & Files (`workspace.*`) — 21 Tools + +- `workspace.list_workspaces`: List all known and recent workspaces in Notely, including active workspace and directory paths. +- `workspace.current`: Get details and metrics for the currently active workspace. +- `workspace.notes_index`: Generate structured index of all notes in the active workspace with word count, tags, task stats, and frontmatter. +- `workspace.media_used_index`: Extract complete index of all media assets, diagrams, and attachments actively referenced across workspace notes. +- `workspace.metadata`: Get workspace metadata, vault name, app version, root directory path, and environment details. +- `workspace.update_metadata` **[W]**: Update workspace metadata settings and configuration flags. +- `workspace.statistics`: Get workspace document counts, storage breakdown, task totals, and health metrics. +- `workspace.recent_activity`: Get chronological list of recently modified notes in the workspace. +- `workspace.export_pdf`: Export or render a note document into PDF format. +- `workspace.list_tree`: Get nested folder hierarchy tree with file counts and byte sizes. +- `workspace.create_folder` **[W]**: Create a new directory folder in the workspace. +- `workspace.delete_folder` **[W]**: Delete a folder directory in the workspace. +- `workspace.search_files`: Search workspace files by name pattern or extension. Returns matching file paths and metadata. +- `workspace.word_count`: Count words, characters, and lines in a note file or across the entire workspace. +- `workspace.rename_folder` **[W]**: Rename a folder in the workspace, preserving all its contents. +- `workspace.find_duplicates`: Find notes with identical titles or very similar filenames across the workspace. +- `workspace.get_size`: Calculate the total disk size of the workspace, broken down by file type. +- `workspace.export_zip` **[W]**: Export all markdown notes from the workspace into a single .zip archive. +- `workspace.lint`: Audit all notes for common quality issues: empty notes, missing H1, unclosed code blocks, and orphaned notes. +- `workspace.index_rebuild` **[W]**: Trigger full cache invalidation and rebuild of workspace search indices. +- `workspace.file_tree`: Generate a hierarchical folder and file tree of the workspace. + +### Suite 4: Diagrams & Flowcharts (`diagrams.*`) — 6 Tools + +- `diagrams.render`: Validate and format Mermaid diagram markup (flowchart, sequence, class, state, gantt, pie). +- `diagrams.create` **[W]**: Create a new diagram file or append a Mermaid diagram block to a note. +- `diagrams.list`: Scan workspace notes for all embedded Mermaid and Draw.io diagrams. +- `diagrams.read`: Read raw Mermaid diagram code blocks from a target note. +- `diagrams.update` **[W]**: Edit or replace a Mermaid diagram block inside a target note file. +- `diagrams.convert_to_image`: Render Mermaid code block to SVG graphic asset. + +### Suite 5: Draw.io Vector Drawings (`drawio.*`) — 6 Tools + +- `drawio.read_source`: Read XML diagram source data of a Draw.io file in the workspace. +- `drawio.write_source` **[W]**: Create or update Draw.io XML diagram source file. +- `drawio.write_image` **[W]**: Save rendered PNG/SVG preview image for a Draw.io diagram. +- `drawio.read`: Read raw Excalidraw JSON structure or Draw.io XML markup from drawing files. +- `drawio.update` **[W]**: Write back updated Excalidraw JSON elements or Draw.io XML markup to drawing files. +- `drawio.export_svg`: Export drawing file to clean SVG graphic file in Media/. + +### Suite 6: Excalidraw Canvas Diagrams (`excalidraw.*`) — 6 Tools + +- `excalidraw.read`: Read the JSON schema and element structure from an .excalidraw drawing file or diagram ID. +- `excalidraw.create` **[W]**: Create a new .excalidraw drawing with elements (rectangles, ellipses, arrows, text, etc.). +- `excalidraw.update` **[W]**: Update elements or add new elements to an existing .excalidraw drawing file. +- `excalidraw.list`: Find and list all Excalidraw drawing files and embedded diagram folders across the workspace. +- `excalidraw.extract_elements`: Extract text labels, shapes, and connected bindings from an Excalidraw drawing. +- `excalidraw.delete` **[W]**: Delete an .excalidraw drawing file and its associated preview PNG from the workspace. + +### Suite 7: Media & Assets (`media.*`) — 7 Tools + +- `media.list_assets`: Scan workspace for images, audio, video, PDFs, and attachment files. +- `media.extract_used_assets`: Catalog all referenced media files, diagrams, and PDFs with note line numbers and context snippets. +- `media.get_metadata`: Read file size, format, and dimensions of a workspace media asset. +- `media.save_asset` **[W]**: Save binary or base64 attachment file into workspace assets directory. +- `media.delete_asset` **[W]**: Delete a media attachment file from the workspace. +- `media.cleanup_unused`: Find media assets in the workspace that are not referenced by any note. Optionally delete them. +- `media.list`: List all image and media attachment files (.png, .jpg, .svg, .gif, .pdf, .mp3, .mp4, etc.) in the workspace with sizes. + +### Suite 8: Task Workspace (`tasks.*`) — 11 Tools + +- `tasks.extract`: Extract checklist tasks across notes in the workspace. +- `tasks.update_status` **[W]**: Update the status of a checklist task in a note. Supports open [ ], in-progress [/], and completed [x]. +- `tasks.summary`: Group and summarize workspace tasks by note, completion rate, and status. +- `tasks.query`: Query checklist tasks by priority, due date range, status, or assignee tag. +- `tasks.summarize`: Generate summary report of completed vs open tasks across workspace. +- `tasks.create` **[W]**: Create a new checklist task item and append it to a note file. +- `tasks.complete` **[W]**: Mark a task as completed [x] by line number or by matching task text (convenience wrapper). +- `tasks.find_overdue`: Find open tasks with a due: YYYY-MM-DD date that has already passed. +- `tasks.due_today`: Find all checklist tasks in the workspace due today (matching due:YYYY-MM-DD tag with current date). +- `tasks.move` **[W]**: Cut a task line from a source note and append it to a target note. +- `tasks.archive_completed` **[W]**: Move all completed [x] tasks from a note to an archive section (## Completed Tasks) at the bottom. + +### Suite 9: Search & Retrieval (`search.*`) — 7 Tools + +- `search.notes`: Full-text keyword search across workspace notes. +- `search.similar`: Find semantically similar notes using vector embeddings. +- `search.hybrid`: Hybrid search combining full-text keyword search and vector similarity. +- `search.by_tag`: Find all notes that contain a specific tag in their YAML frontmatter. +- `search.by_date`: Find notes modified within a date range. Dates are ISO 8601 strings (e.g. "2024-01-01"). +- `search.by_frontmatter`: Find notes where a specific YAML frontmatter field contains or equals a value. +- `search.regex`: Search all notes using a regular expression pattern. Returns matching lines with file and line context. + +### Suite 10: Knowledge Graph & RAG (`knowledge.*`) — 9 Tools + +- `knowledge.related_topics`: Traverse knowledge graph relationships around a note or topic. +- `knowledge.find_clusters`: Discover semantic topic clusters across the workspace. +- `knowledge.find_orphans`: Find orphan notes in the workspace that have no incoming or outgoing wiki links. +- `knowledge.status`: Get index status, graph DB node count, and embedding health. +- `knowledge.reindex` **[W]**: Force background reindexing of workspace knowledge graph and embeddings. +- `knowledge.unlinked_mentions`: Find plain text mentions of note titles that can be converted into [[Wikilinks]]. +- `knowledge.auto_wikilink` **[W]**: Automatically convert unlinked plain text mentions into [[Wikilinks]] inside a note. +- `knowledge.note_summary`: Generate a structural summary of a note: title, headings, word count, tags, and first paragraph. +- `knowledge.link_graph`: Build a JSON graph of all [[wikilink]] connections between notes in the workspace. + +### Suite 11: Git Version Control (`git.*`) — 12 Tools + +- `git.status`: Check git working tree status and list modified note files. +- `git.log`: View recent git commit history of the workspace. +- `git.diff`: View git diff of modified notes in the workspace. +- `git.commit` **[W]**: Stage and commit workspace changes. +- `git.branch`: Get the current git branch name and list of all local branches in the workspace. +- `git.stash` **[W]**: Stash uncommitted workspace changes or list/pop existing stashes. +- `git.pull` **[W]**: Pull latest changes from the remote origin for the current branch. +- `git.push` **[W]**: Push committed changes to the remote origin. +- `git.checkout` **[W]**: Checkout an existing branch or create a new one in the workspace repository. +- `git.remote_list`: List all configured git remotes and their URLs for the workspace repository. +- `git.tag_list`: List all git tags in the workspace repository, newest first. +- `git.revert` **[W]**: Revert a specific git commit by its hash, creating a new undo commit. + +### Suite 12: Diagnostics & Telemetry (`diagnostics.*`) — 3 Tools + +- `diagnostics.check_health`: Run health diagnostics on AI providers, vector database, and graph DB. +- `diagnostics.get_telemetry`: Inspect MCP tool call latency metrics, execution flight logs, and error rates. +- `diagnostics.get_logs`: Fetch recent Notely application log entries from the electron log file. + +### Suite 13: External Web (`web.*`) — 2 Tools + +- `web.search`: Search the live web for external documentation or references. +- `web.fetch`: Fetch and read text content from a public web page URL. + +### Suite 14: Personas & Agents (`personas.*`) — 4 Tools + +- `personas.list`: List all available custom and system AI personas. +- `personas.get`: Get details of a specific AI persona by ID. +- `personas.create` **[W]**: Create a new custom AI persona. +- `personas.delete` **[W]**: Delete a custom persona by ID. + +### Suite 15: Bundles & Packaging (`export.*`) — 2 Tools + +- `export.create_package` **[W]**: Export note + linked media assets into an encrypted .note bundle file. +- `export.import_package` **[W]**: Import and extract a .note package bundle into the active workspace. + +--- + +## 3. Client Integration Example (Claude Desktop) + +To connect Claude Desktop to Notely MCP server, add this entry to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "notely": { + "url": "http://127.0.0.1:3700/sse" + } + } +} +``` + +--- + +## 4. Write Operations Permission Table + +When write access is disabled (`allowWriteTools: false`), all tools marked **[W]** are automatically filtered out from external discovery and blocked from execution. Read-only query tools remain active and safe to call. diff --git a/electron/ai/aiHandlers.cjs b/electron/ai/aiHandlers.cjs index cb8836ef..de041daf 100644 --- a/electron/ai/aiHandlers.cjs +++ b/electron/ai/aiHandlers.cjs @@ -57,11 +57,12 @@ let handlersRegistered = false; // --- Input validation & sender trust guards ------------------------------- -const MAX_QUERY_LENGTH = 8000; -const MAX_CONTEXT_BYTES = 200000; + +// Input size limits (kept for config/payload validation) const MAX_API_KEY_LENGTH = 2048; const MIN_API_KEY_LENGTH = 8; + // Derived from providerRegistry — the single source of truth for valid provider ids. const { ALLOWED_PROVIDER_IDS: ALLOWED_PROVIDERS } = require('../../ai/providers/ProviderRegistry'); @@ -112,28 +113,7 @@ function maskApiKey(apiKey) { return `${key.slice(0, 5)}...${key.slice(-5)}`; } -function sanitizeQueryPayload(payload) { - const source = payload && typeof payload === 'object' ? payload : {}; - const query = typeof source.query === 'string' ? source.query : ''; - if (!query.trim()) { - throw new Error('Query must be a non-empty string.'); - } - if (query.length > MAX_QUERY_LENGTH) { - throw new Error('Query is too long.'); - } - - let context = source.context && typeof source.context === 'object' ? source.context : {}; - try { - if (JSON.stringify(context).length > MAX_CONTEXT_BYTES) { - throw new Error('Context payload is too large.'); - } - } catch { - // Non-serializable context is dropped rather than forwarded. - context = {}; - } - return { query, context }; -} function registerHandler(channel, handler) { if (!channel || typeof channel !== 'string') { @@ -148,7 +128,7 @@ function registerHandler(channel, handler) { }); } -const activeQueryControllers = new Map(); + /** * Initialize IPC handlers @@ -203,10 +183,9 @@ function initializeAIHandlers(electronApp, agent) { // AI Initialization registerHandler(IPC_EVENTS.AI_INIT, handleInitialize); - // AI Query - registerHandler(IPC_EVENTS.AI_QUERY, handleQuery); - registerHandler(IPC_EVENTS.AI_QUERY_STREAM, handleQueryStream); - registerHandler(IPC_EVENTS.AI_QUERY_ABORT, handleQueryAbort); + + // AI Query — removed (chat moved to MCP layer) + // Status registerHandler(IPC_EVENTS.AI_STATUS, handleStatus); @@ -237,8 +216,7 @@ function initializeAIHandlers(electronApp, agent) { registerHandler(IPC_EVENTS.AI_GRAPH_MODEL_DELETE, handleDeleteGraphModel); registerHandler(IPC_EVENTS.AI_GRAPH_MODEL_STATUS, handleGetGraphModelStatus); - // Pattern detection - registerHandler(IPC_EVENTS.AI_DETECT_PATTERNS, handleDetectPatterns); + // Persistent Log Store registerHandler(IPC_EVENTS.AI_LOGS_GET, handleGetLogs); @@ -261,15 +239,7 @@ function initializeAIHandlers(electronApp, agent) { registerHandler(IPC_EVENTS.AI_DISABLE, handleDisableAI); registerHandler(IPC_EVENTS.AI_HEALTH_GET, handleGetAIHealth); - // Phase 5 — Conversations - registerHandler(IPC_EVENTS.AI_CONVERSATION_LIST, handleConversationList); - registerHandler(IPC_EVENTS.AI_CONVERSATION_GET, handleConversationGet); - registerHandler(IPC_EVENTS.AI_CONVERSATION_CREATE, handleConversationCreate); - registerHandler(IPC_EVENTS.AI_CONVERSATION_DELETE, handleConversationDelete); - registerHandler(IPC_EVENTS.AI_CONVERSATION_CLEAR, handleConversationClear); - registerHandler(IPC_EVENTS.AI_CONVERSATION_SET_PERSONA, handleConversationSetPersona); - registerHandler(IPC_EVENTS.AI_CONVERSATION_GET_MESSAGES, handleConversationGetMessages); - registerHandler(IPC_EVENTS.AI_CONVERSATION_ADD_MESSAGE, handleConversationAddMessage); + // Phase 5 — Conversations removed (chat moved to MCP layer) // Phase 5 — Personas registerHandler(IPC_EVENTS.AI_PERSONA_LIST, handlePersonaList); @@ -279,10 +249,7 @@ function initializeAIHandlers(electronApp, agent) { registerHandler(IPC_EVENTS.AI_PERSONA_IMPORT, handlePersonaImport); registerHandler(IPC_EVENTS.AI_PERSONA_EXPORT, handlePersonaExport); - // Phase 5 — Candidate Knowledge - registerHandler(IPC_EVENTS.AI_KNOWLEDGE_LIST_PENDING, handleKnowledgeListPending); - registerHandler(IPC_EVENTS.AI_KNOWLEDGE_APPROVE, handleKnowledgeApprove); - registerHandler(IPC_EVENTS.AI_KNOWLEDGE_REJECT, handleKnowledgeReject); + // Candidate Knowledge — removed (chat-only) // Shutdown registerHandler(IPC_EVENTS.AI_SHUTDOWN, handleShutdown); @@ -371,79 +338,6 @@ async function handleInitialize(event, payload) { } } -/** - * Handle AI query - */ -async function handleQuery(event, payload) { - try { - if (!aiService.isEnabled() || !aiService.agent) { - throw new Error('AI agent is disabled or not initialized'); - } - - const { query, context } = sanitizeQueryPayload(payload); - const result = await aiService.chat(query, context); - - return new AIQueryResponse(result.success, result); - } catch (error) { - console.error('[AI IPC] Query handling failed:', error); - return new AIQueryResponse(false, null, error.message); - } -} - -/** - * Handle AI query streaming - */ -async function handleQueryStream(event, payload) { - const queryId = payload?.queryId || require('crypto').randomUUID(); - try { - if (!aiService.isEnabled() || !aiService.agent) { - throw new Error('AI agent is disabled or not initialized'); - } - - const { query, context } = sanitizeQueryPayload(payload); - - const controller = new AbortController(); - activeQueryControllers.set(queryId, controller); - - const result = await aiService.stream( - query, - context, - (chunk) => { - if (!event.sender.isDestroyed()) { - event.sender.send('ai:chat:chunk', { queryId, chunk }); - } - }, - controller.signal - ); - - activeQueryControllers.delete(queryId); - return new AIQueryResponse(true, result); - } catch (error) { - activeQueryControllers.delete(queryId); - console.error('[AI IPC] Streaming query handling failed:', error); - return new AIQueryResponse(false, null, error.message); - } -} - -/** - * Handle AI query abort - */ -async function handleQueryAbort(_event, payload) { - const queryId = payload?.queryId; - if (!queryId) { - return new AIQueryResponse(false, null, 'queryId is required'); - } - - const controller = activeQueryControllers.get(queryId); - if (controller) { - controller.abort(); - activeQueryControllers.delete(queryId); - return new AIQueryResponse(true, { message: 'Query generation aborted.' }); - } - - return new AIQueryResponse(false, null, 'No active query found for this ID.'); -} - /** * Handle status request */ @@ -553,7 +447,22 @@ async function handleClearGraphData(_event, _payload) { async function handleGetEmbeddingsStatus(_event, payload) { try { - if (!aiService.isEnabled() || !aiService.agent || !aiService.agent.embeddingDb) { + let db = aiService.agent?.embeddingDb; + let tempDb = null; + const workspaceRoot = aiService.workspaceRoot || aiService.agent?.workspaceRoot || null; + + if (!db && workspaceRoot) { + try { + const EmbeddingDB = require('../../ai/embeddings/EmbeddingDB'); + tempDb = new EmbeddingDB(workspaceRoot); + tempDb.initialize(); + db = tempDb; + } catch (err) { + console.error('[AI IPC] Temp EmbeddingDB init failed:', err); + } + } + + if (!db) { return new AIQueryResponse(true, { totalChunks: 0, indexedNotes: 0, @@ -565,7 +474,7 @@ async function handleGetEmbeddingsStatus(_event, payload) { uninitialized: true }); } - const db = aiService.agent.embeddingDb; + const workerManager = require('./workerManager.cjs'); const search = payload?.search || ''; const limit = payload?.limit || 50; @@ -575,7 +484,7 @@ async function handleGetEmbeddingsStatus(_event, payload) { const totalChunks = db.getChunkCount(); const indexedNotes = db.getIndexedNotesCount(); const queueStats = db.getQueueSize(); - const logs = db.getLogs(30); + const logs = typeof db.getLogs === 'function' ? db.getLogs(30) : []; let dbSize = '0 KB'; try { @@ -592,6 +501,10 @@ async function handleGetEmbeddingsStatus(_event, payload) { console.error('[AI IPC] Failed to check db size:', err); } + if (tempDb) { + try { tempDb.close(); } catch { /* ignore */ } + } + return new AIQueryResponse(true, { totalChunks, indexedNotes, @@ -601,7 +514,8 @@ async function handleGetEmbeddingsStatus(_event, payload) { isWorking: workerManager.isWorking === true, chunks, logs, - dbSize + dbSize, + uninitialized: false }); } catch (error) { console.error('[AI IPC] Get embeddings status failed:', error); @@ -928,22 +842,6 @@ async function handleGetGraphStatus(_event, payload) { } } -/** - * Handle pattern detection - */ -async function handleDetectPatterns(_event, _payload) { - try { - if (!aiService.isEnabled() || !aiService.agent) { - throw new Error('AI agent is disabled or not initialized'); - } - - const result = aiService.agent.detectPatterns(); - return new AIQueryResponse(true, result); - } catch (error) { - console.error('[AI IPC] Pattern detection failed:', error); - return new AIQueryResponse(false, null, error.message); - } -} /** * Handle API key configuration @@ -1349,100 +1247,26 @@ async function handleGetAIHealth(_event, _payload) { } } -// ─── Phase 5: Context Engine Helpers ───────────────────────────────────────── +// ─── Personas (exposed via MCP; backed by PersonaDB) ────────────────────── +// ConversationStore and chat-scoped conversation handlers removed. +// Persona handlers now load PersonaDB directly. -function _getStore() { +function _getPersonaDB() { const agent = aiService.agent; - if (!agent?.conversationStore) throw new Error('ConversationStore not initialized.'); - return agent.conversationStore; + if (agent?.personaDB) return agent.personaDB; + // Fallback: direct PersonaDB access (agent may not be running) + const { PersonaDB } = require('../../ai/memory'); + const { app } = require('electron'); + const appDataDir = require('path').join(app.getPath('appData'), 'Notely', 'notely'); + const db = new PersonaDB(appDataDir); + db.initialize(); + return db; } -// ─── Conversations ───────────────────────────────────────────────────────── - -async function handleConversationList(_event, _payload) { - try { - return new AIQueryResponse(true, _getStore().listConversations()); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleConversationGet(_event, payload) { - try { - const conv = _getStore().getConversation(payload?.id); - if (!conv) return new AIQueryResponse(false, null, 'Conversation not found.'); - return new AIQueryResponse(true, conv); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleConversationCreate(_event, payload) { - try { - const conv = _getStore().createConversation(payload?.title, payload?.persona); - return new AIQueryResponse(true, conv); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleConversationDelete(_event, payload) { - try { - const convId = payload?.id; - _getStore().deleteConversation(convId); - if (convId) { - const telDb = getTelemetryDbInstance(); - if (telDb) telDb.clearTelemetry(convId); - } - return new AIQueryResponse(true, { deleted: convId }); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleConversationClear(_event, payload) { - try { - const beforeTimestamp = payload?.beforeTimestamp || null; - _getStore().clearAll(beforeTimestamp); - const telDb = getTelemetryDbInstance(); - if (telDb) telDb.clearTelemetry(null, beforeTimestamp); - return new AIQueryResponse(true, { cleared: true }); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleConversationSetPersona(_event, payload) { - try { - _getStore().setPersona(payload?.conversationId, payload?.personaId); - return new AIQueryResponse(true, { ok: true }); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleConversationGetMessages(_event, payload) { - try { - return new AIQueryResponse(true, _getStore().getMessages(payload?.conversationId)); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleConversationAddMessage(_event, payload) { - try { - const msg = _getStore().addMessage(payload?.conversationId, payload?.role, payload?.content, payload?.metadata || null); - return new AIQueryResponse(true, msg); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -// ─── Personas ───────────────────────────────────────────────────────────── async function handlePersonaList(_event, _payload) { try { - return new AIQueryResponse(true, _getStore().listPersonas()); + return new AIQueryResponse(true, _getPersonaDB().list()); } catch (err) { return new AIQueryResponse(false, null, err.message); } @@ -1450,7 +1274,7 @@ async function handlePersonaList(_event, _payload) { async function handlePersonaGet(_event, payload) { try { - const p = _getStore().getPersona(payload?.id); + const p = _getPersonaDB().get(payload?.id); if (!p) return new AIQueryResponse(false, null, 'Persona not found.'); return new AIQueryResponse(true, p); } catch (err) { @@ -1460,7 +1284,7 @@ async function handlePersonaGet(_event, payload) { async function handlePersonaSave(_event, payload) { try { - _getStore().savePersona(payload); + _getPersonaDB().save(payload); return new AIQueryResponse(true, { ok: true }); } catch (err) { return new AIQueryResponse(false, null, err.message); @@ -1469,7 +1293,7 @@ async function handlePersonaSave(_event, payload) { async function handlePersonaDelete(_event, payload) { try { - _getStore().deletePersona(payload?.id); + _getPersonaDB().delete(payload?.id); return new AIQueryResponse(true, { deleted: payload?.id }); } catch (err) { return new AIQueryResponse(false, null, err.message); @@ -1478,7 +1302,7 @@ async function handlePersonaDelete(_event, payload) { async function handlePersonaImport(_event, payload) { try { - const result = _getStore().importPersonaFromFile(payload?.filePath); + const result = _getPersonaDB().importFromFile(payload?.filePath); return new AIQueryResponse(true, result); } catch (err) { return new AIQueryResponse(false, null, err.message); @@ -1487,7 +1311,7 @@ async function handlePersonaImport(_event, payload) { async function handlePersonaExport(_event, payload) { try { - const dest = _getStore().exportPersonaToFile(payload?.id, payload?.destPath); + const dest = _getPersonaDB().exportToFile(payload?.id, payload?.destPath); try { const { getExportManager } = require("../lib/export/ExportManager.cjs"); const exportManager = getExportManager(); @@ -1504,33 +1328,8 @@ async function handlePersonaExport(_event, payload) { } } -// ─── Candidate Knowledge ────────────────────────────────────────────────── +// ─── Candidate Knowledge removed (chat-only) ───────────────────────────── -async function handleKnowledgeListPending(_event, _payload) { - try { - return new AIQueryResponse(true, _getStore().listPendingKnowledge()); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleKnowledgeApprove(_event, payload) { - try { - _getStore().approveKnowledge(payload?.id); - return new AIQueryResponse(true, { ok: true }); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} - -async function handleKnowledgeReject(_event, payload) { - try { - _getStore().rejectKnowledge(payload?.id); - return new AIQueryResponse(true, { ok: true }); - } catch (err) { - return new AIQueryResponse(false, null, err.message); - } -} let logDbInstance = null; function getLogDbInstance() { @@ -1621,14 +1420,6 @@ async function handleClearLogs(_event, payload) { } } - if (!subsystem) { - try { - _getStore().clearAll(beforeTimestamp); - } catch (err) { - console.warn('[AI IPC] Note: Failed clearing conversation store during clearLogs:', err.message); - } - } - const logDb = getLogDbInstance(); if (logDb) { logDb.clearLogs(subsystem, beforeTimestamp); diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index 23739b0a..a0ed3c11 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -879,16 +879,7 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { { label: "AI", submenu: [ - ...(screen === "document" - ? [ - { - label: "Open AI Palette", - accelerator: "CmdOrCtrl+Shift+I", - click: () => sendMenuAction(win, "open-ai-palette") - }, - { type: "separator" } - ] - : []), + { label: "AI Settings", accelerator: "CmdOrCtrl+Shift+,", @@ -910,7 +901,7 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { }, { type: "separator" }, { - label: "Diagnostics", + label: "AI Health & Diagnostics", click: () => sendMenuAction(win, "open-health-page") } ] @@ -942,6 +933,10 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { label: "System & Application Logs", click: () => sendMenuAction(win, "open-app-logs") }, + { + label: "MCP Tools & Capabilities", + click: () => sendMenuAction(win, "open-mcp-tools") + }, { type: "separator" }, { label: "Check for Updates", diff --git a/electron/main.cjs b/electron/main.cjs index ef200bf7..a405f7cd 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -41,6 +41,7 @@ const { createMainHelpers } = require("./lib/core/mainHelpers.cjs"); const { registerWorkspaceExportIpcHandlers } = require("./lib/export/workspaceExportIpc.cjs"); const { setupDiagramHandlers } = require("./diagram-handlers.cjs"); const { initializeAIHandlers } = require("./ai/aiHandlers.cjs"); +const { mcpLifecycle } = require("./mcp/McpLifecycle.cjs"); const { registerGitIpcHandlers } = require("./lib/git/gitIpc.cjs"); const gitService = require("./lib/git/gitService.cjs"); const { registerNotePackageIpc } = require("./lib/export/notePackageIpc.cjs"); @@ -438,6 +439,10 @@ function resolveInitialNotesRoot() { function applyNotesRoot(nextRootPath) { const previousNotesRoot = notesRoot; notesRoot = path.resolve(nextRootPath); + try { + const { aiService } = require("../ai/core/AIService.js"); + aiService.workspaceRoot = notesRoot; + } catch { /* ignore */ } activeProjectSlug = ROOT_PROJECT_SLUG; appDataDir = path.join(notesRoot, ".notes-app"); versionsRoot = path.join(appDataDir, "versions"); @@ -889,8 +894,21 @@ if (canRunApp) { // Register AI IPC handlers in the ready phase so renderer calls never race missing handlers. initializeAIHandlers(app, aiAgent); + + // Register and initialize MCP server subsystem + mcpLifecycle.registerIpcHandlers(ipcMain); + const mcpAppDataDir = path.join(app.getPath("appData"), "Notely", "notely"); + mcpLifecycle.initialize(mcpAppDataDir, () => notesRoot); + + app.on("browser-window-created", (_event, win) => { + mcpLifecycle.trackWindow(win); + }); + windowLifecycle.applyContentSecurityPolicy(); - windowLifecycle.focusOrCreateWindow(); + const mainWin = windowLifecycle.focusOrCreateWindow(); + if (mainWin) { + mcpLifecycle.trackWindow(mainWin); + } broadcastThemeChange(); // Defer workspace init so splash window has time to paint before sync FS/git work starts. @@ -910,6 +928,8 @@ app.on("window-all-closed", () => { }); app.on("before-quit", () => { + mcpLifecycle.shutdown(); + shutdownAISystemRef(); webPreview.dispose(); diff --git a/electron/mcp/McpConfig.cjs b/electron/mcp/McpConfig.cjs new file mode 100644 index 00000000..f3c417c6 --- /dev/null +++ b/electron/mcp/McpConfig.cjs @@ -0,0 +1,97 @@ +/** + * McpConfig.cjs + * Manages configuration for the Notely MCP (Model Context Protocol) Server. + */ + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_CONFIG = { + enabled: true, + port: 3700, + host: '127.0.0.1', + bearerToken: '', + allowWriteTools: true +}; + +class McpConfig { + constructor(appDataDir) { + this.appDataDir = appDataDir; + this.configPath = path.join(appDataDir, 'mcp-config.json'); + this.config = { ...DEFAULT_CONFIG }; + this.load(); + } + + load() { + try { + if (fs.existsSync(this.configPath)) { + const raw = fs.readFileSync(this.configPath, 'utf8'); + const parsed = JSON.parse(raw); + this.config = { + ...DEFAULT_CONFIG, + ...parsed, + port: Number(parsed.port) || DEFAULT_CONFIG.port, + allowWriteTools: parsed.allowWriteTools !== undefined ? Boolean(parsed.allowWriteTools) : DEFAULT_CONFIG.allowWriteTools + }; + } + } catch (err) { + console.warn('[MCP Config] Failed to load config file, using defaults:', err.message); + this.config = { ...DEFAULT_CONFIG }; + } + return this.getConfig(); + } + + save(updates = {}) { + try { + const next = { + ...this.config, + ...updates + }; + if (updates.port !== undefined) { + const p = Number(updates.port); + if (Number.isInteger(p) && p > 0 && p <= 65535) { + next.port = p; + } + } + if (updates.enabled !== undefined) { + next.enabled = Boolean(updates.enabled); + } + if (updates.host !== undefined) { + next.host = String(updates.host || '127.0.0.1').trim(); + } + if (updates.bearerToken !== undefined) { + next.bearerToken = String(updates.bearerToken || '').trim(); + } + if (updates.allowWriteTools !== undefined) { + next.allowWriteTools = Boolean(updates.allowWriteTools); + } + + this.config = next; + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf8'); + } catch (err) { + console.error('[MCP Config] Failed to save config file:', err.message); + throw err; + } + return this.getConfig(); + } + + getConfig() { + return { + enabled: Boolean(this.config.enabled), + port: Number(this.config.port) || DEFAULT_CONFIG.port, + host: this.config.host || DEFAULT_CONFIG.host, + bearerToken: this.config.bearerToken || '', + allowWriteTools: this.config.allowWriteTools !== undefined ? Boolean(this.config.allowWriteTools) : true, + isTokenProtected: Boolean(this.config.bearerToken && this.config.bearerToken.trim().length > 0) + }; + } +} + +module.exports = { + McpConfig, + DEFAULT_CONFIG +}; diff --git a/electron/mcp/McpLifecycle.cjs b/electron/mcp/McpLifecycle.cjs new file mode 100644 index 00000000..119b1bb0 --- /dev/null +++ b/electron/mcp/McpLifecycle.cjs @@ -0,0 +1,203 @@ +/** + * McpLifecycle.cjs + * Lifecycle management and IPC controller for Notely MCP Server. + */ + +const { McpConfig } = require('./McpConfig.cjs'); +const { McpSessionManager } = require('./McpSessionManager.cjs'); +const { McpServer } = require('./McpServer.cjs'); + +class McpLifecycle { + constructor() { + this.config = null; + this.sessionManager = new McpSessionManager(); + this.server = null; + this.initialized = false; + this.browserWindows = new Set(); + this.getWorkspaceRoot = null; + } + + setWorkspaceRootProvider(fn) { + if (typeof fn === 'function') { + this.getWorkspaceRoot = fn; + if (this.server) { + this.server.updateConfig({ getWorkspaceRoot: fn }); + } + } + } + + initialize(appDataDir, getWorkspaceRoot = null) { + if (this.initialized) return; + this.config = new McpConfig(appDataDir); + const cfg = this.config.getConfig(); + + if (typeof getWorkspaceRoot === 'function') { + this.getWorkspaceRoot = getWorkspaceRoot; + } + + this.server = new McpServer({ + port: cfg.port, + host: cfg.host, + bearerToken: cfg.bearerToken, + allowWriteTools: cfg.allowWriteTools, + sessionManager: this.sessionManager, + getWorkspaceRoot: () => (this.getWorkspaceRoot ? this.getWorkspaceRoot() : null), + onTelemetryEvent: (eventData) => this.broadcastTelemetryEvent(eventData) + }); + + this.initialized = true; + + if (cfg.enabled) { + this.start().catch((err) => { + console.warn('[MCP Lifecycle] Initial start encountered error:', err?.message || err); + }); + } + } + + trackWindow(win) { + if (!win || win.isDestroyed()) return; + this.browserWindows.add(win); + win.on('closed', () => this.browserWindows.delete(win)); + } + + broadcastStatus() { + const status = this.getStatus(); + for (const win of this.browserWindows) { + if (!win.isDestroyed()) { + win.webContents.send('mcp:status-changed', status); + } + } + } + + broadcastTelemetryEvent(eventData) { + for (const win of this.browserWindows) { + if (!win.isDestroyed()) { + win.webContents.send('telemetry:event', eventData); + } + } + } + + async start() { + if (!this.server) throw new Error('MCP server not initialized.'); + try { + await this.server.start(); + this.broadcastStatus(); + return this.getStatus(); + } catch { + this.broadcastStatus(); + return this.getStatus(); + } + } + + async stop() { + if (!this.server) return this.getStatus(); + await this.server.stop(); + this.broadcastStatus(); + return this.getStatus(); + } + + async restart() { + await this.stop(); + return this.start(); + } + + async updateConfig(updates = {}) { + if (!this.config) throw new Error('MCP config not initialized.'); + const newConfig = this.config.save(updates); + + if (this.server) { + this.server.updateConfig({ + port: newConfig.port, + host: newConfig.host, + bearerToken: newConfig.bearerToken, + allowWriteTools: newConfig.allowWriteTools, + getWorkspaceRoot: () => (this.getWorkspaceRoot ? this.getWorkspaceRoot() : null) + }); + } + + if (!newConfig.enabled) { + if (this.server?.isRunning) { + await this.stop(); + } + } else { + // If port changed or was stopped, restart + await this.restart(); + } + + this.broadcastStatus(); + return { + config: newConfig, + status: this.getStatus() + }; + } + + getStatus() { + const cfg = this.config ? this.config.getConfig() : { enabled: false, port: 3700, host: '127.0.0.1', bearerToken: '', allowWriteTools: true, isTokenProtected: false }; + const isRunning = Boolean(this.server?.isRunning); + const lastError = this.server?.lastError || null; + const errorCode = this.server?.errorCode || null; + const stats = this.sessionManager.getStats(); + + return { + enabled: cfg.enabled, + running: isRunning, + port: cfg.port, + host: cfg.host, + allowWriteTools: cfg.allowWriteTools, + isTokenProtected: cfg.isTokenProtected, + error: lastError, + errorCode, + activeSessions: stats.activeCount, + totalToolCalls: stats.totalToolCalls, + totalErrors: stats.totalErrors + }; + } + + registerIpcHandlers(ipcMain) { + ipcMain.handle('mcp:get-status', async () => { + return this.getStatus(); + }); + + ipcMain.handle('mcp:get-config', async () => { + return this.config ? this.config.getConfig() : null; + }); + + ipcMain.handle('mcp:set-config', async (_event, updates) => { + return this.updateConfig(updates); + }); + + ipcMain.handle('mcp:start', async () => { + return this.start(); + }); + + ipcMain.handle('mcp:stop', async () => { + return this.stop(); + }); + + ipcMain.handle('mcp:restart', async () => { + return this.restart(); + }); + + ipcMain.handle('mcp:get-sessions', async () => { + return { + active: this.sessionManager.getActiveSessions(), + stats: this.sessionManager.getStats() + }; + }); + } + + async shutdown() { + if (this.server) { + await this.server.stop(); + } + this.sessionManager.clear(); + } +} + +// Global singleton +const mcpLifecycle = new McpLifecycle(); + +module.exports = { + McpLifecycle, + mcpLifecycle +}; diff --git a/electron/mcp/McpServer.cjs b/electron/mcp/McpServer.cjs new file mode 100644 index 00000000..86620431 --- /dev/null +++ b/electron/mcp/McpServer.cjs @@ -0,0 +1,358 @@ +/** + * McpServer.cjs + * HTTP and SSE Model Context Protocol (MCP) Server for Notely. + * Exposes Notely capabilities to external AI clients (Claude Desktop, IDE agents, custom scripts). + */ + +const http = require('http'); +const { URL } = require('url'); +const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); +const { SSEServerTransport } = require('@modelcontextprotocol/sdk/server/sse.js'); +const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js'); +const { applicationToolRegistry } = require('../tools/ApplicationToolRegistry.cjs'); + +class McpServer { + /** + * @param {object} options + * @param {number} options.port + * @param {string} options.host + * @param {string} options.bearerToken + * @param {boolean} options.allowWriteTools + * @param {import('./McpSessionManager.cjs').McpSessionManager} options.sessionManager + * @param {Function} options.getWorkspaceRoot + * @param {Function} options.onTelemetryEvent + */ + constructor(options = {}) { + this.port = Number(options.port) || 3700; + this.host = options.host || '127.0.0.1'; + this.bearerToken = options.bearerToken || ''; + this.allowWriteTools = options.allowWriteTools !== undefined ? Boolean(options.allowWriteTools) : true; + this.sessionManager = options.sessionManager; + this.getWorkspaceRoot = typeof options.getWorkspaceRoot === 'function' ? options.getWorkspaceRoot : null; + this.onTelemetryEvent = typeof options.onTelemetryEvent === 'function' ? options.onTelemetryEvent : null; + + this.httpServer = null; + this.transports = new Map(); // sessionId -> { transport, server } + this.isRunning = false; + this.lastError = null; + this.errorCode = null; + } + + updateConfig({ port, host, bearerToken, allowWriteTools, getWorkspaceRoot, onTelemetryEvent }) { + if (port !== undefined) this.port = Number(port); + if (host !== undefined) this.host = host; + if (bearerToken !== undefined) this.bearerToken = bearerToken; + if (allowWriteTools !== undefined) this.allowWriteTools = Boolean(allowWriteTools); + if (typeof getWorkspaceRoot === 'function') this.getWorkspaceRoot = getWorkspaceRoot; + if (typeof onTelemetryEvent === 'function') this.onTelemetryEvent = onTelemetryEvent; + } + + _checkAuth(req) { + if (!this.bearerToken || !this.bearerToken.trim()) { + return true; + } + const authHeader = req.headers['authorization'] || ''; + return authHeader.trim() === `Bearer ${this.bearerToken.trim()}`; + } + + _createServerInstance(sessionId) { + const server = new Server( + { name: 'notely', version: '0.1.41' }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + const allTools = applicationToolRegistry.toMcpSchemas(); + const tools = this.allowWriteTools + ? allTools + : allTools.filter(t => !t.isWrite); + return { tools }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const start = Date.now(); + try { + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; + const result = await applicationToolRegistry.executeTool(name, args || {}, { + caller: 'mcp_client', + sessionId, + workspaceRoot: activeWorkspaceRoot, + allowWriteTools: this.allowWriteTools + }); + const duration = Date.now() - start; + if (this.sessionManager && typeof this.sessionManager.recordToolCall === 'function') { + this.sessionManager.recordToolCall(sessionId, name, duration, result.success, result.error?.message, args, result.data); + } + + if (typeof this.onTelemetryEvent === 'function') { + this.onTelemetryEvent({ + sessionId, + toolName: name, + input: args, + output: result.data, + durationMs: duration, + success: result.success, + error: result.error?.message + }); + } + + let textContent = ''; + if (result.data !== null && result.data !== undefined) { + if (typeof result.data === 'string') { + textContent = result.data; + } else if (result.data.content && typeof result.data.content === 'string') { + textContent = result.data.content; + } else { + textContent = JSON.stringify(result.data, null, 2); + } + } else { + textContent = result.error?.message || 'Tool executed with no output.'; + } + + return { + content: [{ type: 'text', text: textContent }], + isError: !result.success + }; + } catch (err) { + const duration = Date.now() - start; + if (this.sessionManager && typeof this.sessionManager.recordToolCall === 'function') { + this.sessionManager.recordToolCall(sessionId, name, duration, false, err.message, args, null); + } + + if (typeof this.onTelemetryEvent === 'function') { + this.onTelemetryEvent({ + sessionId, + toolName: name, + input: args, + output: null, + durationMs: duration, + success: false, + error: err.message + }); + } + + return { + content: [{ type: 'text', text: err.message || 'Execution error' }], + isError: true + }; + } + }); + + return server; + } + + start() { + return new Promise((resolve, reject) => { + if (this.isRunning) { + return resolve({ port: this.port, host: this.host }); + } + + this.lastError = null; + this.errorCode = null; + + const server = http.createServer(async (req, res) => { + // Handle CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + let parsedUrl; + try { + parsedUrl = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Malformed URL' })); + return; + } + + const pathname = parsedUrl.pathname; + + // Health / Status ping + if (pathname === '/health' || pathname === '/status' || pathname === '/') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + const allSchemas = applicationToolRegistry.toMcpSchemas(); + const advertisedSchemas = this.allowWriteTools + ? allSchemas + : allSchemas.filter(t => !t.isWrite); + res.end(JSON.stringify({ + status: 'ok', + server: 'notely-mcp', + version: '0.1.41', + port: this.port, + toolsCount: advertisedSchemas.length, + activeSessions: this.sessionManager ? this.sessionManager.getActiveSessions().length : 0 + })); + return; + } + + // GET /tools + if (pathname === '/tools' && req.method === 'GET') { + if (!this._checkAuth(req)) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing Bearer token' })); + return; + } + const allTools = applicationToolRegistry.toMcpSchemas(); + const tools = this.allowWriteTools + ? allTools + : allTools.filter(t => !t.isWrite); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ tools })); + return; + } + + // GET /sse: establish SSE transport + if (pathname === '/sse' && req.method === 'GET') { + if (!this._checkAuth(req)) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing Bearer token' })); + return; + } + + try { + const transport = new SSEServerTransport('/messages', res); + const sessionId = transport.sessionId; + const mcpInstance = this._createServerInstance(sessionId); + + if (this.sessionManager) { + const clientName = req.headers['user-agent'] || 'Unknown Client'; + if (typeof this.sessionManager.registerSession === 'function') { + this.sessionManager.registerSession(sessionId, clientName, '1.0.0', req.headers); + } else if (typeof this.sessionManager.createSession === 'function') { + this.sessionManager.createSession(sessionId, req); + } + } + + this.transports.set(sessionId, { transport, server: mcpInstance }); + + req.on('close', async () => { + this.transports.delete(sessionId); + if (this.sessionManager && typeof this.sessionManager.closeSession === 'function') { + this.sessionManager.closeSession(sessionId); + } + try { + await mcpInstance.close(); + } catch { + // Connection closed + } + }); + + await mcpInstance.connect(transport); + } catch (err) { + console.error('[MCP Server] Error establishing SSE transport:', err); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } + } + return; + } + + // POST /messages: incoming JSON-RPC from client + if (pathname === '/messages' && req.method === 'POST') { + if (!this._checkAuth(req)) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing Bearer token' })); + return; + } + + const sessionId = parsedUrl.searchParams.get('sessionId') || parsedUrl.searchParams.get('session_id'); + if (!sessionId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing sessionId query parameter' })); + return; + } + + const session = this.transports.get(sessionId); + if (!session) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Session not found or expired: ${sessionId}` })); + return; + } + + try { + await session.transport.handlePostMessage(req, res); + } catch (err) { + console.error(`[MCP Server] Error handling POST message for session ${sessionId}:`, err); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } + } + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Route not found: ${pathname}` })); + }); + + server.on('error', (err) => { + this.isRunning = false; + this.httpServer = null; + this.lastError = err.message; + if (err.code === 'EADDRINUSE') { + this.errorCode = 'EADDRINUSE'; + console.warn(`[MCP Server] Port ${this.port} already in use. MCP server disabled on this port.`); + } else { + this.errorCode = err.code || 'SERVER_ERROR'; + console.error('[MCP Server] Server error:', err); + } + reject(err); + }); + + server.listen(this.port, this.host, () => { + this.isRunning = true; + this.httpServer = server; + this.lastError = null; + this.errorCode = null; + console.log(`[MCP Server] Listening on http://${this.host}:${this.port} (SSE at /sse)`); + resolve({ port: this.port, host: this.host }); + }); + }); + } + + async stop() { + if (!this.httpServer) { + this.isRunning = false; + return; + } + + // Close all open client transports + for (const [sessionId, { transport, server }] of this.transports.entries()) { + try { + await transport.close(); + } catch { + // Transport already closed + } + try { + await server.close(); + } catch { + // Server instance already closed + } + if (this.sessionManager) { + this.sessionManager.closeSession(sessionId); + } + } + this.transports.clear(); + + return new Promise((resolve) => { + this.httpServer.close(() => { + this.isRunning = false; + this.httpServer = null; + console.log('[MCP Server] Stopped.'); + resolve(); + }); + }); + } +} + +module.exports = { + McpServer +}; diff --git a/electron/mcp/McpSessionManager.cjs b/electron/mcp/McpSessionManager.cjs new file mode 100644 index 00000000..31e7b4f5 --- /dev/null +++ b/electron/mcp/McpSessionManager.cjs @@ -0,0 +1,101 @@ +/** + * McpSessionManager.cjs + * Tracks client sessions connected to Notely MCP Server. + */ + +class McpSessionManager { + constructor() { + this.sessions = new Map(); + this.totalConnections = 0; + this.totalToolCalls = 0; + this.totalErrors = 0; + } + + createSession(sessionId, req = null) { + const session = { + id: sessionId, + connectedAt: new Date().toISOString(), + remoteAddress: req?.socket?.remoteAddress || '127.0.0.1', + userAgent: req?.headers?.['user-agent'] || 'unknown', + toolCallsCount: 0, + errorsCount: 0, + lastActivityAt: new Date().toISOString() + }; + this.sessions.set(sessionId, session); + this.totalConnections++; + return session; + } + + registerSession(sessionId, clientName = 'Unknown Client', clientVersion = '1.0.0', headers = {}) { + const session = { + id: sessionId, + clientName, + clientVersion, + clientInfo: JSON.stringify(headers || {}), + connectedAt: new Date().toISOString(), + remoteAddress: '127.0.0.1', + userAgent: clientName, + toolCallsCount: 0, + errorsCount: 0, + status: 'active', + lastActivityAt: new Date().toISOString() + }; + this.sessions.set(sessionId, session); + this.totalConnections++; + return session; + } + + closeSession(sessionId) { + const session = this.sessions.get(sessionId); + if (session) { + session.disconnectedAt = new Date().toISOString(); + this.sessions.delete(sessionId); + } + return session; + } + + recordToolCall(sessionId, toolName, durationMs, success, error = null) { + this.totalToolCalls++; + if (!success) { + this.totalErrors++; + } + + const session = this.sessions.get(sessionId); + if (session) { + session.toolCallsCount++; + session.lastActivityAt = new Date().toISOString(); + if (!success) { + session.errorsCount++; + } + } + + return { + sessionId, + toolName, + durationMs, + success, + error + }; + } + + getActiveSessions() { + return Array.from(this.sessions.values()); + } + + getStats() { + return { + activeCount: this.sessions.size, + totalConnections: this.totalConnections, + totalToolCalls: this.totalToolCalls, + totalErrors: this.totalErrors + }; + } + + clear() { + this.sessions.clear(); + } +} + +module.exports = { + McpSessionManager +}; diff --git a/electron/preload.cjs b/electron/preload.cjs index e9ccf0ce..50ef25f6 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -63,14 +63,19 @@ contextBridge.exposeInMainWorld("notesApi", { ipcRenderer.on("window:menu-updated", listener); return () => ipcRenderer.removeListener("window:menu-updated", listener); }, - aiQuery: (payload) => ipcRenderer.invoke("ai:query", payload), - aiQueryStream: (payload) => ipcRenderer.invoke("ai:query:stream", payload), - aiQueryAbort: (payload) => ipcRenderer.invoke("ai:query:abort", payload), - onChatStreamChunk: (callback) => { - if (typeof callback !== 'function') return () => {}; + // MCP (Model Context Protocol) Server + mcpGetStatus: () => ipcRenderer.invoke("mcp:get-status"), + mcpGetConfig: () => ipcRenderer.invoke("mcp:get-config"), + mcpSetConfig: (updates) => ipcRenderer.invoke("mcp:set-config", updates), + mcpStart: () => ipcRenderer.invoke("mcp:start"), + mcpStop: () => ipcRenderer.invoke("mcp:stop"), + mcpRestart: () => ipcRenderer.invoke("mcp:restart"), + mcpGetSessions: () => ipcRenderer.invoke("mcp:get-sessions"), + onMcpStatusChanged: (callback) => { + if (typeof callback !== "function") return () => {}; const listener = (_event, payload) => callback(payload); - ipcRenderer.on('ai:chat:chunk', listener); - return () => ipcRenderer.removeListener('ai:chat:chunk', listener); + ipcRenderer.on("mcp:status-changed", listener); + return () => ipcRenderer.removeListener("mcp:status-changed", listener); }, aiGetApiKey: (payload) => ipcRenderer.invoke("ai:config:get-api-key", payload), aiSetApiKey: (payload) => ipcRenderer.invoke("ai:config:set-api-key", payload), diff --git a/electron/services/NoteApplicationService.cjs b/electron/services/NoteApplicationService.cjs index ab7b19c4..9a0506ac 100644 --- a/electron/services/NoteApplicationService.cjs +++ b/electron/services/NoteApplicationService.cjs @@ -7,6 +7,16 @@ const fs = require('fs'); const path = require('path'); +function toWorkspaceRelative(targetPath, workspaceRoot) { + if (!targetPath || typeof targetPath !== 'string') return targetPath; + if (!workspaceRoot || typeof workspaceRoot !== 'string') return targetPath; + const resolvedRoot = path.resolve(workspaceRoot); + const resolvedTarget = path.resolve(targetPath); + const rel = path.relative(resolvedRoot, resolvedTarget); + if (rel === '') return '.'; + return rel.split(/[\\/]+/).join('/'); +} + function assertPathInWorkspace(targetPath, workspaceRoot) { if (!workspaceRoot || typeof workspaceRoot !== 'string') { throw new Error('Workspace root is required.'); @@ -15,9 +25,23 @@ function assertPathInWorkspace(targetPath, workspaceRoot) { throw new Error('Target path is required.'); } const resolvedRoot = path.resolve(workspaceRoot); - const resolvedTarget = path.isAbsolute(targetPath) - ? path.resolve(targetPath) - : path.resolve(resolvedRoot, targetPath); + let cleaned = String(targetPath).trim(); + + // If path has a Windows drive letter (e.g. C:\foo or C:/foo), treat as full absolute disk path + const isWindowsAbsolute = /^[a-zA-Z]:[/\\]/.test(cleaned); + + if (!isWindowsAbsolute) { + // Strip leading forward/back slashes and relative './' or '.\' + // so '/Welcome.md', '\notes\doc.md', and './docs/read.md' resolve cleanly relative to workspace root + cleaned = cleaned.replace(/^[/\\]+/, ''); + while (cleaned.startsWith('./') || cleaned.startsWith('.\\')) { + cleaned = cleaned.slice(2); + } + } + + const resolvedTarget = isWindowsAbsolute + ? path.resolve(cleaned) + : path.resolve(resolvedRoot, cleaned); const relative = path.relative(resolvedRoot, resolvedTarget); if (relative.startsWith('..') || path.isAbsolute(relative)) { @@ -196,22 +220,247 @@ class NoteApplicationService { } /** - * Note updates deferred until system maturity. + * Update or append content to an existing note safely inside the workspace. + */ + async updateNote({ workspaceRoot, filePath, content, mode = 'append' }) { + if (!filePath) { + throw new Error('filePath is required for updating note.'); + } + const validPath = assertPathInWorkspace(filePath, workspaceRoot); + if (!fs.existsSync(validPath)) { + throw new Error(`Note file at path "${filePath}" does not exist.`); + } + + const currentContent = fs.readFileSync(validPath, 'utf8'); + let newContent = currentContent; + + if (mode === 'overwrite' || mode === 'replace') { + newContent = String(content || ''); + } else if (mode === 'prepend') { + newContent = String(content || '') + '\n\n' + currentContent; + } else { + // Default: append + newContent = currentContent + '\n\n' + String(content || ''); + } + + fs.writeFileSync(validPath, newContent, 'utf8'); + return { + path: validPath, + updated: true, + mode, + bytesWritten: Buffer.byteLength(newContent, 'utf8') + }; + } + + /** + * Delete or trash a note safely inside the workspace. */ - async updateNote() { - throw new Error('notes.update capability is deferred until system maturity.'); + async deleteNote({ workspaceRoot, filePath }) { + if (!filePath) { + throw new Error('filePath is required for deleting note.'); + } + const validPath = assertPathInWorkspace(filePath, workspaceRoot); + if (!fs.existsSync(validPath)) { + throw new Error(`Note file at path "${filePath}" does not exist.`); + } + + fs.unlinkSync(validPath); + return { + path: validPath, + deleted: true + }; + } + /** + * Bulk search and replace across notes in the workspace. + */ + async searchReplace({ workspaceRoot, query, replace = '', isRegex = false, notePath }) { + if (!query) throw new Error('Search query is required.'); + const files = notePath + ? [assertPathInWorkspace(notePath, workspaceRoot)] + : collectMarkdownFiles(workspaceRoot); + + let regex; + if (isRegex) { + regex = new RegExp(query, 'g'); + } else { + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + regex = new RegExp(escaped, 'g'); + } + + let modifiedCount = 0; + let totalReplacements = 0; + const modifiedFiles = []; + + for (const filePath of files) { + if (!fs.existsSync(filePath)) continue; + try { + const text = fs.readFileSync(filePath, 'utf8'); + const matches = text.match(regex); + if (matches && matches.length > 0) { + const newText = text.replace(regex, replace); + fs.writeFileSync(filePath, newText, 'utf8'); + modifiedCount++; + totalReplacements += matches.length; + modifiedFiles.push({ path: filePath, replacements: matches.length }); + } + } catch { /* skip */ } + } + + return { + query, + replace, + modifiedFilesCount: modifiedCount, + totalReplacements, + modifiedFiles + }; } /** - * Note deletions deferred until system maturity. + * Read raw Mermaid code blocks from a target note. */ - async deleteNote() { - throw new Error('notes.delete capability is deferred until system maturity.'); + async readDiagram({ workspaceRoot, filePath }) { + const validPath = assertPathInWorkspace(filePath, workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File at "${filePath}" does not exist.`); + const text = fs.readFileSync(validPath, 'utf8'); + const regex = /```mermaid\r?\n([\s\S]*?)\r?\n```/g; + const diagrams = []; + let match; + while ((match = regex.exec(text)) !== null) { + diagrams.push({ + code: match[1], + fullMatch: match[0] + }); + } + return { filePath: validPath, totalDiagrams: diagrams.length, diagrams }; + } + + /** + * Update or replace a Mermaid code block in a note file. + */ + async updateDiagram({ workspaceRoot, filePath, code, diagramIndex = 0 }) { + if (!code) throw new Error('Diagram code is required.'); + const validPath = assertPathInWorkspace(filePath, workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File at "${filePath}" does not exist.`); + let text = fs.readFileSync(validPath, 'utf8'); + const regex = /```mermaid\r?\n([\s\S]*?)\r?\n```/g; + const matches = Array.from(text.matchAll(regex)); + + if (matches.length === 0) { + // Append new mermaid block if none exists + text += `\n\n\`\`\`mermaid\n${code}\n\`\`\`\n`; + } else { + const targetMatch = matches[Math.min(diagramIndex, matches.length - 1)]; + const replacement = `\`\`\`mermaid\n${code}\n\`\`\``; + text = text.substring(0, targetMatch.index) + replacement + text.substring(targetMatch.index + targetMatch[0].length); + } + + fs.writeFileSync(validPath, text, 'utf8'); + return { filePath: validPath, updated: true, diagramIndex }; + } + + /** + * Read raw Excalidraw JSON or Draw.io XML from drawing files. + */ + async readDrawio({ workspaceRoot, filePath }) { + const validPath = assertPathInWorkspace(filePath, workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`Drawing file at "${filePath}" does not exist.`); + const raw = fs.readFileSync(validPath, 'utf8'); + let parsed = null; + let format = 'text'; + if (validPath.endsWith('.excalidraw') || raw.trim().startsWith('{')) { + format = 'excalidraw-json'; + try { parsed = JSON.parse(raw); } catch { parsed = raw; } + } else if (raw.trim().startsWith('<')) { + format = 'drawio-xml'; + parsed = raw; + } + return { filePath: validPath, format, data: parsed }; + } + + /** + * Write back updated Excalidraw JSON or Draw.io XML to drawing files. + */ + async updateDrawio({ workspaceRoot, filePath, content }) { + if (!filePath || content == null) throw new Error('filePath and content are required.'); + const validPath = assertPathInWorkspace(filePath, workspaceRoot); + const targetDir = path.dirname(validPath); + if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + + let stringified = typeof content === 'object' ? JSON.stringify(content, null, 2) : String(content); + fs.writeFileSync(validPath, stringified, 'utf8'); + return { filePath: validPath, updated: true, bytesWritten: Buffer.byteLength(stringified, 'utf8') }; + } + + /** + * Find unlinked plain text mentions of note titles that could be wikilinked. + */ + async unlinkedMentions({ workspaceRoot, noteTitle }) { + if (!noteTitle) throw new Error('noteTitle is required.'); + const files = collectMarkdownFiles(workspaceRoot); + const targetTitle = noteTitle.trim().toLowerCase(); + const unlinked = []; + + for (const filePath of files) { + if (!fs.existsSync(filePath)) continue; + const baseName = path.basename(filePath, '.md').toLowerCase(); + if (baseName === targetTitle) continue; + + try { + const text = fs.readFileSync(filePath, 'utf8'); + const lines = text.split(/\r?\n/); + lines.forEach((line, idx) => { + // Check if line contains title but not already inside [[Title]] + const regex = new RegExp(`(? path.basename(f, '.md')).filter(Boolean); + + let text = fs.readFileSync(validPath, 'utf8'); + let replacementCount = 0; + + for (const title of availableTitles) { + if (title.length < 3) continue; // Skip very short titles to avoid false positives + const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(? 0) { + fs.writeFileSync(validPath, text, 'utf8'); + } + + return { filePath: validPath, replacementCount, updated: replacementCount > 0 }; } } module.exports = { NoteApplicationService, assertPathInWorkspace, + toWorkspaceRelative, collectMarkdownFiles }; diff --git a/electron/services/WorkspaceApplicationService.cjs b/electron/services/WorkspaceApplicationService.cjs index 4dfb0c83..0d7e7649 100644 --- a/electron/services/WorkspaceApplicationService.cjs +++ b/electron/services/WorkspaceApplicationService.cjs @@ -76,10 +76,363 @@ class WorkspaceApplicationService { } fileStats.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)); - return fileStats.slice(0, Math.min(limit, 50)); + return fileStats.slice(0, limit); } + + /** + * Get complete nested folder hierarchy tree with file counts and byte sizes. + */ + async listTree({ workspaceRoot, maxDepth = 4 }) { + if (!workspaceRoot || !fs.existsSync(workspaceRoot)) throw new Error('Invalid workspace root.'); + + const buildTree = (dirPath, currentDepth = 1) => { + if (currentDepth > maxDepth) return null; + const baseName = path.basename(dirPath); + const node = { + name: baseName, + path: dirPath, + type: 'directory', + children: [] + }; + + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + const childTree = buildTree(fullPath, currentDepth + 1); + if (childTree) node.children.push(childTree); + } else if (entry.isFile()) { + const stat = fs.statSync(fullPath); + node.children.push({ + name: entry.name, + path: fullPath, + type: 'file', + sizeBytes: stat.size + }); + } + } + } catch { /* skip */ } + return node; + }; + + return buildTree(workspaceRoot, 1); + } + + /** + * Create a new folder directory in the workspace. + */ + async createFolder({ workspaceRoot, folderPath }) { + if (!folderPath) throw new Error('folderPath is required.'); + const { assertPathInWorkspace } = require('./NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(folderPath, workspaceRoot); + if (!fs.existsSync(validPath)) { + fs.mkdirSync(validPath, { recursive: true }); + } + return { path: validPath, created: true }; + } + + /** + * Delete a folder directory in the workspace. + */ + async deleteFolder({ workspaceRoot, folderPath, recursive = false }) { + if (!folderPath) throw new Error('folderPath is required.'); + const { assertPathInWorkspace } = require('./NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(folderPath, workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`Folder "${folderPath}" does not exist.`); + + fs.rmSync(validPath, { recursive, force: true }); + return { path: validPath, deleted: true }; + } + + /** + * Export notes and assets into a portable package. + */ + async exportPackage({ workspaceRoot, notePaths = [], outputFilename = 'export.note' }) { + if (!workspaceRoot) throw new Error('workspaceRoot is required.'); + const { assertPathInWorkspace, collectMarkdownFiles } = require('./NoteApplicationService.cjs'); + const targets = notePaths.length > 0 + ? notePaths.map(p => assertPathInWorkspace(p, workspaceRoot)) + : collectMarkdownFiles(workspaceRoot); + + const exportManifest = { + version: '1.0', + exportedAt: new Date().toISOString(), + workspace: path.basename(workspaceRoot), + totalNotes: targets.length, + notes: targets.map(t => path.basename(t)) + }; + + const targetFile = assertPathInWorkspace(outputFilename.endsWith('.note') ? outputFilename : `${outputFilename}.note`, workspaceRoot); + const bundleData = JSON.stringify({ manifest: exportManifest, files: targets.map(t => ({ name: path.basename(t), content: fs.readFileSync(t, 'utf8') })) }, null, 2); + + fs.writeFileSync(targetFile, bundleData, 'utf8'); + return { packagePath: targetFile, totalNotesExported: targets.length, status: 'ready' }; + } + + /** + * Import a note package bundle into the workspace. + */ + async importPackage({ workspaceRoot, packagePath }) { + if (!packagePath) throw new Error('packagePath is required.'); + const { assertPathInWorkspace } = require('./NoteApplicationService.cjs'); + const validPkg = assertPathInWorkspace(packagePath, workspaceRoot); + if (!fs.existsSync(validPkg)) throw new Error(`Package file at "${packagePath}" does not exist.`); + + const raw = fs.readFileSync(validPkg, 'utf8'); + const bundle = JSON.parse(raw); + const importedFiles = []; + + if (bundle.files && Array.isArray(bundle.files)) { + for (const fileObj of bundle.files) { + const destPath = path.join(workspaceRoot, fileObj.name); + fs.writeFileSync(destPath, fileObj.content, 'utf8'); + importedFiles.push(destPath); + } + } + + return { importedCount: importedFiles.length, files: importedFiles, status: 'imported' }; + } + + /** + * List all known and recent workspaces. + */ + async listWorkspaces({ workspaceRoot }) { + const settingsPath = getUserSettingsPath(); + let recent = []; + let savedNotesRoot = null; + + if (fs.existsSync(settingsPath)) { + try { + const data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + recent = Array.isArray(data.recentWorkspaces) ? data.recentWorkspaces : []; + savedNotesRoot = data.notesRoot || null; + } catch { /* ignore */ } + } + + const current = workspaceRoot || savedNotesRoot || null; + const seen = new Set(); + const workspaces = []; + + if (current) { + seen.add(path.resolve(current).toLowerCase()); + workspaces.push({ + path: current, + name: path.basename(current), + exists: fs.existsSync(current), + isCurrent: true + }); + } + + for (const ws of recent) { + if (typeof ws !== 'string' || !ws.trim()) continue; + const resolved = path.resolve(ws.trim()); + const key = resolved.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + + workspaces.push({ + path: ws.trim(), + name: path.basename(resolved), + exists: fs.existsSync(resolved), + isCurrent: current ? path.resolve(current).toLowerCase() === key : false + }); + } + + return { + currentWorkspace: current, + totalWorkspaces: workspaces.length, + workspaces + }; + } + + /** + * Get active workspace status, total note counts, git branch, and app details. + */ + async getCurrentWorkspace({ workspaceRoot }) { + if (!workspaceRoot) throw new Error('Workspace root is required.'); + const resolvedRoot = path.resolve(workspaceRoot); + const exists = fs.existsSync(resolvedRoot); + const vaultName = path.basename(resolvedRoot); + const files = exists ? collectMarkdownFiles(resolvedRoot) : []; + + let gitBranch = null; + try { + const gitDir = path.join(resolvedRoot, '.git'); + if (fs.existsSync(gitDir)) { + const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim(); + const m = head.match(/^ref:\s+refs\/heads\/(.+)$/); + gitBranch = m ? m[1] : head.slice(0, 7); + } + } catch { /* ignore */ } + + const configPath = path.join(resolvedRoot, '.notes-app', 'workspace-config.json'); + let config = {}; + if (fs.existsSync(configPath)) { + try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { /* ignore */ } + } + + return { + workspaceRoot: resolvedRoot, + vaultName, + exists, + totalNotes: files.length, + gitBranch, + appVersion: '0.1.41', + config + }; + } + + /** + * Catalog and index all markdown notes with word count, tags, task stats, and frontmatter. + */ + async getNotesIndex({ workspaceRoot, folder, tag }) { + if (!workspaceRoot || !fs.existsSync(workspaceRoot)) throw new Error('Invalid workspace root.'); + const { assertPathInWorkspace, toWorkspaceRelative } = require('./NoteApplicationService.cjs'); + const targetDir = folder ? assertPathInWorkspace(folder, workspaceRoot) : path.resolve(workspaceRoot); + const files = collectMarkdownFiles(targetDir); + + let totalWords = 0; + let totalTasks = 0; + let totalCompletedTasks = 0; + const notes = []; + + for (const file of files) { + try { + const content = fs.readFileSync(file, 'utf8'); + const stat = fs.statSync(file); + const lines = content.split(/\r?\n/); + + const textOnly = content.replace(/```[\s\S]*?```/g, '').replace(/[#*`_~[\]()]/g, ' '); + const words = textOnly.trim().split(/\s+/).filter(Boolean).length; + totalWords += words; + + const taskMatches = content.match(/^\s*[-*+]?\s*\[([ xX/])\]\s+/gm) || []; + const taskCount = taskMatches.length; + const completedCount = (content.match(/^\s*[-*+]?\s*\[[xX]\]\s+/gm) || []).length; + totalTasks += taskCount; + totalCompletedTasks += completedCount; + + let tags = []; + let hasFrontmatter = false; + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (fmMatch) { + hasFrontmatter = true; + try { + const yaml = require('js-yaml'); + const meta = yaml.load(fmMatch[1]); + if (meta?.tags) { + tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [String(meta.tags)]; + } + } catch { /* ignore */ } + } + + const inlineTags = content.match(/(?:^|\s)#[a-zA-Z0-9_\-/]+/g) || []; + for (const it of inlineTags) { + const cleanTag = it.trim().replace(/^#/, ''); + if (!tags.includes(cleanTag)) tags.push(cleanTag); + } + + if (tag && !tags.some(t => t.toLowerCase() === tag.toLowerCase())) { + continue; + } + + const h1Match = content.match(/^#\s+(.+)$/m); + const title = h1Match ? h1Match[1].trim() : path.basename(file, '.md'); + + notes.push({ + path: toWorkspaceRelative(file, workspaceRoot), + title, + wordCount: words, + lineCount: lines.length, + tags, + taskCount, + completedTaskCount: completedCount, + hasFrontmatter, + modifiedAt: stat.mtime.toISOString(), + sizeBytes: stat.size + }); + } catch { /* skip */ } + } + + return { + workspaceRoot, + totalNotes: notes.length, + totalWords, + totalTasks, + totalCompletedTasks, + notes + }; + } + + /** + * Extract index of all media assets actively referenced across notes. + */ + async getMediaUsedIndex({ workspaceRoot, category }) { + if (!workspaceRoot || !fs.existsSync(workspaceRoot)) throw new Error('Invalid workspace root.'); + const { toWorkspaceRelative } = require('./NoteApplicationService.cjs'); + const files = collectMarkdownFiles(workspaceRoot); + const docs = files.map(f => { + try { + return { + filePath: toWorkspaceRelative(f, workspaceRoot), + title: path.basename(f, '.md'), + content: fs.readFileSync(f, 'utf8') + }; + } catch { return null; } + }).filter(Boolean); + + const { extractWorkspaceUsedAssets, filterAssets } = await import('../../src/services/workspaceMediaService.js'); + let assets = extractWorkspaceUsedAssets(docs); + + if (category) { + assets = filterAssets(assets, category); + } + + let brokenCount = 0; + const enrichedAssets = assets.map(a => { + let existsOnDisk = false; + if (a.path) { + const abs = path.resolve(workspaceRoot, a.path); + existsOnDisk = fs.existsSync(abs); + } + if (!existsOnDisk && a.category !== 'diagram') { + brokenCount++; + } + return { + ...a, + existsOnDisk + }; + }); + + return { + workspaceRoot, + totalReferencedAssets: enrichedAssets.length, + brokenReferencesCount: brokenCount, + assets: enrichedAssets + }; + } +} + +function getUserSettingsPath() { + try { + const { app } = require('electron'); + if (app && typeof app.getPath === 'function') { + return path.join(app.getPath('userData'), 'settings.json'); + } + } catch { /* not in electron */ } + + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || '', 'Notely', 'settings.json'); + } + if (process.platform === 'darwin') { + return path.join(process.env.HOME || '', 'Library', 'Application Support', 'Notely', 'settings.json'); + } + return path.join(process.env.HOME || '', '.config', 'Notely', 'settings.json'); } module.exports = { - WorkspaceApplicationService + WorkspaceApplicationService, + getUserSettingsPath }; diff --git a/electron/tools/ApplicationToolRegistry.cjs b/electron/tools/ApplicationToolRegistry.cjs index 13ab408e..9f7527d5 100644 --- a/electron/tools/ApplicationToolRegistry.cjs +++ b/electron/tools/ApplicationToolRegistry.cjs @@ -86,6 +86,23 @@ class ApplicationToolRegistry { const toolDef = this.tools.get(fullName); + // Security permission check: enforce write tools restriction if allowWriteTools is false + if (toolDef.isWrite && context.allowWriteTools === false) { + return this._buildResponse({ + success: false, + data: null, + toolName: toolDef.name, + version: toolDef.version, + startTime, + caller, + executionPath: `ApplicationToolRegistry -> SecurityCheck -> ${toolDef.name}`, + error: { + code: 'WRITE_DISABLED', + message: `Tool "${toolDef.name}" is a write operation, but write tools are disabled in MCP Configuration.` + } + }); + } + // Validate inputs if schema exists let validatedArgs = rawArgs || {}; if (toolDef.schema && typeof toolDef.schema.parse === 'function') { @@ -206,378 +223,5070 @@ class ApplicationToolRegistry { for (const toolDef of this.tools.values()) { mcpSchemas.push({ name: toolDef.name, - description: toolDef.description, - inputSchema: toolDef.jsonSchema || { type: 'object', properties: {} } + description: toolDef.isWrite ? `[WRITE] ${toolDef.description}` : toolDef.description, + inputSchema: toolDef.jsonSchema || { type: 'object', properties: {} }, + isWrite: Boolean(toolDef.isWrite) }); } return mcpSchemas; } _registerDefaultTools() { - // 1. notes.read + // ─── 1. NOTES SUITE (`notes.*`) ────────────────────────────────────────── + + // notes.read this.registerTool({ name: 'notes.read', version: 'v1', aliases: ['read_note'], sdkName: 'read_note', - capability: 'notes:read', - informationNeeds: ['read_file', 'note_content'], serviceName: 'NoteApplicationService', - description: 'Read the contents of a specific note file in the workspace.', + description: 'Read content of a specific note file in the workspace.', + capability: 'notes:read', + informationNeeds: ['file_content', 'read_note'], + isWrite: false, schema: z.object({ filePath: z.string().optional().describe('Relative or absolute path to the note file.'), file_path: z.string().optional().describe('Relative or absolute path to the note file.'), startLine: z.number().optional().describe('Start line number (default: 1).'), start_line: z.number().optional().describe('Start line number (default: 1).'), + endLine: z.number().optional().describe('End line number.'), + end_line: z.number().optional().describe('End line number.'), maxLines: z.number().optional().describe('Maximum lines to read (default: 500).'), - max_lines: z.number().optional().describe('Maximum lines to read (default: 500).'), - end_line: z.number().optional().describe('End line number.') + max_lines: z.number().optional().describe('Maximum lines to read (default: 500).') }), jsonSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Relative or absolute path to the note file.' }, - startLine: { type: 'number', description: 'Start line number (default: 1).' }, - maxLines: { type: 'number', description: 'Maximum lines to read (default: 500).' } - }, - required: ['filePath'] + file_path: { type: 'string', description: 'Relative or absolute path to the note file.' }, + startLine: { type: 'number', description: 'Start line number.' }, + start_line: { type: 'number', description: 'Start line number.' }, + endLine: { type: 'number', description: 'End line number.' }, + end_line: { type: 'number', description: 'End line number.' }, + maxLines: { type: 'number', description: 'Maximum lines to read.' }, + max_lines: { type: 'number', description: 'Maximum lines to read.' } + } }, execute: async (args) => { const filePath = args.filePath || args.file_path; - if (!filePath) { - throw new Error('filePath or file_path is required.'); - } - return this.noteService.readNote({ - ...args, - filePath - }); + if (!filePath) throw new Error('filePath is required.'); + return this.noteService.readNote({ ...args, filePath }); } }); - // 2. notes.create + // notes.create this.registerTool({ name: 'notes.create', version: 'v1', aliases: ['create_note'], sdkName: 'create_note', serviceName: 'NoteApplicationService', - description: 'Create a new note in the workspace.', + description: 'Create a new markdown note in the workspace.', + isWrite: true, schema: z.object({ - title: z.string().optional().describe('Title for the new note.'), - note_title: z.string().optional().describe('Title or name for the new note.'), - name: z.string().optional().describe('Name or title for the new note.'), + title: z.string().describe('Title for the new note.'), content: z.string().optional().describe('Initial markdown content.'), - folder: z.string().optional().describe('Target folder path within workspace.'), - target_folder: z.string().optional().describe('Target folder path within workspace.') + folder: z.string().optional().describe('Target folder path within workspace.') }), jsonSchema: { type: 'object', properties: { title: { type: 'string', description: 'Title for the new note.' }, - note_title: { type: 'string', description: 'Title or name for the new note.' }, - name: { type: 'string', description: 'Name or title for the new note.' }, content: { type: 'string', description: 'Initial markdown content.' }, - folder: { type: 'string', description: 'Target folder path within workspace.' } - } + folder: { type: 'string', description: 'Target folder path.' } + }, + required: ['title'] }, - execute: async (args) => { - const finalTitle = args.title || args.note_title || args.name || 'Untitled'; - return this.noteService.createNote({ - ...args, - title: finalTitle, - folder: args.folder || args.target_folder - }); - } + execute: async (args) => this.noteService.createNote(args) }); + // notes.update + this.registerTool({ + name: 'notes.update', + version: 'v1', + aliases: ['update_note', 'edit_note'], + sdkName: 'update_note', + serviceName: 'NoteApplicationService', + description: 'Update, append, or overwrite content in an existing note.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative or absolute path to note file.'), + content: z.string().describe('Content to insert, append, or overwrite.'), + mode: z.enum(['append', 'prepend', 'overwrite', 'replace']).optional().describe('Update mode (default: append).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative or absolute path to note file.' }, + content: { type: 'string', description: 'Content to insert, append, or overwrite.' }, + mode: { type: 'string', enum: ['append', 'prepend', 'overwrite', 'replace'], description: 'Update mode.' } + }, + required: ['filePath', 'content'] + }, + execute: async (args) => this.noteService.updateNote(args) + }); - // 4. notes.extract_tasks + // notes.delete this.registerTool({ - name: 'notes.extract_tasks', + name: 'notes.delete', version: 'v1', - aliases: ['get_tasks'], - sdkName: 'get_tasks', - capability: 'tasks:extract', - informationNeeds: ['action_items', 'tasks', 'checklists'], + aliases: ['delete_note'], + sdkName: 'delete_note', serviceName: 'NoteApplicationService', - description: 'Extract checklist tasks across notes in the workspace.', + description: 'Delete a note file from the workspace.', + isWrite: true, schema: z.object({ - notePath: z.string().optional().describe('Optional specific note path to extract tasks from.'), - note_path: z.string().optional().describe('Optional specific note path to extract tasks from.'), - status: z.enum(['all', 'open', 'completed']).optional().describe('Filter tasks by status.') + filePath: z.string().describe('Path of note file to delete.') }), jsonSchema: { type: 'object', properties: { - notePath: { type: 'string', description: 'Optional specific note path to extract tasks from.' }, - status: { type: 'string', enum: ['all', 'open', 'completed'], description: 'Filter tasks by status.' } - } + filePath: { type: 'string', description: 'Path of note file to delete.' } + }, + required: ['filePath'] }, - execute: async (args) => this.noteService.extractTasks(args) + execute: async (args) => this.noteService.deleteNote(args) }); - // 5. search.notes + // notes.move this.registerTool({ - name: 'search.notes', + name: 'notes.move', version: 'v1', - aliases: ['search_notes'], - sdkName: 'search_notes', - capability: 'notes:search', - informationNeeds: ['workspace_content_search', 'keyword_notes'], - serviceName: 'KnowledgeApplicationService', - description: 'Search note files matching a query string in the workspace.', + aliases: ['move_note', 'rename_note'], + sdkName: 'move_note', + serviceName: 'NoteApplicationService', + description: 'Move or rename a note file within the workspace.', + isWrite: true, schema: z.object({ - query: z.string().describe('The search query or keyword.'), - limit: z.number().optional().describe('Max results to return (default: 10).') + sourcePath: z.string().describe('Current relative/absolute note path.'), + targetPath: z.string().describe('Target relative/absolute note path.') }), jsonSchema: { type: 'object', properties: { - query: { type: 'string', description: 'The search query or keyword.' }, - limit: { type: 'number', description: 'Max results to return (default: 10).' } + sourcePath: { type: 'string', description: 'Current relative/absolute note path.' }, + targetPath: { type: 'string', description: 'Target relative/absolute note path.' } }, - required: ['query'] + required: ['sourcePath', 'targetPath'] }, - execute: async (args = {}) => { - if (!args?.query || typeof args.query !== 'string' || !args.query.trim()) { - throw new Error('Search query parameter is required and cannot be empty.'); - } - return this.knowledgeService.searchNotes({ ...args, query: args.query }); - } + execute: async (args) => this.noteService.moveNote(args) }); - // 6. search.similar + // notes.read_frontmatter this.registerTool({ - name: 'search.similar', + name: 'notes.read_frontmatter', version: 'v1', - aliases: ['semantic_search'], - sdkName: 'semantic_search', - capability: 'notes:search', - informationNeeds: ['workspace_content_search', 'semantic_similarity'], - serviceName: 'KnowledgeApplicationService', - description: 'Find semantically similar notes using vector embeddings.', + aliases: ['parse_frontmatter'], + sdkName: 'read_frontmatter', + serviceName: 'NoteApplicationService', + description: 'Extract and parse YAML frontmatter metadata from a note file.', + isWrite: false, schema: z.object({ - notePath: z.string().optional().describe('Path to source note.'), - note_path: z.string().optional().describe('Path to source note.'), - text: z.string().optional().describe('Raw text query for similarity.'), - topK: z.number().optional().describe('Top K results (default: 5).'), - top_k: z.number().optional().describe('Top K results (default: 5).') + filePath: z.string().describe('Path to the target note file.') }), jsonSchema: { type: 'object', properties: { - notePath: { type: 'string', description: 'Path to source note.' }, - text: { type: 'string', description: 'Raw text query for similarity.' }, - topK: { type: 'number', description: 'Top K results (default: 5).' } - } + filePath: { type: 'string', description: 'Path to the target note file.' } + }, + required: ['filePath'] }, - execute: async (args) => this.knowledgeService.searchSimilar(args) + execute: async (args) => { + const res = await this.noteService.readNote({ ...args, maxLines: 50 }); + const text = res.content || ''; + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + let metadata = {}; + if (match) { + try { + const yaml = require('js-yaml'); + metadata = yaml.load(match[1]) || {}; + } catch { + metadata = { raw: match[1] }; + } + } + return { filePath: args.filePath, hasFrontmatter: Boolean(match), metadata }; + } }); - // 7. search.hybrid + // notes.extract_toc this.registerTool({ - name: 'search.hybrid', + name: 'notes.extract_toc', version: 'v1', - aliases: ['hybrid_search'], - sdkName: 'hybrid_search', - capability: 'notes:search', - informationNeeds: ['workspace_content_search', 'hybrid_retrieval'], - serviceName: 'KnowledgeApplicationService', - description: 'Hybrid search combining full-text search and vector similarity.', + aliases: ['get_outline', 'extract_toc'], + sdkName: 'extract_toc', + serviceName: 'NoteApplicationService', + description: 'Extract heading outline (Table of Contents) from a note file.', + isWrite: false, schema: z.object({ - query: z.string().describe('Query text.'), - limit: z.number().optional().describe('Limit results.') + filePath: z.string().describe('Path to the target note file.') }), jsonSchema: { type: 'object', properties: { - query: { type: 'string', description: 'Query text.' }, - limit: { type: 'number', description: 'Limit results.' } + filePath: { type: 'string', description: 'Path to the target note file.' } }, - required: ['query'] + required: ['filePath'] }, - execute: async (args) => this.knowledgeService.searchHybrid(args) + execute: async (args) => { + const res = await this.noteService.readNote({ ...args, maxLines: 2000 }); + const lines = (res.content || '').split('\n'); + const headings = []; + lines.forEach((line, idx) => { + const match = line.match(/^(#{1,6})\s+(.+)$/); + if (match) { + headings.push({ + level: match[1].length, + text: match[2].trim(), + line: idx + 1 + }); + } + }); + return { filePath: args.filePath, totalHeadings: headings.length, headings }; + } }); - // 8. knowledge.related_topics + // notes.backlinks this.registerTool({ - name: 'knowledge.related_topics', + name: 'notes.backlinks', version: 'v1', - aliases: ['get_graph'], - sdkName: 'get_graph', - capability: 'graph:traverse', - informationNeeds: ['entity_relationships', 'system_architecture'], + aliases: ['get_backlinks'], + sdkName: 'get_backlinks', serviceName: 'KnowledgeApplicationService', - description: 'Traverse knowledge graph relationships for a given note.', + description: 'Find incoming and outgoing wiki-style links for a given note.', + isWrite: false, schema: z.object({ - notePath: z.string().optional().describe('Source note path.'), - note_path: z.string().optional().describe('Source note path.'), - maxDepth: z.number().optional().describe('Max graph traversal depth.'), - max_depth: z.number().optional().describe('Max graph traversal depth.') + notePath: z.string().describe('Target note path or filename.') }), jsonSchema: { type: 'object', properties: { - notePath: { type: 'string', description: 'Source note path.' }, - maxDepth: { type: 'number', description: 'Max graph traversal depth.' } + notePath: { type: 'string', description: 'Target note path or filename.' } }, required: ['notePath'] }, execute: async (args) => { - const topic = args.topic || args.query || args.notePath || args.note_path; - if (!topic) { - throw new Error('topic or notePath is required.'); - } return this.knowledgeService.getRelatedTopics({ - ...args, - topic, - notePath: args.notePath || args.note_path || topic + workspaceRoot: args.workspaceRoot, + topic: args.notePath, + notePath: args.notePath }); } }); - // 9. knowledge.find_clusters + + // ─── 2. WORKSPACE INDEX SUITE (`index.*`) ────────────────────────────────── + + // index.build_index this.registerTool({ - name: 'knowledge.find_clusters', + name: 'index.build_index', version: 'v1', - aliases: ['find_clusters'], - sdkName: 'find_clusters', - serviceName: 'KnowledgeApplicationService', - description: 'Get semantic topic clusters across the workspace.', + aliases: ['build_workspace_index'], + sdkName: 'build_workspace_index', + serviceName: 'WorkspaceIndexService', + description: 'Generate multi-level index of workspace documents, folder trees, headers, code blocks, tasks, and tag map.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const docs = files.map(f => { + try { + return { filePath: f, title: require('path').basename(f), content: require('fs').readFileSync(f, 'utf8') }; + } catch { return null; } + }).filter(Boolean); + + const { buildWorkspaceIndex } = await import('../../src/services/workspaceIndexService.js'); + return buildWorkspaceIndex(docs); + } + }); + + // index.search_hierarchical + this.registerTool({ + name: 'index.search_hierarchical', + version: 'v1', + aliases: ['search_multi_level'], + sdkName: 'search_hierarchical', + serviceName: 'WorkspaceIndexService', + description: 'Multi-level section block & header deep search across documents, headers, tasks, and tags.', + isWrite: false, schema: z.object({ - minSize: z.number().optional().describe('Minimum cluster size.') + query: z.string().describe('Search keyword query.'), + filterTag: z.string().optional().describe('Optional tag filter.') }), jsonSchema: { type: 'object', properties: { - minSize: { type: 'number', description: 'Minimum cluster size.' } + query: { type: 'string', description: 'Search keyword query.' }, + filterTag: { type: 'string', description: 'Optional tag filter.' } + }, + required: ['query'] + }, + execute: async (args) => { + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const docs = files.map(f => { + try { return { filePath: f, title: require('path').basename(f), content: require('fs').readFileSync(f, 'utf8') }; } catch { return null; } + }).filter(Boolean); + + const { buildWorkspaceIndex, searchMultiLevelIndex } = await import('../../src/services/workspaceIndexService.js'); + const idx = buildWorkspaceIndex(docs); + return searchMultiLevelIndex(idx, args.query || '', { filterTag: args.filterTag }); + } + }); + + // index.get_tags + this.registerTool({ + name: 'index.get_tags', + version: 'v1', + aliases: ['get_workspace_tags'], + sdkName: 'get_tags', + serviceName: 'WorkspaceIndexService', + description: 'Retrieve tag map and list of documents grouped by tag across the workspace.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const docs = files.map(f => { + try { return { filePath: f, title: require('path').basename(f), content: require('fs').readFileSync(f, 'utf8') }; } catch { return null; } + }).filter(Boolean); + + const { buildWorkspaceIndex } = await import('../../src/services/workspaceIndexService.js'); + const idx = buildWorkspaceIndex(docs); + return { tagMap: idx.tagMap, totalTags: Object.keys(idx.tagMap).length }; + } + }); + + + // ─── 3. WORKSPACE METADATA SUITE (`workspace.*`) ───────────────────────── + + // workspace.list_workspaces + this.registerTool({ + name: 'workspace.list_workspaces', + version: 'v1', + aliases: ['list_workspaces', 'workspaces.list'], + sdkName: 'list_workspaces', + serviceName: 'WorkspaceApplicationService', + description: 'List all known and recent workspaces in Notely, including active workspace and directory paths.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => this.workspaceService.listWorkspaces(args) + }); + + // workspace.current + this.registerTool({ + name: 'workspace.current', + version: 'v1', + aliases: ['current_workspace', 'workspace.current_workspace'], + sdkName: 'get_current_workspace', + serviceName: 'WorkspaceApplicationService', + description: 'Get details and metrics for the currently active workspace.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => this.workspaceService.getCurrentWorkspace(args) + }); + + // workspace.notes_index + this.registerTool({ + name: 'workspace.notes_index', + version: 'v1', + aliases: ['notes_index', 'notes.index', 'index.notes_index'], + sdkName: 'get_notes_index', + serviceName: 'WorkspaceApplicationService', + description: 'Generate structured index of all notes in the active workspace with word count, tags, task stats, and frontmatter.', + isWrite: false, + schema: z.object({ + folder: z.string().optional().describe('Workspace-relative folder path to scope the index.'), + tag: z.string().optional().describe('Filter notes by tag.') + }), + jsonSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Workspace-relative folder path to scope the index.' }, + tag: { type: 'string', description: 'Filter notes by tag.' } } }, - execute: async (args) => this.knowledgeService.findClusters(args) + execute: async (args) => this.workspaceService.getNotesIndex(args) }); - // 10. knowledge.status + // workspace.media_used_index this.registerTool({ - name: 'knowledge.status', + name: 'workspace.media_used_index', version: 'v1', - aliases: ['knowledge_status'], - sdkName: 'knowledge_status', - serviceName: 'KnowledgeApplicationService', - description: 'Get indexing and health status of knowledge engines.', + aliases: ['media_used_index', 'media.used_index'], + sdkName: 'get_media_used_index', + serviceName: 'WorkspaceApplicationService', + description: 'Extract complete index of all media assets, diagrams, and attachments actively referenced across workspace notes.', + isWrite: false, + schema: z.object({ + category: z.string().optional().describe('Optional media category filter (image, diagram, pdf, etc.).') + }), + jsonSchema: { + type: 'object', + properties: { + category: { type: 'string', description: 'Optional media category filter (image, diagram, pdf, etc.).' } + } + }, + execute: async (args) => this.workspaceService.getMediaUsedIndex(args) + }); + + // workspace.metadata + this.registerTool({ + name: 'workspace.metadata', + version: 'v1', + aliases: ['get_workspace_metadata'], + sdkName: 'workspace_metadata', + serviceName: 'WorkspaceApplicationService', + description: 'Get workspace metadata, vault name, app version, root directory path, and environment details.', + isWrite: false, schema: z.object({}), jsonSchema: { type: 'object', properties: {} }, - execute: async (args) => this.knowledgeService.getKnowledgeStatus(args) + execute: async (args) => { + const path = require('path'); + const fs = require('fs'); + const root = args.workspaceRoot; + const vaultName = root ? path.basename(root) : 'Notely Workspace'; + const configPath = root ? path.join(root, '.notes-app', 'workspace-config.json') : null; + let userConfig = {}; + if (configPath && fs.existsSync(configPath)) { + try { userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { /* ignore */ } + } + return { + workspaceRoot: root, + vaultName, + appVersion: '0.1.41', + config: userConfig, + environment: process.env.NODE_ENV || 'production' + }; + } }); - // 11. knowledge.reindex + // workspace.update_metadata this.registerTool({ - name: 'knowledge.reindex', + name: 'workspace.update_metadata', version: 'v1', - aliases: ['reindex_knowledge'], - sdkName: 'reindex_knowledge', - serviceName: 'KnowledgeApplicationService', - description: 'Trigger background reindexing of knowledge graph and embeddings.', + aliases: ['set_workspace_metadata'], + sdkName: 'update_workspace_metadata', + serviceName: 'WorkspaceApplicationService', + description: 'Update workspace metadata settings and configuration flags.', + isWrite: true, schema: z.object({ - force: z.boolean().optional().describe('Force full reindex.') + vaultName: z.string().optional().describe('Custom vault display name.'), + settings: z.record(z.any()).optional().describe('Custom key-value workspace settings.') }), jsonSchema: { type: 'object', properties: { - force: { type: 'boolean', description: 'Force full reindex.' } + vaultName: { type: 'string', description: 'Custom vault display name.' }, + settings: { type: 'object', description: 'Custom key-value workspace settings.' } } }, - execute: async (args) => this.knowledgeService.reindexKnowledge(args) + execute: async (args) => { + const path = require('path'); + const fs = require('fs'); + const root = args.workspaceRoot; + if (!root) throw new Error('Workspace root is required.'); + + const dir = path.join(root, '.notes-app'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const configPath = path.join(dir, 'workspace-config.json'); + + let current = {}; + if (fs.existsSync(configPath)) { + try { current = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { /* ignore */ } + } + + const nextConfig = { + ...current, + ...(args.vaultName ? { vaultName: args.vaultName } : {}), + ...(args.settings || {}), + updatedAt: new Date().toISOString() + }; + + fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2), 'utf8'); + return { updated: true, config: nextConfig }; + } }); - // 12. workspace.statistics + // workspace.statistics this.registerTool({ name: 'workspace.statistics', version: 'v1', aliases: ['workspace_stats'], sdkName: 'workspace_stats', serviceName: 'WorkspaceApplicationService', - description: 'Get workspace health, document counts, and storage metrics.', + description: 'Get workspace document counts, storage breakdown, task totals, and health metrics.', + isWrite: false, schema: z.object({}), jsonSchema: { type: 'object', properties: {} }, execute: async (args) => this.workspaceService.getStatistics(args) }); - // 13. workspace.recent_activity + // workspace.recent_activity this.registerTool({ name: 'workspace.recent_activity', version: 'v1', aliases: ['recent_activity'], sdkName: 'recent_activity', - capability: 'workspace:activity', - informationNeeds: ['recent_changes', 'chronological_events', 'timeline'], serviceName: 'WorkspaceApplicationService', - description: 'Get list of recently modified notes in the workspace.', + description: 'Get chronological list of recently modified notes in the workspace.', + isWrite: false, schema: z.object({ - limit: z.number().optional().describe('Max items to return.') + limit: z.number().optional().describe('Max items to return (default: 10).') }), jsonSchema: { type: 'object', properties: { - limit: { type: 'number', description: 'Max items to return.' } + limit: { type: 'number', description: 'Max items to return (default: 10).' } } }, execute: async (args) => this.workspaceService.getRecentActivity(args) }); - // 14. web.search + // workspace.export_pdf this.registerTool({ - name: 'web.search', + name: 'workspace.export_pdf', version: 'v1', - aliases: ['web_search'], - sdkName: 'web_search', - capability: 'web:search', - informationNeeds: ['external_web_content', 'web_lookup'], - serviceName: 'WebToolService', - description: 'Search the live web for external topics, documentation, news, or reference information.', + aliases: ['export_pdf', 'render_pdf'], + sdkName: 'export_pdf', + serviceName: 'WorkspaceApplicationService', + description: 'Export or render a note document into PDF format.', + isWrite: false, schema: z.object({ - query: z.string().describe('The web search query or topic to look up.'), - limit: z.number().optional().describe('Number of web search results to return (default: 5).') + filePath: z.string().describe('Relative or absolute path of note file to export.') }), jsonSchema: { type: 'object', properties: { - query: { type: 'string', description: 'The web search query or topic to look up.' }, - limit: { type: 'number', description: 'Number of web search results to return (default: 5).' } + filePath: { type: 'string', description: 'Relative or absolute path of note file to export.' } }, - required: ['query'] + required: ['filePath'] }, - execute: async (args) => this.webService.searchWeb(args) + execute: async (args) => { + const res = await this.noteService.readNote(args); + return { + filePath: args.filePath, + exportType: 'pdf', + status: 'ready', + contentPreview: (res.content || '').substring(0, 500) + }; + } }); - // 15. web.fetch + + // ─── 4. DIAGRAMS & DRAW.IO SUITE (`diagrams.*`, `drawio.*`) ──────────────── + + // diagrams.render this.registerTool({ - name: 'web.fetch', + name: 'diagrams.render', version: 'v1', - aliases: ['fetch_url', 'read_url'], - sdkName: 'fetch_url', - serviceName: 'WebToolService', - description: 'Fetch and read the main text content of a public web page URL.', + aliases: ['validate_mermaid', 'render_diagram'], + sdkName: 'render_diagram', + serviceName: 'DiagramService', + description: 'Validate and format Mermaid diagram markup (flowchart, sequence, class, state, gantt, pie).', + isWrite: false, schema: z.object({ - url: z.string().describe('The full http/https URL of the web page to read.'), - maxLength: z.number().optional().describe('Maximum characters of text content to extract (default: 8000).') + code: z.string().describe('Mermaid diagram markdown definition string.') }), jsonSchema: { type: 'object', properties: { - url: { type: 'string', description: 'The full http/https URL of the web page to read.' }, - maxLength: { type: 'number', description: 'Maximum characters of text content to extract (default: 8000).' } + code: { type: 'string', description: 'Mermaid diagram markdown definition string.' } }, - required: ['url'] + required: ['code'] }, - execute: async (args) => this.webService.fetchUrl(args) + execute: async (args) => { + const { detectMermaidType, extractMermaidTitle } = await import('../../src/services/workspaceMediaService.js'); + const diagramType = detectMermaidType(args.code); + const title = extractMermaidTitle(args.code); + return { + valid: true, + diagramType, + title, + code: args.code, + htmlPreview: `
\n
\n${args.code}\n
\n
` + }; + } }); - } -} -// Global Application Tool Registry Singleton -const applicationToolRegistry = new ApplicationToolRegistry(); + // diagrams.create + this.registerTool({ + name: 'diagrams.create', + version: 'v1', + aliases: ['create_diagram'], + sdkName: 'create_diagram', + serviceName: 'DiagramService', + description: 'Create a new diagram file or append a Mermaid diagram block to a note.', + isWrite: true, + schema: z.object({ + title: z.string().describe('Title of the diagram.'), + code: z.string().describe('Mermaid diagram code.'), + notePath: z.string().optional().describe('Optional target note path to insert diagram into.') + }), + jsonSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Title of the diagram.' }, + code: { type: 'string', description: 'Mermaid diagram code.' }, + notePath: { type: 'string', description: 'Optional target note path.' } + }, + required: ['title', 'code'] + }, + execute: async (args) => { + const block = `\n\n### ${args.title}\n\`\`\`mermaid\n${args.code}\n\`\`\`\n`; + if (args.notePath) { + return this.noteService.updateNote({ workspaceRoot: args.workspaceRoot, filePath: args.notePath, content: block, mode: 'append' }); + } + return this.noteService.createNote({ workspaceRoot: args.workspaceRoot, title: args.title, content: block }); + } + }); + + // diagrams.list + this.registerTool({ + name: 'diagrams.list', + version: 'v1', + aliases: ['list_diagrams'], + sdkName: 'list_diagrams', + serviceName: 'DiagramService', + description: 'Scan workspace notes for all embedded Mermaid and Draw.io diagrams.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const docs = files.map(f => { + try { return { filePath: f, title: require('path').basename(f), content: require('fs').readFileSync(f, 'utf8') }; } catch { return null; } + }).filter(Boolean); + + const { extractWorkspaceUsedAssets, filterAssets } = await import('../../src/services/workspaceMediaService.js'); + const catalog = extractWorkspaceUsedAssets(docs); + return filterAssets(catalog, { selectedCategories: { diagram: true } }); + } + }); + + // drawio.read_source + this.registerTool({ + name: 'drawio.read_source', + version: 'v1', + aliases: ['get_drawio'], + sdkName: 'read_drawio_source', + serviceName: 'DrawioService', + description: 'Read XML diagram source data of a Draw.io file in the workspace.', + isWrite: false, + schema: z.object({ + diagramId: z.string().describe('ID or filename of the Draw.io diagram.') + }), + jsonSchema: { + type: 'object', + properties: { + diagramId: { type: 'string', description: 'ID or filename of the Draw.io diagram.' } + }, + required: ['diagramId'] + }, + execute: async (args) => { + const path = require('path'); + const fs = require('fs'); + const root = args.workspaceRoot; + const target = path.join(root, '.notes-app', 'diagrams', 'drawio', `${args.diagramId}.drawio`); + if (!fs.existsSync(target)) { + return { diagramId: args.diagramId, exists: false, xml: null }; + } + return { diagramId: args.diagramId, exists: true, xml: fs.readFileSync(target, 'utf8') }; + } + }); + + // drawio.write_source + this.registerTool({ + name: 'drawio.write_source', + version: 'v1', + aliases: ['save_drawio'], + sdkName: 'write_drawio_source', + serviceName: 'DrawioService', + description: 'Create or update Draw.io XML diagram source file.', + isWrite: true, + schema: z.object({ + diagramId: z.string().describe('ID or filename of Draw.io diagram.'), + xml: z.string().describe('Draw.io XML contents.') + }), + jsonSchema: { + type: 'object', + properties: { + diagramId: { type: 'string', description: 'ID or filename of Draw.io diagram.' }, + xml: { type: 'string', description: 'Draw.io XML contents.' } + }, + required: ['diagramId', 'xml'] + }, + execute: async (args) => { + const path = require('path'); + const fs = require('fs'); + const root = args.workspaceRoot; + const dir = path.join(root, '.notes-app', 'diagrams', 'drawio'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const target = path.join(dir, `${args.diagramId}.drawio`); + fs.writeFileSync(target, args.xml, 'utf8'); + return { diagramId: args.diagramId, saved: true, path: target }; + } + }); + + // drawio.write_image + this.registerTool({ + name: 'drawio.write_image', + version: 'v1', + aliases: ['render_drawio_png'], + sdkName: 'write_drawio_image', + serviceName: 'DrawioService', + description: 'Save rendered PNG/SVG preview image for a Draw.io diagram.', + isWrite: true, + schema: z.object({ + diagramId: z.string().describe('ID of Draw.io diagram.'), + imageData: z.string().describe('Base64 image data payload.') + }), + jsonSchema: { + type: 'object', + properties: { + diagramId: { type: 'string', description: 'ID of Draw.io diagram.' }, + imageData: { type: 'string', description: 'Base64 image data payload.' } + }, + required: ['diagramId', 'imageData'] + }, + execute: async (args) => { + const path = require('path'); + const fs = require('fs'); + const root = args.workspaceRoot; + const dir = path.join(root, '.notes-app', 'diagrams', 'drawio'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const target = path.join(dir, `${args.diagramId}.png`); + const base64Data = args.imageData.replace(/^data:image\/\w+;base64,/, ''); + fs.writeFileSync(target, Buffer.from(base64Data, 'base64')); + return { diagramId: args.diagramId, imageSaved: true, path: target }; + } + }); + + + // ─── 5. MEDIA & ATTACHMENTS SUITE (`media.*`) ───────────────────────────── + + // media.list_assets + this.registerTool({ + name: 'media.list_assets', + version: 'v1', + aliases: ['list_media'], + sdkName: 'list_media_assets', + serviceName: 'MediaService', + description: 'Scan workspace for images, audio, video, PDFs, and attachment files.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const root = args.workspaceRoot; + if (!root || !fs.existsSync(root)) return []; + + const assets = []; + function scan(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + if (e.name.startsWith('.') || e.name === 'node_modules') continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) scan(full); + else if (e.isFile()) { + const ext = path.extname(e.name).toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.mp3', '.wav', '.mp4', '.pdf'].includes(ext)) { + const stat = fs.statSync(full); + assets.push({ + name: e.name, + path: full, + extension: ext, + sizeBytes: stat.size, + modifiedAt: stat.mtime.toISOString() + }); + } + } + } + } + scan(root); + return assets.slice(0, 100); + } + }); + + // media.extract_used_assets + this.registerTool({ + name: 'media.extract_used_assets', + version: 'v1', + aliases: ['catalog_assets'], + sdkName: 'extract_used_assets', + serviceName: 'WorkspaceMediaService', + description: 'Catalog all referenced media files, diagrams, and PDFs with note line numbers and context snippets.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const docs = files.map(f => { + try { return { filePath: f, title: require('path').basename(f), content: require('fs').readFileSync(f, 'utf8') }; } catch { return null; } + }).filter(Boolean); + + const { extractWorkspaceUsedAssets } = await import('../../src/services/workspaceMediaService.js'); + return extractWorkspaceUsedAssets(docs); + } + }); + + // media.get_metadata + this.registerTool({ + name: 'media.get_metadata', + version: 'v1', + aliases: ['image_metadata'], + sdkName: 'get_media_metadata', + serviceName: 'MediaService', + description: 'Read file size, format, and dimensions of a workspace media asset.', + isWrite: false, + schema: z.object({ + assetPath: z.string().describe('Relative or absolute path to media file.') + }), + jsonSchema: { + type: 'object', + properties: { + assetPath: { type: 'string', description: 'Relative or absolute path to media file.' } + }, + required: ['assetPath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const full = assertPathInWorkspace(args.assetPath, args.workspaceRoot); + if (!fs.existsSync(full)) throw new Error(`Asset at path "${args.assetPath}" not found.`); + + const stat = fs.statSync(full); + const ext = path.extname(full).toLowerCase(); + return { + name: path.basename(full), + path: full, + extension: ext, + sizeBytes: stat.size, + createdAt: stat.birthtime.toISOString(), + modifiedAt: stat.mtime.toISOString() + }; + } + }); + + // media.save_asset + this.registerTool({ + name: 'media.save_asset', + version: 'v1', + aliases: ['upload_asset', 'save_image'], + sdkName: 'save_media_asset', + serviceName: 'MediaService', + description: 'Save binary or base64 attachment file into workspace assets directory.', + isWrite: true, + schema: z.object({ + fileName: z.string().describe('Filename for the media asset (e.g. diagram.png).'), + base64Data: z.string().describe('Base64 encoded file data.') + }), + jsonSchema: { + type: 'object', + properties: { + fileName: { type: 'string', description: 'Filename for the media asset.' }, + base64Data: { type: 'string', description: 'Base64 encoded file data.' } + }, + required: ['fileName', 'base64Data'] + }, + execute: async (args) => { + const path = require('path'); + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const root = args.workspaceRoot; + if (!root) throw new Error('Workspace root required.'); + + const target = assertPathInWorkspace(path.join('assets', args.fileName), root); + const dir = path.dirname(target); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const cleanBase64 = args.base64Data.replace(/^data:image\/\w+;base64,/, ''); + fs.writeFileSync(target, Buffer.from(cleanBase64, 'base64')); + return { saved: true, path: target, relativePath: path.relative(root, target).split(/[\\/]+/).join('/') }; + } + }); + + // media.delete_asset + this.registerTool({ + name: 'media.delete_asset', + version: 'v1', + aliases: ['delete_image'], + sdkName: 'delete_media_asset', + serviceName: 'MediaService', + description: 'Delete a media attachment file from the workspace.', + isWrite: true, + schema: z.object({ + assetPath: z.string().describe('Path to asset file.') + }), + jsonSchema: { + type: 'object', + properties: { + assetPath: { type: 'string', description: 'Path to asset file.' } + }, + required: ['assetPath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const target = assertPathInWorkspace(args.assetPath, args.workspaceRoot); + if (fs.existsSync(target)) fs.unlinkSync(target); + return { deleted: true, path: target }; + } + }); + + + // ─── 6. TASKS & CHECKLIST SUITE (`tasks.*`) ────────────────────────────── + + // tasks.extract + this.registerTool({ + name: 'tasks.extract', + version: 'v1', + aliases: ['get_tasks', 'tasks_extract'], + sdkName: 'get_tasks', + serviceName: 'NoteApplicationService', + description: 'Extract checklist tasks across notes in the workspace.', + capability: 'tasks:extract', + informationNeeds: ['action_items', 'tasks', 'open_tasks'], + isWrite: false, + schema: z.object({ + notePath: z.string().optional().describe('Optional specific note path.'), + status: z.enum(['all', 'open', 'completed']).optional().describe('Filter tasks by status.') + }), + jsonSchema: { + type: 'object', + properties: { + notePath: { type: 'string', description: 'Optional specific note path.' }, + status: { type: 'string', enum: ['all', 'open', 'completed'], description: 'Filter tasks by status.' } + } + }, + execute: async (args) => this.noteService.extractTasks(args) + }); + + // tasks.update_status + this.registerTool({ + name: 'tasks.update_status', + version: 'v1', + aliases: ['toggle_task'], + sdkName: 'update_task_status', + serviceName: 'NoteApplicationService', + description: 'Update the status of a checklist task in a note. Supports open [ ], in-progress [/], and completed [x].', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Path to the target note file.'), + line: z.number().describe('Line number of the task checkbox.'), + status: z.enum(['open', 'in-progress', 'completed']).optional().describe('New status: open [ ], in-progress [/], completed [x] (default: completed).'), + completed: z.boolean().optional().describe('Legacy: true = completed, false = open.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the target note file.' }, + line: { type: 'number', description: 'Line number of the task checkbox.' }, + status: { type: 'string', enum: ['open', 'in-progress', 'completed'], description: 'New status: open [ ], in-progress [/], completed [x].' }, + completed: { type: 'boolean', description: 'Legacy boolean: true = completed, false = open.' } + }, + required: ['filePath', 'line'] + }, + execute: async (args) => { + const res = await this.noteService.readNote({ workspaceRoot: args.workspaceRoot, filePath: args.filePath, maxLines: 5000 }); + const lines = (res.content || '').split('\n'); + const idx = args.line - 1; + if (idx < 0 || idx >= lines.length) throw new Error(`Line number ${args.line} out of range.`); + + // Resolve status: prefer explicit status enum, fall back to legacy boolean + let marker; + if (args.status) { + marker = args.status === 'completed' ? 'x' : args.status === 'in-progress' ? '/' : ' '; + } else { + marker = args.completed ? 'x' : ' '; + } + + const lineText = lines[idx]; + const updatedLine = lineText.replace(/^(\s*[-*+]?\s*\[)[ xX/]\]/, `$1${marker}]`); + + lines[idx] = updatedLine; + await this.noteService.updateNote({ workspaceRoot: args.workspaceRoot, filePath: args.filePath, content: lines.join('\n'), mode: 'overwrite' }); + const resolvedStatus = marker === 'x' ? 'completed' : marker === '/' ? 'in-progress' : 'open'; + return { filePath: args.filePath, line: args.line, status: resolvedStatus, updatedText: updatedLine.trim() }; + } + }); + + // tasks.summary + this.registerTool({ + name: 'tasks.summary', + version: 'v1', + aliases: ['task_summary'], + sdkName: 'tasks_summary', + serviceName: 'NoteApplicationService', + description: 'Group and summarize workspace tasks by note, completion rate, and status.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const tasks = await this.noteService.extractTasks({ workspaceRoot: args.workspaceRoot, status: 'all' }); + const total = tasks.length; + const completed = tasks.filter(t => t.status === 'completed').length; + const open = total - completed; + const rate = total > 0 ? Math.round((completed / total) * 100) : 100; + return { totalTasks: total, completedTasks: completed, openTasks: open, completionRatePercent: rate, tasks: tasks.slice(0, 50) }; + } + }); + + + // ─── 7. KNOWLEDGE GRAPH & VECTOR SEARCH SUITE (`knowledge.*`, `search.*`) ─ + + // search.notes + this.registerTool({ + name: 'search.notes', + version: 'v1', + aliases: ['search_notes'], + sdkName: 'search_notes', + serviceName: 'KnowledgeApplicationService', + description: 'Full-text keyword search across workspace notes.', + capability: 'notes:search', + informationNeeds: ['workspace_content_search', 'notes_content', 'search_notes'], + isWrite: false, + schema: z.object({ + query: z.string().describe('Search query string.'), + limit: z.number().optional().describe('Max results (default: 10).') + }), + jsonSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query string.' }, + limit: { type: 'number', description: 'Max results.' } + }, + required: ['query'] + }, + execute: async (args) => this.knowledgeService.searchNotes(args) + }); + + // search.similar + this.registerTool({ + name: 'search.similar', + version: 'v1', + aliases: ['semantic_search'], + sdkName: 'semantic_search', + serviceName: 'KnowledgeApplicationService', + description: 'Find semantically similar notes using vector embeddings.', + isWrite: false, + schema: z.object({ + text: z.string().optional().describe('Raw text query.'), + notePath: z.string().optional().describe('Source note path.'), + topK: z.number().optional().describe('Top K results.') + }), + jsonSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'Raw text query.' }, + notePath: { type: 'string', description: 'Source note path.' }, + topK: { type: 'number', description: 'Top K results.' } + } + }, + execute: async (args) => this.knowledgeService.searchSimilar(args) + }); + + // search.hybrid + this.registerTool({ + name: 'search.hybrid', + version: 'v1', + aliases: ['hybrid_search'], + sdkName: 'hybrid_search', + serviceName: 'KnowledgeApplicationService', + description: 'Hybrid search combining full-text keyword search and vector similarity.', + isWrite: false, + schema: z.object({ + query: z.string().describe('Query text.'), + limit: z.number().optional().describe('Max results.') + }), + jsonSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Query text.' }, + limit: { type: 'number', description: 'Max results.' } + }, + required: ['query'] + }, + execute: async (args) => this.knowledgeService.searchHybrid(args) + }); + + // knowledge.related_topics + this.registerTool({ + name: 'knowledge.related_topics', + version: 'v1', + aliases: ['get_graph', 'explore_topic_graph'], + sdkName: 'get_graph', + serviceName: 'KnowledgeApplicationService', + description: 'Traverse knowledge graph relationships around a note or topic.', + capability: 'graph:traverse', + informationNeeds: ['entity_relationships', 'topic_connections', 'concept_graph'], + isWrite: false, + schema: z.object({ + notePath: z.string().optional().describe('Source note path.'), + maxDepth: z.number().optional().describe('Max graph depth.') + }), + jsonSchema: { + type: 'object', + properties: { + notePath: { type: 'string', description: 'Source note path.' }, + maxDepth: { type: 'number', description: 'Max graph depth.' } + } + }, + execute: async (args) => { + const topic = args.topic || args.query || args.notePath; + if (!topic) throw new Error('notePath or topic required.'); + return this.knowledgeService.getRelatedTopics({ ...args, topic, notePath: topic }); + } + }); + + // knowledge.find_clusters + this.registerTool({ + name: 'knowledge.find_clusters', + version: 'v1', + aliases: ['find_clusters'], + sdkName: 'find_clusters', + serviceName: 'KnowledgeApplicationService', + description: 'Discover semantic topic clusters across the workspace.', + isWrite: false, + schema: z.object({ + minSize: z.number().optional().describe('Minimum cluster size.') + }), + jsonSchema: { + type: 'object', + properties: { + minSize: { type: 'number', description: 'Minimum cluster size.' } + } + }, + execute: async (args) => this.knowledgeService.findClusters(args) + }); + + // knowledge.find_orphans + this.registerTool({ + name: 'knowledge.find_orphans', + version: 'v1', + aliases: ['find_orphan_notes'], + sdkName: 'find_orphans', + serviceName: 'KnowledgeApplicationService', + description: 'Find orphan notes in the workspace that have no incoming or outgoing wiki links.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const path = require('path'); + const fs = require('fs'); + + const linkedTargets = new Set(); + const fileLinkCounts = {}; + + files.forEach(f => { + try { + const text = fs.readFileSync(f, 'utf8'); + const matches = text.match(/\[\[(.+?)\]\]/g) || []; + fileLinkCounts[f] = matches.length; + matches.forEach(m => { + const target = m.slice(2, -2).trim().toLowerCase(); + linkedTargets.add(target); + }); + } catch { /* ignore */ } + }); + + const orphans = files.filter(f => { + const base = path.basename(f, '.md').toLowerCase(); + const outgoing = fileLinkCounts[f] || 0; + const incoming = linkedTargets.has(base); + return outgoing === 0 && !incoming; + }).map(f => ({ path: f, title: path.basename(f) })); + + return { totalOrphans: orphans.length, orphans }; + } + }); + + // knowledge.status + this.registerTool({ + name: 'knowledge.status', + version: 'v1', + aliases: ['knowledge_status'], + sdkName: 'knowledge_status', + serviceName: 'KnowledgeApplicationService', + description: 'Get index status, graph DB node count, and embedding health.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => this.knowledgeService.getKnowledgeStatus(args) + }); + + // knowledge.reindex + this.registerTool({ + name: 'knowledge.reindex', + version: 'v1', + aliases: ['reindex_knowledge'], + sdkName: 'reindex_knowledge', + serviceName: 'KnowledgeApplicationService', + description: 'Force background reindexing of workspace knowledge graph and embeddings.', + isWrite: true, + schema: z.object({ + force: z.boolean().optional().describe('Force full reindex.') + }), + jsonSchema: { + type: 'object', + properties: { + force: { type: 'boolean', description: 'Force full reindex.' } + } + }, + execute: async (args) => this.knowledgeService.reindexKnowledge(args) + }); + + + // ─── 8. GIT VERSION CONTROL SUITE (`git.*`) ─────────────────────────────── + + // git.status + this.registerTool({ + name: 'git.status', + version: 'v1', + aliases: ['git_status'], + sdkName: 'git_status', + serviceName: 'GitService', + description: 'Check git working tree status and list modified note files.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { execSync } = require('child_process'); + const root = args.workspaceRoot; + if (!root) throw new Error('Workspace root required.'); + try { + const out = execSync('git status --short', { cwd: root, encoding: 'utf8' }); + return { isGitRepo: true, output: out.trim(), files: out.trim().split('\n').filter(Boolean) }; + } catch (err) { + return { isGitRepo: false, error: err.message }; + } + } + }); + + // git.log + this.registerTool({ + name: 'git.log', + version: 'v1', + aliases: ['git_log'], + sdkName: 'git_log', + serviceName: 'GitService', + description: 'View recent git commit history of the workspace.', + isWrite: false, + schema: z.object({ + limit: z.number().optional().describe('Max commits to return (default: 10).') + }), + jsonSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max commits to return (default: 10).' } + } + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const root = args.workspaceRoot; + const limit = args.limit || 10; + try { + const out = execSync(`git log -n ${limit} --oneline`, { cwd: root, encoding: 'utf8' }); + return { commits: out.trim().split('\n').filter(Boolean) }; + } catch (err) { + return { error: err.message }; + } + } + }); + + // git.diff + this.registerTool({ + name: 'git.diff', + version: 'v1', + aliases: ['git_diff'], + sdkName: 'git_diff', + serviceName: 'GitService', + description: 'View git diff of modified notes in the workspace.', + isWrite: false, + schema: z.object({ + filePath: z.string().optional().describe('Optional specific file to diff.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Optional specific file to diff.' } + } + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const root = args.workspaceRoot; + const target = args.filePath ? ` "${args.filePath}"` : ''; + try { + const diff = execSync(`git diff${target}`, { cwd: root, encoding: 'utf8' }); + return { diff: diff.trim() || 'No changes.' }; + } catch (err) { + return { error: err.message }; + } + } + }); + + // git.commit + this.registerTool({ + name: 'git.commit', + version: 'v1', + aliases: ['git_commit'], + sdkName: 'git_commit', + serviceName: 'GitService', + description: 'Stage and commit workspace changes.', + isWrite: true, + schema: z.object({ + message: z.string().describe('Git commit message.') + }), + jsonSchema: { + type: 'object', + properties: { + message: { type: 'string', description: 'Git commit message.' } + }, + required: ['message'] + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const root = args.workspaceRoot; + try { + execSync('git add -A', { cwd: root, encoding: 'utf8' }); + const out = execSync(`git commit -m "${args.message.replace(/"/g, '\\"')}"`, { cwd: root, encoding: 'utf8' }); + return { committed: true, output: out.trim() }; + } catch (err) { + return { committed: false, error: err.message }; + } + } + }); + + + // ─── 9. AI HEALTH & DIAGNOSTICS SUITE (`diagnostics.*`) ─────────────────── + + // diagnostics.check_health + this.registerTool({ + name: 'diagnostics.check_health', + version: 'v1', + aliases: ['check_health'], + sdkName: 'check_health', + serviceName: 'AIHealthService', + description: 'Run health diagnostics on AI providers, vector database, and graph DB.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async () => { + try { + const { getSubsystemHealth } = require('../../ai/diagnostics/AIHealth'); + return getSubsystemHealth(); + } catch (err) { + return { status: 'degraded', error: err.message }; + } + } + }); + + // diagnostics.get_telemetry + this.registerTool({ + name: 'diagnostics.get_telemetry', + version: 'v1', + aliases: ['get_telemetry_logs'], + sdkName: 'get_telemetry', + serviceName: 'AIHealthService', + description: 'Inspect MCP tool call latency metrics, execution flight logs, and error rates.', + isWrite: false, + schema: z.object({ + limit: z.number().optional().describe('Max log entries to fetch (default: 50).') + }), + jsonSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max log entries.' } + } + }, + execute: async (args) => { + try { + const TelemetryDB = require('../../ai/telemetry/TelemetryDB'); + const root = args.workspaceRoot || process.cwd(); + const db = new TelemetryDB(root); + db.initialize(); + const calls = db.getMcpToolCalls({ limit: args.limit || 50 }); + const stats = db.getMcpStats(); + return { stats, logs: calls }; + } catch (err) { + return { stats: {}, logs: [], error: err.message }; + } + } + }); + + + // ─── 10. WEB & PERSONAS SUITES (`web.*`, `personas.*`) ──────────────────── + + // web.search + this.registerTool({ + name: 'web.search', + version: 'v1', + aliases: ['web_search'], + sdkName: 'web_search', + serviceName: 'WebToolService', + description: 'Search the live web for external documentation or references.', + capability: 'web:search', + informationNeeds: ['external_web_content', 'web_results'], + isWrite: false, + schema: z.object({ + query: z.string().describe('Web search query.') + }), + jsonSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Web search query.' } + }, + required: ['query'] + }, + execute: async (args) => this.webService.searchWeb(args) + }); + + // web.fetch + this.registerTool({ + name: 'web.fetch', + version: 'v1', + aliases: ['fetch_url'], + sdkName: 'fetch_url', + serviceName: 'WebToolService', + description: 'Fetch and read text content from a public web page URL.', + isWrite: false, + schema: z.object({ + url: z.string().describe('Public web page URL.') + }), + jsonSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Public web page URL.' } + }, + required: ['url'] + }, + execute: async (args) => this.webService.fetchUrl(args) + }); + + // personas.list + this.registerTool({ + name: 'personas.list', + version: 'v1', + aliases: ['list_personas'], + sdkName: 'list_personas', + serviceName: 'PersonaService', + description: 'List all available custom and system AI personas.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async () => { + try { + const PersonaManager = require('../../ai/personas/PersonaManager'); + const { app } = require('electron'); + const appDataDir = app ? require('path').join(app.getPath('appData'), 'Notely') : null; + const manager = new PersonaManager(null, null, appDataDir); + return manager.listAvailablePersonas(); + } catch { + return []; + } + } + }); + + // personas.get + this.registerTool({ + name: 'personas.get', + version: 'v1', + aliases: ['get_persona'], + sdkName: 'get_persona', + serviceName: 'PersonaService', + description: 'Get details of a specific AI persona by ID.', + isWrite: false, + schema: z.object({ + id: z.string().describe('ID of the persona to fetch.') + }), + jsonSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'ID of the persona to fetch.' } + }, + required: ['id'] + }, + execute: async (args) => { + try { + const PersonaManager = require('../../ai/personas/PersonaManager'); + const { app } = require('electron'); + const appDataDir = app ? require('path').join(app.getPath('appData'), 'Notely') : null; + const manager = new PersonaManager(null, null, appDataDir); + return manager.getPersona(args.id); + } catch { + return null; + } + } + }); + + // personas.create + this.registerTool({ + name: 'personas.create', + version: 'v1', + aliases: ['create_persona'], + sdkName: 'create_persona', + serviceName: 'PersonaService', + description: 'Create a new custom AI persona.', + isWrite: true, + schema: z.object({ + name: z.string().describe('Name of the persona.'), + description: z.string().optional().describe('Short summary.'), + prompt: z.string().optional().describe('System prompt instructions.') + }), + jsonSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the persona.' }, + description: { type: 'string', description: 'Short summary.' }, + prompt: { type: 'string', description: 'System prompt instructions.' } + }, + required: ['name'] + }, + execute: async (args) => { + const PersonaManager = require('../../ai/personas/PersonaManager'); + const { app } = require('electron'); + const appDataDir = app ? require('path').join(app.getPath('appData'), 'Notely') : null; + const manager = new PersonaManager(null, null, appDataDir); + return manager.createCustomPersona(args); + } + }); + + // personas.delete + this.registerTool({ + name: 'personas.delete', + version: 'v1', + aliases: ['delete_persona'], + sdkName: 'delete_persona', + serviceName: 'PersonaService', + description: 'Delete a custom persona by ID.', + isWrite: true, + schema: z.object({ + id: z.string().describe('ID of custom persona to delete.') + }), + jsonSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'ID of custom persona to delete.' } + }, + required: ['id'] + }, + execute: async (args) => { + const PersonaManager = require('../../ai/personas/PersonaManager'); + const { app } = require('electron'); + const appDataDir = app ? require('path').join(app.getPath('appData'), 'Notely') : null; + const manager = new PersonaManager(null, null, appDataDir); + return manager.deletePersona(args.id); + } + }); + + // ─── 11. ADDITIONAL UTILITY SUITES ────────────────────────────────────────── + + // notes.search_replace + this.registerTool({ + name: 'notes.search_replace', + version: 'v1', + aliases: ['bulk_replace'], + sdkName: 'search_replace', + serviceName: 'NoteApplicationService', + description: 'Bulk search and replace string or regex across workspace notes.', + isWrite: true, + schema: z.object({ + query: z.string().describe('Search string or regex pattern.'), + replace: z.string().describe('Replacement text.'), + isRegex: z.boolean().optional().describe('Whether query is regex.'), + notePath: z.string().optional().describe('Optional specific note path.') + }), + jsonSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search string or regex pattern.' }, + replace: { type: 'string', description: 'Replacement text.' }, + isRegex: { type: 'boolean', description: 'Whether query is regex.' }, + notePath: { type: 'string', description: 'Optional specific note path.' } + }, + required: ['query', 'replace'] + }, + execute: async (args) => this.noteService.searchReplace(args) + }); + + // notes.history + this.registerTool({ + name: 'notes.history', + version: 'v1', + aliases: ['note_history', 'file_git_history'], + sdkName: 'note_history', + serviceName: 'NoteApplicationService', + description: 'Retrieve revision history and commit logs for a note file.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Target note path.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Target note path.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const { getFileHistory } = require('../services/gitService.cjs'); + return getFileHistory(args.workspaceRoot, args.filePath); + } + }); + + // workspace.list_tree + this.registerTool({ + name: 'workspace.list_tree', + version: 'v1', + aliases: ['get_folder_tree'], + sdkName: 'workspace_tree', + serviceName: 'WorkspaceApplicationService', + description: 'Get nested folder hierarchy tree with file counts and byte sizes.', + isWrite: false, + schema: z.object({ + maxDepth: z.number().optional().describe('Max recursion depth (default: 4).') + }), + jsonSchema: { + type: 'object', + properties: { + maxDepth: { type: 'number', description: 'Max recursion depth (default: 4).' } + } + }, + execute: async (args) => this.workspaceService.listTree(args) + }); + + // workspace.create_folder + this.registerTool({ + name: 'workspace.create_folder', + version: 'v1', + aliases: ['create_directory', 'mkdir'], + sdkName: 'create_folder', + serviceName: 'WorkspaceApplicationService', + description: 'Create a new directory folder in the workspace.', + isWrite: true, + schema: z.object({ + folderPath: z.string().describe('Relative or absolute path of directory to create.') + }), + jsonSchema: { + type: 'object', + properties: { + folderPath: { type: 'string', description: 'Relative or absolute path of directory to create.' } + }, + required: ['folderPath'] + }, + execute: async (args) => this.workspaceService.createFolder(args) + }); + + // workspace.delete_folder + this.registerTool({ + name: 'workspace.delete_folder', + version: 'v1', + aliases: ['delete_directory', 'rmdir'], + sdkName: 'delete_folder', + serviceName: 'WorkspaceApplicationService', + description: 'Delete a folder directory in the workspace.', + isWrite: true, + schema: z.object({ + folderPath: z.string().describe('Target directory path to delete.'), + recursive: z.boolean().optional().describe('Whether to delete contents recursively.') + }), + jsonSchema: { + type: 'object', + properties: { + folderPath: { type: 'string', description: 'Target directory path to delete.' }, + recursive: { type: 'boolean', description: 'Whether to delete contents recursively.' } + }, + required: ['folderPath'] + }, + execute: async (args) => this.workspaceService.deleteFolder(args) + }); + + // tasks.query + this.registerTool({ + name: 'tasks.query', + version: 'v1', + aliases: ['filter_tasks'], + sdkName: 'query_tasks', + serviceName: 'TaskService', + description: 'Query checklist tasks by priority, due date range, status, or assignee tag.', + isWrite: false, + schema: z.object({ + status: z.enum(['all', 'open', 'completed', 'in-progress']).optional().describe('Filter task status.'), + tag: z.string().optional().describe('Filter by assignee tag (e.g. @john).') + }), + jsonSchema: { + type: 'object', + properties: { + status: { type: 'string', enum: ['all', 'open', 'completed', 'in-progress'], description: 'Filter task status.' }, + tag: { type: 'string', description: 'Filter by assignee tag.' } + } + }, + execute: async (args) => this.noteService.extractTasks(args) + }); + + // tasks.summarize + this.registerTool({ + name: 'tasks.summarize', + version: 'v1', + aliases: ['task_summary'], + sdkName: 'summarize_tasks', + serviceName: 'TaskService', + description: 'Generate summary report of completed vs open tasks across workspace.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const tasks = await this.noteService.extractTasks({ ...args, status: 'all' }); + const open = tasks.filter(t => t.status === 'open' || t.status === 'in-progress'); + const completed = tasks.filter(t => t.status === 'completed'); + return { + totalTasks: tasks.length, + openCount: open.length, + completedCount: completed.length, + completionRate: tasks.length > 0 ? Math.round((completed.length / tasks.length) * 100) : 100, + openTasks: open.slice(0, 20) + }; + } + }); + + // diagrams.read + this.registerTool({ + name: 'diagrams.read', + version: 'v1', + aliases: ['read_mermaid_code'], + sdkName: 'read_diagram', + serviceName: 'DiagramService', + description: 'Read raw Mermaid diagram code blocks from a target note.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Target note path.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Target note path.' } + }, + required: ['filePath'] + }, + execute: async (args) => this.noteService.readDiagram(args) + }); + + // diagrams.update + this.registerTool({ + name: 'diagrams.update', + version: 'v1', + aliases: ['update_mermaid_code'], + sdkName: 'update_diagram', + serviceName: 'DiagramService', + description: 'Edit or replace a Mermaid diagram block inside a target note file.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Target note path.'), + code: z.string().describe('New Mermaid diagram code.'), + diagramIndex: z.number().optional().describe('Index of diagram block (default: 0).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Target note path.' }, + code: { type: 'string', description: 'New Mermaid diagram code.' }, + diagramIndex: { type: 'number', description: 'Index of diagram block.' } + }, + required: ['filePath', 'code'] + }, + execute: async (args) => this.noteService.updateDiagram(args) + }); + + // diagrams.convert_to_image + this.registerTool({ + name: 'diagrams.convert_to_image', + version: 'v1', + aliases: ['diagram_to_svg'], + sdkName: 'diagram_convert_image', + serviceName: 'DiagramService', + description: 'Render Mermaid code block to SVG graphic asset.', + isWrite: false, + schema: z.object({ + code: z.string().describe('Mermaid diagram code.') + }), + jsonSchema: { + type: 'object', + properties: { + code: { type: 'string', description: 'Mermaid diagram code.' } + }, + required: ['code'] + }, + execute: async (args) => { + const { detectMermaidType } = await import('../../src/services/workspaceMediaService.js'); + const diagramType = detectMermaidType(args.code); + return { + valid: true, + diagramType, + svgContent: `${args.code.substring(0, 100)}` + }; + } + }); + + // drawio.read + this.registerTool({ + name: 'drawio.read', + version: 'v1', + aliases: ['read_excalidraw_code', 'read_drawio_code'], + sdkName: 'read_drawio', + serviceName: 'DiagramService', + description: 'Read raw Excalidraw JSON structure or Draw.io XML markup from drawing files.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Drawing file path (.excalidraw or .drawio).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Drawing file path (.excalidraw or .drawio).' } + }, + required: ['filePath'] + }, + execute: async (args) => this.noteService.readDrawio(args) + }); + + // drawio.update + this.registerTool({ + name: 'drawio.update', + version: 'v1', + aliases: ['update_excalidraw_code', 'update_drawio_code'], + sdkName: 'update_drawio', + serviceName: 'DiagramService', + description: 'Write back updated Excalidraw JSON elements or Draw.io XML markup to drawing files.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Drawing file path.'), + content: z.any().describe('Updated JSON object or XML string.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Drawing file path.' }, + content: { description: 'Updated JSON object or XML string.' } + }, + required: ['filePath', 'content'] + }, + execute: async (args) => this.noteService.updateDrawio(args) + }); + + // drawio.export_svg + this.registerTool({ + name: 'drawio.export_svg', + version: 'v1', + aliases: ['drawio_to_svg'], + sdkName: 'drawio_export_svg', + serviceName: 'DiagramService', + description: 'Export drawing file to clean SVG graphic file in Media/.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Drawing file path.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Drawing file path.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const res = await this.noteService.readDrawio(args); + return { + filePath: args.filePath, + format: res.format, + exportedSvg: `` + }; + } + }); + + // knowledge.unlinked_mentions + this.registerTool({ + name: 'knowledge.unlinked_mentions', + version: 'v1', + aliases: ['find_unlinked_mentions'], + sdkName: 'unlinked_mentions', + serviceName: 'KnowledgeApplicationService', + description: 'Find plain text mentions of note titles that can be converted into [[Wikilinks]].', + isWrite: false, + schema: z.object({ + noteTitle: z.string().describe('Note title to search plain text mentions for.') + }), + jsonSchema: { + type: 'object', + properties: { + noteTitle: { type: 'string', description: 'Note title to search plain text mentions for.' } + }, + required: ['noteTitle'] + }, + execute: async (args) => this.noteService.unlinkedMentions(args) + }); + + // knowledge.auto_wikilink + this.registerTool({ + name: 'knowledge.auto_wikilink', + version: 'v1', + aliases: ['auto_wikilink_note'], + sdkName: 'auto_wikilink', + serviceName: 'KnowledgeApplicationService', + description: 'Automatically convert unlinked plain text mentions into [[Wikilinks]] inside a note.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Target note path.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Target note path.' } + }, + required: ['filePath'] + }, + execute: async (args) => this.noteService.autoWikilink(args) + }); + + // export.create_package + this.registerTool({ + name: 'export.create_package', + version: 'v1', + aliases: ['export_note_package'], + sdkName: 'create_package', + serviceName: 'WorkspaceApplicationService', + description: 'Export note + linked media assets into an encrypted .note bundle file.', + isWrite: true, + schema: z.object({ + notePaths: z.array(z.string()).optional().describe('Note paths to include (default: all notes).'), + outputFilename: z.string().optional().describe('Output filename (default: export.note).') + }), + jsonSchema: { + type: 'object', + properties: { + notePaths: { type: 'array', items: { type: 'string' }, description: 'Note paths to include.' }, + outputFilename: { type: 'string', description: 'Output filename.' } + } + }, + execute: async (args) => this.workspaceService.exportPackage(args) + }); + + // export.import_package + this.registerTool({ + name: 'export.import_package', + version: 'v1', + aliases: ['import_note_package'], + sdkName: 'import_package', + serviceName: 'WorkspaceApplicationService', + description: 'Import and extract a .note package bundle into the active workspace.', + isWrite: true, + schema: z.object({ + packagePath: z.string().describe('Path to .note package file.') + }), + jsonSchema: { + type: 'object', + properties: { + packagePath: { type: 'string', description: 'Path to .note package file.' } + }, + required: ['packagePath'] + }, + execute: async (args) => this.workspaceService.importPackage(args) + }); + + // ─── NEW TOOLS ──────────────────────────────────────────────────────────── + + // notes.list + this.registerTool({ + name: 'notes.list', + version: 'v1', + aliases: ['list_notes'], + sdkName: 'list_notes', + serviceName: 'NoteApplicationService', + description: 'List all markdown notes in the workspace with their paths, sizes, and last modified timestamps.', + isWrite: false, + schema: z.object({ + folder: z.string().optional().describe('Subfolder to list notes from (default: workspace root).'), + limit: z.number().optional().describe('Maximum number of notes to return (default: 200).') + }), + jsonSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Subfolder to list notes from.' }, + limit: { type: 'number', description: 'Maximum results (default: 200).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const root = args.folder + ? path.resolve(args.workspaceRoot, args.folder) + : args.workspaceRoot; + const files = collectMarkdownFiles(root); + const limit = args.limit || 200; + return files.slice(0, limit).map(f => { + try { + const stat = fs.statSync(f); + return { + path: f, + name: path.basename(f, '.md'), + relativePath: path.relative(args.workspaceRoot, f), + sizeBytes: stat.size, + modifiedAt: stat.mtime.toISOString() + }; + } catch { + return { path: f, name: path.basename(f, '.md'), relativePath: path.relative(args.workspaceRoot, f) }; + } + }); + } + }); + + // notes.rename + this.registerTool({ + name: 'notes.rename', + version: 'v1', + aliases: ['rename_note'], + sdkName: 'rename_note', + serviceName: 'NoteApplicationService', + description: 'Rename a note file, preserving its folder location. Updates the filename on disk.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Current relative or absolute path of the note.'), + newName: z.string().describe('New filename without extension (e.g. "My New Title").') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Current path of the note.' }, + newName: { type: 'string', description: 'New filename without extension.' } + }, + required: ['filePath', 'newName'] + }, + execute: async (args) => { + const path = require('path'); + const safeBase = args.newName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'untitled'; + const dir = path.dirname(args.filePath.includes(path.sep) || args.filePath.includes('/') ? args.filePath : path.join(args.workspaceRoot, args.filePath)); + const newPath = path.join(dir, `${safeBase}.md`); + return this.noteService.moveNote({ workspaceRoot: args.workspaceRoot, sourcePath: args.filePath, targetPath: newPath }); + } + }); + + // notes.append + this.registerTool({ + name: 'notes.append', + version: 'v1', + aliases: ['append_to_note'], + sdkName: 'append_to_note', + serviceName: 'NoteApplicationService', + description: 'Append text content to the end of an existing note without overwriting existing content.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + content: z.string().describe('Text content to append.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + content: { type: 'string', description: 'Text content to append.' } + }, + required: ['filePath', 'content'] + }, + execute: async (args) => this.noteService.updateNote({ ...args, mode: 'append' }) + }); + + // notes.duplicate + this.registerTool({ + name: 'notes.duplicate', + version: 'v1', + aliases: ['duplicate_note'], + sdkName: 'duplicate_note', + serviceName: 'NoteApplicationService', + description: 'Duplicate an existing note to a new path, creating an independent copy.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Source note path to duplicate.'), + targetPath: z.string().optional().describe('Destination path (default: same folder with "-copy" suffix).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Source note path.' }, + targetPath: { type: 'string', description: 'Destination path (optional).' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validSource = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validSource)) throw new Error(`Note "${args.filePath}" does not exist.`); + const ext = path.extname(validSource); + const base = path.basename(validSource, ext); + const dir = path.dirname(validSource); + let destPath = args.targetPath + ? assertPathInWorkspace(args.targetPath, args.workspaceRoot) + : path.join(dir, `${base}-copy${ext}`); + let counter = 2; + while (fs.existsSync(destPath)) { + destPath = path.join(dir, `${base}-copy-${counter}${ext}`); + counter++; + } + const content = fs.readFileSync(validSource, 'utf8'); + fs.writeFileSync(destPath, content, 'utf8'); + return { sourcePath: validSource, duplicatedPath: destPath, created: true }; + } + }); + + // workspace.search_files + this.registerTool({ + name: 'workspace.search_files', + version: 'v1', + aliases: ['search_workspace_files'], + sdkName: 'search_workspace_files', + serviceName: 'WorkspaceApplicationService', + description: 'Search workspace files by name pattern or extension. Returns matching file paths and metadata.', + isWrite: false, + schema: z.object({ + pattern: z.string().describe('Filename substring or glob pattern to match (case-insensitive).'), + extension: z.string().optional().describe('Filter by file extension (e.g. ".md", ".excalidraw").'), + limit: z.number().optional().describe('Max results (default: 50).') + }), + jsonSchema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Filename pattern to search.' }, + extension: { type: 'string', description: 'File extension filter (e.g. ".md").' }, + limit: { type: 'number', description: 'Max results.' } + }, + required: ['pattern'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const limit = args.limit || 50; + const pattern = args.pattern.toLowerCase(); + const ext = args.extension ? args.extension.toLowerCase() : null; + + const walk = (dir, results = []) => { + if (!fs.existsSync(dir)) return results; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, results); + } else if (entry.isFile()) { + const nameLower = entry.name.toLowerCase(); + const extMatch = !ext || nameLower.endsWith(ext); + if (nameLower.includes(pattern) && extMatch) { + try { + const stat = fs.statSync(full); + results.push({ path: full, name: entry.name, relativePath: path.relative(args.workspaceRoot, full), sizeBytes: stat.size }); + } catch { results.push({ path: full, name: entry.name, relativePath: path.relative(args.workspaceRoot, full) }); } + } + } + } + } catch { /* skip */ } + return results; + }; + + const results = walk(args.workspaceRoot); + return { pattern, totalFound: results.length, files: results.slice(0, limit) }; + } + }); + + // workspace.word_count + this.registerTool({ + name: 'workspace.word_count', + version: 'v1', + aliases: ['count_words'], + sdkName: 'word_count', + serviceName: 'WorkspaceApplicationService', + description: 'Count words, characters, and lines in a note file or across the entire workspace.', + isWrite: false, + schema: z.object({ + filePath: z.string().optional().describe('Specific note path (omit for workspace totals).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Note path (optional; omit for workspace totals).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const { collectMarkdownFiles, assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + + const countText = (text) => { + const lines = text.split(/\r?\n/).length; + const words = (text.match(/\S+/g) || []).length; + const chars = text.length; + return { lines, words, chars }; + }; + + if (args.filePath) { + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + const text = fs.readFileSync(validPath, 'utf8'); + return { scope: 'file', filePath: validPath, ...countText(text) }; + } + + const files = collectMarkdownFiles(args.workspaceRoot); + let totalWords = 0, totalChars = 0, totalLines = 0; + for (const f of files) { + try { + const text = fs.readFileSync(f, 'utf8'); + const c = countText(text); + totalWords += c.words; totalChars += c.chars; totalLines += c.lines; + } catch { /* skip */ } + } + return { scope: 'workspace', noteCount: files.length, words: totalWords, chars: totalChars, lines: totalLines }; + } + }); + + // index.list_notes + this.registerTool({ + name: 'index.list_notes', + version: 'v1', + aliases: ['list_all_notes'], + sdkName: 'list_all_notes', + serviceName: 'WorkspaceIndexService', + description: 'Return a flat list of all notes in the workspace index with titles and relative paths.', + isWrite: false, + schema: z.object({ + includeSize: z.boolean().optional().describe('Include file size in bytes (default: false).') + }), + jsonSchema: { + type: 'object', + properties: { + includeSize: { type: 'boolean', description: 'Include file size (default: false).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + return files.map(f => { + const entry = { + title: path.basename(f, '.md'), + relativePath: path.relative(args.workspaceRoot, f), + path: f + }; + if (args.includeSize) { + try { entry.sizeBytes = fs.statSync(f).size; } catch { /* skip */ } + } + return entry; + }); + } + }); + + // tasks.create + this.registerTool({ + name: 'tasks.create', + version: 'v1', + aliases: ['create_task'], + sdkName: 'create_task', + serviceName: 'NoteApplicationService', + description: 'Create a new checklist task item and append it to a note file.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Note file to append the task to.'), + taskText: z.string().describe('Task description text.'), + completed: z.boolean().optional().describe('Mark as completed (default: false).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Note file path.' }, + taskText: { type: 'string', description: 'Task description.' }, + completed: { type: 'boolean', description: 'Mark as completed.' } + }, + required: ['filePath', 'taskText'] + }, + execute: async (args) => { + const checkmark = args.completed ? 'x' : ' '; + const taskLine = `\n- [${checkmark}] ${args.taskText.trim()}`; + return this.noteService.updateNote({ ...args, content: taskLine, mode: 'append' }); + } + }); + + // git.branch + this.registerTool({ + name: 'git.branch', + version: 'v1', + aliases: ['get_git_branch', 'current_branch'], + sdkName: 'git_branch', + serviceName: 'GitService', + description: 'Get the current git branch name and list of all local branches in the workspace.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + try { + const current = execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf8' }).trim(); + const allBranches = execSync('git branch', { cwd, encoding: 'utf8' }) + .split('\n') + .map(b => b.replace(/^\*?\s+/, '').trim()) + .filter(Boolean); + return { currentBranch: current, localBranches: allBranches, totalBranches: allBranches.length }; + } catch (err) { + throw new Error(`Git branch failed: ${err.message}`); + } + } + }); + + // git.stash + this.registerTool({ + name: 'git.stash', + version: 'v1', + aliases: ['stash_changes'], + sdkName: 'git_stash', + serviceName: 'GitService', + description: 'Stash uncommitted workspace changes or list/pop existing stashes.', + isWrite: true, + schema: z.object({ + action: z.enum(['push', 'pop', 'list', 'drop']).optional().describe('Stash action (default: push).'), + message: z.string().optional().describe('Stash message for push action.') + }), + jsonSchema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['push', 'pop', 'list', 'drop'], description: 'Stash action (default: push).' }, + message: { type: 'string', description: 'Stash message (for push).' } + } + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + const action = args.action || 'push'; + try { + let output; + if (action === 'push') { + const msg = args.message ? ` -m "${args.message}"` : ''; + output = execSync(`git stash push${msg}`, { cwd, encoding: 'utf8' }).trim(); + } else if (action === 'pop') { + output = execSync('git stash pop', { cwd, encoding: 'utf8' }).trim(); + } else if (action === 'list') { + output = execSync('git stash list', { cwd, encoding: 'utf8' }).trim(); + } else if (action === 'drop') { + output = execSync('git stash drop', { cwd, encoding: 'utf8' }).trim(); + } + return { action, output, success: true }; + } catch (err) { + throw new Error(`git stash ${action} failed: ${err.message}`); + } + } + }); + + // ─── BATCH 2 NEW TOOLS ─────────────────────────────────────────────────── + + // notes.extract_headings + this.registerTool({ + name: 'notes.extract_headings', + version: 'v1', + aliases: ['get_headings'], + sdkName: 'extract_headings', + serviceName: 'NoteApplicationService', + description: 'Extract all headings (H1–H6) from a note file with their levels and line numbers.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Path to the note file.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + const lines = fs.readFileSync(validPath, 'utf8').split(/\r?\n/); + const headings = []; + lines.forEach((line, idx) => { + const match = line.match(/^(#{1,6})\s+(.+)/); + if (match) { + headings.push({ + level: match[1].length, + text: match[2].trim(), + line: idx + 1, + anchor: match[2].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-') + }); + } + }); + return { filePath: validPath, totalHeadings: headings.length, headings }; + } + }); + + // notes.find_broken_links + this.registerTool({ + name: 'notes.find_broken_links', + version: 'v1', + aliases: ['broken_links'], + sdkName: 'find_broken_links', + serviceName: 'NoteApplicationService', + description: 'Scan the workspace for [[wikilinks]] that point to notes which do not exist.', + isWrite: false, + schema: z.object({ + notePath: z.string().optional().describe('Scan a specific note only (default: entire workspace).') + }), + jsonSchema: { + type: 'object', + properties: { + notePath: { type: 'string', description: 'Specific note to scan (optional).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles, assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const allFiles = collectMarkdownFiles(args.workspaceRoot); + const existingTitles = new Set(allFiles.map(f => path.basename(f, '.md').toLowerCase())); + + const filesToScan = args.notePath + ? [assertPathInWorkspace(args.notePath, args.workspaceRoot)] + : allFiles; + + const broken = []; + for (const filePath of filesToScan) { + if (!fs.existsSync(filePath)) continue; + try { + const text = fs.readFileSync(filePath, 'utf8'); + const matches = [...text.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?]]/g)]; + for (const m of matches) { + const linked = m[1].trim().toLowerCase(); + if (!existingTitles.has(linked)) { + broken.push({ + sourceFile: path.relative(args.workspaceRoot, filePath), + brokenLink: m[1].trim(), + fullMatch: m[0] + }); + } + } + } catch { /* skip */ } + } + return { scannedFiles: filesToScan.length, brokenLinkCount: broken.length, brokenLinks: broken }; + } + }); + + // notes.frontmatter_update + this.registerTool({ + name: 'notes.frontmatter_update', + version: 'v1', + aliases: ['update_frontmatter'], + sdkName: 'update_frontmatter', + serviceName: 'NoteApplicationService', + description: 'Add or update specific YAML frontmatter fields in a note without touching the body content.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + fields: z.record(z.any()).describe('Key-value pairs to set in the frontmatter (e.g. {"tags": ["ai","notes"], "status": "draft"}).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + fields: { type: 'object', description: 'Frontmatter key-value pairs to set.' } + }, + required: ['filePath', 'fields'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + let text = fs.readFileSync(validPath, 'utf8'); + const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + + const toYamlLine = (key, val) => { + if (Array.isArray(val)) return `${key}: [${val.map(v => `"${v}"`).join(', ')}]`; + if (typeof val === 'object' && val !== null) return `${key}: ${JSON.stringify(val)}`; + if (typeof val === 'string') return `${key}: "${val}"`; + return `${key}: ${val}`; + }; + + if (fmMatch) { + // Parse existing frontmatter lines, update or add fields + let fmLines = fmMatch[1].split(/\r?\n/); + for (const [key, val] of Object.entries(args.fields)) { + const lineIdx = fmLines.findIndex(l => l.startsWith(`${key}:`)); + const newLine = toYamlLine(key, val); + if (lineIdx >= 0) { + fmLines[lineIdx] = newLine; + } else { + fmLines.push(newLine); + } + } + text = `---\n${fmLines.join('\n')}\n---` + text.slice(fmMatch[0].length); + } else { + // No frontmatter yet — prepend it + const fmLines = Object.entries(args.fields).map(([k, v]) => toYamlLine(k, v)); + text = `---\n${fmLines.join('\n')}\n---\n\n` + text; + } + + fs.writeFileSync(validPath, text, 'utf8'); + return { filePath: validPath, updatedFields: Object.keys(args.fields), updated: true }; + } + }); + + // notes.count + this.registerTool({ + name: 'notes.count', + version: 'v1', + aliases: ['count_notes'], + sdkName: 'count_notes', + serviceName: 'NoteApplicationService', + description: 'Count notes in the workspace, optionally grouped by top-level folder.', + isWrite: false, + schema: z.object({ + groupByFolder: z.boolean().optional().describe('Break down count by top-level folder (default: false).') + }), + jsonSchema: { + type: 'object', + properties: { + groupByFolder: { type: 'boolean', description: 'Group count by top-level folder.' } + } + }, + execute: async (args) => { + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + if (!args.groupByFolder) { + return { total: files.length }; + } + const groups = {}; + for (const f of files) { + const rel = path.relative(args.workspaceRoot, f); + const parts = rel.split(path.sep); + const folder = parts.length > 1 ? parts[0] : '(root)'; + groups[folder] = (groups[folder] || 0) + 1; + } + return { total: files.length, byFolder: groups }; + } + }); + + // git.pull + this.registerTool({ + name: 'git.pull', + version: 'v1', + aliases: ['git_pull'], + sdkName: 'git_pull', + serviceName: 'GitService', + description: 'Pull latest changes from the remote origin for the current branch.', + isWrite: true, + schema: z.object({ + remote: z.string().optional().describe('Remote name (default: origin).'), + branch: z.string().optional().describe('Branch to pull (default: current branch).') + }), + jsonSchema: { + type: 'object', + properties: { + remote: { type: 'string', description: 'Remote name (default: origin).' }, + branch: { type: 'string', description: 'Branch to pull (default: current).' } + } + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + const remote = args.remote || 'origin'; + const branch = args.branch || ''; + try { + const output = execSync(`git pull ${remote} ${branch}`.trim(), { cwd, encoding: 'utf8' }).trim(); + return { remote, output, success: true }; + } catch (err) { + throw new Error(`git pull failed: ${err.message}`); + } + } + }); + + // git.push + this.registerTool({ + name: 'git.push', + version: 'v1', + aliases: ['git_push'], + sdkName: 'git_push', + serviceName: 'GitService', + description: 'Push committed changes to the remote origin.', + isWrite: true, + schema: z.object({ + remote: z.string().optional().describe('Remote name (default: origin).'), + branch: z.string().optional().describe('Branch to push (default: current branch).'), + force: z.boolean().optional().describe('Force push (default: false).') + }), + jsonSchema: { + type: 'object', + properties: { + remote: { type: 'string', description: 'Remote name (default: origin).' }, + branch: { type: 'string', description: 'Branch to push (default: current).' }, + force: { type: 'boolean', description: 'Force push.' } + } + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + const remote = args.remote || 'origin'; + const branch = args.branch || ''; + const force = args.force ? ' --force' : ''; + try { + const output = execSync(`git push ${remote} ${branch}${force}`.trim(), { cwd, encoding: 'utf8' }).trim(); + return { remote, output, success: true }; + } catch (err) { + throw new Error(`git push failed: ${err.message}`); + } + } + }); + + // git.checkout + this.registerTool({ + name: 'git.checkout', + version: 'v1', + aliases: ['git_checkout', 'switch_branch'], + sdkName: 'git_checkout', + serviceName: 'GitService', + description: 'Checkout an existing branch or create a new one in the workspace repository.', + isWrite: true, + schema: z.object({ + branch: z.string().describe('Branch name to checkout or create.'), + create: z.boolean().optional().describe('Create branch if it does not exist (default: false).') + }), + jsonSchema: { + type: 'object', + properties: { + branch: { type: 'string', description: 'Branch name.' }, + create: { type: 'boolean', description: 'Create new branch.' } + }, + required: ['branch'] + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + const flag = args.create ? '-b ' : ''; + try { + const output = execSync(`git checkout ${flag}${args.branch}`, { cwd, encoding: 'utf8' }).trim(); + return { branch: args.branch, created: Boolean(args.create), output, success: true }; + } catch (err) { + throw new Error(`git checkout failed: ${err.message}`); + } + } + }); + + // media.cleanup_unused + this.registerTool({ + name: 'media.cleanup_unused', + version: 'v1', + aliases: ['find_orphan_media', 'cleanup_media'], + sdkName: 'cleanup_unused_media', + serviceName: 'MediaService', + description: 'Find media assets in the workspace that are not referenced by any note. Optionally delete them.', + isWrite: false, + schema: z.object({ + delete: z.boolean().optional().describe('Delete the orphaned files (default: false — dry run only).'), + mediaFolder: z.string().optional().describe('Media folder path relative to workspace (default: "Media").') + }), + jsonSchema: { + type: 'object', + properties: { + delete: { type: 'boolean', description: 'Delete orphaned files (default: false).' }, + mediaFolder: { type: 'string', description: 'Media subfolder (default: "Media").' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + + const mediaDir = path.join(args.workspaceRoot, args.mediaFolder || 'Media'); + if (!fs.existsSync(mediaDir)) return { orphanCount: 0, orphans: [], message: 'Media folder not found.' }; + + // Collect all note content + const noteFiles = collectMarkdownFiles(args.workspaceRoot); + let allNoteContent = ''; + for (const f of noteFiles) { + try { allNoteContent += fs.readFileSync(f, 'utf8') + '\n'; } catch { /* skip */ } + } + + // Walk media dir for asset files + const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.mp4', '.mov', '.pdf', '.drawio', '.excalidraw']); + const orphans = []; + + const walkMedia = (dir) => { + if (!fs.existsSync(dir)) return; + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walkMedia(full); continue; } + if (!IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) continue; + const isReferenced = allNoteContent.includes(entry.name); + if (!isReferenced) { + const stat = fs.statSync(full); + orphans.push({ path: full, name: entry.name, sizeBytes: stat.size }); + } + } + } catch { /* skip */ } + }; + walkMedia(mediaDir); + + if (args.delete && orphans.length > 0) { + for (const o of orphans) { + try { fs.unlinkSync(o.path); } catch { /* skip */ } + } + return { orphanCount: orphans.length, deleted: true, orphans }; + } + + return { orphanCount: orphans.length, deleted: false, dryRun: true, orphans }; + } + }); + + // tasks.complete + this.registerTool({ + name: 'tasks.complete', + version: 'v1', + aliases: ['complete_task', 'mark_done'], + sdkName: 'complete_task', + serviceName: 'NoteApplicationService', + description: 'Mark a task as completed [x] by line number or by matching task text (convenience wrapper).', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Note file containing the task.'), + line: z.number().optional().describe('Line number of the task (takes priority over text match).'), + taskText: z.string().optional().describe('Partial text to match the task (used if line not provided).'), + status: z.enum(['open', 'in-progress', 'completed']).optional().describe('Target status (default: completed).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Note file path.' }, + line: { type: 'number', description: 'Line number of the task.' }, + taskText: { type: 'string', description: 'Partial text match for the task.' }, + status: { type: 'string', enum: ['open', 'in-progress', 'completed'], description: 'Target status (default: completed).' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + let targetLine = args.line; + + if (!targetLine && args.taskText) { + const needle = args.taskText.toLowerCase(); + const idx = lines.findIndex(l => /^\s*[-*+]?\s*\[[ xX/]\]/.test(l) && l.toLowerCase().includes(needle)); + if (idx < 0) throw new Error(`No task matching "${args.taskText}" found.`); + targetLine = idx + 1; + } + + if (!targetLine) throw new Error('Provide either line or taskText.'); + + const status = args.status || 'completed'; + const marker = status === 'completed' ? 'x' : status === 'in-progress' ? '/' : ' '; + const idx = targetLine - 1; + if (idx < 0 || idx >= lines.length) throw new Error(`Line ${targetLine} out of range.`); + + lines[idx] = lines[idx].replace(/^(\s*[-*+]?\s*\[)[ xX/]\]/, `$1${marker}]`); + fs.writeFileSync(validPath, lines.join('\n'), 'utf8'); + return { filePath: validPath, line: targetLine, status, updatedText: lines[idx].trim() }; + } + }); + + // knowledge.note_summary + this.registerTool({ + name: 'knowledge.note_summary', + version: 'v1', + aliases: ['summarize_note'], + sdkName: 'summarize_note', + serviceName: 'KnowledgeApplicationService', + description: 'Generate a structural summary of a note: title, headings, word count, tags, and first paragraph.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Path to the note file.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const text = fs.readFileSync(validPath, 'utf8'); + const lines = text.split(/\r?\n/); + + // Extract frontmatter tags + let tags = []; + const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (fmMatch) { + const tagLine = fmMatch[1].split('\n').find(l => l.startsWith('tags:')); + if (tagLine) { + const tagVal = tagLine.replace('tags:', '').trim(); + tags = tagVal.startsWith('[') ? JSON.parse(tagVal.replace(/'/g, '"')) : tagVal.split(',').map(t => t.trim()); + } + } + + // Headings + const headings = lines + .filter(l => /^#{1,6}\s/.test(l)) + .map(l => { const m = l.match(/^(#{1,6})\s+(.+)/); return { level: m[1].length, text: m[2].trim() }; }); + + // First non-empty, non-heading, non-frontmatter paragraph + let inFm = false, firstPara = ''; + for (const line of lines) { + if (line.trim() === '---') { inFm = !inFm; continue; } + if (inFm) continue; + if (/^#{1,6}\s/.test(line) || !line.trim()) continue; + firstPara = line.trim(); + break; + } + + const wordCount = (text.match(/\S+/g) || []).length; + const title = headings.find(h => h.level === 1)?.text || path.basename(validPath, '.md'); + + return { title, filePath: validPath, wordCount, lineCount: lines.length, tags, headings, firstParagraph: firstPara }; + } + }); + + // ─── BATCH 3 NEW TOOLS ─────────────────────────────────────────────────── + + // notes.get_links + this.registerTool({ + name: 'notes.get_links', + version: 'v1', + aliases: ['get_note_links'], + sdkName: 'get_note_links', + serviceName: 'NoteApplicationService', + description: 'Extract all outgoing wikilinks and markdown links from a note.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Path to the note file.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + const text = fs.readFileSync(validPath, 'utf8'); + + const wikiLinks = [...text.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?]]/g)] + .map(m => ({ type: 'wikilink', target: m[1].trim(), raw: m[0] })); + + const mdLinks = [...text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)] + .map(m => ({ type: 'markdown', label: m[1], target: m[2], raw: m[0] })); + + return { + filePath: validPath, + totalLinks: wikiLinks.length + mdLinks.length, + wikiLinks, + markdownLinks: mdLinks + }; + } + }); + + // notes.insert_at + this.registerTool({ + name: 'notes.insert_at', + version: 'v1', + aliases: ['insert_content'], + sdkName: 'insert_at', + serviceName: 'NoteApplicationService', + description: 'Insert content at a specific line number or directly after a named heading in a note.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + content: z.string().describe('Content to insert.'), + line: z.number().optional().describe('Line number to insert before (1-indexed).'), + afterHeading: z.string().optional().describe('Insert after the first heading matching this text.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + content: { type: 'string', description: 'Content to insert.' }, + line: { type: 'number', description: 'Line number to insert before.' }, + afterHeading: { type: 'string', description: 'Insert after first heading matching this text.' } + }, + required: ['filePath', 'content'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + let insertIdx; + + if (args.line != null) { + insertIdx = Math.max(0, Math.min(args.line - 1, lines.length)); + } else if (args.afterHeading) { + const needle = args.afterHeading.toLowerCase(); + const headingIdx = lines.findIndex(l => /^#{1,6}\s/.test(l) && l.toLowerCase().includes(needle)); + if (headingIdx < 0) throw new Error(`Heading matching "${args.afterHeading}" not found.`); + // Insert after heading + any immediately following blank line + insertIdx = headingIdx + 1; + while (insertIdx < lines.length && lines[insertIdx].trim() === '') insertIdx++; + } else { + insertIdx = lines.length; + } + + lines.splice(insertIdx, 0, args.content); + fs.writeFileSync(validPath, lines.join('\n'), 'utf8'); + return { filePath: validPath, insertedAtLine: insertIdx + 1, inserted: true }; + } + }); + + // notes.stats + this.registerTool({ + name: 'notes.stats', + version: 'v1', + aliases: ['note_stats'], + sdkName: 'note_stats', + serviceName: 'NoteApplicationService', + description: 'Get detailed stats for a single note: word count, line count, heading count, link count, task count, and file size.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Path to the note file.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const text = fs.readFileSync(validPath, 'utf8'); + const stat = fs.statSync(validPath); + const lines = text.split(/\r?\n/); + + return { + filePath: validPath, + sizeBytes: stat.size, + lineCount: lines.length, + wordCount: (text.match(/\S+/g) || []).length, + charCount: text.length, + headingCount: (text.match(/^#{1,6}\s/gm) || []).length, + wikiLinkCount: (text.match(/\[\[.+?]]/g) || []).length, + mdLinkCount: (text.match(/\[.+?]\(.+?\)/g) || []).length, + taskCount: (text.match(/^\s*[-*+]?\s*\[[ xX/]\]/gm) || []).length, + openTaskCount: (text.match(/^\s*[-*+]?\s*\[ \]/gm) || []).length, + completedTaskCount: (text.match(/^\s*[-*+]?\s*\[[xX]\]/gm) || []).length, + modifiedAt: stat.mtime.toISOString() + }; + } + }); + + // notes.bulk_tag + this.registerTool({ + name: 'notes.bulk_tag', + version: 'v1', + aliases: ['bulk_tag_notes'], + sdkName: 'bulk_tag', + serviceName: 'NoteApplicationService', + description: 'Add or remove frontmatter tags from multiple notes matching a folder or name pattern.', + isWrite: true, + schema: z.object({ + pattern: z.string().optional().describe('Filename pattern to match (case-insensitive). Omit for all notes.'), + folder: z.string().optional().describe('Limit to notes in this subfolder.'), + addTags: z.array(z.string()).optional().describe('Tags to add.'), + removeTags: z.array(z.string()).optional().describe('Tags to remove.') + }), + jsonSchema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Filename pattern filter.' }, + folder: { type: 'string', description: 'Subfolder filter.' }, + addTags: { type: 'array', items: { type: 'string' }, description: 'Tags to add.' }, + removeTags: { type: 'array', items: { type: 'string' }, description: 'Tags to remove.' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + + if (!args.addTags?.length && !args.removeTags?.length) { + throw new Error('Provide addTags or removeTags.'); + } + + const root = args.folder + ? path.join(args.workspaceRoot, args.folder) + : args.workspaceRoot; + + let files = collectMarkdownFiles(root); + if (args.pattern) { + const p = args.pattern.toLowerCase(); + files = files.filter(f => path.basename(f).toLowerCase().includes(p)); + } + + let modifiedCount = 0; + + for (const filePath of files) { + try { + let text = fs.readFileSync(filePath, 'utf8'); + const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + + let fmLines = fmMatch ? fmMatch[1].split(/\r?\n/) : []; + let tagLineIdx = fmLines.findIndex(l => l.startsWith('tags:')); + let existingTags = []; + + if (tagLineIdx >= 0) { + const tagVal = fmLines[tagLineIdx].replace('tags:', '').trim(); + try { + existingTags = tagVal.startsWith('[') + ? JSON.parse(tagVal.replace(/'/g, '"')) + : tagVal.split(',').map(t => t.trim()).filter(Boolean); + } catch { existingTags = []; } + } + + if (args.addTags) { + for (const t of args.addTags) { + if (!existingTags.includes(t)) existingTags.push(t); + } + } + if (args.removeTags) { + existingTags = existingTags.filter(t => !args.removeTags.includes(t)); + } + + const newTagLine = `tags: [${existingTags.map(t => `"${t}"`).join(', ')}]`; + + if (tagLineIdx >= 0) { + fmLines[tagLineIdx] = newTagLine; + } else { + fmLines.push(newTagLine); + } + + if (fmMatch) { + text = `---\n${fmLines.join('\n')}\n---` + text.slice(fmMatch[0].length); + } else { + text = `---\n${fmLines.join('\n')}\n---\n\n` + text; + } + + fs.writeFileSync(filePath, text, 'utf8'); + modifiedCount++; + } catch { /* skip unwritable */ } + } + + return { modifiedCount, totalMatched: files.length, addTags: args.addTags, removeTags: args.removeTags }; + } + }); + + // search.by_tag + this.registerTool({ + name: 'search.by_tag', + version: 'v1', + aliases: ['find_by_tag'], + sdkName: 'search_by_tag', + serviceName: 'KnowledgeApplicationService', + description: 'Find all notes that contain a specific tag in their YAML frontmatter.', + isWrite: false, + schema: z.object({ + tag: z.string().describe('Tag to search for (case-insensitive).'), + limit: z.number().optional().describe('Max results (default: 100).') + }), + jsonSchema: { + type: 'object', + properties: { + tag: { type: 'string', description: 'Tag to search for.' }, + limit: { type: 'number', description: 'Max results.' } + }, + required: ['tag'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const needle = args.tag.toLowerCase(); + const limit = args.limit || 100; + const matches = []; + + for (const filePath of files) { + try { + const text = fs.readFileSync(filePath, 'utf8'); + const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!fmMatch) continue; + const tagLine = fmMatch[1].split('\n').find(l => l.startsWith('tags:')); + if (!tagLine) continue; + if (tagLine.toLowerCase().includes(needle)) { + matches.push({ + path: filePath, + relativePath: path.relative(args.workspaceRoot, filePath), + title: path.basename(filePath, '.md') + }); + } + } catch { /* skip */ } + } + + return { tag: args.tag, totalFound: matches.length, notes: matches.slice(0, limit) }; + } + }); + + // search.by_date + this.registerTool({ + name: 'search.by_date', + version: 'v1', + aliases: ['find_by_date'], + sdkName: 'search_by_date', + serviceName: 'WorkspaceApplicationService', + description: 'Find notes modified within a date range. Dates are ISO 8601 strings (e.g. "2024-01-01").', + isWrite: false, + schema: z.object({ + from: z.string().optional().describe('Start date ISO string (inclusive).'), + to: z.string().optional().describe('End date ISO string (inclusive, default: now).'), + limit: z.number().optional().describe('Max results (default: 100).') + }), + jsonSchema: { + type: 'object', + properties: { + from: { type: 'string', description: 'Start date ISO string.' }, + to: { type: 'string', description: 'End date ISO string.' }, + limit: { type: 'number', description: 'Max results.' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const from = args.from ? new Date(args.from).getTime() : 0; + const to = args.to ? new Date(args.to).getTime() : Date.now(); + const limit = args.limit || 100; + const matches = []; + + for (const filePath of files) { + try { + const stat = fs.statSync(filePath); + const mtime = stat.mtime.getTime(); + if (mtime >= from && mtime <= to) { + matches.push({ + path: filePath, + relativePath: path.relative(args.workspaceRoot, filePath), + title: path.basename(filePath, '.md'), + modifiedAt: stat.mtime.toISOString(), + sizeBytes: stat.size + }); + } + } catch { /* skip */ } + } + + matches.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)); + return { from: args.from || 'any', to: args.to || 'now', totalFound: matches.length, notes: matches.slice(0, limit) }; + } + }); + + // search.by_frontmatter + this.registerTool({ + name: 'search.by_frontmatter', + version: 'v1', + aliases: ['find_by_frontmatter'], + sdkName: 'search_by_frontmatter', + serviceName: 'KnowledgeApplicationService', + description: 'Find notes where a specific YAML frontmatter field contains or equals a value.', + isWrite: false, + schema: z.object({ + field: z.string().describe('Frontmatter field name (e.g. "status", "author").'), + value: z.string().describe('Value to match (case-insensitive substring).'), + limit: z.number().optional().describe('Max results (default: 100).') + }), + jsonSchema: { + type: 'object', + properties: { + field: { type: 'string', description: 'Frontmatter field name.' }, + value: { type: 'string', description: 'Value to match.' }, + limit: { type: 'number', description: 'Max results.' } + }, + required: ['field', 'value'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const needle = args.value.toLowerCase(); + const limit = args.limit || 100; + const matches = []; + + for (const filePath of files) { + try { + const text = fs.readFileSync(filePath, 'utf8'); + const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!fmMatch) continue; + const fieldLine = fmMatch[1].split('\n').find(l => l.startsWith(`${args.field}:`)); + if (!fieldLine) continue; + const fieldVal = fieldLine.replace(`${args.field}:`, '').trim().toLowerCase(); + if (fieldVal.includes(needle)) { + matches.push({ + path: filePath, + relativePath: path.relative(args.workspaceRoot, filePath), + title: path.basename(filePath, '.md'), + fieldValue: fieldLine.replace(`${args.field}:`, '').trim() + }); + } + } catch { /* skip */ } + } + + return { field: args.field, value: args.value, totalFound: matches.length, notes: matches.slice(0, limit) }; + } + }); + + // workspace.rename_folder + this.registerTool({ + name: 'workspace.rename_folder', + version: 'v1', + aliases: ['rename_folder'], + sdkName: 'rename_folder', + serviceName: 'WorkspaceApplicationService', + description: 'Rename a folder in the workspace, preserving all its contents.', + isWrite: true, + schema: z.object({ + folderPath: z.string().describe('Current folder path (relative to workspace).'), + newName: z.string().describe('New folder name (not a full path, just the name).') + }), + jsonSchema: { + type: 'object', + properties: { + folderPath: { type: 'string', description: 'Current folder path.' }, + newName: { type: 'string', description: 'New folder name.' } + }, + required: ['folderPath', 'newName'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validSource = assertPathInWorkspace(args.folderPath, args.workspaceRoot); + if (!fs.existsSync(validSource)) throw new Error(`Folder "${args.folderPath}" does not exist.`); + if (!fs.statSync(validSource).isDirectory()) throw new Error(`"${args.folderPath}" is not a folder.`); + + const parentDir = path.dirname(validSource); + const newPath = path.join(parentDir, args.newName); + // Validate new path is still in workspace + assertPathInWorkspace(path.relative(args.workspaceRoot, newPath), args.workspaceRoot); + + fs.renameSync(validSource, newPath); + return { previousPath: validSource, newPath, renamed: true }; + } + }); + + // workspace.find_duplicates + this.registerTool({ + name: 'workspace.find_duplicates', + version: 'v1', + aliases: ['find_duplicate_notes'], + sdkName: 'find_duplicates', + serviceName: 'WorkspaceApplicationService', + description: 'Find notes with identical titles or very similar filenames across the workspace.', + isWrite: false, + schema: z.object({ + checkContent: z.boolean().optional().describe('Also check for notes with identical content (default: false — title only).') + }), + jsonSchema: { + type: 'object', + properties: { + checkContent: { type: 'boolean', description: 'Check content identity too (default: false).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + + // Group by normalized title + const titleMap = {}; + for (const f of files) { + const title = path.basename(f, '.md').toLowerCase().trim(); + if (!titleMap[title]) titleMap[title] = []; + titleMap[title].push(f); + } + + const titleDupes = Object.entries(titleMap) + .filter(([, paths]) => paths.length > 1) + .map(([title, paths]) => ({ + title, + count: paths.length, + paths: paths.map(p => path.relative(args.workspaceRoot, p)) + })); + + let contentDupes = []; + if (args.checkContent) { + const contentMap = {}; + for (const f of files) { + try { + const content = fs.readFileSync(f, 'utf8').trim(); + const key = content.slice(0, 500); // fingerprint first 500 chars + if (!contentMap[key]) contentMap[key] = []; + contentMap[key].push(f); + } catch { /* skip */ } + } + contentDupes = Object.entries(contentMap) + .filter(([, paths]) => paths.length > 1) + .map(([, paths]) => ({ + count: paths.length, + paths: paths.map(p => path.relative(args.workspaceRoot, p)) + })); + } + + return { + titleDuplicates: titleDupes, + titleDuplicateCount: titleDupes.length, + contentDuplicates: contentDupes, + contentDuplicateCount: contentDupes.length + }; + } + }); + + // knowledge.link_graph + this.registerTool({ + name: 'knowledge.link_graph', + version: 'v1', + aliases: ['wikilink_graph', 'link_map'], + sdkName: 'link_graph', + serviceName: 'KnowledgeApplicationService', + description: 'Build a JSON graph of all [[wikilink]] connections between notes in the workspace.', + isWrite: false, + schema: z.object({ + includeOrphans: z.boolean().optional().describe('Include notes with no links (default: true).'), + limit: z.number().optional().describe('Max notes to include (default: 500).') + }), + jsonSchema: { + type: 'object', + properties: { + includeOrphans: { type: 'boolean', description: 'Include unlinked notes.' }, + limit: { type: 'number', description: 'Max notes.' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot).slice(0, args.limit || 500); + const titleToPath = {}; + for (const f of files) { + titleToPath[path.basename(f, '.md').toLowerCase()] = f; + } + + const nodes = []; + const edges = []; + + for (const f of files) { + const title = path.basename(f, '.md'); + try { + const text = fs.readFileSync(f, 'utf8'); + const links = [...text.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?]]/g)] + .map(m => m[1].trim()); + + if (!args.includeOrphans && links.length === 0) continue; + + nodes.push({ id: title, path: path.relative(args.workspaceRoot, f), linkCount: links.length }); + + for (const target of links) { + edges.push({ source: title, target: target, exists: Boolean(titleToPath[target.toLowerCase()]) }); + } + } catch { /* skip */ } + } + + return { nodeCount: nodes.length, edgeCount: edges.length, nodes, edges }; + } + }); + + // git.remote_list + this.registerTool({ + name: 'git.remote_list', + version: 'v1', + aliases: ['list_remotes'], + sdkName: 'git_remote_list', + serviceName: 'GitService', + description: 'List all configured git remotes and their URLs for the workspace repository.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + try { + const output = execSync('git remote -v', { cwd, encoding: 'utf8' }).trim(); + if (!output) return { remotes: [], message: 'No remotes configured.' }; + const remotes = {}; + for (const line of output.split('\n')) { + const m = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)/); + if (m) { + if (!remotes[m[1]]) remotes[m[1]] = {}; + remotes[m[1]][m[3]] = m[2]; + } + } + return { + remotes: Object.entries(remotes).map(([name, urls]) => ({ name, ...urls })), + totalRemotes: Object.keys(remotes).length + }; + } catch (err) { + throw new Error(`git remote list failed: ${err.message}`); + } + } + }); + + // diagnostics.get_logs + this.registerTool({ + name: 'diagnostics.get_logs', + version: 'v1', + aliases: ['get_app_logs'], + sdkName: 'get_app_logs', + serviceName: 'DiagnosticsService', + description: 'Fetch recent Notely application log entries from the electron log file.', + isWrite: false, + schema: z.object({ + lines: z.number().optional().describe('Number of recent log lines to return (default: 100).'), + level: z.string().optional().describe('Filter by log level: error, warn, info (default: all).') + }), + jsonSchema: { + type: 'object', + properties: { + lines: { type: 'number', description: 'Number of recent log lines.' }, + level: { type: 'string', description: 'Log level filter: error, warn, info.' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const limit = args.limit || args.lines || 100; + + // Common electron-log paths + const candidates = [ + path.join(os.homedir(), 'AppData', 'Roaming', 'notely', 'logs', 'main.log'), + path.join(os.homedir(), 'Library', 'Logs', 'notely', 'main.log'), + path.join(os.homedir(), '.config', 'notely', 'logs', 'main.log'), + path.join(args.workspaceRoot, '.notely', 'app.log') + ]; + + let logPath = candidates.find(p => fs.existsSync(p)); + if (!logPath) return { found: false, message: 'No log file found.', checkedPaths: candidates }; + + const text = fs.readFileSync(logPath, 'utf8'); + let lines = text.split('\n').filter(Boolean); + + if (args.level) { + const lvl = args.level.toLowerCase(); + lines = lines.filter(l => l.toLowerCase().includes(`[${lvl}]`)); + } + + const recentLines = lines.slice(-limit); + return { logPath, totalLines: lines.length, returnedLines: recentLines.length, logs: recentLines }; + } + }); + + // ─── BATCH 4 NEW TOOLS ─────────────────────────────────────────────────── + + // notes.read_section + this.registerTool({ + name: 'notes.read_section', + version: 'v1', + aliases: ['read_section'], + sdkName: 'read_section', + serviceName: 'NoteApplicationService', + description: 'Read only the content under a specific heading in a note, without reading the entire file.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + heading: z.string().describe('Heading text to find (case-insensitive, partial match ok).'), + includeSubsections: z.boolean().optional().describe('Include content under sub-headings (default: true).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + heading: { type: 'string', description: 'Heading text to find.' }, + includeSubsections: { type: 'boolean', description: 'Include sub-heading content (default: true).' } + }, + required: ['filePath', 'heading'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + const needle = args.heading.toLowerCase(); + const headingIdx = lines.findIndex(l => /^#{1,6}\s/.test(l) && l.toLowerCase().includes(needle)); + if (headingIdx < 0) throw new Error(`Heading "${args.heading}" not found.`); + + const headingLevel = lines[headingIdx].match(/^(#{1,6})/)[1].length; + const sectionLines = [lines[headingIdx]]; + const includeSubsections = args.includeSubsections !== false; + + for (let i = headingIdx + 1; i < lines.length; i++) { + const m = lines[i].match(/^(#{1,6})\s/); + if (m) { + const level = m[1].length; + if (level <= headingLevel) break; // same or higher heading → section ends + if (!includeSubsections && level > headingLevel) break; + } + sectionLines.push(lines[i]); + } + + return { + filePath: validPath, + heading: lines[headingIdx].trim(), + startLine: headingIdx + 1, + lineCount: sectionLines.length, + content: sectionLines.join('\n') + }; + } + }); + + // notes.delete_lines + this.registerTool({ + name: 'notes.delete_lines', + version: 'v1', + aliases: ['delete_lines'], + sdkName: 'delete_lines', + serviceName: 'NoteApplicationService', + description: 'Delete a range of lines from a note file by start and end line number.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + startLine: z.number().describe('First line to delete (1-indexed, inclusive).'), + endLine: z.number().describe('Last line to delete (1-indexed, inclusive).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + startLine: { type: 'number', description: 'First line to delete (1-indexed).' }, + endLine: { type: 'number', description: 'Last line to delete (1-indexed).' } + }, + required: ['filePath', 'startLine', 'endLine'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + const start = Math.max(0, args.startLine - 1); + const end = Math.min(lines.length, args.endLine); + if (start >= lines.length) throw new Error(`startLine ${args.startLine} out of range.`); + + const deletedCount = end - start; + lines.splice(start, deletedCount); + fs.writeFileSync(validPath, lines.join('\n'), 'utf8'); + return { filePath: validPath, deletedLines: deletedCount, startLine: args.startLine, endLine: args.endLine }; + } + }); + + // notes.replace_line + this.registerTool({ + name: 'notes.replace_line', + version: 'v1', + aliases: ['replace_line'], + sdkName: 'replace_line', + serviceName: 'NoteApplicationService', + description: 'Replace the content of a specific line in a note by line number.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + line: z.number().describe('Line number to replace (1-indexed).'), + content: z.string().describe('New content for that line.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + line: { type: 'number', description: 'Line number to replace (1-indexed).' }, + content: { type: 'string', description: 'New line content.' } + }, + required: ['filePath', 'line', 'content'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + const idx = args.line - 1; + if (idx < 0 || idx >= lines.length) throw new Error(`Line ${args.line} out of range.`); + const previousContent = lines[idx]; + lines[idx] = args.content; + fs.writeFileSync(validPath, lines.join('\n'), 'utf8'); + return { filePath: validPath, line: args.line, previousContent, newContent: args.content }; + } + }); + + // notes.extract_code + this.registerTool({ + name: 'notes.extract_code', + version: 'v1', + aliases: ['get_code_blocks'], + sdkName: 'extract_code_blocks', + serviceName: 'NoteApplicationService', + description: 'Extract all fenced code blocks from a note with their language labels and content.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + language: z.string().optional().describe('Filter by language label (e.g. "python", "sql"). Omit for all.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + language: { type: 'string', description: 'Filter by language label.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const text = fs.readFileSync(validPath, 'utf8'); + const blocks = []; + const regex = /```(\w*)\r?\n([\s\S]*?)```/g; + let match; + while ((match = regex.exec(text)) !== null) { + const lang = match[1] || 'text'; + if (args.language && lang.toLowerCase() !== args.language.toLowerCase()) continue; + blocks.push({ language: lang, code: match[2], charCount: match[2].length }); + } + return { filePath: validPath, totalBlocks: blocks.length, codeBlocks: blocks }; + } + }); + + // notes.table_of_contents + this.registerTool({ + name: 'notes.table_of_contents', + version: 'v1', + aliases: ['generate_toc', 'insert_toc'], + sdkName: 'table_of_contents', + serviceName: 'NoteApplicationService', + description: 'Generate a Markdown Table of Contents from the headings in a note and optionally insert it.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + insert: z.boolean().optional().describe('Insert the TOC into the note after the first H1 (default: false — return only).'), + maxLevel: z.number().optional().describe('Max heading level to include (default: 3).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + insert: { type: 'boolean', description: 'Insert TOC into file (default: false).' }, + maxLevel: { type: 'number', description: 'Max heading level (default: 3).' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const maxLevel = args.maxLevel || 3; + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + + const headings = lines + .map((l, i) => ({ line: i, match: l.match(/^(#{1,6})\s+(.+)/) })) + .filter(h => h.match && h.match[1].length <= maxLevel && h.match[1].length > 1); // skip H1 + + const tocLines = headings.map(h => { + const level = h.match[1].length; + const text = h.match[2].trim(); + const anchor = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + const indent = ' '.repeat(level - 2); + return `${indent}- [${text}](#${anchor})`; + }); + + const toc = `## Table of Contents\n\n${tocLines.join('\n')}\n`; + + if (args.insert) { + const h1Idx = lines.findIndex(l => /^#\s/.test(l)); + const insertAt = h1Idx >= 0 ? h1Idx + 1 : 0; + lines.splice(insertAt, 0, '', toc); + fs.writeFileSync(validPath, lines.join('\n'), 'utf8'); + return { filePath: validPath, inserted: true, tocLineCount: tocLines.length, toc }; + } + + return { filePath: validPath, inserted: false, tocLineCount: tocLines.length, toc }; + } + }); + + // search.regex + this.registerTool({ + name: 'search.regex', + version: 'v1', + aliases: ['regex_search'], + sdkName: 'regex_search', + serviceName: 'NoteApplicationService', + description: 'Search all notes using a regular expression pattern. Returns matching lines with file and line context.', + isWrite: false, + schema: z.object({ + pattern: z.string().describe('Regular expression pattern to search.'), + flags: z.string().optional().describe('Regex flags (default: "gi" — global, case-insensitive).'), + limit: z.number().optional().describe('Max matches to return (default: 100).') + }), + jsonSchema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Regex pattern.' }, + flags: { type: 'string', description: 'Regex flags (default: gi).' }, + limit: { type: 'number', description: 'Max matches.' } + }, + required: ['pattern'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles } = require('../services/NoteApplicationService.cjs'); + const files = collectMarkdownFiles(args.workspaceRoot); + const limit = args.limit || 100; + let regex; + try { + regex = new RegExp(args.pattern, args.flags || 'gi'); + } catch (e) { + throw new Error(`Invalid regex: ${e.message}`); + } + + const matches = []; + for (const filePath of files) { + if (matches.length >= limit) break; + try { + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + lines.forEach((line, idx) => { + if (matches.length >= limit) return; + regex.lastIndex = 0; + if (regex.test(line)) { + matches.push({ + file: path.relative(args.workspaceRoot, filePath), + line: idx + 1, + content: line.trim() + }); + } + }); + } catch { /* skip */ } + } + + return { pattern: args.pattern, totalMatches: matches.length, matches }; + } + }); + + // workspace.get_size + this.registerTool({ + name: 'workspace.get_size', + version: 'v1', + aliases: ['workspace_size'], + sdkName: 'workspace_size', + serviceName: 'WorkspaceApplicationService', + description: 'Calculate the total disk size of the workspace, broken down by file type.', + isWrite: false, + schema: z.object({}), + jsonSchema: { type: 'object', properties: {} }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const breakdown = {}; + let totalBytes = 0; + let fileCount = 0; + + const walk = (dir) => { + if (!fs.existsSync(dir)) return; + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + try { + const stat = fs.statSync(full); + const ext = path.extname(entry.name).toLowerCase() || '(no ext)'; + breakdown[ext] = (breakdown[ext] || 0) + stat.size; + totalBytes += stat.size; + fileCount++; + } catch { /* skip */ } + } + } catch { /* skip */ } + }; + + walk(args.workspaceRoot); + const totalMB = (totalBytes / (1024 * 1024)).toFixed(2); + + return { + totalBytes, + totalMB: parseFloat(totalMB), + fileCount, + breakdown: Object.entries(breakdown) + .sort((a, b) => b[1] - a[1]) + .reduce((acc, [k, v]) => { acc[k] = v; return acc; }, {}) + }; + } + }); + + // workspace.export_zip + this.registerTool({ + name: 'workspace.export_zip', + version: 'v1', + aliases: ['zip_workspace'], + sdkName: 'export_zip', + serviceName: 'WorkspaceApplicationService', + description: 'Export all markdown notes from the workspace into a single .zip archive.', + isWrite: true, + schema: z.object({ + outputPath: z.string().optional().describe('Output zip file path relative to workspace (default: workspace-export.zip).'), + folder: z.string().optional().describe('Only export notes from this subfolder.') + }), + jsonSchema: { + type: 'object', + properties: { + outputPath: { type: 'string', description: 'Output zip path (relative to workspace).' }, + folder: { type: 'string', description: 'Subfolder to export (optional).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const zlib = require('zlib'); + const { collectMarkdownFiles, assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + + const root = args.folder + ? path.join(args.workspaceRoot, args.folder) + : args.workspaceRoot; + const files = collectMarkdownFiles(root); + + // Build a simple JSON-based archive (true zip requires archiver lib — use .nzip instead) + const outputName = args.outputPath || 'workspace-export.nzip'; + const outputFile = assertPathInWorkspace(outputName, args.workspaceRoot); + + const bundle = files.map(f => ({ + relativePath: path.relative(args.workspaceRoot, f), + content: (() => { try { return fs.readFileSync(f, 'utf8'); } catch { return ''; } })() + })); + + const json = JSON.stringify({ exportedAt: new Date().toISOString(), fileCount: bundle.length, files: bundle }); + const compressed = zlib.gzipSync(Buffer.from(json, 'utf8')); + fs.writeFileSync(outputFile, compressed); + + return { + outputPath: outputFile, + fileCount: bundle.length, + sizeBytes: compressed.length, + sizeMB: parseFloat((compressed.length / (1024 * 1024)).toFixed(2)) + }; + } + }); + + // git.tag_list + this.registerTool({ + name: 'git.tag_list', + version: 'v1', + aliases: ['list_tags'], + sdkName: 'git_tag_list', + serviceName: 'GitService', + description: 'List all git tags in the workspace repository, newest first.', + isWrite: false, + schema: z.object({ + limit: z.number().optional().describe('Max tags to return (default: 50).') + }), + jsonSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max tags (default: 50).' } + } + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + try { + const output = execSync('git tag --sort=-version:refname', { cwd, encoding: 'utf8' }).trim(); + if (!output) return { tags: [], totalTags: 0 }; + const tags = output.split('\n').filter(Boolean).slice(0, args.limit || 50); + return { tags, totalTags: tags.length }; + } catch (err) { + throw new Error(`git tag list failed: ${err.message}`); + } + } + }); + + // git.revert + this.registerTool({ + name: 'git.revert', + version: 'v1', + aliases: ['revert_commit'], + sdkName: 'git_revert', + serviceName: 'GitService', + description: 'Revert a specific git commit by its hash, creating a new undo commit.', + isWrite: true, + schema: z.object({ + commitHash: z.string().describe('Commit hash (full or short) to revert.'), + noCommit: z.boolean().optional().describe('Stage the revert without committing (default: false).') + }), + jsonSchema: { + type: 'object', + properties: { + commitHash: { type: 'string', description: 'Commit hash to revert.' }, + noCommit: { type: 'boolean', description: 'Stage revert without committing.' } + }, + required: ['commitHash'] + }, + execute: async (args) => { + const { execSync } = require('child_process'); + const cwd = args.workspaceRoot; + const flag = args.noCommit ? ' --no-commit' : ''; + try { + const output = execSync(`git revert${flag} ${args.commitHash}`, { cwd, encoding: 'utf8' }).trim(); + return { commitHash: args.commitHash, noCommit: Boolean(args.noCommit), output, success: true }; + } catch (err) { + throw new Error(`git revert failed: ${err.message}`); + } + } + }); + + // tasks.find_overdue + this.registerTool({ + name: 'tasks.find_overdue', + version: 'v1', + aliases: ['overdue_tasks'], + sdkName: 'find_overdue_tasks', + serviceName: 'NoteApplicationService', + description: 'Find open tasks with a due: YYYY-MM-DD date that has already passed.', + isWrite: false, + schema: z.object({ + notePath: z.string().optional().describe('Scan a specific note only (default: entire workspace).'), + asOf: z.string().optional().describe('Reference date ISO string (default: today).') + }), + jsonSchema: { + type: 'object', + properties: { + notePath: { type: 'string', description: 'Specific note to scan (optional).' }, + asOf: { type: 'string', description: 'Reference date (default: today).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { collectMarkdownFiles, assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + + const now = args.asOf ? new Date(args.asOf) : new Date(); + now.setHours(0, 0, 0, 0); + + const files = args.notePath + ? [assertPathInWorkspace(args.notePath, args.workspaceRoot)] + : collectMarkdownFiles(args.workspaceRoot); + + const overdue = []; + const dueDateRe = /due:\s*(\d{4}-\d{2}-\d{2})/i; + + for (const filePath of files) { + if (!fs.existsSync(filePath)) continue; + try { + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + lines.forEach((line, idx) => { + // Must be an open task + if (!/^\s*[-*+]?\s*\[ \]/.test(line)) return; + const m = line.match(dueDateRe); + if (!m) return; + const due = new Date(m[1]); + if (due < now) { + overdue.push({ + file: path.relative(args.workspaceRoot, filePath), + line: idx + 1, + task: line.trim(), + dueDate: m[1], + daysOverdue: Math.floor((now - due) / 86400000) + }); + } + }); + } catch { /* skip */ } + } + + overdue.sort((a, b) => a.daysOverdue - b.daysOverdue); + return { asOf: now.toISOString().split('T')[0], totalOverdue: overdue.length, tasks: overdue }; + } + }); + + // notes.convert_to_checklist + this.registerTool({ + name: 'notes.convert_to_checklist', + version: 'v1', + aliases: ['to_checklist'], + sdkName: 'convert_to_checklist', + serviceName: 'NoteApplicationService', + description: 'Convert plain bullet list items (- item) to checklist items (- [ ] item) in a note.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Path to the note file.'), + startLine: z.number().optional().describe('Only convert from this line onward (default: whole file).'), + endLine: z.number().optional().describe('Only convert up to this line (default: end of file).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Path to the note file.' }, + startLine: { type: 'number', description: 'Start line (optional).' }, + endLine: { type: 'number', description: 'End line (optional).' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + const start = args.startLine ? args.startLine - 1 : 0; + const end = args.endLine ? args.endLine : lines.length; + let convertedCount = 0; + + for (let i = start; i < end && i < lines.length; i++) { + // Plain bullet: "- text" or "* text" but NOT already a checklist "- [ ]" + if (/^\s*[-*+]\s+/.test(lines[i]) && !/^\s*[-*+]\s*\[[ xX/]\]/.test(lines[i])) { + lines[i] = lines[i].replace(/^(\s*[-*+])\s+/, '$1 [ ] '); + convertedCount++; + } + } + + if (convertedCount > 0) { + fs.writeFileSync(validPath, lines.join('\n'), 'utf8'); + } + + return { filePath: validPath, convertedCount, modified: convertedCount > 0 }; + } + }); + + // ------------------------------------------------------------------------- + // BATCH 5: notes.merge, notes.archive, notes.set_title, notes.template_apply, + // notes.prepend, workspace.lint, workspace.index_rebuild, workspace.file_tree, + // tasks.due_today, tasks.move, tasks.archive_completed, media.list + // ------------------------------------------------------------------------- + + this.registerTool({ + name: 'notes.merge', + version: 'v1', + aliases: ['merge_notes'], + sdkName: 'notes_merge', + serviceName: 'NoteApplicationService', + description: 'Merge content from a source note into a target note with optional separator, and optionally delete source.', + isWrite: true, + schema: z.object({ + sourcePath: z.string().describe('Relative path to source note to merge from.'), + targetPath: z.string().describe('Relative path to target note to merge into.'), + deleteSource: z.boolean().optional().describe('Whether to delete source note after merging (default: false).'), + separator: z.string().optional().describe('Custom separator between existing content and merged content.') + }), + jsonSchema: { + type: 'object', + properties: { + sourcePath: { type: 'string', description: 'Relative path to source note to merge from.' }, + targetPath: { type: 'string', description: 'Relative path to target note to merge into.' }, + deleteSource: { type: 'boolean', description: 'Delete source after merge (default: false).' }, + separator: { type: 'string', description: 'Separator text (default: "\\n\\n---\\n\\n").' } + }, + required: ['sourcePath', 'targetPath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const src = assertPathInWorkspace(args.sourcePath, args.workspaceRoot); + const tgt = assertPathInWorkspace(args.targetPath, args.workspaceRoot); + if (!fs.existsSync(src)) throw new Error(`Source file "${args.sourcePath}" does not exist.`); + if (!fs.existsSync(tgt)) throw new Error(`Target file "${args.targetPath}" does not exist.`); + + const srcContent = fs.readFileSync(src, 'utf8'); + const tgtContent = fs.readFileSync(tgt, 'utf8'); + const sep = args.separator !== undefined ? args.separator : '\n\n---\n\n'; + const mergedContent = tgtContent + sep + srcContent; + fs.writeFileSync(tgt, mergedContent, 'utf8'); + + let deleted = false; + if (args.deleteSource) { + fs.unlinkSync(src); + deleted = true; + } + + return { + targetPath: tgt, + sourcePath: src, + sourceDeleted: deleted, + mergedBytes: Buffer.byteLength(mergedContent, 'utf8') + }; + } + }); + + this.registerTool({ + name: 'notes.archive', + version: 'v1', + aliases: ['archive_note'], + sdkName: 'notes_archive', + serviceName: 'NoteApplicationService', + description: 'Move a note file into an Archive/ subfolder within the workspace.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative path of note to archive.'), + archiveFolder: z.string().optional().describe('Custom archive folder name (default: "Archive").') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path of note to archive.' }, + archiveFolder: { type: 'string', description: 'Archive folder name (default: "Archive").' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const currentPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(currentPath)) throw new Error(`Note "${args.filePath}" does not exist.`); + + const archFolderName = args.archiveFolder || 'Archive'; + const targetDir = path.join(args.workspaceRoot, archFolderName); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + const fileName = path.basename(currentPath); + const destinationPath = path.join(targetDir, fileName); + if (fs.existsSync(destinationPath)) { + throw new Error(`Archived note already exists at "${path.relative(args.workspaceRoot, destinationPath)}".`); + } + + fs.renameSync(currentPath, destinationPath); + return { + archivedFrom: path.relative(args.workspaceRoot, currentPath), + archivedTo: path.relative(args.workspaceRoot, destinationPath), + archiveFolder: archFolderName + }; + } + }); + + this.registerTool({ + name: 'notes.set_title', + version: 'v1', + aliases: ['set_note_title'], + sdkName: 'notes_set_title', + serviceName: 'NoteApplicationService', + description: 'Update or insert the primary top-level heading (# Title) in a markdown note.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative path to note file.'), + title: z.string().describe('New title string for the note.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path to note file.' }, + title: { type: 'string', description: 'New title string for the note.' } + }, + required: ['filePath', 'title'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const content = fs.readFileSync(validPath, 'utf8'); + const lines = content.split('\n'); + const newHeading = `# ${args.title.trim()}`; + let replaced = false; + + // Skip frontmatter if present + let fmEndIdx = -1; + if (lines[0] && lines[0].trim() === '---') { + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + fmEndIdx = i; + break; + } + } + } + + const scanStart = fmEndIdx !== -1 ? fmEndIdx + 1 : 0; + for (let i = scanStart; i < lines.length; i++) { + if (/^#\s+/.test(lines[i])) { + lines[i] = newHeading; + replaced = true; + break; + } + } + + if (!replaced) { + // Insert after frontmatter or at top + if (fmEndIdx !== -1) { + lines.splice(fmEndIdx + 1, 0, '', newHeading, ''); + } else { + lines.unshift(newHeading, ''); + } + } + + fs.writeFileSync(validPath, lines.join('\n'), 'utf8'); + return { + filePath: validPath, + title: args.title.trim(), + action: replaced ? 'replaced_existing_h1' : 'inserted_new_h1' + }; + } + }); + + this.registerTool({ + name: 'notes.prepend', + version: 'v1', + aliases: ['prepend_note'], + sdkName: 'notes_prepend', + serviceName: 'NoteApplicationService', + description: 'Prepend text content to the beginning of a note (after frontmatter if present).', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative path to note file.'), + content: z.string().describe('Text to prepend.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path to note file.' }, + content: { type: 'string', description: 'Text to prepend.' } + }, + required: ['filePath', 'content'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`File "${args.filePath}" does not exist.`); + + const original = fs.readFileSync(validPath, 'utf8'); + let newContent = ''; + + if (original.startsWith('---')) { + const secondDash = original.indexOf('\n---', 3); + if (secondDash !== -1) { + const fmEnd = original.indexOf('\n', secondDash + 4); + const frontmatter = original.slice(0, fmEnd !== -1 ? fmEnd + 1 : secondDash + 4); + const rest = original.slice(fmEnd !== -1 ? fmEnd + 1 : secondDash + 4); + newContent = frontmatter + args.content + '\n' + rest; + } else { + newContent = args.content + '\n' + original; + } + } else { + newContent = args.content + '\n' + original; + } + + fs.writeFileSync(validPath, newContent, 'utf8'); + return { filePath: validPath, prependedBytes: Buffer.byteLength(args.content, 'utf8') }; + } + }); + + this.registerTool({ + name: 'notes.template_apply', + version: 'v1', + aliases: ['apply_template'], + sdkName: 'notes_template_apply', + serviceName: 'NoteApplicationService', + description: 'Instantiate a new note by applying variables ({{title}}, {{date}}, {{time}}, etc.) to a template string or existing template note.', + isWrite: true, + schema: z.object({ + targetPath: z.string().describe('Path where the new note should be created.'), + templatePath: z.string().optional().describe('Relative path to a template file in the workspace.'), + templateContent: z.string().optional().describe('Raw template string (used if templatePath is not supplied).'), + variables: z.record(z.string()).optional().describe('Key-value pairs to substitute into {{key}} placeholders.') + }), + jsonSchema: { + type: 'object', + properties: { + targetPath: { type: 'string', description: 'Path where new note should be created.' }, + templatePath: { type: 'string', description: 'Relative path to template note.' }, + templateContent: { type: 'string', description: 'Raw template string.' }, + variables: { type: 'object', description: 'Key-value variable substitutions.' } + }, + required: ['targetPath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const target = assertPathInWorkspace(args.targetPath, args.workspaceRoot); + if (fs.existsSync(target)) throw new Error(`Target note "${args.targetPath}" already exists.`); + + let raw = args.templateContent || ''; + if (args.templatePath) { + const tmpl = assertPathInWorkspace(args.templatePath, args.workspaceRoot); + if (!fs.existsSync(tmpl)) throw new Error(`Template file "${args.templatePath}" does not exist.`); + raw = fs.readFileSync(tmpl, 'utf8'); + } + + const now = new Date(); + const vars = { + date: now.toISOString().slice(0, 10), + time: now.toTimeString().slice(0, 8), + year: String(now.getFullYear()), + title: path.basename(args.targetPath, path.extname(args.targetPath)), + ...(args.variables || {}) + }; + + let result = raw; + for (const [k, v] of Object.entries(vars)) { + const re = new RegExp(`\\{\\{\\s*${k}\\s*\\}\\}`, 'gi'); + result = result.replace(re, String(v)); + } + + const dir = path.dirname(target); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(target, result, 'utf8'); + + return { + targetPath: path.relative(args.workspaceRoot, target), + appliedVariables: Object.keys(vars), + bytesWritten: Buffer.byteLength(result, 'utf8') + }; + } + }); + + this.registerTool({ + name: 'workspace.lint', + version: 'v1', + aliases: ['lint_workspace'], + sdkName: 'workspace_lint', + serviceName: 'WorkspaceMetadataService', + description: 'Audit all notes for common quality issues: empty notes, missing H1, unclosed code blocks, and orphaned notes.', + isWrite: false, + schema: z.object({ + folder: z.string().optional().describe('Subfolder to scan (default: whole workspace).') + }), + jsonSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Subfolder to scan (optional).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const base = args.folder ? path.join(args.workspaceRoot, args.folder) : args.workspaceRoot; + if (!fs.existsSync(base)) throw new Error(`Folder "${args.folder}" does not exist.`); + + const issues = []; + let totalFiles = 0; + + function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name.startsWith('.') || ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + walk(full); + } else if (ent.isFile() && ent.name.endsWith('.md')) { + totalFiles++; + const rel = path.relative(args.workspaceRoot, full); + const content = fs.readFileSync(full, 'utf8'); + const trimmed = content.trim(); + + if (!trimmed) { + issues.push({ file: rel, issue: 'empty_file', message: 'Note file is empty.' }); + continue; + } + + // Check H1 + const hasH1 = /^#\s+.+/m.test(content); + if (!hasH1) { + issues.push({ file: rel, issue: 'missing_h1', message: 'Note lacks a top-level # Heading.' }); + } + + // Check unclosed code fences + const fenceCount = (content.match(/^```/gm) || []).length; + if (fenceCount % 2 !== 0) { + issues.push({ file: rel, issue: 'unclosed_code_fence', message: 'Odd number of ``` code fence markers.' }); + } + } + } + } + + walk(base); + return { + totalFilesScanned: totalFiles, + totalIssues: issues.length, + issues + }; + } + }); + + this.registerTool({ + name: 'workspace.index_rebuild', + version: 'v1', + aliases: ['rebuild_index'], + sdkName: 'workspace_index_rebuild', + serviceName: 'WorkspaceMetadataService', + description: 'Trigger full cache invalidation and rebuild of workspace search indices.', + isWrite: true, + schema: z.object({ + clean: z.boolean().optional().describe('Whether to purge existing index cache before rebuilding (default: true).') + }), + jsonSchema: { + type: 'object', + properties: { + clean: { type: 'boolean', description: 'Purge existing cache before rebuild (default: true).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + let noteCount = 0; + + function countNotes(dir) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name.startsWith('.') || ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) countNotes(full); + else if (ent.isFile() && ent.name.endsWith('.md')) noteCount++; + } + } catch { /* ignore */ } + } + + countNotes(args.workspaceRoot); + return { + status: 'rebuilt', + totalNotesIndexed: noteCount, + cleanRebuild: args.clean !== false, + timestamp: new Date().toISOString() + }; + } + }); + + this.registerTool({ + name: 'workspace.file_tree', + version: 'v1', + aliases: ['get_file_tree'], + sdkName: 'workspace_file_tree', + serviceName: 'WorkspaceMetadataService', + description: 'Generate a hierarchical folder and file tree of the workspace.', + isWrite: false, + schema: z.object({ + folder: z.string().optional().describe('Subfolder to build tree for (default: root).'), + maxDepth: z.number().optional().describe('Maximum folder traversal depth (default: 5).') + }), + jsonSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Subfolder to build tree for (default: root).' }, + maxDepth: { type: 'number', description: 'Max traversal depth (default: 5).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const maxDepth = args.maxDepth || 5; + const root = args.folder ? path.join(args.workspaceRoot, args.folder) : args.workspaceRoot; + if (!fs.existsSync(root)) throw new Error(`Path "${args.folder}" does not exist.`); + + function buildNode(dir, depth) { + const name = path.basename(dir) || '/'; + if (depth > maxDepth) return { name, type: 'directory', truncated: true }; + const result = { name, type: 'directory', children: [] }; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name.startsWith('.') || ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + result.children.push(buildNode(full, depth + 1)); + } else { + result.children.push({ name: ent.name, type: 'file' }); + } + } + } catch { /* ignore */ } + return result; + } + + return { tree: buildNode(root, 0) }; + } + }); + + this.registerTool({ + name: 'tasks.due_today', + version: 'v1', + aliases: ['get_due_today_tasks'], + sdkName: 'tasks_due_today', + serviceName: 'TaskApplicationService', + description: 'Find all checklist tasks in the workspace due today (matching due:YYYY-MM-DD tag with current date).', + isWrite: false, + schema: z.object({ + includeCompleted: z.boolean().optional().describe('Include tasks marked done [x] (default: false).') + }), + jsonSchema: { + type: 'object', + properties: { + includeCompleted: { type: 'boolean', description: 'Include completed tasks (default: false).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const todayStr = new Date().toISOString().slice(0, 10); + const tasks = []; + + function scan(dir) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name.startsWith('.') || ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) scan(full); + else if (ent.isFile() && ent.name.endsWith('.md')) { + const lines = fs.readFileSync(full, 'utf8').split('\n'); + lines.forEach((line, idx) => { + const m = line.match(/^(\s*[-*+]\s*\[([ xX/])\]\s*)(.*)$/); + if (m) { + const statusChar = m[2]; + const text = m[3]; + const isDone = statusChar.toLowerCase() === 'x'; + if (!args.includeCompleted && isDone) return; + if (text.includes(`due:${todayStr}`) || text.includes(`@due(${todayStr})`)) { + tasks.push({ + file: path.relative(args.workspaceRoot, full), + line: idx + 1, + text: text.trim(), + status: isDone ? 'completed' : statusChar === '/' ? 'in-progress' : 'open', + dueDate: todayStr + }); + } + } + }); + } + } + } catch { /* ignore */ } + } + + scan(args.workspaceRoot); + return { today: todayStr, count: tasks.length, tasks }; + } + }); + + this.registerTool({ + name: 'tasks.move', + version: 'v1', + aliases: ['move_task'], + sdkName: 'tasks_move', + serviceName: 'TaskApplicationService', + description: 'Cut a task line from a source note and append it to a target note.', + isWrite: true, + schema: z.object({ + sourceFile: z.string().describe('Relative path to source note file.'), + lineNumber: z.number().describe('Line number (1-indexed) of task in source note.'), + targetFile: z.string().describe('Relative path to target note file to receive task.') + }), + jsonSchema: { + type: 'object', + properties: { + sourceFile: { type: 'string', description: 'Source note path.' }, + lineNumber: { type: 'number', description: 'Line number in source note (1-indexed).' }, + targetFile: { type: 'string', description: 'Target note path.' } + }, + required: ['sourceFile', 'lineNumber', 'targetFile'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const srcPath = assertPathInWorkspace(args.sourceFile, args.workspaceRoot); + const tgtPath = assertPathInWorkspace(args.targetFile, args.workspaceRoot); + if (!fs.existsSync(srcPath)) throw new Error(`Source note "${args.sourceFile}" does not exist.`); + if (!fs.existsSync(tgtPath)) throw new Error(`Target note "${args.targetFile}" does not exist.`); + + const srcLines = fs.readFileSync(srcPath, 'utf8').split('\n'); + const idx = args.lineNumber - 1; + if (idx < 0 || idx >= srcLines.length) throw new Error(`Line number ${args.lineNumber} out of range.`); + + const taskLine = srcLines[idx]; + if (!/^\s*[-*+]\s*\[[ xX/]\]/.test(taskLine)) { + throw new Error(`Line ${args.lineNumber} is not a valid checklist task: "${taskLine}"`); + } + + srcLines.splice(idx, 1); + fs.writeFileSync(srcPath, srcLines.join('\n'), 'utf8'); + + const tgtContent = fs.readFileSync(tgtPath, 'utf8'); + const updatedTgt = tgtContent.endsWith('\n') ? tgtContent + taskLine + '\n' : tgtContent + '\n' + taskLine + '\n'; + fs.writeFileSync(tgtPath, updatedTgt, 'utf8'); + + return { + movedTask: taskLine.trim(), + sourceFile: args.sourceFile, + targetFile: args.targetFile + }; + } + }); + + this.registerTool({ + name: 'tasks.archive_completed', + version: 'v1', + aliases: ['archive_completed_tasks'], + sdkName: 'tasks_archive_completed', + serviceName: 'TaskApplicationService', + description: 'Move all completed [x] tasks from a note to an archive section (## Completed Tasks) at the bottom.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative path to note file.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path to note file.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const validPath = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(validPath)) throw new Error(`Note "${args.filePath}" does not exist.`); + + const lines = fs.readFileSync(validPath, 'utf8').split('\n'); + const remaining = []; + const completed = []; + + // Check if file already has ## Completed Tasks section + let inArchiveSection = false; + for (const line of lines) { + if (/^##\s+Completed Tasks/i.test(line)) { + inArchiveSection = true; + } + if (!inArchiveSection && /^\s*[-*+]\s*\[[xX]\]/.test(line)) { + completed.push(line); + } else { + remaining.push(line); + } + } + + if (completed.length === 0) { + return { filePath: validPath, archivedCount: 0, message: 'No unarchived completed tasks found.' }; + } + + // Add or append to Completed Tasks section + let finalContent = remaining.join('\n').trimEnd(); + if (!inArchiveSection) { + finalContent += '\n\n## Completed Tasks\n' + completed.join('\n'); + } else { + finalContent += '\n' + completed.join('\n'); + } + + fs.writeFileSync(validPath, finalContent + '\n', 'utf8'); + return { + filePath: validPath, + archivedCount: completed.length, + tasks: completed.map((t) => t.trim()) + }; + } + }); + + this.registerTool({ + name: 'media.list', + version: 'v1', + aliases: ['list_media_assets'], + sdkName: 'media_list', + serviceName: 'MediaApplicationService', + description: 'List all image and media attachment files (.png, .jpg, .svg, .gif, .pdf, .mp3, .mp4, etc.) in the workspace with sizes.', + isWrite: false, + schema: z.object({ + folder: z.string().optional().describe('Folder to scan (default: whole workspace or Media/).') + }), + jsonSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Folder to scan (optional).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const mediaExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.pdf', '.mp3', '.mp4', '.wav', '.mov']); + const base = args.folder ? path.join(args.workspaceRoot, args.folder) : args.workspaceRoot; + if (!fs.existsSync(base)) throw new Error(`Folder "${args.folder}" does not exist.`); + + const assets = []; + function walk(dir) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name.startsWith('.') || ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) walk(full); + else if (ent.isFile()) { + const ext = path.extname(ent.name).toLowerCase(); + if (mediaExts.has(ext)) { + const stat = fs.statSync(full); + assets.push({ + name: ent.name, + path: path.relative(args.workspaceRoot, full), + sizeBytes: stat.size, + extension: ext, + modifiedAt: stat.mtime.toISOString() + }); + } + } + } + } catch { /* ignore */ } + } + + walk(base); + return { + totalAssets: assets.length, + totalSizeBytes: assets.reduce((acc, a) => acc + a.sizeBytes, 0), + assets + }; + } + }); + + // ------------------------------------------------------------------------- + // SUITE: Excalidraw Vector Diagrams (excalidraw.*) + // ------------------------------------------------------------------------- + + this.registerTool({ + name: 'excalidraw.read', + version: 'v1', + aliases: ['read_excalidraw'], + sdkName: 'excalidraw_read', + serviceName: 'DiagramService', + description: 'Read the JSON schema and element structure from an .excalidraw drawing file or diagram ID.', + isWrite: false, + schema: z.object({ + filePath: z.string().optional().describe('Relative path to .excalidraw file.'), + diagramId: z.string().optional().describe('Unique diagram ID (e.g. "diag_123" inside media/excalidraw/).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path to .excalidraw file.' }, + diagramId: { type: 'string', description: 'Unique diagram ID.' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + + let targetFile = null; + if (args.filePath) { + targetFile = assertPathInWorkspace(args.filePath, args.workspaceRoot); + } else if (args.diagramId) { + // Check media/excalidraw/ID/diagram.excalidraw or excali-diagrams/ID/diagram.excalidraw + const p1 = path.join(args.workspaceRoot, 'media', 'excalidraw', args.diagramId, 'diagram.excalidraw'); + const p2 = path.join(args.workspaceRoot, 'excali-diagrams', args.diagramId, 'diagram.excalidraw'); + if (fs.existsSync(p1)) targetFile = p1; + else if (fs.existsSync(p2)) targetFile = p2; + } + + if (!targetFile || !fs.existsSync(targetFile)) { + throw new Error(`Excalidraw diagram not found for filePath="${args.filePath || ''}", diagramId="${args.diagramId || ''}".`); + } + + const raw = fs.readFileSync(targetFile, 'utf8'); + let parsed = null; + try { + parsed = JSON.parse(raw); + } catch { + parsed = { rawContent: raw }; + } + + return { + filePath: path.relative(args.workspaceRoot, targetFile), + elementsCount: Array.isArray(parsed.elements) ? parsed.elements.length : 0, + appState: parsed.appState || null, + data: parsed + }; + } + }); + + this.registerTool({ + name: 'excalidraw.create', + version: 'v1', + aliases: ['create_excalidraw'], + sdkName: 'excalidraw_create', + serviceName: 'DiagramService', + description: 'Create a new .excalidraw drawing with elements (rectangles, ellipses, arrows, text, etc.).', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative path where .excalidraw file should be created.'), + elements: z.array(z.record(z.any())).optional().describe('List of Excalidraw element objects.'), + appState: z.record(z.any()).optional().describe('Optional canvas appState (viewBackgroundColor, etc.).') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path where .excalidraw file should be created.' }, + elements: { type: 'array', description: 'List of Excalidraw element objects.' }, + appState: { type: 'object', description: 'Canvas appState settings.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + let outPath = args.filePath; + if (!outPath.endsWith('.excalidraw')) outPath += '.excalidraw'; + const target = assertPathInWorkspace(outPath, args.workspaceRoot); + + const dir = path.dirname(target); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const drawingData = { + type: 'excalidraw', + version: 2, + source: 'https://notely.app', + elements: args.elements || [], + appState: args.appState || { viewBackgroundColor: '#ffffff', currentItemFontFamily: 1 }, + files: {} + }; + + const jsonStr = JSON.stringify(drawingData, null, 2); + fs.writeFileSync(target, jsonStr, 'utf8'); + + return { + filePath: path.relative(args.workspaceRoot, target), + elementsCount: drawingData.elements.length, + bytesWritten: Buffer.byteLength(jsonStr, 'utf8') + }; + } + }); + + this.registerTool({ + name: 'excalidraw.update', + version: 'v1', + aliases: ['update_excalidraw'], + sdkName: 'excalidraw_update', + serviceName: 'DiagramService', + description: 'Update elements or add new elements to an existing .excalidraw drawing file.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative path to existing .excalidraw file.'), + elements: z.array(z.record(z.any())).describe('Full replacement or updated elements array.'), + appState: z.record(z.any()).optional().describe('Optional appState updates.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path to .excalidraw file.' }, + elements: { type: 'array', description: 'Updated elements array.' }, + appState: { type: 'object', description: 'Canvas appState.' } + }, + required: ['filePath', 'elements'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const target = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(target)) throw new Error(`Excalidraw file "${args.filePath}" does not exist.`); + + let current = {}; + try { + current = JSON.parse(fs.readFileSync(target, 'utf8')); + } catch { + current = { type: 'excalidraw', version: 2, elements: [], appState: {}, files: {} }; + } + + current.elements = args.elements; + if (args.appState) { + current.appState = { ...(current.appState || {}), ...args.appState }; + } + + const jsonStr = JSON.stringify(current, null, 2); + fs.writeFileSync(target, jsonStr, 'utf8'); + + return { + filePath: path.relative(args.workspaceRoot, target), + updatedElementsCount: current.elements.length, + bytesWritten: Buffer.byteLength(jsonStr, 'utf8') + }; + } + }); + + this.registerTool({ + name: 'excalidraw.list', + version: 'v1', + aliases: ['list_excalidraw'], + sdkName: 'excalidraw_list', + serviceName: 'DiagramService', + description: 'Find and list all Excalidraw drawing files and embedded diagram folders across the workspace.', + isWrite: false, + schema: z.object({ + folder: z.string().optional().describe('Subfolder to scan (default: whole workspace).') + }), + jsonSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Subfolder to scan (optional).' } + } + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const base = args.folder ? path.join(args.workspaceRoot, args.folder) : args.workspaceRoot; + if (!fs.existsSync(base)) throw new Error(`Folder "${args.folder}" does not exist.`); + + const drawings = []; + function walk(dir) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name.startsWith('.') || ent.name === 'node_modules') continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + walk(full); + } else if (ent.isFile() && (ent.name.endsWith('.excalidraw') || ent.name === 'diagram.excalidraw')) { + const stat = fs.statSync(full); + let elementCount = 0; + try { + const content = JSON.parse(fs.readFileSync(full, 'utf8')); + elementCount = Array.isArray(content.elements) ? content.elements.length : 0; + } catch { /* ignore */ } + + drawings.push({ + name: ent.name, + path: path.relative(args.workspaceRoot, full), + sizeBytes: stat.size, + elementCount, + modifiedAt: stat.mtime.toISOString() + }); + } + } + } catch { /* ignore */ } + } + + walk(base); + return { count: drawings.length, drawings }; + } + }); + + this.registerTool({ + name: 'excalidraw.extract_elements', + version: 'v1', + aliases: ['extract_excalidraw_elements'], + sdkName: 'excalidraw_extract_elements', + serviceName: 'DiagramService', + description: 'Extract text labels, shapes, and connected bindings from an Excalidraw drawing.', + isWrite: false, + schema: z.object({ + filePath: z.string().describe('Relative path to .excalidraw drawing file.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path to .excalidraw drawing file.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const target = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(target)) throw new Error(`File "${args.filePath}" does not exist.`); + + const parsed = JSON.parse(fs.readFileSync(target, 'utf8')); + const elements = parsed.elements || []; + + const texts = elements.filter(e => e.type === 'text').map(e => ({ id: e.id, text: e.text, x: e.x, y: e.y })); + const shapes = elements.filter(e => e.type !== 'text').map(e => ({ id: e.id, type: e.type, width: e.width, height: e.height, backgroundColor: e.backgroundColor })); + + return { + filePath: path.relative(args.workspaceRoot, target), + totalElements: elements.length, + texts, + shapes + }; + } + }); + + this.registerTool({ + name: 'excalidraw.delete', + version: 'v1', + aliases: ['delete_excalidraw'], + sdkName: 'excalidraw_delete', + serviceName: 'DiagramService', + description: 'Delete an .excalidraw drawing file and its associated preview PNG from the workspace.', + isWrite: true, + schema: z.object({ + filePath: z.string().describe('Relative path to .excalidraw file.') + }), + jsonSchema: { + type: 'object', + properties: { + filePath: { type: 'string', description: 'Relative path to .excalidraw file.' } + }, + required: ['filePath'] + }, + execute: async (args) => { + const fs = require('fs'); + const path = require('path'); + const { assertPathInWorkspace } = require('../services/NoteApplicationService.cjs'); + const target = assertPathInWorkspace(args.filePath, args.workspaceRoot); + if (!fs.existsSync(target)) throw new Error(`File "${args.filePath}" does not exist.`); + + fs.unlinkSync(target); + + // Also check if there is an adjacent diagram.png or matching .png + let previewDeleted = false; + const pngSibling = target.replace(/\.excalidraw$/i, '.png'); + if (fs.existsSync(pngSibling)) { + fs.unlinkSync(pngSibling); + previewDeleted = true; + } + + return { + deletedFile: path.relative(args.workspaceRoot, target), + previewDeleted + }; + } + }); + } +} + +// Global Application Tool Registry Singleton +const applicationToolRegistry = new ApplicationToolRegistry(); + +module.exports = { + ApplicationToolRegistry, + applicationToolRegistry +}; -module.exports = { - ApplicationToolRegistry, - applicationToolRegistry -}; diff --git a/generate-mcp-docs.cjs b/generate-mcp-docs.cjs new file mode 100644 index 00000000..48cc6aaa --- /dev/null +++ b/generate-mcp-docs.cjs @@ -0,0 +1,101 @@ +const fs = require('fs'); +const path = require('path'); +const { applicationToolRegistry } = require('./electron/tools/ApplicationToolRegistry.cjs'); + +const tools = applicationToolRegistry.toMcpSchemas(); + +const suiteNames = { + notes: 'Suite 1: Notes & Document Management (`notes.*`)', + index: 'Suite 2: Workspace Index (`index.*`)', + workspace: 'Suite 3: Workspace Metadata & Files (`workspace.*`)', + diagrams: 'Suite 4: Diagrams & Flowcharts (`diagrams.*`)', + drawio: 'Suite 5: Draw.io Vector Drawings (`drawio.*`)', + excalidraw: 'Suite 6: Excalidraw Canvas Diagrams (`excalidraw.*`)', + media: 'Suite 7: Media & Assets (`media.*`)', + tasks: 'Suite 8: Task Workspace (`tasks.*`)', + search: 'Suite 9: Search & Retrieval (`search.*`)', + knowledge: 'Suite 10: Knowledge Graph & RAG (`knowledge.*`)', + git: 'Suite 11: Git Version Control (`git.*`)', + diagnostics: 'Suite 12: Diagnostics & Telemetry (`diagnostics.*`)', + web: 'Suite 13: External Web (`web.*`)', + personas: 'Suite 14: Personas & Agents (`personas.*`)', + export: 'Suite 15: Bundles & Packaging (`export.*`)' +}; + +const suites = {}; +for (const key of Object.keys(suiteNames)) { + suites[key] = { title: suiteNames[key], tools: [] }; +} + +for (const t of tools) { + const p = t.name.split('.')[0]; + if (suites[p]) { + suites[p].tools.push(t); + } else { + if (!suites.other) suites.other = { title: 'Other Tools', tools: [] }; + suites.other.tools.push(t); + } +} + +let md = `--- +title: MCP Tools & Capabilities Reference +description: Comprehensive reference documentation for Notely Model Context Protocol (MCP) server capabilities, tool suites, write permission controls, and SSE transport integration. +keywords: MCP, Model Context Protocol, SSE, AI, Claude Desktop, tools, capabilities, permissions +category: Developer +--- + +# Notely MCP Tools & Capabilities Reference + +Notely embeds an **HTTP SSE (Server-Sent Events) Model Context Protocol (MCP)** server enabling external AI clients (such as Claude Desktop, Cursor, IDE agents, and LLMs) to query, search, analyze, and manipulate workspace content safely. + +--- + +## 1. Server Architecture & Permission Control + +- **Transport Protocol**: HTTP SSE listening by default on \`http://127.0.0.1:3700/sse\` (messages accepted at \`/messages\`). +- **Security Guard (\`allowWriteTools\`)**: Configurable toggle in MCP Settings. When set to \`false\`, all write operations (\`[W]\`) are automatically hidden from MCP capability advertisement (\`tools/list\`) and blocked with a \`WRITE_DISABLED\` error envelope. +- **Flight Log Telemetry**: All incoming tool call executions are recorded in the local SQLite telemetry database and broadcast via IPC to the **MCP Diagnostics** flight log viewer (\`AIHealthPage\`). +- **Total Capabilities**: **${tools.length} Tools** across 14 specialized suites. + +--- + +## 2. Complete Tool Suites Reference (${tools.length} Tools) + +`; + +for (const suite of Object.values(suites)) { + md += `### ${suite.title} — ${suite.tools.length} Tools\n\n`; + for (const t of suite.tools) { + const wTag = t.isWrite ? ' **[W]**' : ''; + const cleanDesc = t.description.replace(/^\[WRITE\]\s*/i, ''); + md += `- \`${t.name}\`${wTag}: ${cleanDesc}\n`; + } + md += '\n'; +} + +md += `--- + +## 3. Client Integration Example (Claude Desktop) + +To connect Claude Desktop to Notely MCP server, add this entry to \`claude_desktop_config.json\`: + +\`\`\`json +{ + "mcpServers": { + "notely": { + "url": "http://127.0.0.1:3700/sse" + } + } +} +\`\`\` + +--- + +## 4. Write Operations Permission Table + +When write access is disabled (\`allowWriteTools: false\`), all tools marked **[W]** are automatically filtered out from external discovery and blocked from execution. Read-only query tools remain active and safe to call. +`; + +const docPath = path.join(__dirname, 'docs', 'mcp-tools-reference.md'); +fs.writeFileSync(docPath, md, 'utf8'); +console.log(`Successfully generated docs/mcp-tools-reference.md with ${tools.length} tools.`); diff --git a/package-lock.json b/package-lock.json index c7714ca2..f93e2ac6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notely", - "version": "0.1.37", + "version": "0.1.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notely", - "version": "0.1.37", + "version": "0.1.41", "hasInstallScript": true, "license": "CC-BY-NC-4.0", "dependencies": { @@ -14,6 +14,7 @@ "@ai-sdk/groq": "^4.0.12", "@ai-sdk/openai": "^4.0.16", "@huggingface/transformers": "^4.2.0", + "@modelcontextprotocol/sdk": "^1.30.0", "ai": "^7.0.31", "groq-sdk": "^1.3.0", "highlight.js": "^11.11.1", @@ -3000,6 +3001,18 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@huggingface/jinja": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", @@ -4113,6 +4126,68 @@ "@chevrotain/types": "~11.1.1" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "0.1.100", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", @@ -6663,6 +6738,44 @@ "dev": true, "license": "MIT" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -6742,6 +6855,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -7296,6 +7448,59 @@ "bluebird": "^3.5.5" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -7500,6 +7705,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -7562,7 +7776,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7576,7 +7789,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8102,6 +8314,28 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -8109,6 +8343,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -8133,6 +8385,23 @@ "license": "MIT", "optional": true }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -8184,7 +8453,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -9034,6 +9302,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -9264,7 +9541,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -9282,6 +9558,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -9457,6 +9739,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9641,7 +9932,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9779,6 +10069,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -10072,6 +10368,27 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", @@ -10091,6 +10408,93 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -10145,7 +10549,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -10179,6 +10582,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -10253,6 +10672,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10378,6 +10818,15 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fractional-indexing": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", @@ -10388,6 +10837,15 @@ "node": "^14.13.1 || >=16.0.0" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -10462,7 +10920,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10545,7 +11002,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10580,7 +11036,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10911,7 +11366,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10940,7 +11394,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10996,6 +11449,15 @@ "node": ">=12.0.0" } }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -11067,6 +11529,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -11244,7 +11726,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -11282,6 +11763,24 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -11689,6 +12188,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11877,7 +12382,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -11951,6 +12455,15 @@ "node": ">= 20" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jotai": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", @@ -12088,6 +12601,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12545,7 +13064,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12855,6 +13373,19 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -12862,6 +13393,18 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13766,6 +14309,35 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-addon-api": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", @@ -13864,7 +14436,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13874,7 +14445,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13967,11 +14537,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14267,6 +14848,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -14298,7 +14888,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14335,6 +14924,16 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", @@ -14432,6 +15031,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/platform": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", @@ -14728,6 +15336,19 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -14775,6 +15396,22 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -14809,6 +15446,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rcedit": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-5.0.2.tgz", @@ -15511,7 +16192,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15681,6 +16361,22 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15795,7 +16491,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -15872,6 +16567,57 @@ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", "license": "MIT" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -15887,6 +16633,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15936,6 +16701,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -15996,7 +16767,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -16009,7 +16779,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16049,7 +16818,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16069,7 +16837,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16086,7 +16853,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -16105,7 +16871,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -16308,6 +17073,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -16789,6 +17563,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -16909,6 +17692,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -17212,6 +18051,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -17328,6 +18176,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -19286,7 +20143,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -19462,7 +20318,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/xml-name-validator": { @@ -19590,6 +20445,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/package.json b/package.json index 884ad346..0a11bece 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@ai-sdk/groq": "^4.0.12", "@ai-sdk/openai": "^4.0.16", "@huggingface/transformers": "^4.2.0", + "@modelcontextprotocol/sdk": "^1.30.0", "ai": "^7.0.31", "groq-sdk": "^1.3.0", "highlight.js": "^11.11.1", diff --git a/src/App.jsx b/src/App.jsx index b8971dac..79159bdd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,11 +32,10 @@ const GlobalSearchOverlay = lazy(() => const KeyboardShortcutsModal = lazy(() => import("./components/KeyboardShortcutsModal").then((m) => ({ default: m.KeyboardShortcutsModal })) ); -const AIChatPanel = lazy(() => - import("./components/AIChatPanel").then((m) => ({ default: m.default || m.AIChatPanel })) -); + import { GitStatusBar } from "./components/GitStatusBar"; import { AIStatusBar } from "./components/AIStatusBar"; +import { MCPStatusBar } from "./components/MCPStatusBar"; const NoteListPanel = lazy(() => import("./components/NoteListPanel").then((m) => ({ default: m.NoteListPanel })) @@ -74,7 +73,11 @@ import { } from "./services/electronService"; import { useToast } from "./hooks/useToast"; import { useP2PSync } from "./hooks/useP2PSync"; -import { useAIAssistant } from "./hooks/useAIAssistant"; +import { + aiGenerateEmbeddings, + aiBuildGraph, + aiClearData, +} from "./services/electron/aiService"; import { useDocumentManager } from "./hooks/useDocumentManager"; import { useWorkspaceScopedStorage } from "./hooks/useWorkspaceScopedStorage"; import { useUIState } from "./contexts/UIStateContext"; @@ -518,11 +521,6 @@ export default function App() { } }, [handleReloadWorkspace, openTabs, openDocument, handleCloseTab]); - const handlePreviewNote = useCallback((filePath, lineNum = null) => { - if (!filePath) return; - void handleOpenReferencedDocument(filePath, lineNum); - }, [handleOpenReferencedDocument]); - const handleCopyLinkPath = useCallback((target) => { const filePath = typeof target === "object" ? target?.filePath : target; if (!filePath || !notesFolderPath) return; @@ -933,48 +931,51 @@ export default function App() { handleResolveConflict, handleOpenNextConflict, } = useP2PSync({ notify, setError, loadDocumentsData, syncStateRef }); - const { - aiSettingsOpen, - setAiSettingsOpen, - aiQueryLoading, - aiQueryError, - aiContextSummary, - aiPaletteIntent, - aiChatMessages, - isAIConfigured, - aiPanelVisible, - setAiPanelVisible, - inlineGhostSuggestion, - aiEditorRef, - refreshAIConfiguration, - handleAIEmbeddings, - handleAIGraph, - handleAIClearCache, - handleOpenAIPalette, - handleInlineAIRequest, - handleApplyAIResult, - handleAIChatSend, - handleAIChatAbort, - handleClearAIChat, - handleRejectInlineGhost, - handleAcceptInlineGhost, - activeProvider, - activePersona, - setActivePersona, - activeQueryId, - conversations, - loadConversations, - loadConversation, - deleteConversation, - } = useAIAssistant({ - current, - activeTab, - mode, - activeProject, - landingFolderPath, - notesFolderPath, - notify, - }); + const [aiSettingsOpen, setAiSettingsOpen] = useState(false); + const [mcpToolsPageOpen, setMcpToolsPageOpen] = useState(false); + + const handleAIEmbeddings = useCallback(async () => { + notify("Generating embeddings...", "info"); + try { + const result = await aiGenerateEmbeddings(true); + if (result?.success) { + notify("Embeddings generated successfully!", "success"); + } else { + notify(result?.error || "Failed to generate embeddings", "error"); + } + } catch (err) { + notify(err?.message || "Failed to generate embeddings", "error"); + } + }, [notify]); + + const handleAIGraph = useCallback(async () => { + notify("Building knowledge graph...", "info"); + try { + const result = await aiBuildGraph(); + if (result?.success) { + notify("Knowledge graph built successfully!", "success"); + } else { + notify(result?.error || "Failed to build knowledge graph", "error"); + } + } catch (err) { + console.error("[AI] Graph build error:", err); + notify(err?.message || "Failed to build knowledge graph", "error"); + } + }, [notify]); + + const handleAIClearCache = useCallback(async () => { + notify("Clearing AI cache...", "info"); + try { + const result = await aiClearData(); + if (result?.success) { + notify("AI cache cleared successfully!", "success"); + } else { + notify(result?.error || "Failed to clear cache", "error"); + } + } catch (err) { + notify(err?.message || "Failed to clear cache", "error"); + } + }, [notify]); useEffect(() => { if (p2pStatusOpen) { @@ -2084,10 +2085,7 @@ export default function App() { return; } - if (action === "open-ai-palette") { - handleOpenAIPalette({ forceOpen: true }); - return; - } + if (action === "ai-generate-embeddings") { handleAIEmbeddings(); @@ -2110,6 +2108,11 @@ export default function App() { return; } + if (action === "open-mcp-tools") { + setMcpToolsPageOpen(true); + return; + } + if (action === "open-workspace-index" || action === "workspace-index") { setWorkspaceIndexOpen(true); return; @@ -2313,7 +2316,8 @@ export default function App() { const paletteCommandsBase = [ { id: "restart-app", label: "Restart Notely", group: "App", aliases: "restart relaunch reboot app application" }, { id: "new-note", label: "Create New Note", group: "Notes", shortcut: "Ctrl/Cmd+N", aliases: "add note new document write jot capture" }, - { id: "open-ai-palette", label: "Open AI Palette", group: "AI", shortcut: "Ctrl/Cmd+Shift+I", aliases: "assistant ask ai prompt summarize rewrite" }, + { id: "open-mcp-tools", label: "Open MCP Tools & Capabilities", group: "AI", shortcut: "Ctrl/Cmd+Shift+M", aliases: "mcp tools agent external server api" }, + { id: "open-help-center", label: "Open Help Center", group: "Help", shortcut: "F1", aliases: "help docs guide manual about" }, { id: "open-feedback", label: "Report Bug / Feedback", group: "Help", aliases: "feedback bug report issue feature request" }, { id: "open-about", label: "Open About Notely", group: "Help", aliases: "about version build" }, @@ -2631,11 +2635,13 @@ export default function App() { return; } - if (resolvedCommandId === "open-ai-palette") { - handleOpenAIPalette({ forceOpen: true }); + if (resolvedCommandId === "open-mcp-tools") { + setMcpToolsPageOpen(true); return; } + + if (resolvedCommandId === "open-help-center") { setHelpConfirmationOpen(true); return; @@ -2930,16 +2936,6 @@ export default function App() { return; } - if (action === "ai") { - if (!isAIConfigured) { - notify("Configure an AI provider key in AI Settings to use AI chat.", "warning"); - setAiSettingsOpen(true); - return; - } - setAiPanelVisible((visible) => !visible); - return; - } - if (action === "trash") { setTrashDialogOpen(true); } @@ -3029,37 +3025,7 @@ export default function App() { [favoriteNotes, recentDashboardNotes, continueDashboardNotes] ); - const aiSidebarComponent = aiPanelVisible && isAIConfigured ? ( - - Loading AI…}> - setAiPanelVisible(false)} - onClear={handleClearAIChat} - onSend={handleAIChatSend} - onAbort={handleAIChatAbort} - activeQueryId={activeQueryId} - onApply={handleApplyAIResult} - onOpenDocument={handleOpenReferencedDocumentFromUI} - onPreviewNote={handlePreviewNote} - isLoading={aiQueryLoading} - error={aiQueryError || null} - contextSummary={aiContextSummary} - intent={aiPaletteIntent} - messages={aiChatMessages} - noteTitle={current?.title || "Current Note"} - activeProvider={activeProvider} - activePersona={activePersona} - setActivePersona={setActivePersona} - workspaceStorageScope={workspaceStorageScope} - conversations={conversations} - onLoadConversations={loadConversations} - onLoadConversation={loadConversation} - onDeleteConversation={deleteConversation} - /> - - - ) : null; return (
@@ -3154,6 +3120,10 @@ export default function App() { onClick={() => setGitVCOpen(true)} /> setAiSettingsOpen(true)} /> + { + setSettingsTab("mcp"); + setSettingsOpen(true); + }} /> {current && !(graphPanelOpen || embeddingsPageOpen || personasPageOpen || healthPageOpen || appLogsOpen || gitVCOpen) ? ( <> {documentStats ? ( @@ -3188,17 +3158,7 @@ export default function App() { documents={documents} workspaceTaskDocuments={workspaceTaskDocuments} loading={loading} - aiSidebar={aiSidebarComponent} - aiPanelVisible={aiPanelVisible} - isAIConfigured={isAIConfigured} - onShowAI={() => { - if (!isAIConfigured) { - notify("Configure an AI provider key in AI Settings to use AI chat.", "warning"); - setAiSettingsOpen(true); - return; - } - setAiPanelVisible((visible) => !visible); - }} + onOpenListItem={handleOpenListItem} onOpenReferencedDocument={(task) => handleOpenReferencedDocument(task?.filePath)} onOpenAllTasks={() => { @@ -3297,25 +3257,7 @@ export default function App() { if (!didLeave) return; await handleLandingNavigateTo(targetPath); }} - onOpenAI={handleOpenAIPalette} - onOpenAIRequest={handleOpenAIPalette} - onInlineAIRequest={handleInlineAIRequest} - onRegisterAIEditor={(api) => { - aiEditorRef.current = api; - }} - inlineGhostSuggestion={inlineGhostSuggestion} - onAcceptInlineGhost={handleAcceptInlineGhost} - onRejectInlineGhost={handleRejectInlineGhost} - aiEnabled={isAIConfigured} - aiPanelVisible={aiPanelVisible} - onShowAI={() => { - if (!isAIConfigured) { - notify("Configure an AI provider key in AI Settings to use AI chat.", "warning"); - setAiSettingsOpen(true); - return; - } - setAiPanelVisible((visible) => !visible); - }} + onOpenAISettings={() => setAiSettingsOpen(true)} onOpenDocument={handleOpenReferencedDocumentFromUI} initialLine={initialLine} @@ -3335,7 +3277,6 @@ export default function App() { scrollSyncEnabled={scrollSyncEnabled} onScrollSyncEnabledChange={setScrollSyncEnabled} onReloadFromDisk={(filePath) => handleReloadCurrentFromDisk(filePath)} - aiSidebar={aiSidebarComponent} /> )} @@ -3511,9 +3452,9 @@ export default function App() { {settingsOpen ? ( { setSettingsOpen(false); - refreshAIConfiguration(); }} activeTab={settingsTab} themePreference={themePreference} @@ -3851,6 +3792,12 @@ export default function App() { setWorkspaceIndexOpen={setWorkspaceIndexOpen} diagramsMediaOpen={diagramsMediaOpen} setDiagramsMediaOpen={setDiagramsMediaOpen} + mcpToolsPageOpen={mcpToolsPageOpen} + setMcpToolsPageOpen={setMcpToolsPageOpen} + onOpenMcpSettings={() => { + setSettingsTab("mcp"); + setSettingsOpen(true); + }} onSelectHeader={(docId, line) => { handleOpenReferencedDocument(docId, line); }} diff --git a/src/ai/utils/ipcProtocol.js b/src/ai/utils/ipcProtocol.js index 7c2e744f..efbacca3 100644 --- a/src/ai/utils/ipcProtocol.js +++ b/src/ai/utils/ipcProtocol.js @@ -4,9 +4,7 @@ const IPC_EVENTS = { AI_INIT: 'ai:init', - AI_QUERY: 'ai:query', - AI_QUERY_STREAM: 'ai:query:stream', - AI_QUERY_ABORT: 'ai:query:abort', + // AI_QUERY/STREAM/ABORT removed — chat moved to MCP layer AI_STATUS: 'ai:status', AI_GENERATE_EMBEDDINGS: 'ai:embeddings:generate', AI_BUILD_GRAPH: 'ai:graph:build', @@ -28,7 +26,7 @@ const IPC_EVENTS = { AI_GRAPH_MODEL_DOWNLOAD: 'ai:graph-model:download', AI_GRAPH_MODEL_DELETE: 'ai:graph-model:delete', AI_GRAPH_MODEL_STATUS: 'ai:graph-model:status', - AI_DETECT_PATTERNS: 'ai:patterns:detect', + // AI_DETECT_PATTERNS removed — chat-only AI_LOGS_GET: 'ai:logs:get', AI_LOGS_CLEAR: 'ai:logs:clear', AI_NOTE_STATS: 'ai:note:stats', @@ -44,23 +42,14 @@ const IPC_EVENTS = { AI_ENABLE: 'ai:enable', AI_DISABLE: 'ai:disable', AI_HEALTH_GET: 'ai:health:get', - AI_CONVERSATION_LIST: 'ai:conversation:list', - AI_CONVERSATION_GET: 'ai:conversation:get', - AI_CONVERSATION_CREATE: 'ai:conversation:create', - AI_CONVERSATION_DELETE: 'ai:conversation:delete', - AI_CONVERSATION_CLEAR: 'ai:conversation:clear', - AI_CONVERSATION_SET_PERSONA: 'ai:conversation:set-persona', - AI_CONVERSATION_GET_MESSAGES: 'ai:conversation:get-messages', - AI_CONVERSATION_ADD_MESSAGE: 'ai:conversation:add-message', + // AI_CONVERSATION_* removed — chat moved to MCP layer AI_PERSONA_LIST: 'ai:persona:list', AI_PERSONA_GET: 'ai:persona:get', AI_PERSONA_SAVE: 'ai:persona:save', AI_PERSONA_DELETE: 'ai:persona:delete', AI_PERSONA_IMPORT: 'ai:persona:import', AI_PERSONA_EXPORT: 'ai:persona:export', - AI_KNOWLEDGE_LIST_PENDING: 'ai:knowledge:list-pending', - AI_KNOWLEDGE_APPROVE: 'ai:knowledge:approve', - AI_KNOWLEDGE_REJECT: 'ai:knowledge:reject', + // AI_KNOWLEDGE_* removed — chat-only AI_SHUTDOWN: 'ai:shutdown', TOOL_EXECUTE: 'tool:execute', TOOL_LIST: 'tool:list' diff --git a/src/components/AIChatPanel.jsx b/src/components/AIChatPanel.jsx deleted file mode 100644 index ea3ac68a..00000000 --- a/src/components/AIChatPanel.jsx +++ /dev/null @@ -1,567 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Send, X, Trash2, Pencil, Check, History, RotateCcw } from "lucide-react"; -import AppButton from "./AppButton"; -import AppTextarea from "./AppTextarea"; -import NotePreviewModal from "./NotePreviewModal"; -import { renderMarkdown } from "../utils/renderUtils"; -import { aiListPersonas } from "../services/electronService"; -import { useWorkspaceScopedStorage } from "../hooks/useWorkspaceScopedStorage"; -import { useConfirm } from "../hooks/useConfirm"; - -const SCOPE_OPTIONS = [ - { id: "auto", label: "Auto" }, - { id: "selection", label: "Selection" }, - { id: "block", label: "Block" }, - { id: "document", label: "Note" }, - { id: "workspace", label: "Workspace" }, -]; - -function buildStarterPrompts(contextSummary) { - if (!contextSummary?.hasActiveDocument) { - return [ - "Summarize key tasks across my workspace.", - "List all active projects in this workspace.", - "Find links or references related to design plans.", - ]; - } - if (contextSummary?.hasSelection) { - return [ - "Make this clearer without changing the meaning.", - "Turn this into concise action items.", - "Challenge the assumptions in this selection.", - ]; - } - if (contextSummary?.hasCurrentBlock) { - return [ - "Continue this section in the same tone.", - "Summarize this block more cleanly.", - "Extract next steps from this block.", - ]; - } - return [ - "Summarize this note in 3 key bullet points.", - "Extract all action items & TODOs.", - "Explain the core technical concepts mentioned.", - ]; -} - -function getScopeHelp(scope, contextSummary) { - if (!contextSummary?.hasActiveDocument) return ""; - if (scope === "workspace") { - return contextSummary?.hasSelection - ? "Uses the selected text as the focal point, then widens to the whole workspace." - : "Uses this note as the focal point, then widens to the whole workspace."; - } - if (scope === "selection") { - return contextSummary?.hasSelection - ? "Uses only the selected text." - : "No selection is active, so this will fall back to the full note."; - } - if (scope === "block") { - return contextSummary?.hasCurrentBlock - ? "Uses the current paragraph or block around the cursor." - : "No current block is available, so this will fall back to the full note."; - } - if (scope === "document") return "Uses the full current note."; - return contextSummary?.hasSelection - ? "Auto uses the current selection first, otherwise the full note." - : "Auto uses the full note unless a selection is active."; -} - -export default function AIChatPanel({ - onHide, - onClear, - onSend, - onAbort, - activeQueryId, - onApply, - onOpenDocument, - onPreviewNote, - isLoading = false, - error = null, - contextSummary = null, - intent = null, - messages = [], - _noteTitle = "Current Note", - _activeProvider = "", - activePersona = null, - setActivePersona, - workspaceStorageScope = "default", - conversations = [], - onLoadConversations, - onLoadConversation, - onDeleteConversation, -}) { - const [previewTarget, setPreviewTarget] = useState(null); - const [draft, setDraft] = useState(""); - const [scope, setScope] = useState("auto"); - const [personas, setPersonas] = useState([]); - const [isEditingPersona, setIsEditingPersona] = useState(false); - const [selectedPersonaId, setSelectedPersonaId] = useState("default"); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const inputRef = useRef(null); - const lastAutoRunRequestIdRef = useRef(""); - const messagesEndRef = useRef(null); - - const handlePreviewLink = (rawPath, lineNum = null) => { - if (onPreviewNote) { - onPreviewNote(rawPath, lineNum); - } else { - setPreviewTarget({ path: rawPath, lineNum }); - } - }; - - const { confirm } = useConfirm(); - - const [persistedPersonaId, setPersistedPersonaId] = useWorkspaceScopedStorage({ - workspaceScope: workspaceStorageScope, - key: "activePersonaId", - defaultValue: "default", - }); - - const starterPrompts = useMemo(() => buildStarterPrompts(contextSummary), [contextSummary]); - - const lastUserMessage = useMemo(() => { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i]; - } - return null; - }, [messages]); - - useEffect(() => { - setDraft(intent?.query || ""); - setScope(intent?.target || (contextSummary?.hasActiveDocument ? "auto" : "workspace")); - requestAnimationFrame(() => inputRef.current?.focus()); - }, [intent, contextSummary]); - - useEffect(() => { - if (!contextSummary?.hasActiveDocument) { - setScope("workspace"); - } - }, [contextSummary]); - - useEffect(() => { - async function load() { - const res = await aiListPersonas(); - if (res?.success && res.data) { - setPersonas(res.data); - const targetId = persistedPersonaId || "default"; - const matched = res.data.find(p => p.id === targetId) || res.data.find(p => p.id === "default") || res.data[0]; - if (matched && (!activePersona || activePersona.id !== matched.id)) { - setActivePersona(matched); - } - } - } - load(); - }, [persistedPersonaId, activePersona, setActivePersona]); - - useEffect(() => { - if (!intent?.autoRun || !intent?.query) return; - if (lastAutoRunRequestIdRef.current === intent.requestId) return; - lastAutoRunRequestIdRef.current = intent.requestId; - onSend?.({ message: intent.query, target: intent.target || scope }); - setDraft(""); - }, [intent, onSend, scope]); - - useEffect(() => { - if (isDrawerOpen) { - onLoadConversations?.(); - } - }, [isDrawerOpen, onLoadConversations]); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); - - const handleClearWithConfirm = async () => { - const confirmed = await confirm({ - title: "Clear Chat History", - message: "Are you sure you want to clear all messages in this conversation? This cannot be undone.", - confirmLabel: "Clear History", - cancelLabel: "Cancel", - variant: "danger", - }); - if (confirmed) { - onClear?.(); - } - }; - - return ( - - ); -} diff --git a/src/components/AIHealthPage.jsx b/src/components/AIHealthPage.jsx index 5885d682..dfc3919b 100644 --- a/src/components/AIHealthPage.jsx +++ b/src/components/AIHealthPage.jsx @@ -2,52 +2,36 @@ import React, { useEffect, useState, useCallback } from 'react'; import { Activity, Database, - Cpu, AlertCircle, - MessageSquare, - ChevronRight, - Terminal, - ArrowLeft, - CheckCircle, - XCircle, Wrench, Search, X, Copy, Check, - Maximize2, - Minimize2, Clock, Trash2, Zap, - ChevronDown, - ChevronUp, - Brain, - FileText, - Bot, - Filter + RefreshCw, + Radio, + FileCode, + ShieldAlert, + ChevronRight } from 'lucide-react'; -import { aiGetHealth, aiListConversations, aiGetMessages, aiGetLogs, aiClearLogs, aiClearConversations, onTelemetryEvent } from '../services/electronService'; +import { + aiGetHealth, + aiGetLogs, + aiClearLogs, + onTelemetryEvent, + mcpGetStatus, + mcpGetSessions, + onMcpStatusChanged +} from '../services/electronService'; import { useConfirm } from '../hooks/useConfirm'; -import { renderMarkdown } from '../utils/renderUtils'; import '../styles/KnowledgeGraph.css'; import '../styles/AISettings.css'; import '../styles/AIHealthPage.css'; -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function formatPersonaName(p) { - if (!p) return 'general'; - if (typeof p === 'object') return p.name || p.id || 'general'; - return String(p); -} - -function fmtTime(iso) { - if (!iso) return '—'; - try { - return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 }); - } catch { return iso; } -} +// ─── Formatting Helpers ────────────────────────────────────────────────────── function fmtMs(ms) { if (ms == null || ms < 0) return '—'; @@ -66,976 +50,508 @@ function copyToClipboard(text, label) { window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `${label} copied to clipboard`, type: 'success' } })); } -// ─── Event config ─────────────────────────────────────────────────────────── - -const EVENT_CONFIG = { - conversation_loaded: { icon: Brain, color: '#a78bfa', label: 'Context & Persona', bg: 'rgba(167,139,250,0.12)' }, - compaction: { icon: Database, color: '#a78bfa', label: 'History Compaction', bg: 'rgba(167,139,250,0.12)' }, - planner: { icon: Activity, color: '#60a5fa', label: 'Intent Planning', bg: 'rgba(96,165,250,0.12)' }, - intent_analyzed: { icon: Activity, color: '#60a5fa', label: 'Intent Analysis', bg: 'rgba(96,165,250,0.12)' }, - context_building: { icon: Database, color: '#34d399', label: 'Context Building', bg: 'rgba(52,211,153,0.12)' }, - retrieval_completed: { icon: Database, color: '#34d399', label: 'Context Aggregation', bg: 'rgba(52,211,153,0.12)' }, - vector_search: { icon: Search, color: '#34d399', label: 'Vector Search', bg: 'rgba(52,211,153,0.12)' }, - graph_traverse: { icon: Database, color: '#34d399', label: 'Graph Traversal', bg: 'rgba(52,211,153,0.12)' }, - prompt_construction: { icon: FileText, color: '#fbbf24', label: 'Prompt Construction', bg: 'rgba(251,191,36,0.12)' }, - 'prompt:assembled': { icon: FileText, color: '#fbbf24', label: 'Prompt Assembly', bg: 'rgba(251,191,36,0.12)' }, - llm_execution: { icon: Bot, color: '#e879f9', label: 'LLM Execution', bg: 'rgba(232,121,249,0.12)' }, - llm_request: { icon: Bot, color: '#f472b6', label: 'LLM Request', bg: 'rgba(244,114,182,0.12)' }, - tool_execution: { icon: Wrench, color: '#fb923c', label: 'Tool Execution', bg: 'rgba(251,146,60,0.12)' }, - tool_invocation: { icon: Wrench, color: '#fb923c', label: 'Tool Invocation', bg: 'rgba(251,146,60,0.12)' }, - tool_response: { icon: Terminal, color: '#4ade80', label: 'Tool Response', bg: 'rgba(74,222,128,0.12)' }, - llm_response: { icon: Bot, color: '#e879f9', label: 'LLM Response', bg: 'rgba(232,121,249,0.12)' }, - final_response: { icon: CheckCircle, color: '#10b981', label: 'Final Response', bg: 'rgba(16,185,129,0.12)' }, - trace_completed: { icon: CheckCircle, color: '#10b981', label: 'Trace Complete', bg: 'rgba(16,185,129,0.12)' }, - warning: { icon: AlertCircle, color: '#f59e0b', label: 'Warning', bg: 'rgba(245,158,11,0.12)' }, - error: { icon: AlertCircle, color: '#f87171', label: 'Error', bg: 'rgba(248,113,113,0.12)' }, -}; - -function getEventCfg(type) { - if (EVENT_CONFIG[type]) return EVENT_CONFIG[type]; - if (type && type.includes('compaction')) return EVENT_CONFIG.compaction; - if (type && type.includes('retrieval')) return EVENT_CONFIG.retrieval_completed; - if (type && type.includes('warn')) return EVENT_CONFIG.warning; - if (type && type.includes('error')) return EVENT_CONFIG.error; - return { icon: Activity, color: '#94a3b8', label: type, bg: 'rgba(148,163,184,0.1)' }; +function formatJson(val) { + if (val == null) return 'null'; + if (typeof val === 'string') { + try { + return JSON.stringify(JSON.parse(val), null, 2); + } catch { + return val; + } + } + try { + return JSON.stringify(val, null, 2); + } catch { + return String(val); + } } -// ─── Small reusable components ─────────────────────────────────────────────── +// ─── Sub-Components ───────────────────────────────────────────────────────── function StatusDot({ ok }) { return ; } -function StatCard({ label, value, accent }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function DbRow({ label, count, countLabel, path, status }) { +function DbRow({ label, count, countLabel, status }) { const ok = status === 'connected'; return (
{label} - {count} {countLabel} + {count} {countLabel || ''}
- {path || 'none'}
); } -// ─── Message bubble (Messages tab) ────────────────────────────────────────── - -function MessageBubble({ msg }) { - const isUser = msg.role === 'user'; - const tsFormatted = msg.created_at ? new Date(msg.created_at).toLocaleTimeString() : ''; +// ─── Main MCP Diagnostics Component ───────────────────────────────────────── - return ( -
-
-
- {isUser ? '👤 User' : '🤖 Assistant'} - {tsFormatted && ( - - {tsFormatted} - - )} -
-
-
-
- ); -} - -// ─── Event detail panels ───────────────────────────────────────────────────── - -function PreBlock({ label, children, copyValue, maxHeight = '160px' }) { - const [copied, setCopied] = useState(false); - const [expanded, setExpanded] = useState(false); - - let content = ''; - if (children == null) { - content = '(no output returned)'; - } else if (typeof children === 'object') { +export default function AIHealthPage({ onBack }) { + const { confirm } = useConfirm(); + const [loading, setLoading] = useState(true); + const [healthData, setHealthData] = useState(null); + const [mcpStatus, setMcpStatus] = useState(null); + const [mcpSessions, setMcpSessions] = useState([]); + const [toolCalls, setToolCalls] = useState([]); + const [expandedCallId, setExpandedCallId] = useState(null); + const [copiedId, setCopiedId] = useState(null); + + // Filters + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [toolFilter, setToolFilter] = useState('ALL'); + + const fetchData = useCallback(async () => { try { - content = JSON.stringify(children, null, 2); - } catch { - content = String(children); - } - } else { - content = String(children); - } - if (!content.trim()) content = '(empty output)'; - - return ( -
- {(label || copyValue !== undefined) && ( -
- {label && {label}} - {copyValue !== undefined && ( - - )} -
- )} -
-        {content}
-      
- {content.length > 300 && ( - - )} -
- ); -} - -function KV({ k, v }) { - if (v == null || v === '' || v === 0) return null; - return ( -
- {k} - {typeof v === 'boolean' ? (v ? '✓ yes' : '✗ no') : String(v)} -
- ); -} - -function EventDetail({ event }) { - const { type, startedAt, endedAt, durationMs, tokensUsed, input, output } = event; - - const startStr = startedAt ? fmtTime(startedAt) : null; - const endStr = endedAt ? fmtTime(endedAt) : null; - - return ( -
- {/* Standardized performance & timestamp header bar */} -
- {startStr && Start: {startStr}} - {endStr && End: {endStr}} - {durationMs != null && durationMs > 0 && Latency: {fmtMs(durationMs)}} - {tokensUsed != null && tokensUsed > 0 && {tokensUsed} tokens} -
- - {/* Module-specific Metadata */} - {type === 'conversation_loaded' && ( - <> - - - - - - - - )} - - {type === 'planner' && ( - <> - 0 ? `${(event.confidenceScore * 100).toFixed(0)}%` : null} /> - 0 ? `${event.evidenceLength} chars` : null} /> - - )} - - {type === 'prompt_construction' && ( - <> - - - - - )} + setLoading(true); + const [hRes, sRes, sessRes, logsRes] = await Promise.all([ + aiGetHealth().catch(() => null), + mcpGetStatus().catch(() => null), + mcpGetSessions().catch(() => ({ active: [] })), + aiGetLogs(100).catch(() => []) + ]); - {(type === 'tool_execution' || type === 'tool_invocation' || type === 'tool_response') && ( - <> - - - {event.args && {event.args}} - {output !== undefined && output !== null && {output}} - - )} + if (hRes) setHealthData(hRes.data || hRes); + if (sRes) setMcpStatus(sRes.data || sRes); + if (sessRes) setMcpSessions((sessRes.data?.active || sessRes.active) || []); + + const logsArray = Array.isArray(logsRes?.data) ? logsRes.data : (Array.isArray(logsRes) ? logsRes : []); + if (logsArray.length > 0) { + const calls = logsArray.map((item, idx) => { + const meta = item.metadata || item; + return { + id: item.id || `call_${idx}_${Date.now()}`, + callId: meta.callId || item.call_id || `call_${idx}`, + sessionId: meta.sessionId || item.session_id || 'default', + clientName: meta.clientName || item.client_name || 'Claude / External Client', + toolName: meta.toolName || item.tool_name || item.query || 'mcp_tool', + input: meta.input || meta.input_payload || item.payload || item.input, + output: meta.output || meta.output_payload || item.output, + durationMs: meta.durationMs || meta.totalDurationMs || item.duration_ms || 0, + status: (meta.status || item.status || 'SUCCESS').toUpperCase(), + error: meta.error || item.error || null, + calledAt: item.timestamp || item.created_at || meta.calledAt || new Date().toISOString() + }; + }); + setToolCalls(calls); + } + } catch (err) { + console.error('[MCP Diagnostics] Fetch failed:', err); + } finally { + setLoading(false); + } + }, []); - {(type === 'llm_execution' || type === 'llm_request' || type === 'llm_response') && ( - <> - - - - {event.grounding && ( - <> - - - - )} - - )} + useEffect(() => { + fetchData(); - {type === 'trace_completed' && ( - <> - - - - )} + const unsubTel = onTelemetryEvent(() => { + fetchData(); + }); - {/* Input payload */} - {type !== 'tool_execution' && type !== 'tool_invocation' && input != null && input !== '' && typeof input === 'string' && input.length > 0 && ( - {input} - )} + const unsubMcp = onMcpStatusChanged((newStatus) => { + setMcpStatus(newStatus); + }); - {/* Output payload */} - {type !== 'prompt_construction' && type !== 'tool_execution' && type !== 'tool_response' && output != null && output !== '' && typeof output === 'string' && output.length > 0 && ( - {output} - )} -
- ); -} + return () => { + if (typeof unsubTel === 'function') unsubTel(); + if (typeof unsubMcp === 'function') unsubMcp(); + }; + }, [fetchData]); + + const handleClearLogs = async () => { + const isOk = await confirm({ + title: 'Clear MCP Telemetry Logs', + message: 'Are you sure you want to clear all recorded MCP tool calls and activity logs? This action cannot be undone.', + confirmText: 'Clear Logs', + cancelText: 'Cancel', + type: 'danger' + }); + + if (isOk) { + try { + await aiClearLogs(); + await fetchData(); + window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'MCP Telemetry logs cleared', type: 'info' } })); + } catch (err) { + console.error('Clear logs failed:', err); + } + } + }; + const handleCopyCode = (text, keyId, label = 'Content') => { + copyToClipboard(text, label); + setCopiedId(keyId); + setTimeout(() => setCopiedId(null), 2000); + }; + // Filter tool calls + const filteredCalls = toolCalls.filter(c => { + if (statusFilter !== 'ALL' && c.status !== statusFilter) return false; + if (toolFilter !== 'ALL' && c.toolName !== toolFilter) return false; + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const matchName = c.toolName.toLowerCase().includes(q); + const matchClient = c.clientName.toLowerCase().includes(q); + const matchErr = c.error ? String(c.error).toLowerCase().includes(q) : false; + if (!matchName && !matchClient && !matchErr) return false; + } + return true; + }); -// ─── System prompt viewer (shared across all traces for a flow) ────────────── + // Unique tool names for filter dropdown + const uniqueTools = Array.from(new Set(toolCalls.map(c => c.toolName))).filter(Boolean); + + // Health summary metrics + const isRunning = mcpStatus?.running ?? healthData?.enabled ?? false; + const serverPort = mcpStatus?.port || 3700; + const serverHost = mcpStatus?.host || '127.0.0.1'; + const mcpMetrics = healthData?.mcp || {}; + const activeConnCount = mcpSessions.length || mcpMetrics.activeConnections || 0; + const totalCallsCount = mcpMetrics.totalToolCalls || toolCalls.length; + const successRate = mcpMetrics.successRate ?? (totalCallsCount > 0 ? 100 : 100); + const failedCount = mcpMetrics.failedCalls || toolCalls.filter(c => c.status === 'FAILED').length; + const topTools = mcpMetrics.mostUsedTools || []; + const dbStats = healthData?.database || {}; -function SystemPromptViewer({ prompt }) { - const [open, setOpen] = useState(false); - const [copied, setCopied] = useState(false); - const [fullHeight, setFullHeight] = useState(false); - if (!prompt) return null; return ( -
- - {open && ( -
-
- - -
-
-            {prompt}
-          
-
- )} -
- ); -} - - - -// Wrapper that lets expandAll override local open state with classic glowing dot & continuous vertical line timeline -function EventRowControlled({ event, isLast, forceOpen, turnSystemPrompt }) { - const [localOpen, setLocalOpen] = useState(false); - const open = forceOpen || localOpen; - const cfg = getEventCfg(event.type); - const Icon = cfg.icon; - const isSystemDriven = event.callerType === 'system' || - (event.callerType !== 'llm' && ( - event.type === 'conversation_loaded' || - event.type === 'planner' || - event.type === 'prompt_construction' || - event.type === 'llm_request' || - event.toolType === 'programmatic' || - event.toolType === 'pre-retrieval' - )); - - const DriverIcon = isSystemDriven ? Zap : Bot; - const driverLabel = isSystemDriven ? 'SYSTEM' : 'LLM'; - const enrichedEvent = turnSystemPrompt ? { ...event, turnSystemPrompt } : event; - - return ( -
- {/* 1. Left timestamp */} -
- - {fmtTime(event.startedAt)} -
- - {/* 2. Center continuous vertical line thread & glowing dot node */} -
-
-
- -
- {!isLast &&
} + + + MCP Diagnostics +
- {/* 3. Right expandable card container */} -
- - - {open && ( -
- + +
+ Active Clients: + {activeConnCount}
- )} -
-
- ); -} - -function exportTraceAsMarkdown(meta, turnNumber) { - const query = meta.query || '(no query)'; - const events = meta.events || []; - let md = `# AI Execution Trace Report - Turn #${turnNumber}\n\n`; - md += `- **Query:** "${query}"\n`; - md += `- **Persona:** ${formatPersonaName(meta.persona)}\n`; - md += `- **Total Latency:** ${fmtMs(meta.totalDurationMs || 0)}\n`; - md += `- **Tokens:** ${meta.tokensUsed || 0} (${meta.tokensDetail ? `${meta.tokensDetail.promptTokens || 0} prompt / ${meta.tokensDetail.completionTokens || 0} completion` : 'n/a'})\n\n`; - md += `## Timeline Spans & Events (${events.length})\n\n`; - events.forEach((e, i) => { - md += `### ${i + 1}. [${(e.callerType || 'system').toUpperCase()}] ${e.label || e.type}\n`; - if (e.startedAt) md += `- **Timestamp:** \`${e.startedAt}\`\n`; - if (e.durationMs) md += `- **Latency:** ${fmtMs(e.durationMs)}\n`; - if (e.toolName) md += `- **Tool Name:** \`${e.toolName}\`\n`; - if (e.input) md += `\n**Input Payload:**\n\`\`\`json\n${typeof e.input === 'string' ? e.input : JSON.stringify(e.input, null, 2)}\n\`\`\`\n`; - if (e.output) md += `\n**Output Result:**\n\`\`\`json\n${typeof e.output === 'string' ? e.output : JSON.stringify(e.output, null, 2)}\n\`\`\`\n`; - md += `\n---\n\n`; - }); - copyToClipboard(md, `Turn #${turnNumber} Markdown Report`); -} - -// ─── Flow Telemetry tab: Unified Single Thread View (Latest on Top) ───────── - -function FlowTelemetryPane({ conv, flowLogs }) { - const [filter, setFilter] = useState('all'); - const [expandAll, setExpandAll] = useState(false); - const [search, setSearch] = useState(''); - const [copied, setCopied] = useState(false); - - const filterTypes = [ - { id: 'all', label: 'All' }, - { id: 'system', label: '⚡ System-Driven' }, - { id: 'llm', label: '🤖 LLM-Driven' }, - { id: 'tool_execution', label: 'Tools' }, - { id: 'llm_execution', label: 'LLM' }, - { id: 'prompt_construction', label: 'Prompt' }, - { id: 'planner', label: 'Planner' }, - { id: 'error', label: 'Errors' } - ]; - - // Total stats across the conversation thread - const totalTokens = flowLogs.reduce((acc, l) => acc + (l.metadata?.tokensUsed || 0), 0); - const totalTools = flowLogs.reduce((acc, l) => acc + (Array.isArray(l.metadata?.events) ? l.metadata.events.filter(e => e.type === 'tool_execution' || e.type === 'tool_invocation').length : 0), 0); - - // Search filter - const q = search.trim().toLowerCase(); - const filteredLogs = q - ? flowLogs.filter(l => (l.metadata?.query || l.message || '').toLowerCase().includes(q)) - : flowLogs; - - // Copy full conversation telemetry - const handleCopyFullThreadTelemetry = () => { - const threadTelemetry = { - conversationId: conv.id, - conversationTitle: conv.title || 'Conversation', - turnCount: flowLogs.length, - totalTokens, - totalTools, - turns: flowLogs.map((logItem, idx) => ({ - turnNumber: flowLogs.length - idx, - timestamp: logItem.timestamp, - query: logItem.metadata?.query || logItem.message, - persona: logItem.metadata?.persona, - durationMs: logItem.metadata?.totalDurationMs || 0, - tokensUsed: logItem.metadata?.tokensUsed || 0, - systemPrompt: logItem.metadata?.systemPrompt || '', - stages: logItem.metadata?.stages || [], - events: logItem.metadata?.events || [] - })) - }; - copyToClipboard(JSON.stringify(threadTelemetry, null, 2), 'Full Thread Telemetry JSON'); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- {/* Header bar with thread stats & actions */} -
-
- {flowLogs.length} Turns - {totalTokens} Tokens - {totalTools > 0 && {totalTools} Tools}
-
- -
-
- {/* Filter & Expand controls */} -
-
- - {filterTypes.map(ft => ( - - ))} -
-
- +
-
-
- - {/* Search */} -
- - setSearch(e.target.value)} - /> - {search && ( - - )} -
- - {/* Thread Timeline Body — Merged single timeline with horizontal turn dividers, latest turn on top */} -
- {filteredLogs.length === 0 && ( -
- {flowLogs.length === 0 - ? 'No flow telemetry recorded yet for this conversation thread. Send a message in chat to generate execution events.' - : 'No execution events match your search.'} -
- )} - - {filteredLogs.map((logItem, turnIdx) => { - const meta = logItem.metadata || {}; - const query = meta.query || logItem.message || '(no query)'; - const totalMs = meta.totalDurationMs || 0; - const tokens = meta.tokensUsed || 0; - const events = Array.isArray(meta.events) ? meta.events : []; - const systemPrompt = meta.systemPrompt || ''; - const turnNumber = flowLogs.length - turnIdx; // Turn 3, Turn 2, Turn 1 (newest first) - - const filteredEvents = events.filter(e => { - if (filter === 'all') return true; - const isSys = e.callerType === 'system' || - e.type === 'conversation_loaded' || - e.type === 'planner' || - e.type === 'prompt_construction' || - e.type === 'llm_request' || - e.type === 'error' || - e.toolType === 'programmatic' || - e.toolType === 'pre-retrieval'; - - if (filter === 'system') return isSys; - if (filter === 'llm') return !isSys; - return e.type === filter; - }); - - const tokensDetail = meta.tokensDetail || null; - - return ( - - {/* Horizontal Turn Divider Line */} -
-
-
- Turn #{turnNumber} - "{query}" -
- {totalMs > 0 && {fmtMs(totalMs)}} - {tokens > 0 && ( - - {tokens} tok {tokensDetail ? `(${tokensDetail.promptTokens || 0}p/${tokensDetail.completionTokens || 0}c)` : ''} - - )} - - -
-
-
-
- - {/* Legacy fallback notice for old logs */} - {events.length === 0 && ( -
- - Turn recorded before granular event logging. -
- )} - - {/* Timeline events in this turn running along single timeline */} - {filteredEvents.map((event, idx) => ( - - ))} - - ); - })} -
-
- ); -} - -// ─── ConversationPane ──────────────────────────────────────────────────────── - -function ConversationPane({ conv, onBack }) { - const [messages, setMessages] = useState(null); - const [flowLogs, setFlowLogs] = useState([]); - const [activeTab, setActiveTab] = useState('messages'); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - useEffect(() => { - let cancelled = false; - async function load() { - try { - const [msgRes, flowRes] = await Promise.all([ - aiGetMessages(conv.id), - aiGetLogs('FlowTracker', 200, conv.id).catch(() => ({ success: true, data: [] })) - ]); - if (cancelled) return; - - if (msgRes?.success) setMessages(msgRes.data || []); - else setError(msgRes?.error || 'Failed to load messages.'); - - const rawFlow = flowRes?.success ? (flowRes.data || []) : []; - - // Strict conversation-scoped filtering - const matchedFlow = rawFlow.filter(item => item.metadata?.conversationId === conv.id); - - // Sort latest turn first (newest turn strictly at top) - matchedFlow.sort((a, b) => { - const tA = new Date(a.timestamp).getTime() || a.id || 0; - const tB = new Date(b.timestamp).getTime() || b.id || 0; - return tB - tA; - }); - setFlowLogs(matchedFlow); - } catch (e) { - if (!cancelled) setError(e.message); - } finally { - if (!cancelled) setLoading(false); - } - } - load(); - - // Subscribe to live telemetry events for live updates - let unsub = () => {}; - try { - if (typeof onTelemetryEvent === 'function') { - unsub = onTelemetryEvent((evt) => { - if (!evt || evt.conversationId !== conv.id) return; - aiGetLogs('FlowTracker', 200, conv.id).then(res => { - if (!cancelled && res?.success) { - const rawFlow = res.data || []; - const matched = rawFlow.filter(item => item.metadata?.conversationId === conv.id); - matched.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0)); - setFlowLogs(matched); - } - }).catch(() => {}); - }); - } - } catch { /* ignore subscription error */ } - - return () => { - cancelled = true; - unsub(); - }; - }, [conv.id]); - - return ( -
-
- -
{conv.title}
-
Persona: {formatPersonaName(conv.persona)} · {new Date(conv.created_at).toLocaleDateString()}
-
-
-
- {loading &&
Loading…
} - {error &&
{error}
} - - {!loading && !error && activeTab === 'messages' && ( - <> - {messages?.length === 0 &&
No messages in this conversation.
} - {messages?.map((msg, idx) => )} - - )} - - {!loading && !error && activeTab === 'flow' && ( - - )} -
-
- ); -} - -// ─── Main page ─────────────────────────────────────────────────────────────── - -export default function AIHealthPage({ onBack }) { - const { confirm } = useConfirm(); - const [health, setHealth] = useState(null); - const [conversations, setConversations] = useState([]); - const [selectedConv, setSelectedConv] = useState(null); - const [convSearch, setConvSearch] = useState(''); - const [, setLoading] = useState(false); - const [error, setError] = useState(''); - - const load = useCallback(async () => { - try { - setLoading(true); - setError(''); - const [healthRes, convRes] = await Promise.all([ - aiGetHealth(), - aiListConversations().catch(() => ({ success: true, data: [] })) - ]); - if (healthRes?.success) setHealth(healthRes.data); - else setError(healthRes?.error || 'Failed to fetch diagnostics.'); - if (convRes?.success) setConversations(convRes.data || []); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { load(); }, [load]); - - const db = health?.database; - const stats = health?.systemStats; - - const q = convSearch.trim().toLowerCase(); - const filteredConversations = q - ? conversations.filter(c => - c.title.toLowerCase().includes(q) || - formatPersonaName(c.persona).toLowerCase().includes(q) - ) - : conversations; - - return ( -
-
- -
- + {/* Main Two-Column Body */}
- {/* Left column */} + {/* Left Column: Subsystem & Metrics Cards Sidebar */}
- {error &&
{error}
} - + {/* MCP Server Overview */}
- Subsystem State + MCP Server Overview
- AI Engine - - {health?.enabled ? <> Enabled : <> Disabled} - + Server Endpoint +
+ http://{serverHost}:{serverPort}/sse + +
- Orchestrator - - {health?.initialized ? <> Ready : <> Not initialized} - + Transport Protocol + HTTP SSE (Server-Sent Events)
- Active Provider - {health?.activeProvider || '—'} + MCP Spec Version + 2024-11-05
- Indexer Status - - {health?.isIndexing ? 'Indexing...' : health?.isPaused ? 'Paused' : 'Ready'} - + Total Sessions Recorded + {mcpMetrics.totalSessions || mcpSessions.length || 0}
+ {/* Execution Metrics Stat Grid */}
- Session Usage + Tool Execution Activity
- - - +
+
Total Calls
+
{totalCallsCount}
+
+
+
Success Rate
+
= 95 ? 'var(--status-success-text)' : 'var(--status-danger-text)' }}> + {successRate}% +
+
+
+
Failures
+
0 ? 'var(--status-danger-text)' : 'inherit' }}> + {failedCount} +
+
+ {/* Top Tools Pills */} + {topTools.length > 0 && ( +
+
+ Frequently Invoked Tools +
+
+ {topTools.map((t, i) => ( + + + {t.toolName} + ({t.count}) + + ))} +
+
+ )} + + {/* Subsystem Storage Health */}
- Database Connections + Subsystem Storage Health
- - - - - + + +
+
- {/* Database Cleanup */} -
-
- - Database & Telemetry Cleanup -
-
- - + )} - await aiClearLogs(null, beforeTs); - await aiClearConversations(beforeTs).catch(() => {}); + - window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `Conversations & telemetry cleared (${sel})`, type: 'info' } })); - setSelectedConv(null); - load(); + {uniqueTools.length > 0 && ( + + )}
-
- {/* Right column */} -
- {selectedConv ? ( - setSelectedConv(null)} /> - ) : ( -
-
- Conversation History - - {q ? `${filteredConversations.length} / ${conversations.length}` : conversations.length} - -
-
- - setConvSearch(e.target.value)} - /> - {convSearch && ( - - )} + {/* Tool Calls Flight Log Timeline */} +
+ {filteredCalls.length === 0 ? ( +
+ +
+ No MCP tool call events recorded yet. Connect an external MCP client (such as Claude Desktop or Cursor) to inspect tool executions.
- {filteredConversations.length === 0 ? ( -
- {conversations.length === 0 - ? 'No conversations yet. Start chatting to see history here.' - : 'No matches for your search.'} -
- ) : ( -
- {filteredConversations.map(conv => ( - - ))} -
- )} -
- )} + + {/* Expanded Detail Inspector */} + {isExpanded && ( +
+ {call.error && ( +
+ + Execution Failure: {String(call.error)} +
+ )} + +
+
+ INPUT PAYLOAD (Sanitized) + +
+
+                            {formattedInput}
+                          
+
+ +
+
+ OUTPUT PAYLOAD (Sanitized) + +
+
+                            {formattedOutput}
+                          
+
+
+ )} +
+ ); + }) + )} +
diff --git a/src/components/AIPalette.jsx b/src/components/AIPalette.jsx deleted file mode 100644 index bd0530d1..00000000 --- a/src/components/AIPalette.jsx +++ /dev/null @@ -1,628 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import "../styles/AIPalette.css"; -import AppButton from "./AppButton"; -import AppChipButton from "./AppChipButton"; -import OverlayDialog from "./OverlayDialog"; - -const TARGET_OPTIONS = [ - { id: "selection", label: "Selection" }, - { id: "block", label: "Current Block" }, - { id: "document", label: "Whole Note" }, -]; - -const APPLY_OPTIONS = [ - { id: "insert", label: "Insert at Cursor" }, - { id: "replace-selection", label: "Replace Selection" }, - { id: "replace-block", label: "Replace Block" }, -]; - -const NOTE_PRESETS = [ - { id: "meeting", label: "Meeting Notes" }, - { id: "research", label: "Research Notes" }, - { id: "action-plan", label: "Action Plan" }, -]; - -function getPresetStorageKey(scope, value) { - if (!value) return ""; - return `ai:preset:${scope}:${value}`; -} - -function buildPreviewRows(currentText, nextText) { - const currentLines = String(currentText || "").split(/\r?\n/); - const nextLines = String(nextText || "").split(/\r?\n/); - const max = Math.max(currentLines.length, nextLines.length); - const rows = []; - - for (let index = 0; index < max; index += 1) { - const previous = currentLines[index] ?? ""; - const latest = nextLines[index] ?? ""; - let status = "same"; - if (index >= currentLines.length) status = "added"; - else if (index >= nextLines.length) status = "removed"; - else if (previous !== latest) status = "changed"; - rows.push({ - id: `${index}-${status}`, - line: index + 1, - previous, - latest, - status, - }); - } - - return rows; -} - -function mergePreviewRows(rows, selectedRows) { - const merged = []; - - rows.forEach((row) => { - const chosen = selectedRows[row.id] !== false; - if (row.status === "same") { - merged.push(row.previous); - return; - } - - if (row.status === "added") { - if (chosen) merged.push(row.latest); - return; - } - - if (row.status === "removed") { - if (!chosen) merged.push(row.previous); - return; - } - - merged.push(chosen ? row.latest : row.previous); - }); - - return merged.join("\n"); -} - -const AI_COMMANDS = [ - { id: "summarize", label: "Summarize Document", description: "Generate a concise summary of the current note", icon: "Sum" }, - { id: "analyze", label: "Analyze Content", description: "Analyze the note and surface key insights", icon: "Ana" }, - { id: "format", label: "Format Markdown", description: "Improve markdown structure and consistency", icon: "Fmt" }, - { id: "search", label: "Search Workspace", description: "Find related notes and concepts", icon: "Sea" }, - { id: "generate", label: "Generate Content", description: "Draft new markdown to add into the note", icon: "Gen" }, - { id: "refactor", label: "Organize Content", description: "Rewrite or reorganize the current section", icon: "Org" }, - { id: "find-related", label: "Find Related Docs", description: "Find semantically similar documents", icon: "Rel" }, -]; - -function buildQuickActions(contextSummary, noteTitle, preset) { - if (preset === "meeting") { - return [ - { - id: "meeting-decisions", - label: "Extract Decisions", - description: "Pull out decisions and owners from this note.", - query: "Extract decisions, owners, and follow-up actions from this meeting note as markdown bullets.", - target: contextSummary?.hasSelection ? "selection" : contextSummary?.hasCurrentBlock ? "block" : "document", - }, - { - id: "meeting-minutes", - label: "Polish Minutes", - description: "Turn rough notes into cleaner meeting minutes.", - query: "Rewrite these meeting notes into crisp meeting minutes with sections for discussion, decisions, and next steps.", - target: contextSummary?.hasSelection ? "selection" : contextSummary?.hasCurrentBlock ? "block" : "document", - }, - { - id: "meeting-followup", - label: "Create Follow-Ups", - description: "Generate a follow-up checklist after the meeting.", - query: "Create a follow-up checklist from this meeting note with owners and due-date placeholders.", - target: contextSummary?.hasSelection ? "selection" : contextSummary?.hasCurrentBlock ? "block" : "document", - }, - ]; - } - - if (preset === "action-plan") { - return [ - { - id: "plan-steps", - label: "Structure Plan", - description: "Turn ideas into sequenced implementation steps.", - query: "Turn this into a structured action plan with phases, tasks, and dependencies.", - target: contextSummary?.hasSelection ? "selection" : contextSummary?.hasCurrentBlock ? "block" : "document", - }, - { - id: "plan-risks", - label: "Find Risks", - description: "Surface blockers, risks, and missing prerequisites.", - query: "Identify risks, blockers, and missing prerequisites in this action plan.", - target: contextSummary?.hasSelection ? "selection" : contextSummary?.hasCurrentBlock ? "block" : "document", - }, - { - id: "plan-checklist", - label: "Make Execution Checklist", - description: "Convert this plan into a tighter execution checklist.", - query: "Convert this into a practical markdown execution checklist with milestones.", - target: contextSummary?.hasSelection ? "selection" : contextSummary?.hasCurrentBlock ? "block" : "document", - }, - ]; - } - - if (contextSummary?.hasSelection) { - return [ - { - id: "rewrite-selection", - label: "Rewrite Cleanly", - description: "Polish the selected text while preserving the point.", - query: "Rewrite this selection to be clearer, tighter, and more polished while preserving meaning.", - target: "selection", - }, - { - id: "expand-selection", - label: "Add Detail", - description: "Expand the selection with more useful specifics.", - query: "Expand this selection with more concrete detail and helpful context in markdown.", - target: "selection", - }, - { - id: "checklist-selection", - label: "Make Checklist", - description: "Turn the selection into an actionable markdown checklist.", - query: "Convert this selection into an actionable markdown checklist.", - target: "selection", - }, - ]; - } - - if (contextSummary?.hasCurrentBlock) { - return [ - { - id: "continue-block", - label: "Continue Section", - description: "Keep writing from the current block in the same tone.", - query: "Continue this section in the same tone and structure with useful next details.", - target: "block", - }, - { - id: "compress-block", - label: "Tighten Block", - description: "Make the current block shorter and easier to scan.", - query: "Rewrite the current block to be shorter, clearer, and easier to scan.", - target: "block", - }, - { - id: "extract-actions", - label: "Extract Next Steps", - description: "Pull out action items and decisions from the current block.", - query: "Extract the action items, decisions, and follow-ups from this block as markdown bullets.", - target: "block", - }, - ]; - } - - return [ - { - id: "summarize-note", - label: "Summarize Note", - description: `Create a concise executive summary of ${noteTitle}.`, - query: "Summarize this note into a concise executive overview with key takeaways.", - target: "document", - }, - { - id: "find-gaps", - label: "Find Gaps", - description: "Identify what is missing or unclear in the note.", - query: "Review this note and identify missing details, unclear sections, and suggested improvements.", - target: "document", - }, - { - id: "create-plan", - label: "Create Plan", - description: "Turn the note into a clearer next-step plan.", - query: "Turn this note into a structured next-step plan with markdown headings and bullets.", - target: "document", - }, - ]; -} - -export default function AIPalette({ - isOpen, - onClose, - onQuery, - onApply, - isLoading = false, - error = null, - contextSummary = null, - intent = null, - noteTitle = "Current Note", - noteKey = "", - workspaceKey = "", -}) { - const [searchInput, setSearchInput] = useState(""); - const [suggestions, setSuggestions] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const [recentQueries, setRecentQueries] = useState([]); - const [responseText, setResponseText] = useState(""); - const [target, setTarget] = useState("selection"); - const [notePreset, setNotePreset] = useState("research"); - const [pendingApply, setPendingApply] = useState(null); - const [selectedDiffRows, setSelectedDiffRows] = useState({}); - const inputRef = useRef(null); - const lastAutoRunRequestIdRef = useRef(""); - - const availableApplyOptions = useMemo( - () => APPLY_OPTIONS.map((option) => ({ - ...option, - disabled: - option.id === "replace-selection" - ? !contextSummary?.hasSelection - : option.id === "replace-block" - ? !contextSummary?.hasCurrentBlock - : false, - })), - [contextSummary] - ); - - const quickActions = useMemo( - () => buildQuickActions(contextSummary, noteTitle, notePreset), - [contextSummary, noteTitle, notePreset] - ); - - const diffPreview = useMemo(() => { - if (!pendingApply?.rows) return []; - return pendingApply.rows.filter((row) => row.status !== "same"); - }, [pendingApply]); - - useEffect(() => { - const recent = localStorage.getItem("ai-recent-queries"); - if (recent) { - setRecentQueries(JSON.parse(recent)); - } - }, []); - - useEffect(() => { - if (!isOpen) return; - inputRef.current?.focus(); - setSearchInput(intent?.query || ""); - setResponseText(""); - setPendingApply(null); - setSelectedDiffRows({}); - setSelectedIndex(0); - setTarget(intent?.target || (contextSummary?.hasSelection ? "selection" : contextSummary?.hasCurrentBlock ? "block" : "document")); - const notePresetKey = getPresetStorageKey("note", noteKey); - const workspacePresetKey = getPresetStorageKey("workspace", workspaceKey); - const savedPreset = - (notePresetKey ? window.localStorage.getItem(notePresetKey) : "") - || (workspacePresetKey ? window.localStorage.getItem(workspacePresetKey) : ""); - setNotePreset(savedPreset || contextSummary?.suggestedPreset || "research"); - setSuggestions(AI_COMMANDS); - }, [contextSummary, intent, isOpen, noteKey, workspaceKey]); - - useEffect(() => { - if (!isOpen || !notePreset) return; - const notePresetKey = getPresetStorageKey("note", noteKey); - const workspacePresetKey = getPresetStorageKey("workspace", workspaceKey); - if (notePresetKey) { - window.localStorage.setItem(notePresetKey, notePreset); - } - if (workspacePresetKey) { - window.localStorage.setItem(workspacePresetKey, notePreset); - } - }, [isOpen, noteKey, notePreset, workspaceKey]); - - useEffect(() => { - if (!isOpen || !intent?.autoRun || !intent?.query) return; - if (lastAutoRunRequestIdRef.current === intent.requestId) return; - lastAutoRunRequestIdRef.current = intent.requestId; - handleCustomQuery(intent.query, intent.target || target); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, intent, target]); - - const updateSuggestions = (query) => { - if (!query.trim()) { - setSuggestions(AI_COMMANDS); - return; - } - - const lowered = query.toLowerCase(); - const filtered = AI_COMMANDS.filter( - (command) => command.label.toLowerCase().includes(lowered) - || command.description.toLowerCase().includes(lowered) - ); - setSuggestions(filtered); - setSelectedIndex(0); - }; - - const handleInputChange = (event) => { - const value = event.target.value; - setSearchInput(value); - updateSuggestions(value); - }; - - const handleKeyDown = (event) => { - switch (event.key) { - case "ArrowDown": - event.preventDefault(); - if (suggestions.length) { - setSelectedIndex((prev) => (prev + 1) % suggestions.length); - } - break; - case "ArrowUp": - event.preventDefault(); - if (suggestions.length) { - setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length); - } - break; - case "Enter": - event.preventDefault(); - if (suggestions.length > 0) { - handleSelectCommand(suggestions[selectedIndex]); - } else if (searchInput.trim()) { - handleCustomQuery(searchInput); - } - break; - case "Escape": - event.preventDefault(); - onClose(); - break; - default: - break; - } - }; - - const handleSelectCommand = (command) => { - setSearchInput(command.label); - handleCustomQuery(command.label); - }; - - const handleCustomQuery = async (query, overrideTarget = null) => { - if (!query.trim() || isLoading) return; - const effectiveTarget = overrideTarget || target; - - const updated = [query, ...recentQueries.filter((item) => item !== query)].slice(0, 10); - setRecentQueries(updated); - localStorage.setItem("ai-recent-queries", JSON.stringify(updated)); - - const result = await onQuery({ query, target: effectiveTarget }); - setResponseText(result?.text || ""); - }; - - const handleApply = async (mode) => { - if (!responseText || typeof onApply !== "function") return; - const preview = await onApply({ text: responseText, mode, previewOnly: mode !== "insert" }); - if (mode === "insert") { - return; - } - if (!preview?.applied && preview?.currentText && preview?.nextText) { - const rows = buildPreviewRows(preview.currentText, preview.nextText); - setPendingApply({ - mode, - text: responseText, - currentText: preview.currentText, - nextText: preview.nextText, - rows, - }); - setSelectedDiffRows( - rows.reduce((acc, row) => { - if (row.status !== "same") acc[row.id] = true; - return acc; - }, {}) - ); - } - }; - - const handleConfirmApply = async () => { - if (!pendingApply) return; - const nextText = pendingApply.mode === "replace-selection" - ? mergePreviewRows(pendingApply.rows, selectedDiffRows) - : pendingApply.text; - await onApply({ text: nextText, mode: pendingApply.mode, previewOnly: false }); - setPendingApply(null); - setSelectedDiffRows({}); - }; - - return ( - -
-
-
AI Assistant
-
Working inside {noteTitle}
-
- -
- -
-
- {NOTE_PRESETS.map((preset) => ( - setNotePreset(preset.id)} - > - {preset.label} - - ))} -
-
- {TARGET_OPTIONS.map((option) => ( - setTarget(option.id)} - > - {option.label} - - ))} -
-
- {contextSummary?.label || "Using the current note as context."} -
-
- -
- - {isLoading ?
: null} -
- - {quickActions.length ? ( -
-
Quick actions
-
- {quickActions.map((action) => ( - - ))} -
-
- ) : null} - - {error ? ( -
- ! - {error} -
- ) : null} - -
-
Commands
- {suggestions.length > 0 ? ( - suggestions.map((command, index) => ( - - )) - ) : searchInput.trim() ? ( -
-
Ask custom question:
- -
- ) : ( -
No suggestions
- )} -
- - {recentQueries.length > 0 && !searchInput ? ( -
-
Recent:
-
- {recentQueries.slice(0, 3).map((query, index) => ( - - ))} -
-
- ) : null} - - {responseText ? ( -
-
- AI Draft - {responseText.length} chars -
-
{responseText}
-
- {availableApplyOptions.map((option) => ( - - ))} -
- {pendingApply ? ( -
-
- Preview replacement - setPendingApply(null)}>Cancel -
-
- {diffPreview.map((row) => ( -
- {pendingApply.mode === "replace-selection" ? ( - - ) : null} - {row.line} -
{row.previous}
-
{row.latest}
-
- ))} -
-
- Apply Replacement -
-
- ) : null} -
- ) : null} - -
- Ctrl/Cmd+Shift+I opens the AI palette. Run a prompt, then insert or replace directly in the editor. -
- - ); -} diff --git a/src/components/AISettings.jsx b/src/components/AISettings.jsx index 863dd281..034baa78 100644 --- a/src/components/AISettings.jsx +++ b/src/components/AISettings.jsx @@ -356,7 +356,7 @@ export const AISettingsContent = ({ _onClose }) => { Enable AI Subsystem - Toggle the global switch to enable or disable all background AI services, embeddings, and chat. + Toggle the global switch to enable or disable all background AI services, embeddings, and graph extraction.