main.ts
1 import { Plugin, TFolder, TAbstractFile, Menu, Notice } from 'obsidian'; 2 import { UIService } from './core/services/ui-service'; 3 import { GitOperationsService } from './features/dreamnode/utils/git-operations'; 4 import { VaultService } from './core/services/vault-service'; 5 import { PassphraseManager } from './features/social-resonance-filter/services/passphrase-manager'; 6 import { serviceManager } from './core/services/service-manager'; 7 import { DreamspaceView, DREAMSPACE_VIEW_TYPE } from './core/components/DreamspaceView'; 8 import { DreamSongFullScreenView, DREAMSONG_FULLSCREEN_VIEW_TYPE } from './features/dreamweaving/components/DreamSongFullScreenView'; 9 import { CustomUIFullScreenView, CUSTOM_UI_FULLSCREEN_VIEW_TYPE } from './features/dreamweaving/components/CustomUIFullScreenView'; 10 import { LinkFileView, LINK_FILE_VIEW_TYPE } from './features/dreamweaving/components/LinkFileView'; 11 import { DreamExplorerView, DREAM_EXPLORER_VIEW_TYPE } from './features/dream-explorer/components/DreamExplorerView'; 12 import { LeafManagerService } from './core/services/leaf-manager-service'; 13 import { useInterBrainStore } from './core/store/interbrain-store'; 14 import { CONSTELLATION_DEFAULTS } from './features/constellation-layout/constants'; 15 import { calculateFibonacciSpherePositions, DEFAULT_FIBONACCI_CONFIG } from './features/constellation-layout'; 16 import { computeConstellationFilter } from './features/constellation-layout/services/constellation-filter-service'; 17 import { computeConstellationLayout, createFallbackLayout } from './features/constellation-layout/ConstellationLayout'; 18 import { 19 DreamNode, 20 registerDreamNodeCommands, 21 revealContainingDreamNode, 22 convertFolderToDreamNode, 23 openDreamSongForFile, 24 openDreamTalkForFile 25 } from './features/dreamnode'; 26 import { registerSemanticSearchCommands } from './features/semantic-search/commands'; 27 import { registerCameraCommands } from './core/commands/camera-commands'; 28 import { registerSearchCommands } from './features/search'; 29 import { registerConstellationDebugCommands } from './features/constellation-layout'; 30 import { registerEditModeCommands } from './features/dreamnode-editor'; 31 import { registerConversationalCopilotCommands } from './features/conversational-copilot/commands'; 32 import { registerDreamweavingCommands, registerLinkFileCommands, enhanceFileSuggestions } from './features/dreamweaving'; 33 import { DreamSongRelationshipService } from './features/dreamweaving/services/dreamsong-relationship-service'; 34 import { DEFAULT_DREAMSONG_RELATIONSHIP_CONFIG } from './features/dreamweaving/types/relationship'; 35 import { registerRadicleCommands } from './features/social-resonance-filter/commands'; 36 import { registerGitHubCommands } from './features/github-publishing/commands'; 37 import { registerCoherenceBeaconCommands } from './features/coherence-beacon/commands'; 38 import { registerDreamerUpdateCommands } from './features/dreamnode-updater/dreamer-update-commands'; 39 import { registerRelationshipCommands } from './features/liminal-web-layout'; 40 import { registerUpdateCommands } from './features/dreamnode-updater/commands'; 41 import { registerCollaborationTestCommands } from './features/dreamnode-updater/collaboration-test-commands'; 42 import { initializeCollaborationMemoryService } from './features/dreamnode-updater/services/collaboration-memory-service'; 43 import { initializeCherryPickWorkflowService } from './features/dreamnode-updater/services/cherry-pick-workflow-service'; 44 import { 45 registerTranscriptionCommands, 46 cleanupTranscriptionService, 47 initializeRealtimeTranscriptionService 48 } from './features/realtime-transcription'; 49 import { registerFaceTimeCommands } from './features/video-calling/commands'; 50 import { registerTutorialCommands } from './features/tutorial'; 51 import { FaceTimeService } from './features/video-calling/service'; 52 import { CanvasParserService } from './features/dreamweaving/services/canvas-parser-service'; 53 import { SubmoduleManagerService } from './features/dreamweaving/services/submodule-manager-service'; 54 import { CanvasObserverService } from './features/dreamweaving/services/canvas-observer-service'; 55 import { CoherenceBeaconService } from './features/coherence-beacon/service'; 56 import { initializeTranscriptionService } from './features/conversational-copilot/services/transcription-service'; 57 import { initializeConversationRecordingService } from './features/conversational-copilot/services/conversation-recording-service'; 58 import { initializeConversationSummaryService } from './features/conversational-copilot/services/conversation-summary-service'; 59 import { initializeEmailExportService } from './features/conversational-copilot/services/email-export-service'; 60 import { initializePDFGeneratorService } from './features/conversational-copilot/services/pdf-generator-service'; 61 import { initializeAudioRecordingService } from './features/songline/services/audio-recording-service'; 62 import { initializePerspectiveService } from './features/songline/services/perspective-service'; 63 import { initializeAudioTrimmingService } from './features/songline/services/audio-trimming-service'; 64 import { initializeConversationsService } from './features/songline/services/conversations-service'; 65 import { initializeAudioStreamingService } from './features/dreamweaving/services/audio-streaming-service'; 66 import { initializeURIHandlerService } from './features/uri-handler'; 67 import { initializeRadicleBatchInitService } from './features/social-resonance-filter/services/batch-init-service'; 68 import { getPeerSyncService } from './features/social-resonance-filter/services/peer-sync-service'; 69 import { initializeGitHubBatchShareService } from './features/github-publishing/services/batch-share-service'; 70 import { InterBrainSettingTab, InterBrainSettings, DEFAULT_SETTINGS } from './features/settings'; 71 import { closeIndexedDBConnection, setVaultId, gracefulShutdown, markHydrationComplete } from './core/store/indexeddb-storage'; 72 import { serviceLifecycleManager, LifecyclePhase } from './core/services/service-lifecycle-manager'; 73 import { vaultStateService } from './core/services/vault-state-service'; 74 import { SettingsStatusService } from './features/settings/settings-status-service'; 75 import { 76 registerFeedbackCommands, 77 errorCaptureService, 78 showFeedbackModal, 79 } from './features/feedback'; 80 import { 81 registerAIMagicCommands, 82 initializeInferenceService 83 } from './features/ai-magic'; 84 import { startAIBridgeServer, stopAIBridgeServer } from './features/ai-magic/services/ai-bridge-server'; 85 86 export default class InterBrainPlugin extends Plugin { 87 settings!: InterBrainSettings; 88 89 // Service instances 90 private uiService!: UIService; 91 private gitOpsService!: GitOperationsService; 92 private vaultService!: VaultService; 93 private passphraseManager!: PassphraseManager; 94 private faceTimeService!: FaceTimeService; 95 private canvasParserService!: CanvasParserService; 96 private submoduleManagerService!: SubmoduleManagerService; 97 public coherenceBeaconService!: CoherenceBeaconService; 98 private leafManagerService!: LeafManagerService; 99 private canvasObserverService!: CanvasObserverService; 100 101 async onload() { 102 // Suppress benign ResizeObserver loop warnings from flooding the console 103 const resizeObserverErr = window.onerror; 104 window.onerror = (msg, ...args) => { 105 if (typeof msg === 'string' && msg.includes('ResizeObserver loop')) return true; 106 return resizeObserverErr ? (resizeObserverErr as any)(msg, ...args) : false; 107 }; 108 109 const loadStartTime = Date.now(); 110 111 // Get vault path for all services 112 const vaultPath = (this.app.vault.adapter as any).basePath; 113 114 // ========================================================================= 115 // PHASE 1: BOOTSTRAP - Set context, load settings 116 // ========================================================================= 117 serviceLifecycleManager.registerPhaseHandler(LifecyclePhase.BOOTSTRAP, async () => { 118 // Set vault ID for IndexedDB namespacing FIRST 119 setVaultId(vaultPath); 120 vaultStateService.initialize(vaultPath); 121 122 // Load settings 123 await this.loadSettings(); 124 125 // Cache settings for services that need runtime access 126 SettingsStatusService.setSettings({ 127 claudeApiKey: this.settings.claudeApiKey, 128 radiclePassphrase: this.settings.radiclePassphrase, 129 }); 130 131 // Initialize AI Magic inference service 132 initializeInferenceService({ 133 defaultProvider: (this.settings.defaultAIProvider || 'claude') as any, 134 offlineMode: this.settings.offlineMode ?? false, 135 preferLocal: false, 136 claude: this.settings.claudeApiKey ? { apiKey: this.settings.claudeApiKey } : undefined, 137 openai: this.settings.openaiApiKey ? { apiKey: this.settings.openaiApiKey } : undefined, 138 groq: this.settings.groqApiKey ? { apiKey: this.settings.groqApiKey } : undefined, 139 xai: this.settings.xaiApiKey ? { apiKey: this.settings.xaiApiKey } : undefined 140 }); 141 142 // Start AI Bridge WebSocket server for external UIs (AURYN mobile, etc.) 143 startAIBridgeServer().then(port => { 144 console.log(`[InterBrain] AI Bridge available on port ${port}`); 145 }).catch(err => { 146 console.warn('[InterBrain] AI Bridge failed to start:', err.message); 147 }); 148 149 return { vaultPath }; 150 }); 151 152 // ========================================================================= 153 // PHASE 2: HYDRATE - Read IndexedDB, validate persisted data 154 // ========================================================================= 155 serviceLifecycleManager.registerPhaseHandler(LifecyclePhase.HYDRATE, async () => { 156 await useInterBrainStore.persist.rehydrate(); 157 158 // Mark hydration complete - this enables writes to IndexedDB 159 // Must happen AFTER rehydrate to prevent empty state from overwriting persisted data 160 markHydrationComplete(); 161 162 // Check if persisted data matches current vault 163 const store = useInterBrainStore.getState(); 164 const nodeCount = store.dreamNodes.size; 165 166 return { nodeCount }; 167 }); 168 169 // ========================================================================= 170 // PHASE 3: SCAN - Scan vault (only if needed based on vault state) 171 // ========================================================================= 172 serviceLifecycleManager.registerPhaseHandler(LifecyclePhase.SCAN, async () => { 173 // Initialize core services (creates GitDreamNodeService) 174 this.initializeServices(); 175 176 // Check if vault has changed since last scan 177 const changeResult = await vaultStateService.hasVaultChanged(); 178 179 if (!changeResult.hasChanges && changeResult.cachedState) { 180 // Validate persisted node count matches 181 const store = useInterBrainStore.getState(); 182 const persistedCount = store.dreamNodes.size; 183 const cachedCount = changeResult.cachedState.nodeCount; 184 185 if (persistedCount === cachedCount && persistedCount > 0) { 186 return { scanned: false, nodeCount: persistedCount, reason: 'cached' }; 187 } 188 } 189 190 // Need to scan - either vault changed or no cached data 191 const scanResult = await serviceManager.scanVault(); 192 193 if (scanResult) { 194 // Save vault state for next startup 195 const store = useInterBrainStore.getState(); 196 const nodeCount = store.dreamNodes.size; 197 await vaultStateService.saveState(nodeCount); 198 199 return { 200 scanned: true, 201 nodeCount, 202 added: scanResult.added, 203 updated: scanResult.updated, 204 removed: scanResult.removed 205 }; 206 } 207 208 return { scanned: true, nodeCount: 0, error: 'scan returned null' }; 209 }); 210 211 // ========================================================================= 212 // PHASE 4: READY - UI can interact, services available 213 // ========================================================================= 214 serviceLifecycleManager.registerPhaseHandler(LifecyclePhase.READY, async () => { 215 // Initialize essential services for URI handling 216 const radicleService = serviceManager.getRadicleService(); 217 const dreamNodeService = serviceManager.getActive(); 218 initializeURIHandlerService(this.app, this, radicleService, dreamNodeService as any); 219 initializeRadicleBatchInitService(this, radicleService, dreamNodeService as any); 220 initializeGitHubBatchShareService(this, dreamNodeService as any); 221 222 // Initialize error capture for bug reporting 223 this.initializeErrorCapture(); 224 225 // CRITICAL: Compute constellation filter BEFORE UI renders 226 // This prevents all 167 nodes from mounting at once and crashing WebGL 227 const store = useInterBrainStore.getState(); 228 const allNodeIds = Array.from(store.dreamNodes.keys()); 229 const relationshipGraph = store.dreamSongRelationships.graph; 230 const { maxNodes, prioritizeClusters } = store.constellationConfig; 231 232 if (allNodeIds.length > 0) { 233 const filter = computeConstellationFilter( 234 relationshipGraph, 235 allNodeIds, 236 maxNodes, 237 prioritizeClusters 238 ); 239 store.setConstellationFilter(filter); 240 241 // CRITICAL: Apply constellation positions to mounted nodes BEFORE rendering 242 // This ensures nodes have real positions on the Fibonacci sphere, not [0,0,0] 243 const persistedPositions = store.constellationData.positions; 244 const mountedNodeIds = Array.from(filter.mountedNodes); 245 246 // Check if persisted positions exist and are valid for current graph 247 let positionsToApply: Map<string, [number, number, number]> | null = null; 248 const persistedGraphHash = store.constellationData.graphHashWhenPositionsComputed; 249 const currentGraphHash = store.dreamSongRelationships.submoduleStructureHash; 250 251 if (persistedPositions && persistedPositions.size > 0) { 252 // Check 1: Do persisted positions match mounted nodes? 253 const matchCount = mountedNodeIds.filter(id => persistedPositions.has(id)).length; 254 const matchRatio = matchCount / mountedNodeIds.length; 255 256 // Check 2: Is the persisted graph hash still valid? 257 const hashValid = currentGraphHash === persistedGraphHash; 258 259 if (matchRatio >= 0.9 && hashValid) { 260 // 90%+ match AND graph unchanged - use persisted positions 261 positionsToApply = persistedPositions; 262 } 263 } 264 265 if (!positionsToApply) { 266 const mountedDreamNodes = mountedNodeIds 267 .map(id => store.dreamNodes.get(id)?.node) 268 .filter((node): node is DreamNode => !!node); 269 270 if (mountedDreamNodes.length > 0) { 271 try { 272 if (relationshipGraph && relationshipGraph.nodes && relationshipGraph.nodes.size > 0) { 273 // Filter relationship graph to only mounted nodes 274 const mountedNodeIdSet = new Set(mountedNodeIds); 275 const filteredNodes = new Map( 276 Array.from(relationshipGraph.nodes.entries()) 277 .filter(([id]) => mountedNodeIdSet.has(id)) 278 ); 279 const filteredEdges = relationshipGraph.edges.filter( 280 edge => mountedNodeIdSet.has(edge.source) && mountedNodeIdSet.has(edge.target) 281 ); 282 const filteredGraph = { 283 ...relationshipGraph, 284 nodes: filteredNodes, 285 edges: filteredEdges, 286 metadata: { 287 ...relationshipGraph.metadata, 288 totalNodes: filteredNodes.size, 289 } 290 }; 291 292 const layoutResult = computeConstellationLayout(filteredGraph, mountedDreamNodes); 293 positionsToApply = createFallbackLayout(mountedDreamNodes, layoutResult.nodePositions); 294 } else { 295 // No relationship graph - use Fibonacci sphere fallback 296 const fibPositions = calculateFibonacciSpherePositions({ 297 nodeCount: mountedDreamNodes.length, 298 radius: DEFAULT_FIBONACCI_CONFIG.radius 299 }); 300 positionsToApply = new Map( 301 mountedDreamNodes.map((node, i) => [node.id, fibPositions[i].position]) 302 ); 303 } 304 305 // Persist the computed positions and graph hash for future startups 306 if (positionsToApply && positionsToApply.size > 0) { 307 store.setConstellationPositions(positionsToApply); 308 if (currentGraphHash) { 309 store.setGraphHashWhenPositionsComputed(currentGraphHash); 310 } 311 } 312 } catch (error) { 313 console.error(`[Plugin] Position computation failed:`, error); 314 // Fallback to Fibonacci sphere on error 315 const fibPositions = calculateFibonacciSpherePositions({ 316 nodeCount: mountedDreamNodes.length, 317 radius: DEFAULT_FIBONACCI_CONFIG.radius 318 }); 319 positionsToApply = new Map( 320 mountedDreamNodes.map((node, i) => [node.id, fibPositions[i].position]) 321 ); 322 } 323 } 324 } 325 326 // Apply positions to node objects in store 327 if (positionsToApply && positionsToApply.size > 0) { 328 store.batchUpdateNodePositions(positionsToApply); 329 } 330 } 331 332 // CRITICAL: Signal that lifecycle is ready - this unblocks DreamspaceCanvas rendering 333 // Must happen AFTER constellation filter AND positions are set 334 store.setLifecycleReady(true); 335 336 return { ready: true }; 337 }); 338 339 // ========================================================================= 340 // PHASE 5: BACKGROUND - Heavy operations (deferred) 341 // ========================================================================= 342 serviceLifecycleManager.registerPhaseHandler(LifecyclePhase.BACKGROUND, async () => { 343 // These run after READY is complete - no setTimeout needed 344 await this.initializeBackgroundServices(); 345 346 // Radicle Peer Sync (fire-and-forget, non-blocking) 347 // Ensures liminal web relationships are synced with Radicle network 348 this.syncRadiclePeersInBackground(); 349 350 // DreamSong Relationship Scan with change detection: 351 // Only rescan if submodule structure has changed since last scan 352 const bgStore = useInterBrainStore.getState(); 353 const existingGraph = bgStore.dreamSongRelationships.graph; 354 const existingHash = bgStore.dreamSongRelationships.submoduleStructureHash; 355 356 // Defer scan check to run after lifecycle completes for UI stability 357 setTimeout(async () => { 358 try { 359 const relationshipService = new DreamSongRelationshipService(this); 360 const dreamNodes = Array.from(bgStore.dreamNodes.values()).map(d => d.node); 361 362 // Compute current submodule structure hash 363 const currentHash = await relationshipService.computeSubmoduleStructureHash(dreamNodes); 364 365 // Check if rescan needed 366 // IMPORTANT: Also rescan if graph has nodes but NO edges (indicates a failed/partial scan) 367 const hasValidEdges = existingGraph && existingGraph.edges.length > 0; 368 if (existingHash === currentHash && existingGraph && existingGraph.nodes.size > 0 && hasValidEdges) { 369 return; 370 } 371 372 const result = await relationshipService.scanVaultForDreamSongRelationships( 373 DEFAULT_DREAMSONG_RELATIONSHIP_CONFIG 374 ); 375 376 if (result.success && result.graph) { 377 // Get fresh store reference inside setTimeout 378 const scanStore = useInterBrainStore.getState(); 379 scanStore.setDreamSongRelationshipGraph(result.graph); 380 scanStore.setSubmoduleStructureHash(currentHash); 381 } 382 } catch (error) { 383 console.error(`[Plugin] DreamSong relationship scan error:`, error); 384 } 385 }, 3000); // Wait 3s to ensure UI is fully stable 386 387 return { backgroundStarted: true }; 388 }); 389 390 // ========================================================================= 391 // RUN LIFECYCLE 392 // ========================================================================= 393 await serviceLifecycleManager.runLifecycle(); 394 395 const loadDuration = Date.now() - loadStartTime; 396 console.log(`[Plugin] Lifecycle complete in ${loadDuration}ms`); 397 398 // ========================================================================= 399 // POST-LIFECYCLE SETUP (sync, non-blocking) 400 // ========================================================================= 401 402 // Add settings tab 403 this.addSettingTab(new InterBrainSettingTab(this.app, this)); 404 405 // Register view types 406 this.registerView(DREAMSPACE_VIEW_TYPE, (leaf) => new DreamspaceView(leaf)); 407 this.registerView(DREAMSONG_FULLSCREEN_VIEW_TYPE, (leaf) => new DreamSongFullScreenView(leaf)); 408 this.registerView(CUSTOM_UI_FULLSCREEN_VIEW_TYPE, (leaf) => new CustomUIFullScreenView(leaf)); 409 this.registerView(LINK_FILE_VIEW_TYPE, (leaf) => new LinkFileView(leaf)); 410 this.registerView(DREAM_EXPLORER_VIEW_TYPE, (leaf) => new DreamExplorerView(leaf)); 411 412 // Register .link file extension with custom view 413 this.registerExtensions(['link'], LINK_FILE_VIEW_TYPE); 414 415 // Register commands 416 this.registerCommands(); 417 418 // DreamSong relationship scan DISABLED - was causing crash 419 // TODO: Investigate why scan causes UI to become unresponsive 420 // The scan command can still be triggered manually via command palette 421 422 // Register file explorer context menu handler 423 this.registerFileExplorerContextMenu(); 424 425 // Start canvas observer for .link file preview 426 this.canvasObserverService.start(); 427 428 // Add ribbon icon with rotation 429 const ribbonIconEl = this.addRibbonIcon('brain-circuit', 'Open DreamSpace', () => { 430 this.app.commands.executeCommandById('interbrain:open-dreamspace'); 431 }); 432 ribbonIconEl.style.transform = 'rotate(90deg)'; 433 434 // First launch experience: auto-open DreamSpace with InterBrain selected 435 if (!this.settings.hasLaunchedBefore) { 436 this.handleFirstLaunch(); 437 } 438 439 // Every launch: check for reload target UUID, otherwise auto-select InterBrain 440 const reloadTargetUUID = (globalThis as any).__interbrainReloadTargetUUID; 441 if (reloadTargetUUID) { 442 delete (globalThis as any).__interbrainReloadTargetUUID; 443 } 444 this.autoSelectNode(reloadTargetUUID); 445 } 446 447 /** 448 * Auto-select a node on plugin startup (or reload) 449 * Called after lifecycle completes, so store is guaranteed to have nodes. 450 * @param targetUUID - Optional UUID to select. Defaults to InterBrain UUID if not provided. 451 */ 452 private autoSelectNode(targetUUID?: string): void { 453 // Wait for Obsidian's workspace layout to be ready (event-driven, not time-based) 454 this.app.workspace.onLayoutReady(() => { 455 // Detect fresh Obsidian launch vs plugin reload 456 const _existingDreamspaceLeaf = this.app.workspace.getLeavesOfType(DREAMSPACE_VIEW_TYPE); 457 458 const uuidToSelect = targetUUID || '550e8400-e29b-41d4-a716-446655440000'; 459 const store = useInterBrainStore.getState(); 460 const nodeData = store.dreamNodes.get(uuidToSelect); 461 462 if (nodeData) { 463 store.setSelectedNode(nodeData.node); 464 store.setSpatialLayout('liminal-web'); 465 466 // Check if this is a fresh app launch (not plugin reload) 467 const isPluginReload = (globalThis as any).__interbrainPluginReloaded === true; 468 469 if (!isPluginReload) { 470 // Check for InterBrain updates - lifecycle is complete, so this is safe to run immediately 471 this.app.commands.executeCommandById('interbrain:check-interbrain-updates'); 472 } 473 474 // Set reload flag for next time (persists across plugin reloads but not app restarts) 475 (globalThis as any).__interbrainPluginReloaded = true; 476 } else { 477 // Node not found - this shouldn't happen if lifecycle completed correctly 478 console.warn(`[InterBrain] Node not found for UUID: ${uuidToSelect} (store has ${store.dreamNodes.size} nodes)`); 479 } 480 }); 481 } 482 483 /** 484 * First launch experience: open DreamSpace and select InterBrain node 485 */ 486 private async handleFirstLaunch(): Promise<void> { 487 // Wait for Obsidian's workspace layout to be ready (event-driven, not time-based) 488 this.app.workspace.onLayoutReady(async () => { 489 // Open DreamSpace - setViewState returns a promise that resolves when view is ready 490 const leaf = this.app.workspace.getLeaf(true); 491 await leaf.setViewState({ 492 type: DREAMSPACE_VIEW_TYPE, 493 active: true 494 }); 495 this.app.workspace.revealLeaf(leaf); 496 497 // Find and select the InterBrain node by UUID 498 // Lifecycle has completed, so nodes are guaranteed to be in store 499 const interbrainUUID = '550e8400-e29b-41d4-a716-446655440000'; 500 const store = useInterBrainStore.getState(); 501 const nodeData = store.dreamNodes.get(interbrainUUID); 502 503 if (nodeData) { 504 store.setSelectedNode(nodeData.node); 505 store.setSpatialLayout('liminal-web'); 506 } 507 508 // Run transcription auto-setup if enabled 509 if (this.settings.transcriptionEnabled && !this.settings.transcriptionSetupComplete) { 510 this.uiService.showInfo('Setting up transcription in background...'); 511 this.runTranscriptionAutoSetup(); 512 } 513 514 // Mark first launch as complete 515 this.settings.hasLaunchedBefore = true; 516 await this.saveSettings(); 517 }); 518 } 519 520 /** 521 * Run transcription auto-setup in background on first launch 522 */ 523 private async runTranscriptionAutoSetup(): Promise<void> { 524 const vaultPath = (this.app.vault.adapter as any).basePath; 525 const pluginPath = `${vaultPath}/.obsidian/plugins/${this.manifest.id}`; 526 const { exec } = require('child_process'); 527 528 exec(`cd "${pluginPath}/src/features/realtime-transcription/scripts" && bash setup.sh`, 529 async (error: Error | null) => { 530 if (error) { 531 this.uiService.showWarning('Transcription setup failed. You can retry from settings.'); 532 } else { 533 this.settings.transcriptionSetupComplete = true; 534 await this.saveSettings(); 535 this.uiService.showInfo('Transcription setup complete! Ready to use.'); 536 } 537 } 538 ); 539 } 540 541 /** 542 * Initialize error capture for bug reporting 543 * Captures console logs and error events, respecting user preferences 544 * 545 * Rate limiting is GLOBAL (not per-error) to prevent error loops from 546 * spamming the user with modals. After sending a report, no modal will 547 * appear for 30 seconds regardless of whether it's the same or different error. 548 */ 549 private initializeErrorCapture(): void { 550 errorCaptureService.initialize({ 551 onError: (error) => { 552 const store = useInterBrainStore.getState(); 553 const preference = store.feedback.autoReportPreference; 554 555 if (preference === 'never') { 556 return; 557 } 558 559 // Check modal throttle - prevents modal spam during error loops 560 if (!store.canShowModal()) { 561 // Show notice once per throttle period, then silent 562 if (store.shouldShowModalThrottleNotice()) { 563 const secondsRemaining = Math.ceil( 564 (30000 - (Date.now() - (store.feedback.lastModalTimestamp || 0))) / 1000 565 ); 566 new Notice( 567 `Additional error captured. To prevent dialog spam, the report dialog will be available again in ${secondsRemaining}s. You can also report manually via Command Palette → "Report a Bug".`, 568 5000 569 ); 570 store.markModalThrottleNoticeShown(); 571 } 572 return; 573 } 574 575 if (preference === 'always') { 576 store.openFeedbackModal(error); 577 store.recordModalShown(); 578 return; 579 } 580 581 // Default: 'ask' - show modal 582 store.openFeedbackModal(error); 583 store.recordModalShown(); 584 showFeedbackModal(this.app); 585 }, 586 }); 587 588 // Register cleanup on plugin unload 589 this.register(() => { 590 errorCaptureService.cleanup(); 591 }); 592 593 // Reset session counts when plugin reloads 594 useInterBrainStore.getState().resetSessionCounts(); 595 } 596 597 private async initializeBackgroundServices(): Promise<void> { 598 // These run after READY phase is complete - lifecycle manager ensures ordering 599 600 // Initialize copilot/songline services (fast setup, no I/O) 601 initializeTranscriptionService(this.app); 602 initializeConversationRecordingService(this.app); 603 initializeConversationSummaryService(this.app); 604 initializePDFGeneratorService(); 605 initializeEmailExportService(this.app, this); 606 initializeAudioRecordingService(this); 607 initializePerspectiveService(this); 608 initializeAudioTrimmingService(); 609 initializeConversationsService(this); 610 initializeAudioStreamingService(this); 611 612 // Initialize collaboration services 613 const vaultPath = (this.app.vault.adapter as any).basePath; 614 initializeCollaborationMemoryService(vaultPath); 615 initializeCherryPickWorkflowService(this.app); 616 // Note: DreamSong relationship scan moved to post-lifecycle (after commands are registered) 617 } 618 619 /** 620 * Sync Radicle peer following in background (fire-and-forget) 621 * Ensures liminal web relationships are mirrored in Radicle network config 622 */ 623 private syncRadiclePeersInBackground(): void { 624 // Fire-and-forget async operation - doesn't block lifecycle 625 (async () => { 626 try { 627 const radicleService = serviceManager.getRadicleService(); 628 629 // Quick bail-out if Radicle CLI not available (Windows, or not installed) 630 if (!await radicleService.isAvailable()) { 631 return; 632 } 633 634 // Check if passphrase is configured - required for all Radicle operations 635 const passphrase = this.settings?.radiclePassphrase; 636 if (!passphrase) { 637 new Notice('Configure your Radicle passphrase in Settings → InterBrain to enable P2P collaboration'); 638 return; 639 } 640 641 const vaultPath = (this.app.vault.adapter as any).basePath; 642 643 console.log('[Plugin] Starting background Radicle peer sync...'); 644 const peerSyncService = getPeerSyncService(radicleService); 645 const result = await peerSyncService.syncPeerFollowing(vaultPath, passphrase); 646 console.log(`[Plugin] Radicle peer sync complete: ${result.summary}`); 647 } catch (error) { 648 // Non-critical - log and continue 649 console.warn('[Plugin] Background Radicle peer sync failed (non-critical):', error); 650 } 651 })(); 652 } 653 654 private initializeServices(): void { 655 this.uiService = new UIService(this.app); 656 this.gitOpsService = new GitOperationsService(this.app); 657 this.vaultService = new VaultService(this.app.vault, this.app); 658 this.passphraseManager = new PassphraseManager(this.uiService, this); 659 this.faceTimeService = new FaceTimeService(); 660 661 // Initialize dreamweaving services 662 this.canvasParserService = new CanvasParserService(this.vaultService); 663 this.submoduleManagerService = new SubmoduleManagerService( 664 this.app, 665 this.vaultService, 666 this.canvasParserService, 667 serviceManager.getRadicleService() 668 ); 669 this.coherenceBeaconService = new CoherenceBeaconService( 670 this.app, 671 this.vaultService, 672 serviceManager.getRadicleService(), 673 this 674 ); 675 this.leafManagerService = new LeafManagerService(this.app); 676 this.canvasObserverService = new CanvasObserverService(this.app); 677 678 // Make services accessible to ServiceManager BEFORE initialization 679 // Note: Using 'any' here is legitimate - we're extending the plugin with dynamic properties 680 (this as any).vaultService = this.vaultService; 681 (this as any).canvasParserService = this.canvasParserService; 682 (this as any).leafManagerService = this.leafManagerService; 683 (this as any).submoduleManagerService = this.submoduleManagerService; 684 685 // Initialize service manager with plugin instance and services 686 serviceManager.initialize(this); 687 } 688 689 private registerCommands(): void { 690 // Register semantic search commands 691 registerSemanticSearchCommands(this, this.uiService); 692 693 // Register DreamNode commands (flip animations, fullscreen views) 694 registerDreamNodeCommands(this, this.uiService); 695 696 // Register search commands (search toggle) 697 registerSearchCommands(this, this.uiService); 698 699 // Register camera commands (flying controls, camera reset) 700 registerCameraCommands(this, this.uiService); 701 702 // Register constellation debug commands (wireframe sphere, intersection point) 703 registerConstellationDebugCommands(this, this.uiService); 704 705 // Register edit mode commands (unified editing with relationship management) 706 registerEditModeCommands(this, this.uiService); 707 708 // Register conversational copilot commands (real-time transcription and semantic search) 709 registerConversationalCopilotCommands(this, this.uiService); 710 711 // Register FaceTime commands (video calling integration) 712 registerFaceTimeCommands(this, this.uiService, this.vaultService, this.faceTimeService); 713 714 // Register dreamweaving commands (canvas submodule management) 715 registerDreamweavingCommands( 716 this, 717 this.uiService, 718 this.vaultService, 719 this.canvasParserService, 720 this.submoduleManagerService 721 ); 722 723 // Register Radicle commands (peer-to-peer networking) 724 registerRadicleCommands(this, this.uiService, this.passphraseManager); 725 726 // Register GitHub commands (fallback sharing and broadcasting) 727 registerGitHubCommands(this, this.uiService); 728 729 // Register Coherence Beacon commands (network discovery) 730 registerCoherenceBeaconCommands(this); 731 732 // Register Dreamer update commands (check all projects from peer) 733 registerDreamerUpdateCommands(this); 734 735 // Register relationship commands (bidirectional sync) 736 registerRelationshipCommands(this); 737 738 // Register update commands (auto-fetch and update management) 739 registerUpdateCommands(this, this.uiService); 740 741 // Register collaboration test commands (UI testing for cherry-pick workflow) 742 registerCollaborationTestCommands(this, this.uiService); 743 744 // Register link file commands (.link file support) 745 registerLinkFileCommands(this, this.uiService); 746 747 // Enhance file suggestions to include .link files 748 enhanceFileSuggestions(this); 749 750 // Initialize and register real-time transcription 751 initializeRealtimeTranscriptionService(this); 752 registerTranscriptionCommands(this); 753 754 // Register feedback commands (bug reporting) 755 registerFeedbackCommands(this); 756 757 // Register AI Magic commands (provider testing) 758 registerAIMagicCommands(this); 759 760 // Register tutorial commands (onboarding system) 761 registerTutorialCommands(this, this.uiService); 762 763 // Open DreamSpace command 764 this.addCommand({ 765 id: 'open-dreamspace', 766 name: 'Open DreamSpace', 767 callback: async () => { 768 const leaf = this.app.workspace.getLeaf(true); 769 await leaf.setViewState({ 770 type: DREAMSPACE_VIEW_TYPE, 771 active: true 772 }); 773 this.app.workspace.revealLeaf(leaf); 774 } 775 }); 776 777 // Open InterBrain Settings command 778 this.addCommand({ 779 id: 'open-interbrain-settings', 780 name: 'Open InterBrain Settings', 781 callback: () => { 782 // @ts-ignore - Private API 783 this.app.setting.open(); 784 // @ts-ignore - Private API 785 this.app.setting.openTabById('interbrain'); 786 } 787 }); 788 789 // Toggle Creator Mode command 790 this.addCommand({ 791 id: 'toggle-creator-mode', 792 name: 'Toggle Creator Mode', 793 callback: async () => { 794 const store = useInterBrainStore.getState(); 795 const selectedNode = store.selectedNode; 796 797 if (!selectedNode) { 798 this.uiService.showError('Please select a DreamNode first'); 799 return; 800 } 801 802 const { creatorMode } = store; 803 const isCurrentlyActive = creatorMode.isActive && creatorMode.nodeId === selectedNode.id; 804 805 if (isCurrentlyActive) { 806 // Exit creator mode 807 const loadingNotice = this.uiService.showLoading('Exiting creator mode...'); 808 try { 809 // Stash any uncommitted changes when exiting creator mode 810 await this.gitOpsService.stashChanges(selectedNode.repoPath); 811 store.setCreatorMode(false); 812 this.uiService.showSuccess('Exited creator mode - changes stashed'); 813 } catch (error) { 814 console.error('Failed to stash changes:', error); 815 // Still exit creator mode even if stash fails 816 store.setCreatorMode(false); 817 this.uiService.showError('Exited creator mode but failed to stash changes'); 818 } finally { 819 loadingNotice.hide(); 820 } 821 } else { 822 // Enter creator mode 823 const loadingNotice = this.uiService.showLoading('Entering creator mode...'); 824 try { 825 // Pop any existing stash when entering creator mode 826 await this.gitOpsService.popStash(selectedNode.repoPath); 827 store.setCreatorMode(true, selectedNode.id); 828 this.uiService.showSuccess(`Creator mode active for: ${selectedNode.name}`); 829 } catch (error) { 830 console.error('Failed to pop stash:', error); 831 // Still enter creator mode even if pop fails 832 store.setCreatorMode(true, selectedNode.id); 833 this.uiService.showError('Entered creator mode but failed to restore stash'); 834 } finally { 835 loadingNotice.hide(); 836 } 837 } 838 } 839 }); 840 841 // Save DreamNode command - Robust workflow with canvas sync 842 this.addCommand({ 843 id: 'save-dreamnode', 844 name: 'Save DreamNode (commit changes)', 845 callback: async () => { 846 const loadingNotice = this.uiService.showLoading('Saving DreamNode...'); 847 try { 848 const store = useInterBrainStore.getState(); 849 const currentNode = store.selectedNode; 850 if (!currentNode) { 851 throw new Error('No DreamNode selected'); 852 } 853 854 const { exec } = require('child_process'); 855 const { promisify } = require('util'); 856 const path = require('path'); 857 const execAsync = promisify(exec); 858 const fullRepoPath = path.join(this.vaultService.getVaultPath(), currentNode.repoPath); 859 860 // STEP 1: Check if DreamSong.canvas exists 861 const dreamSongPath = `${currentNode.repoPath}/DreamSong.canvas`; 862 const dreamSongFile = this.app.vault.getAbstractFileByPath(dreamSongPath); 863 const hasDreamSong = dreamSongFile !== null; 864 865 let syncDidWork = false; 866 if (hasDreamSong) { 867 loadingNotice.hide(); 868 const syncNotice = this.uiService.showLoading('Syncing canvas submodules...'); 869 870 try { 871 // Run canvas sync workflow (imports submodules, updates paths, commits) 872 // SKIP RADICLE for local-only saves (massive performance improvement) 873 const syncResult = await this.submoduleManagerService.syncCanvasSubmodules( 874 dreamSongPath, 875 { skipRadicle: true } // LOCAL-ONLY: Skip Radicle initialization for fast saves 876 ); 877 878 if (!syncResult.success) { 879 throw new Error(`Canvas sync failed: ${syncResult.error}`); 880 } 881 882 // Track whether sync made meaningful changes 883 const newImports = syncResult.submodulesImported.filter(r => r.success && !r.alreadyExisted); 884 syncDidWork = newImports.length > 0 || syncResult.submodulesRemoved.length > 0; 885 886 syncNotice.hide(); 887 } catch (_syncError) { 888 syncNotice.hide(); 889 // Non-fatal - continue with regular commit 890 this.uiService.showWarning('Canvas sync had issues - continuing with commit'); 891 } 892 } 893 894 // STEP 2: Stage all remaining changes (anything not already committed by canvas sync) 895 await execAsync('git add -A', { cwd: fullRepoPath }); 896 897 // STEP 3: Check if there are changes to commit 898 const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: fullRepoPath }); 899 900 if (!statusOutput.trim()) { 901 // No remaining changes — but sync may have already committed 902 const message = syncDidWork 903 ? 'DreamSong synced and all changes committed' 904 : 'Everything up to date'; 905 this.uiService.showSuccess(message); 906 907 // Exit creator mode even if no changes 908 const { creatorMode } = store; 909 if (creatorMode.isActive && creatorMode.nodeId === currentNode.id) { 910 store.setCreatorMode(false); 911 } 912 913 loadingNotice.hide(); 914 return; 915 } 916 917 // STEP 4: Commit remaining changes 918 const commitNotice = this.uiService.showLoading('Creating commit...'); 919 920 try { 921 const commitMessage = `Save changes in ${currentNode.name}`; 922 await execAsync(`git commit -m "${commitMessage}"`, { cwd: fullRepoPath }); 923 commitNotice.hide(); 924 } catch (commitError) { 925 commitNotice.hide(); 926 throw commitError; 927 } 928 929 // STEP 5: Exit creator mode after successful save 930 const { creatorMode } = store; 931 if (creatorMode.isActive && creatorMode.nodeId === currentNode.id) { 932 store.setCreatorMode(false); 933 } 934 935 // STEP 6: Success feedback 936 const summary = hasDreamSong 937 ? 'DreamSong synced and all changes committed' 938 : 'All changes committed'; 939 this.uiService.showSuccess(summary); 940 941 } catch (error) { 942 console.error('💾 [Save Changes] Failed:', error); 943 this.uiService.showError(error instanceof Error ? error.message : 'Unknown error occurred'); 944 } finally { 945 loadingNotice.hide(); 946 } 947 } 948 }); 949 950 // Create DreamNode command 951 this.addCommand({ 952 id: 'create-dreamnode', 953 name: 'Create new DreamNode', 954 hotkeys: [{ modifiers: ['Ctrl'], key: 'n' }], 955 callback: async () => { 956 957 // Check if DreamSpace is open 958 const dreamspaceLeaf = this.app.workspace.getLeavesOfType(DREAMSPACE_VIEW_TYPE)[0]; 959 if (!dreamspaceLeaf) { 960 // Open DreamSpace first if not already open 961 this.uiService.showError('Please open DreamSpace first'); 962 await this.app.commands.executeCommandById('interbrain:open-dreamspace'); 963 return; 964 } 965 966 // Trigger creation mode in the store 967 const store = useInterBrainStore.getState(); 968 969 // Calculate spawn position (used in both paths) 970 const spawnPosition: [number, number, number] = [0, 0, -25]; 971 972 // Check current layout to determine transition path 973 if (store.spatialLayout === 'liminal-web') { 974 // From liminal-web: First return to constellation, then trigger creation command 975 store.setSelectedNode(null); 976 store.setSpatialLayout('constellation'); 977 978 // Wait for constellation transition to complete, then trigger creation 979 globalThis.setTimeout(() => { 980 const freshStore = useInterBrainStore.getState(); 981 freshStore.startCreationWithData(spawnPosition); 982 }, 1100); // Animation duration (1000ms) + buffer (100ms) 983 } else { 984 // Normal creation from constellation or other states 985 store.startCreationWithData(spawnPosition); 986 } 987 988 } 989 }); 990 991 // Weave Dreams command 992 this.addCommand({ 993 id: 'weave-dreams', 994 name: 'Weave Dreams into higher-order node', 995 callback: async () => { 996 // TODO: Implement multi-node selection in store 997 const store = useInterBrainStore.getState(); 998 const selectedNode = store.selectedNode; 999 if (!selectedNode) { 1000 this.uiService.showError('Select at least 2 DreamNodes to weave'); 1001 return; 1002 } 1003 this.uiService.showPlaceholder('Dream weaving coming soon!'); 1004 } 1005 }); 1006 1007 // Open DreamNode in Finder command 1008 this.addCommand({ 1009 id: 'open-dreamnode-in-finder', 1010 name: 'Open DreamNode in Finder', 1011 hotkeys: [{ modifiers: ['Ctrl'], key: 'o' }], 1012 callback: async () => { 1013 const store = useInterBrainStore.getState(); 1014 const currentNode = store.selectedNode; 1015 if (!currentNode) { 1016 this.uiService.showError('No DreamNode selected'); 1017 return; 1018 } 1019 1020 try { 1021 // Use git service to open the repository folder in Finder 1022 await this.gitOpsService.openInFinder(currentNode.repoPath); 1023 this.uiService.showSuccess(`Opened ${currentNode.name} in Finder`); 1024 } catch (error) { 1025 console.error('Failed to open in Finder:', error); 1026 this.uiService.showError('Failed to open DreamNode in Finder'); 1027 } 1028 } 1029 }); 1030 1031 // Open DreamNode in Terminal and run claude command 1032 this.addCommand({ 1033 id: 'open-dreamnode-in-terminal', 1034 name: 'Open DreamNode in Terminal (run claude)', 1035 hotkeys: [{ modifiers: ['Ctrl'], key: 'c' }], 1036 callback: async () => { 1037 const store = useInterBrainStore.getState(); 1038 const currentNode = store.selectedNode; 1039 if (!currentNode) { 1040 this.uiService.showError('No DreamNode selected'); 1041 return; 1042 } 1043 1044 try { 1045 // Use git service to open terminal at the repository folder and run claude --continue 1046 await this.gitOpsService.openInTerminal(currentNode.repoPath); 1047 this.uiService.showSuccess(`Opened terminal for ${currentNode.name} and running claude --continue`); 1048 } catch (error) { 1049 console.error('Failed to open in Terminal:', error); 1050 this.uiService.showError('Failed to open DreamNode in Terminal'); 1051 } 1052 } 1053 }); 1054 1055 // Delete DreamNode command 1056 this.addCommand({ 1057 id: 'delete-dreamnode', 1058 name: 'Delete DreamNode', 1059 callback: async () => { 1060 const store = useInterBrainStore.getState(); 1061 const currentNode = store.selectedNode; 1062 if (!currentNode) { 1063 this.uiService.showError('No DreamNode selected'); 1064 return; 1065 } 1066 1067 // Safety confirmation using Obsidian Modal 1068 const confirmed = await this.uiService.promptForText( 1069 `⚠️ DELETE "${currentNode.name}" ⚠️`, 1070 `Type "${currentNode.name}" to confirm permanent deletion` 1071 ); 1072 1073 const isConfirmed = confirmed === currentNode.name; 1074 1075 if (!isConfirmed) { 1076 this.uiService.showInfo('Delete operation cancelled'); 1077 return; 1078 } 1079 1080 const loadingNotice = this.uiService.showLoading(`Deleting ${currentNode.name}...`); 1081 try { 1082 // Get the active service for deletion 1083 const dreamNodeService = serviceManager.getActive(); 1084 1085 // Delete the DreamNode through the service layer 1086 await dreamNodeService.delete(currentNode.id); 1087 1088 // Clear the selection since the node no longer exists 1089 store.setSelectedNode(null); 1090 1091 // Return to constellation view 1092 store.setSpatialLayout('constellation'); 1093 1094 this.uiService.showSuccess(`Successfully deleted "${currentNode.name}"`); 1095 1096 } catch (error) { 1097 console.error('Failed to delete DreamNode:', error); 1098 this.uiService.showError(`Failed to delete "${currentNode.name}": ${error instanceof Error ? error.message : 'Unknown error'}`); 1099 } finally { 1100 loadingNotice.hide(); 1101 } 1102 } 1103 }); 1104 1105 // Share DreamNode command 1106 this.addCommand({ 1107 id: 'share-dreamnode', 1108 name: 'Share DreamNode via Coherence Beacon', 1109 callback: async () => { 1110 const store = useInterBrainStore.getState(); 1111 const currentNode = store.selectedNode; 1112 if (!currentNode) { 1113 this.uiService.showError('No DreamNode selected'); 1114 return; 1115 } 1116 this.uiService.showPlaceholder('Coherence Beacon coming soon!'); 1117 } 1118 }); 1119 1120 // Copy share link for selected DreamNode (with optional recipient DID for delegation) 1121 this.addCommand({ 1122 id: 'copy-share-link', 1123 name: 'Copy Share Link for Selected DreamNode', 1124 callback: async () => { 1125 const store = useInterBrainStore.getState(); 1126 const currentNode = store.selectedNode; 1127 if (!currentNode) { 1128 this.uiService.showError('No DreamNode selected'); 1129 return; 1130 } 1131 1132 try { 1133 // Prompt for optional recipient DID (empty = just copy link without delegation) 1134 const recipientDid = await this.uiService.promptForText( 1135 'Enter recipient DID (or leave empty)', 1136 'did:key:z6Mk... (optional)' 1137 ); 1138 1139 const { ShareLinkService } = await import('./features/github-publishing/services/share-link-service'); 1140 const shareLinkService = new ShareLinkService(this.app, this); 1141 1142 // Pass recipientDid if provided (will be undefined if empty string) 1143 const effectiveRecipientDid = recipientDid && recipientDid.trim() !== '' ? recipientDid.trim() : undefined; 1144 await shareLinkService.copyShareLink(currentNode, effectiveRecipientDid); 1145 } catch (error) { 1146 console.error('Failed to copy share link:', error); 1147 this.uiService.showError(`Failed to copy share link: ${error instanceof Error ? error.message : 'Unknown error'}`); 1148 } 1149 } 1150 }); 1151 1152 // Scan vault for DreamNodes 1153 this.addCommand({ 1154 id: 'scan-vault', 1155 name: 'Scan Vault for DreamNodes', 1156 callback: async () => { 1157 const loadingNotice = this.uiService.showLoading('Scanning vault for DreamNodes...'); 1158 try { 1159 const stats = await serviceManager.scanVault(); 1160 if (stats) { 1161 this.uiService.showSuccess( 1162 `Scan complete: ${stats.added} added, ${stats.updated} updated, ${stats.removed} removed` 1163 ); 1164 } 1165 } catch (error) { 1166 this.uiService.showError(error instanceof Error ? error.message : 'Vault scan failed'); 1167 } finally { 1168 loadingNotice.hide(); 1169 } 1170 } 1171 }); 1172 1173 // ========================================================================= 1174 // REFRESH COMMANDS - Separated by concern for faster Cmd+R 1175 // ========================================================================= 1176 1177 // FAST: Refresh plugin (Cmd+R) - Just reload plugin, no heavy operations 1178 this.addCommand({ 1179 id: 'refresh-plugin', 1180 name: 'Refresh Plugin (fast)', 1181 hotkeys: [{ modifiers: ['Mod'], key: 'r' }], 1182 callback: async () => { 1183 console.log(`[Refresh] Starting`); 1184 1185 const store = useInterBrainStore.getState(); 1186 const currentNode = store.selectedNode; 1187 1188 // Store current node UUID for reselection after reload 1189 const existingUUID = (globalThis as any).__interbrainReloadTargetUUID; 1190 if (!existingUUID && currentNode) { 1191 (globalThis as any).__interbrainReloadTargetUUID = currentNode.id; 1192 } 1193 1194 // Use graceful shutdown to wait for pending writes 1195 await gracefulShutdown(2000); 1196 1197 // Lightweight plugin reload 1198 const plugins = (this.app as any).plugins; 1199 await plugins.disablePlugin('interbrain'); 1200 await plugins.enablePlugin('interbrain'); 1201 1202 console.log(`[Refresh] Complete`); 1203 } 1204 }); 1205 1206 // FULL: Refresh with cleanup and indexing (manual) 1207 this.addCommand({ 1208 id: 'refresh-full', 1209 name: 'Refresh Full (cleanup + indexing)', 1210 callback: async () => { 1211 const store = useInterBrainStore.getState(); 1212 const currentNode = store.selectedNode; 1213 1214 // Store current node UUID for reselection 1215 if (currentNode) { 1216 (globalThis as any).__interbrainReloadTargetUUID = currentNode.id; 1217 } 1218 1219 // Clean up dangling relationships 1220 await (this.app as any).commands.executeCommandById('interbrain:clean-dangling-relationships'); 1221 1222 // Index any missing nodes 1223 try { 1224 const { indexingService } = await import('./features/semantic-search/services/indexing-service'); 1225 await indexingService.ensureAllIndexed(); 1226 } catch { 1227 // Indexing failed (non-critical) 1228 } 1229 1230 // Use graceful shutdown 1231 await gracefulShutdown(3000); 1232 1233 // Reload plugin 1234 const plugins = (this.app as any).plugins; 1235 await plugins.disablePlugin('interbrain'); 1236 await plugins.enablePlugin('interbrain'); 1237 } 1238 }); 1239 1240 // SYNC: Radicle sync (manual, opt-in) 1241 this.addCommand({ 1242 id: 'sync-network', 1243 name: 'Sync with Radicle Network', 1244 callback: async () => { 1245 await (this.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following'); 1246 } 1247 }); 1248 1249 // REINDEX: Force full reindex (manual, heavy) 1250 this.addCommand({ 1251 id: 'force-reindex', 1252 name: 'Force Reindex All Nodes', 1253 callback: async () => { 1254 const loadingNotice = this.uiService.showLoading('Re-indexing all nodes...'); 1255 try { 1256 const { indexingService } = await import('./features/semantic-search/services/indexing-service'); 1257 const result = await indexingService.indexAllNodes(); 1258 this.uiService.showSuccess(`Indexed ${result.indexed} nodes (${result.errors} errors)`); 1259 } catch (error) { 1260 this.uiService.showError(`Indexing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); 1261 } finally { 1262 loadingNotice.hide(); 1263 } 1264 } 1265 }); 1266 1267 // Command to redistribute DreamNodes using Fibonacci sphere algorithm 1268 this.addCommand({ 1269 id: 'redistribute-dreamnodes', 1270 name: 'Redistribute DreamNodes', 1271 callback: async () => { 1272 const store = useInterBrainStore.getState(); 1273 const service = serviceManager.getActive(); 1274 1275 try { 1276 // Get all existing DreamNodes 1277 const dreamNodes = await service.list(); 1278 1279 if (dreamNodes.length === 0) { 1280 this.uiService.showInfo('No DreamNodes to redistribute'); 1281 return; 1282 } 1283 1284 // Calculate Fibonacci sphere positions for the current node count 1285 const positions = calculateFibonacciSpherePositions({ 1286 radius: store.fibonacciConfig.radius, 1287 nodeCount: dreamNodes.length, 1288 center: store.fibonacciConfig.center 1289 }); 1290 1291 // Update each node with its new position 1292 for (let i = 0; i < dreamNodes.length; i++) { 1293 const node = dreamNodes[i]; 1294 const newPosition = positions[i].position; 1295 1296 // Update the node's position using the service 1297 await service.update(node.id, { 1298 position: newPosition 1299 }); 1300 1301 } 1302 1303 // The store will automatically reflect the updates via service.update() 1304 // No need to manually refresh 1305 1306 this.uiService.showSuccess(`Redistributed ${dreamNodes.length} DreamNodes using Fibonacci sphere algorithm`); 1307 1308 } catch (error) { 1309 console.error('Failed to redistribute DreamNodes:', error); 1310 this.uiService.showError('Failed to redistribute DreamNodes'); 1311 } 1312 } 1313 }); 1314 1315 // Layout command: Switch to constellation view 1316 this.addCommand({ 1317 id: 'layout-constellation', 1318 name: 'Switch to Constellation View', 1319 callback: () => { 1320 const store = useInterBrainStore.getState(); 1321 store.setSpatialLayout('constellation'); 1322 this.uiService.showSuccess('Switched to constellation view'); 1323 } 1324 }); 1325 1326 // Layout command: Switch to search view 1327 this.addCommand({ 1328 id: 'layout-search', 1329 name: 'Switch to Search View', 1330 callback: () => { 1331 const store = useInterBrainStore.getState(); 1332 store.setSpatialLayout('search'); 1333 this.uiService.showSuccess('Switched to search view'); 1334 } 1335 }); 1336 1337 // Layout command: Switch to focused view 1338 this.addCommand({ 1339 id: 'layout-focused', 1340 name: 'Switch to Focused View', 1341 callback: () => { 1342 const store = useInterBrainStore.getState(); 1343 const currentNode = store.selectedNode; 1344 if (!currentNode) { 1345 this.uiService.showError('No DreamNode selected - select a node first'); 1346 return; 1347 } 1348 store.setSpatialLayout('liminal-web'); 1349 this.uiService.showSuccess(`Focused on: ${currentNode.name}`); 1350 } 1351 }); 1352 1353 // Refresh Git Status command 1354 this.addCommand({ 1355 id: 'refresh-git-status', 1356 name: 'Refresh Git Status Indicators', 1357 callback: async () => { 1358 const loadingNotice = this.uiService.showLoading('Refreshing git status...'); 1359 1360 try { 1361 const service = serviceManager.getActive(); 1362 1363 if (service.refreshGitStatus) { 1364 const result = await service.refreshGitStatus(); 1365 this.uiService.showSuccess(`Git status refreshed: ${result.updated} updated, ${result.errors} errors`); 1366 } else { 1367 this.uiService.showError('Git status refresh not available in current mode'); 1368 } 1369 } catch (error) { 1370 this.uiService.showError(error instanceof Error ? error.message : 'Git status refresh failed'); 1371 console.error('Git status refresh error:', error); 1372 } finally { 1373 loadingNotice.hide(); 1374 } 1375 } 1376 }); 1377 1378 // Undo Layout Change command 1379 this.addCommand({ 1380 id: 'undo-layout-change', 1381 name: 'Undo Layout Change', 1382 hotkeys: [{ modifiers: ['Mod'], key: 'z' }], 1383 callback: async () => { 1384 const store = useInterBrainStore.getState(); 1385 const { history, currentIndex } = store.navigationHistory; 1386 1387 // Check if undo is possible (can undo to index 0, which is the initial constellation state) 1388 if (currentIndex < 1) { 1389 this.uiService.showError('No more layout changes to undo'); 1390 return; 1391 } 1392 1393 // Get the entry to restore 1394 const previousEntry = history[currentIndex - 1]; 1395 if (!previousEntry) { 1396 this.uiService.showError('Invalid history entry'); 1397 return; 1398 } 1399 1400 try { 1401 // Update history index first 1402 const success = store.performUndo(); 1403 if (!success) { 1404 this.uiService.showError('Failed to undo - no history available'); 1405 return; 1406 } 1407 1408 // Set flag to prevent new history entries during restoration 1409 store.setRestoringFromHistory(true); 1410 1411 try { 1412 // Restore the layout state via store-based navigation 1413 if (previousEntry.layout === 'constellation') { 1414 // Going to constellation - set layout and request navigation 1415 store.setSelectedNode(null); 1416 store.setSpatialLayout('constellation'); 1417 store.requestNavigation({ type: 'constellation', interrupt: true }); 1418 } else if (previousEntry.layout === 'liminal-web' && previousEntry.nodeId) { 1419 // Going to liminal-web - need to find and focus on the node 1420 const allNodes = await this.getAllAvailableNodes(); 1421 const targetNode = allNodes.find(node => node.id === previousEntry.nodeId); 1422 1423 if (targetNode) { 1424 store.setSelectedNode(targetNode); 1425 store.setSpatialLayout('liminal-web'); 1426 1427 // Restore flip state BEFORE requesting navigation so orchestrator sees correct state 1428 store.restoreVisualState(previousEntry); 1429 1430 // Use holarchy-focus if the entry was flipped, otherwise liminal-web-focus 1431 if (previousEntry.flipState?.flipSide === 'back') { 1432 store.requestNavigation({ type: 'holarchy-focus', nodeId: targetNode.id, interrupt: true }); 1433 } else { 1434 store.requestNavigation({ type: 'liminal-web-focus', nodeId: targetNode.id, interrupt: true }); 1435 } 1436 } else { 1437 // Handle deleted node case - skip to next valid entry 1438 console.warn(`Node ${previousEntry.nodeId} no longer exists, skipping undo step`); 1439 this.uiService.showError('Target node no longer exists - skipped to previous state'); 1440 } 1441 } 1442 } finally { 1443 // Always clear the flag 1444 store.setRestoringFromHistory(false); 1445 } 1446 1447 } catch (error) { 1448 console.error('Undo failed:', error); 1449 this.uiService.showError('Failed to undo layout change'); 1450 } 1451 } 1452 }); 1453 1454 // Redo Layout Change command 1455 this.addCommand({ 1456 id: 'redo-layout-change', 1457 name: 'Redo Layout Change', 1458 hotkeys: [{ modifiers: ['Mod', 'Shift'], key: 'z' }], 1459 callback: async () => { 1460 const store = useInterBrainStore.getState(); 1461 const { history, currentIndex } = store.navigationHistory; 1462 1463 // Check if redo is possible 1464 if (currentIndex >= history.length - 1) { 1465 this.uiService.showError('No more layout changes to redo'); 1466 return; 1467 } 1468 1469 // Get the entry to restore 1470 const nextEntry = history[currentIndex + 1]; 1471 if (!nextEntry) { 1472 this.uiService.showError('Invalid history entry'); 1473 return; 1474 } 1475 1476 try { 1477 // Update history index first 1478 const success = store.performRedo(); 1479 if (!success) { 1480 this.uiService.showError('Failed to redo - no history available'); 1481 return; 1482 } 1483 1484 // Set flag to prevent new history entries during restoration 1485 store.setRestoringFromHistory(true); 1486 1487 try { 1488 // Restore the layout state via store-based navigation 1489 if (nextEntry.layout === 'constellation') { 1490 // Going to constellation - set layout and request navigation 1491 store.setSelectedNode(null); 1492 store.setSpatialLayout('constellation'); 1493 store.requestNavigation({ type: 'constellation', interrupt: true }); 1494 } else if (nextEntry.layout === 'liminal-web' && nextEntry.nodeId) { 1495 // Going to liminal-web - need to find and focus on the node 1496 const allNodes = await this.getAllAvailableNodes(); 1497 const targetNode = allNodes.find(node => node.id === nextEntry.nodeId); 1498 1499 if (targetNode) { 1500 store.setSelectedNode(targetNode); 1501 store.setSpatialLayout('liminal-web'); 1502 1503 // Restore flip state BEFORE requesting navigation so orchestrator sees correct state 1504 store.restoreVisualState(nextEntry); 1505 1506 // Use holarchy-focus if the entry was flipped, otherwise liminal-web-focus 1507 if (nextEntry.flipState?.flipSide === 'back') { 1508 store.requestNavigation({ type: 'holarchy-focus', nodeId: targetNode.id, interrupt: true }); 1509 } else { 1510 store.requestNavigation({ type: 'liminal-web-focus', nodeId: targetNode.id, interrupt: true }); 1511 } 1512 } else { 1513 // Handle deleted node case 1514 console.warn(`Node ${nextEntry.nodeId} no longer exists, skipping redo step`); 1515 this.uiService.showError('Target node no longer exists - skipped to next state'); 1516 } 1517 } 1518 } finally { 1519 // Always clear the flag 1520 store.setRestoringFromHistory(false); 1521 } 1522 1523 } catch (error) { 1524 console.error('Redo failed:', error); 1525 this.uiService.showError('Failed to redo layout change'); 1526 } 1527 } 1528 }); 1529 1530 // Open Dream Explorer (full-screen holarchy file navigator) 1531 this.addCommand({ 1532 id: 'open-dream-explorer', 1533 name: 'Open Dream Explorer', 1534 callback: async () => { 1535 const store = useInterBrainStore.getState(); 1536 const currentNode = store.selectedNode; 1537 if (!currentNode?.repoPath) { 1538 console.warn('[DreamExplorer] No DreamNode selected — cannot open explorer'); 1539 return; 1540 } 1541 const leafManager = serviceManager.getLeafManagerService(); 1542 if (leafManager) { 1543 await leafManager.openDreamExplorer(currentNode.repoPath, currentNode.name); 1544 } 1545 } 1546 }); 1547 1548 // Note: Semantic search commands now registered via registerSemanticSearchCommands() 1549 } 1550 1551 /** 1552 * Register file explorer context menu for selecting DreamNodes 1553 * Works for any file or folder - intelligently finds the containing DreamNode 1554 */ 1555 private registerFileExplorerContextMenu(): void { 1556 this.registerEvent( 1557 this.app.workspace.on('file-menu', (menu: Menu, file: TAbstractFile) => { 1558 // Show for all files and folders 1559 menu.addItem((item) => { 1560 item 1561 .setTitle('Reveal in DreamSpace') 1562 .setIcon('target') 1563 .onClick(async () => { 1564 await revealContainingDreamNode(this, this.uiService, file); 1565 }); 1566 }); 1567 1568 // Open DreamSong for the containing DreamNode 1569 menu.addItem((item) => { 1570 item 1571 .setTitle('Open DreamSong') 1572 .setIcon('layout-dashboard') 1573 .onClick(async () => { 1574 await openDreamSongForFile(this, this.uiService, file); 1575 }); 1576 }); 1577 1578 // Open DreamTalk for the containing DreamNode 1579 menu.addItem((item) => { 1580 item 1581 .setTitle('Open DreamTalk') 1582 .setIcon('play-circle') 1583 .onClick(async () => { 1584 await openDreamTalkForFile(this, this.uiService, file); 1585 }); 1586 }); 1587 1588 // Show "Convert to DreamNode" for folders only 1589 if (file instanceof TFolder) { 1590 menu.addItem((item) => { 1591 item 1592 .setTitle('Convert to DreamNode') 1593 .setIcon('git-fork') 1594 .onClick(async () => { 1595 const passphrase = (this as any).settings?.radiclePassphrase; 1596 await convertFolderToDreamNode(this, this.uiService, file, passphrase); 1597 }); 1598 }); 1599 } 1600 }) 1601 ); 1602 } 1603 1604 // Helper method to get all available nodes (used by undo/redo) 1605 private async getAllAvailableNodes(): Promise<DreamNode[]> { 1606 try { 1607 const store = useInterBrainStore.getState(); 1608 const dreamNodesMap = store.dreamNodes; 1609 const allNodes = Array.from(dreamNodesMap.values()).map(data => data.node); 1610 return allNodes; 1611 } catch (error) { 1612 console.error('Failed to get available nodes:', error); 1613 return []; 1614 } 1615 } 1616 1617 async loadSettings() { 1618 this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 1619 1620 // Sync constellation settings to Zustand store 1621 // This ensures persisted values are reflected in runtime state 1622 useInterBrainStore.getState().setConstellationConfig({ 1623 maxNodes: this.settings.constellationMaxNodes ?? CONSTELLATION_DEFAULTS.MAX_NODES, 1624 prioritizeClusters: this.settings.constellationPrioritizeClusters ?? CONSTELLATION_DEFAULTS.PRIORITIZE_CLUSTERS 1625 }); 1626 } 1627 1628 async saveSettings() { 1629 await this.saveData(this.settings); 1630 } 1631 1632 async onunload() { 1633 // Note: Passphrase is stored in settings, not cleared on unload 1634 // This preserves user's passphrase configuration across reloads 1635 1636 // Stop canvas observer 1637 if (this.canvasObserverService) { 1638 this.canvasObserverService.stop(); 1639 } 1640 1641 // Clean up leaf manager service 1642 if (this.leafManagerService) { 1643 this.leafManagerService.destroy(); 1644 } 1645 1646 // Clean up transcription service 1647 cleanupTranscriptionService(); 1648 1649 // Stop AI Bridge WebSocket server 1650 await stopAIBridgeServer(); 1651 1652 // GRACEFUL SHUTDOWN: Wait for pending IndexedDB writes before closing 1653 // This prevents the "open timeout" error caused by interrupted transactions 1654 await gracefulShutdown(3000); // 3 second timeout 1655 1656 // Shutdown lifecycle manager 1657 await serviceLifecycleManager.shutdown(); 1658 1659 // Clear vault state so next plugin load rescans (Cmd+R should get fresh data) 1660 // Cold startup (Obsidian restart) still uses cache since state file persists 1661 await vaultStateService.clearState(); 1662 1663 // Close IndexedDB connection to allow clean re-initialization on reload 1664 closeIndexedDBConnection(); 1665 1666 // Reset lifecycle manager for next load 1667 serviceLifecycleManager.reset(); 1668 } 1669 }