/ src / main.ts
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  }