/ src / services / git-dreamnode-service.ts
git-dreamnode-service.ts
   1  import { DreamNode, UDDFile, GitStatus } from '../types/dreamnode';
   2  import { useInterBrainStore, RealNodeData } from '../store/interbrain-store';
   3  import { Plugin } from 'obsidian';
   4  import { indexingService } from '../features/semantic-search/services/indexing-service';
   5  import { UrlMetadata, generateYouTubeIframe, generateMarkdownLink } from '../utils/url-utils';
   6  import { createLinkFileContent, getLinkFileName } from '../utils/link-file-utils';
   7  import { sanitizeTitleToPascalCase } from '../utils/title-sanitization';
   8  
   9  // Access Node.js modules directly in Electron context
  10   
  11  const { exec } = require('child_process');
  12  const { promisify } = require('util');
  13  const fs = require('fs');
  14  const path = require('path');
  15  const crypto = require('crypto');
  16   
  17  
  18  const execAsync = promisify(exec);
  19  const fsPromises = fs.promises;
  20  
  21  // Type for accessing file system path from Obsidian vault adapter
  22  interface VaultAdapter {
  23    path?: string;
  24    basePath?: string;
  25  }
  26  
  27  /**
  28   * GitDreamNodeService - Real git-based DreamNode storage
  29   * 
  30   * Provides DreamNode CRUD operations backed by actual git repositories.
  31   * Uses the Zustand real store for UI performance while syncing with vault.
  32   */
  33  export class GitDreamNodeService {
  34    private plugin: Plugin;
  35    private vaultPath: string;
  36    private templatePath: string;
  37    
  38    constructor(plugin: Plugin) {
  39      this.plugin = plugin;
  40      // Get vault file system path for Node.js fs operations
  41      const adapter = plugin.app.vault.adapter as VaultAdapter;
  42      
  43      // Try different ways to get the vault path
  44      let vaultPath = '';
  45      if (typeof adapter.path === 'string') {
  46        vaultPath = adapter.path;
  47      } else if (typeof adapter.basePath === 'string') {
  48        vaultPath = adapter.basePath;
  49      } else if (adapter.path && typeof adapter.path === 'object') {
  50        // Sometimes path is an object with properties
  51         
  52        vaultPath = (adapter.path as any).path || (adapter.path as any).basePath || '';
  53      }
  54      
  55      this.vaultPath = vaultPath;
  56      
  57      // Template is packaged with the plugin
  58      // Get the plugin directory path from Obsidian's plugin manifest
  59      if (this.vaultPath) {
  60        const pluginDir = path.join(this.vaultPath, '.obsidian', 'plugins', plugin.manifest.id);
  61        this.templatePath = path.join(pluginDir, 'DreamNode-template');
  62      } else {
  63        // Fallback - try to get plugin directory from plugin object
  64        // @ts-ignore - accessing private plugin properties
  65        const adapter = plugin.app?.vault?.adapter as { basePath?: string };
  66        const pluginDir = adapter?.basePath ?
  67          path.join(adapter.basePath, '.obsidian', 'plugins', plugin.manifest.id) :
  68          './DreamNode-template';
  69        this.templatePath = path.join(pluginDir, 'DreamNode-template');
  70        console.warn('GitDreamNodeService: Could not determine vault path, using fallback template path:', this.templatePath);
  71      }
  72      
  73    }
  74    
  75    /**
  76     * Create a new DreamNode with git repository
  77     */
  78    async create(
  79      title: string,
  80      type: 'dream' | 'dreamer',
  81      dreamTalk?: globalThis.File,
  82      position?: [number, number, number],
  83      additionalFiles?: globalThis.File[]
  84    ): Promise<DreamNode> {
  85      // Generate unique ID and repo path
  86      const uuid = crypto.randomUUID();
  87      const repoName = this.sanitizeRepoName(title);
  88      const repoPath = path.join(this.vaultPath, repoName);
  89      
  90      // Calculate position if not provided
  91      const nodePosition = position || this.calculateNewNodePosition();
  92      
  93      // Process dreamTalk media
  94      let dreamTalkMedia: Array<{
  95        path: string;
  96        absolutePath: string;
  97        type: string;
  98        data: string;
  99        size: number;
 100      }> = [];
 101      
 102      if (dreamTalk) {
 103        const dataUrl = await this.fileToDataUrl(dreamTalk);
 104        dreamTalkMedia = [{
 105          path: dreamTalk.name,
 106          absolutePath: path.join(repoPath, dreamTalk.name),
 107          type: dreamTalk.type,
 108          data: dataUrl,
 109          size: dreamTalk.size
 110        }];
 111      }
 112      
 113      // Create DreamNode object
 114      const node: DreamNode = {
 115        id: uuid,
 116        type,
 117        name: title,
 118        position: nodePosition,
 119        dreamTalkMedia,
 120        dreamSongContent: [],
 121        liminalWebConnections: [],
 122        repoPath: repoName, // Relative to vault
 123        hasUnsavedChanges: false,
 124        gitStatus: await this.checkGitStatus(repoPath),
 125        email: undefined,
 126        phone: undefined
 127      };
 128      
 129      // Update store immediately for snappy UI
 130      const store = useInterBrainStore.getState();
 131      const nodeData: RealNodeData = {
 132        node,
 133        fileHash: dreamTalk ? await this.calculateFileHash(dreamTalk) : undefined,
 134        lastSynced: Date.now()
 135      };
 136      store.updateRealNode(uuid, nodeData);
 137      
 138      // Create git repository in parallel (non-blocking)
 139      this.createGitRepository(repoPath, uuid, title, type, dreamTalk, additionalFiles)
 140        .then(async () => {
 141          // Index the new node after git repository is created
 142          try {
 143            await indexingService.indexNode(node);
 144            console.log(`GitDreamNodeService: Indexed new node "${title}"`);
 145          } catch (error) {
 146            console.error('Failed to index new node:', error);
 147            // Don't fail the creation if indexing fails
 148          }
 149        })
 150        .catch(async (error) => {
 151          // Check if git repository actually exists (commit might have succeeded despite stderr)
 152          try {
 153            await execAsync('git rev-parse HEAD', { cwd: repoPath });
 154            console.log(`GitDreamNodeService: Repository created successfully (stderr from hook is normal)`);
 155          } catch {
 156            // Actually failed
 157            console.error('Failed to create git repository:', error);
 158          }
 159        });
 160      
 161      console.log(`GitDreamNodeService: Created ${type} "${title}" with ID ${uuid}`);
 162      return node;
 163    }
 164    
 165    /**
 166     * Update an existing DreamNode
 167     */
 168    async update(id: string, changes: Partial<DreamNode>): Promise<void> {
 169      const store = useInterBrainStore.getState();
 170      const nodeData = store.realNodes.get(id);
 171      
 172      if (!nodeData) {
 173        throw new Error(`DreamNode with ID ${id} not found`);
 174      }
 175      
 176      const originalNode = nodeData.node;
 177      let updatedNode = { ...originalNode, ...changes };
 178      
 179      // Handle folder renaming if name changed
 180      if (changes.name && changes.name !== originalNode.name) {
 181        const newRepoName = this.generateRepoName(changes.name, originalNode.type, id);
 182        const oldRepoPath = path.join(this.vaultPath, originalNode.repoPath);
 183        const newRepoPath = path.join(this.vaultPath, newRepoName);
 184        
 185        // Only rename if the paths are actually different
 186        if (oldRepoPath !== newRepoPath) {
 187          // Check if target name already exists
 188          if (await this.fileExists(newRepoPath)) {
 189            throw new Error(`A DreamNode with the name "${changes.name}" already exists. Please choose a different name.`);
 190          }
 191          
 192          try {
 193            // Check if source exists
 194            if (!await this.fileExists(oldRepoPath)) {
 195              console.warn(`GitDreamNodeService: Source folder doesn't exist: ${oldRepoPath}`);
 196              // Just update the repoPath without renaming
 197              updatedNode = { ...updatedNode, repoPath: newRepoName };
 198            } else {
 199              // Rename the folder
 200              await fsPromises.rename(oldRepoPath, newRepoPath);
 201              
 202              // Update repoPath in the node
 203              updatedNode = { ...updatedNode, repoPath: newRepoName };
 204              
 205              console.log(`GitDreamNodeService: Renamed folder from "${originalNode.repoPath}" to "${newRepoName}"`);
 206            }
 207          } catch (error) {
 208            console.error(`Failed to rename folder for node ${id}:`, error);
 209            throw new Error(`Failed to rename DreamNode folder: ${error instanceof Error ? error.message : 'Unknown error'}`);
 210          }
 211        }
 212      }
 213      
 214      // Update in store
 215      store.updateRealNode(id, {
 216        ...nodeData,
 217        node: updatedNode,
 218        lastSynced: Date.now()
 219      });
 220      
 221      // If metadata changed, update .udd file and auto-commit
 222      if (changes.name || changes.type || changes.dreamTalkMedia || changes.email !== undefined || changes.phone !== undefined) {
 223        await this.updateUDDFile(updatedNode);
 224  
 225        // Auto-commit changes if enabled (only for actual file changes, not position)
 226        await this.autoCommitChanges(updatedNode, changes);
 227      }
 228      
 229      console.log(`GitDreamNodeService: Updated node ${id}`, changes);
 230    }
 231    
 232    /**
 233     * Delete a DreamNode and its git repository
 234     */
 235    async delete(id: string): Promise<void> {
 236      const store = useInterBrainStore.getState();
 237      const nodeData = store.realNodes.get(id);
 238      
 239      if (!nodeData) {
 240        throw new Error(`DreamNode with ID ${id} not found`);
 241      }
 242      
 243      const nodeName = nodeData.node.name;
 244      const repoPath = nodeData.node.repoPath;
 245      const fullRepoPath = path.join(this.vaultPath, repoPath);
 246      
 247      try {
 248        // Delete the actual git repository from disk
 249        console.log(`GitDreamNodeService: Deleting git repository at ${fullRepoPath}`);
 250        
 251        // Use recursive removal to delete the entire directory
 252        await fsPromises.rm(fullRepoPath, { recursive: true, force: true });
 253        
 254        console.log(`GitDreamNodeService: Successfully deleted git repository for ${nodeName}`);
 255      } catch (error) {
 256        console.error(`GitDreamNodeService: Failed to delete git repository for ${nodeName}:`, error);
 257        throw new Error(`Failed to delete git repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
 258      }
 259      
 260      // Remove from store only after successful deletion
 261      store.deleteRealNode(id);
 262      
 263      console.log(`GitDreamNodeService: Deleted node ${nodeName} (${id})`);
 264    }
 265    
 266    /**
 267     * List all DreamNodes from store
 268     */
 269    async list(): Promise<DreamNode[]> {
 270      const store = useInterBrainStore.getState();
 271      return Array.from(store.realNodes.values()).map(data => data.node);
 272    }
 273    
 274    /**
 275     * Get a specific DreamNode by ID
 276     */
 277    async get(id: string): Promise<DreamNode | null> {
 278      const store = useInterBrainStore.getState();
 279      const nodeData = store.realNodes.get(id);
 280      return nodeData ? nodeData.node : null;
 281    }
 282    
 283    /**
 284     * Scan vault for DreamNode repositories and sync with store
 285     */
 286    async scanVault(): Promise<{ added: number; updated: number; removed: number }> {
 287      const stats = { added: 0, updated: 0, removed: 0 };
 288  
 289      try {
 290        console.log('[VaultScan] Starting batch scan...');
 291  
 292        // Get all root-level directories
 293        const entries = await fsPromises.readdir(this.vaultPath, { withFileTypes: true });
 294        const directories = entries.filter((entry: { isDirectory(): boolean }) => entry.isDirectory());
 295  
 296        // Track found nodes for removal detection
 297        const foundNodeIds = new Set<string>();
 298  
 299        // Batch collection - don't update store until all nodes are processed
 300        const nodesToAdd: Array<{ dirPath: string; udd: UDDFile; dirName: string }> = [];
 301        const nodesToUpdate: Array<{ existingData: RealNodeData; dirPath: string; udd: UDDFile; dirName: string }> = [];
 302  
 303        // Check each directory
 304        for (const dir of directories) {
 305          const dirPath = path.join(this.vaultPath, dir.name);
 306  
 307          try {
 308            // Check if it's a valid DreamNode (has .git and .udd)
 309            const isValid = await this.isValidDreamNode(dirPath);
 310            if (!isValid) continue;
 311  
 312            // Read UDD file
 313            const uddPath = path.join(dirPath, '.udd');
 314            const uddContent = await fsPromises.readFile(uddPath, 'utf-8');
 315  
 316            let udd: UDDFile;
 317            try {
 318              udd = JSON.parse(uddContent);
 319            } catch (parseError) {
 320              console.error(`⚠️ [VaultScan] Invalid JSON in ${dir.name}/.udd:`, parseError);
 321              console.error(`⚠️ [VaultScan] File content preview:\n${uddContent.substring(0, 500)}`);
 322              // Skip this node but continue scanning others
 323              continue;
 324            }
 325  
 326            foundNodeIds.add(udd.uuid);
 327  
 328            // Check if node exists in store
 329            const store = useInterBrainStore.getState();
 330            const existingData = store.realNodes.get(udd.uuid);
 331  
 332            if (!existingData) {
 333              // Queue for batch add
 334              nodesToAdd.push({ dirPath, udd, dirName: dir.name });
 335            } else {
 336              // Queue for batch update
 337              nodesToUpdate.push({ existingData, dirPath, udd, dirName: dir.name });
 338            }
 339          } catch (error) {
 340            // Log error for this specific node but continue scanning others
 341            console.error(`⚠️ [VaultScan] Error processing ${dir.name}:`, error);
 342            continue;
 343          }
 344        }
 345  
 346        // Now process all batched operations - build complete Map without triggering re-renders
 347        console.log(`[VaultScan] Processing ${nodesToAdd.length} adds, ${nodesToUpdate.length} updates`);
 348  
 349        const store = useInterBrainStore.getState();
 350        const newRealNodes = new Map(store.realNodes); // Clone existing map
 351  
 352        // Process all new nodes IN PARALLEL for speed (disk I/O can happen concurrently)
 353        const addPromises = nodesToAdd.map(async ({ dirPath, udd, dirName }) => {
 354          const nodeData = await this.buildNodeDataFromVault(dirPath, udd, dirName);
 355          if (nodeData) {
 356            return { uuid: udd.uuid, nodeData };
 357          }
 358          return null;
 359        });
 360  
 361        const addResults = await Promise.all(addPromises);
 362        for (const result of addResults) {
 363          if (result) {
 364            newRealNodes.set(result.uuid, result.nodeData);
 365            stats.added++;
 366          }
 367        }
 368  
 369        // Process all updates IN PARALLEL for speed
 370        const updatePromises = nodesToUpdate.map(async ({ existingData, dirPath, udd, dirName }) => {
 371          const nodeData = await this.buildNodeDataFromVault(dirPath, udd, dirName);
 372          if (nodeData) {
 373            // Check if actually changed before counting as update
 374            const changed = JSON.stringify(existingData.node) !== JSON.stringify(nodeData.node);
 375            if (changed) {
 376              return { uuid: udd.uuid, nodeData };
 377            }
 378          }
 379          return null;
 380        });
 381  
 382        const updateResults = await Promise.all(updatePromises);
 383        for (const result of updateResults) {
 384          if (result) {
 385            newRealNodes.set(result.uuid, result.nodeData);
 386            stats.updated++;
 387          }
 388        }
 389  
 390        // Remove nodes that no longer exist in vault
 391        for (const [id] of store.realNodes) {
 392          if (!foundNodeIds.has(id)) {
 393            newRealNodes.delete(id);
 394            stats.removed++;
 395          }
 396        }
 397  
 398        // Extract and persist lightweight metadata for instant startup
 399        const nodeMetadata = new Map<string, { name: string; type: string; uuid: string }>();
 400        for (const [id, data] of newRealNodes) {
 401          nodeMetadata.set(id, {
 402            name: data.node.name,
 403            type: data.node.type,
 404            uuid: data.node.id
 405          });
 406        }
 407  
 408        // Single store update - triggers only ONE React re-render
 409        store.setRealNodes(newRealNodes);
 410        store.setNodeMetadata(nodeMetadata);
 411  
 412        // CRITICAL: Defer media loading to give React time to render placeholders first
 413        setTimeout(() => {
 414          import('./media-loading-service').then(({ getMediaLoadingService }) => {
 415            try {
 416              const mediaLoadingService = getMediaLoadingService();
 417              mediaLoadingService.loadAllNodesByDistance();
 418            } catch (error) {
 419              console.warn('[VaultScan] Failed to start media loading:', error);
 420            }
 421          });
 422        }, 50); // 50ms delay to let React render
 423  
 424      } catch (error) {
 425        console.error('Vault scan error:', error);
 426      }
 427  
 428      return stats;
 429    }
 430    
 431    /**
 432     * Create git repository with template
 433     */
 434    private async createGitRepository(
 435      repoPath: string,
 436      uuid: string,
 437      title: string,
 438      type: 'dream' | 'dreamer',
 439      dreamTalk?: globalThis.File,
 440      additionalFiles?: globalThis.File[]
 441    ): Promise<void> {
 442      try {
 443        // Create directory
 444        await fsPromises.mkdir(repoPath, { recursive: true });
 445        
 446        // Initialize git with template
 447        console.log(`GitDreamNodeService: Initializing git with template: ${this.templatePath}`);
 448        const initResult = await execAsync(`git init --template="${this.templatePath}" "${repoPath}"`);
 449        console.log(`GitDreamNodeService: Git init result:`, initResult);
 450        
 451        // Make sure hooks are executable
 452        const hooksDir = path.join(repoPath, '.git', 'hooks');
 453        if (await this.fileExists(hooksDir)) {
 454          await execAsync(`chmod +x "${path.join(hooksDir, 'pre-commit')}"`, { cwd: repoPath });
 455          console.log(`GitDreamNodeService: Made pre-commit hook executable`);
 456        }
 457        
 458        // Write dreamTalk file if provided
 459        let dreamTalkPath = '';
 460        if (dreamTalk) {
 461          dreamTalkPath = path.join(repoPath, dreamTalk.name);
 462          const buffer = await dreamTalk.arrayBuffer();
 463          await fsPromises.writeFile(dreamTalkPath, globalThis.Buffer.from(buffer));
 464        }
 465        
 466        // Write additional files
 467        if (additionalFiles) {
 468          for (const file of additionalFiles) {
 469            const filePath = path.join(repoPath, file.name);
 470            const buffer = await file.arrayBuffer();
 471            await fsPromises.writeFile(filePath, globalThis.Buffer.from(buffer));
 472          }
 473        }
 474        
 475        // Replace placeholders in template files (while still in .git directory)
 476        await this.replacePlaceholders(repoPath, {
 477          uuid,
 478          title,
 479          type,
 480          dreamTalk: dreamTalkPath ? dreamTalk!.name : ''
 481        });
 482  
 483        // Move template files from .git/ to working directory
 484        // (This is what the pre-commit hook used to do, but doing it here prevents timing issues)
 485        console.log(`GitDreamNodeService: Moving template files to working directory`);
 486        const gitDir = path.join(repoPath, '.git');
 487  
 488        // Move .udd file
 489        const uddSource = path.join(gitDir, 'udd');
 490        const uddDest = path.join(repoPath, '.udd');
 491        if (await this.fileExists(uddSource)) {
 492          await fsPromises.rename(uddSource, uddDest);
 493          console.log(`GitDreamNodeService: Moved .git/udd to .udd`);
 494        }
 495  
 496        // Move README.md
 497        const readmeSource = path.join(gitDir, 'README.md');
 498        const readmeDest = path.join(repoPath, 'README.md');
 499        if (await this.fileExists(readmeSource)) {
 500          await fsPromises.rename(readmeSource, readmeDest);
 501          console.log(`GitDreamNodeService: Moved .git/README.md to README.md`);
 502        }
 503  
 504        // Move LICENSE
 505        const licenseSource = path.join(gitDir, 'LICENSE');
 506        const licenseDest = path.join(repoPath, 'LICENSE');
 507        if (await this.fileExists(licenseSource)) {
 508          await fsPromises.rename(licenseSource, licenseDest);
 509          console.log(`GitDreamNodeService: Moved .git/LICENSE to LICENSE`);
 510        }
 511  
 512        // Make initial commit
 513        console.log(`GitDreamNodeService: Starting git operations in ${repoPath}`);
 514  
 515        // Add all files
 516        const addResult = await execAsync('git add -A', { cwd: repoPath });
 517        console.log(`GitDreamNodeService: Git add result:`, addResult);
 518        
 519        // Make the initial commit (this triggers the pre-commit hook)
 520        // Escape the title to handle quotes and special characters
 521        const escapedTitle = title.replace(/"/g, '\\"');
 522        try {
 523          const commitResult = await execAsync(`git commit -m "Initialize DreamNode: ${escapedTitle}"`, { cwd: repoPath });
 524          console.log(`GitDreamNodeService: Git commit result:`, commitResult);
 525        } catch (commitError: any) {
 526          // Pre-commit hook outputs to stderr which causes exec to throw even on success
 527          // Check if commit actually succeeded by verifying HEAD exists
 528          try {
 529            const headResult = await execAsync('git rev-parse HEAD', { cwd: repoPath });
 530            console.log(`[GitDreamNodeService] ✅ Commit verified successful despite stderr - HEAD exists: ${headResult.stdout.trim()}`);
 531            // Commit succeeded - don't rethrow, continue normally
 532          } catch (verifyError) {
 533            // Commit actually failed - HEAD doesn't exist
 534            console.error(`[GitDreamNodeService] ❌ Commit failed - HEAD verification failed:`, verifyError);
 535            throw commitError;
 536          }
 537        }
 538  
 539        console.log(`GitDreamNodeService: Git repository created successfully at ${repoPath}`);
 540      } catch (error: any) {
 541        // Don't log error if repository was actually created successfully
 542        // (This can happen if earlier operations like git init had stderr output)
 543        try {
 544          await execAsync('git rev-parse HEAD', { cwd: repoPath });
 545          console.log(`[GitDreamNodeService] ✅ Repository exists despite error - operation succeeded`);
 546          return; // Success - don't throw
 547        } catch {
 548          // Repository doesn't exist - this is a real error
 549          console.error('Failed to create git repository:', error);
 550          throw error;
 551        }
 552      }
 553    }
 554    
 555    /**
 556     * Replace template placeholders in files
 557     */
 558    private async replacePlaceholders(
 559      repoPath: string,
 560      values: {
 561        uuid: string;
 562        title: string;
 563        type: string;
 564        dreamTalk: string;
 565      }
 566    ): Promise<void> {
 567      // Update the udd file while it's still in the .git directory
 568      // The pre-commit hook will move it to .udd in the working directory
 569      const uddPath = path.join(repoPath, '.git', 'udd');
 570      console.log(`GitDreamNodeService: Updating template file at ${uddPath}`);
 571      
 572      let uddContent = await fsPromises.readFile(uddPath, 'utf-8');
 573      
 574      uddContent = uddContent
 575        .replace('TEMPLATE_UUID_PLACEHOLDER', values.uuid)
 576        .replace('TEMPLATE_TITLE_PLACEHOLDER', values.title)
 577        .replace('"type": "dream"', `"type": "${values.type}"`)
 578        .replace('TEMPLATE_DREAMTALK_PLACEHOLDER', values.dreamTalk);
 579      
 580      await fsPromises.writeFile(uddPath, uddContent);
 581      console.log(`GitDreamNodeService: Updated template metadata`);
 582      
 583      // Update README.md (also in .git directory initially)
 584      const readmePath = path.join(repoPath, '.git', 'README.md');
 585      if (await this.fileExists(readmePath)) {
 586        let readmeContent = await fsPromises.readFile(readmePath, 'utf-8');
 587        readmeContent = readmeContent.replace(/TEMPLATE_TITLE_PLACEHOLDER/g, values.title);
 588        await fsPromises.writeFile(readmePath, readmeContent);
 589        console.log(`GitDreamNodeService: Updated README.md template`);
 590      }
 591    }
 592    
 593    /**
 594     * Check if a directory is a valid DreamNode
 595     */
 596    private async isValidDreamNode(dirPath: string): Promise<boolean> {
 597      try {
 598        // Check for .git directory
 599        const gitPath = path.join(dirPath, '.git');
 600        const gitExists = await this.fileExists(gitPath);
 601        
 602        // Check for .udd file
 603        const uddPath = path.join(dirPath, '.udd');
 604        const uddExists = await this.fileExists(uddPath);
 605        
 606        return gitExists && uddExists;
 607      } catch {
 608        return false;
 609      }
 610    }
 611    
 612    /**
 613     * Build node data from vault without updating store (for batching)
 614     */
 615    private async buildNodeDataFromVault(dirPath: string, udd: UDDFile, repoName: string): Promise<RealNodeData | null> {
 616      // Load dreamTalk media if specified
 617      // IMPORTANT: If file temporarily doesn't exist, preserve existing dreamTalkMedia from store
 618      const store = useInterBrainStore.getState();
 619      const existingData = store.realNodes.get(udd.uuid);
 620      let dreamTalkMedia: Array<{
 621        path: string;
 622        absolutePath: string;
 623        type: string;
 624        data: string;
 625        size: number;
 626      }> = existingData?.node.dreamTalkMedia || []; // Preserve existing if we have it
 627  
 628      if (udd.dreamTalk) {
 629        const mediaPath = path.join(dirPath, udd.dreamTalk);
 630        if (await this.fileExists(mediaPath)) {
 631          const stats = await fsPromises.stat(mediaPath);
 632          const mimeType = this.getMimeType(udd.dreamTalk);
 633          // Skip loading media data during vault scan - will lazy load via MediaLoadingService
 634  
 635          dreamTalkMedia = [{
 636            path: udd.dreamTalk,
 637            absolutePath: mediaPath,
 638            type: mimeType,
 639            data: '', // Empty - lazy load on demand
 640            size: stats.size
 641          }];
 642        }
 643        // If file doesn't exist but udd.dreamTalk is set, keep existing dreamTalkMedia
 644        // This prevents flickering when file system is temporarily inaccessible
 645      }
 646  
 647      // Use cached constellation position if available, otherwise random
 648      const cachedPosition = store.constellationData.positions?.get(udd.uuid);
 649  
 650      const node: DreamNode = {
 651        id: udd.uuid,
 652        type: udd.type,
 653        name: udd.title,
 654        position: cachedPosition || this.calculateNewNodePosition(),
 655        dreamTalkMedia,
 656        dreamSongContent: [],
 657        liminalWebConnections: udd.liminalWebRelationships || [],
 658        repoPath: repoName,
 659        hasUnsavedChanges: false,
 660        email: udd.email,
 661        phone: udd.phone,
 662        radicleId: udd.radicleId,
 663        githubRepoUrl: udd.githubRepoUrl,
 664        githubPagesUrl: udd.githubPagesUrl
 665      };
 666      
 667      // Calculate file hash if needed
 668      let fileHash: string | undefined;
 669      if (dreamTalkMedia.length > 0 && udd.dreamTalk) {
 670        const mediaPath = path.join(dirPath, udd.dreamTalk);
 671        fileHash = await this.calculateFileHashFromPath(mediaPath);
 672      }
 673  
 674      // Return node data without updating store
 675      return {
 676        node,
 677        fileHash,
 678        lastSynced: Date.now()
 679      };
 680    }
 681  
 682    /**
 683     * Add node from vault to store (legacy method - use buildNodeDataFromVault for batching)
 684     */
 685    private async addNodeFromVault(dirPath: string, udd: UDDFile, repoName: string): Promise<void> {
 686      const nodeData = await this.buildNodeDataFromVault(dirPath, udd, repoName);
 687      if (nodeData) {
 688        const store = useInterBrainStore.getState();
 689        store.updateRealNode(udd.uuid, nodeData);
 690      }
 691    }
 692    
 693    /**
 694     * Update node from vault if changed
 695     */
 696    private async updateNodeFromVault(
 697      existingData: RealNodeData,
 698      dirPath: string,
 699      udd: UDDFile,
 700      repoName: string
 701    ): Promise<boolean> {
 702      let updated = false;
 703      const node = { ...existingData.node };
 704  
 705      // CRITICAL: Sync repoPath with actual directory name (handles Radicle clone renames)
 706      if (node.repoPath !== repoName) {
 707        console.log(`📁 [GitDreamNodeService] Syncing repoPath: "${node.repoPath}" → "${repoName}"`);
 708        node.repoPath = repoName;
 709        updated = true;
 710      }
 711  
 712      // CRITICAL: Sync display name with .udd title (human-readable)
 713      // .udd file is source of truth for display names, NOT the folder name
 714      // Folder names are PascalCase for compatibility, but display uses human-readable titles
 715      if (node.name !== udd.title) {
 716        console.log(`✏️ [GitDreamNodeService] Syncing display name from .udd: "${node.name}" → "${udd.title}"`);
 717        node.name = udd.title;
 718        updated = true;
 719      }
 720  
 721      // Check metadata changes (type, contact fields, radicleId, and GitHub URLs - name synced from .udd)
 722      if (node.type !== udd.type || node.email !== udd.email || node.phone !== udd.phone ||
 723          node.radicleId !== udd.radicleId || node.githubRepoUrl !== udd.githubRepoUrl ||
 724          node.githubPagesUrl !== udd.githubPagesUrl) {
 725        node.type = udd.type;
 726        node.email = udd.email;
 727        node.phone = udd.phone;
 728        node.radicleId = udd.radicleId;
 729        node.githubRepoUrl = udd.githubRepoUrl;
 730        node.githubPagesUrl = udd.githubPagesUrl;
 731        updated = true;
 732      }
 733      
 734      // Check dreamTalk changes
 735      if (udd.dreamTalk) {
 736        const mediaPath = path.join(dirPath, udd.dreamTalk);
 737        if (await this.fileExists(mediaPath)) {
 738          const newHash = await this.calculateFileHashFromPath(mediaPath);
 739          
 740          if (newHash !== existingData.fileHash) {
 741            // File changed - reload metadata only (data will lazy load)
 742            const stats = await fsPromises.stat(mediaPath);
 743            const mimeType = this.getMimeType(udd.dreamTalk);
 744            // Skip loading media data - will lazy load via MediaLoadingService
 745  
 746            node.dreamTalkMedia = [{
 747              path: udd.dreamTalk,
 748              absolutePath: mediaPath,
 749              type: mimeType,
 750              data: '', // Empty - lazy load on demand
 751              size: stats.size
 752            }];
 753  
 754            existingData.fileHash = newHash;
 755            updated = true;
 756          }
 757        }
 758      }
 759      
 760      // Update store if changed
 761      if (updated) {
 762        const store = useInterBrainStore.getState();
 763        store.updateRealNode(node.id, {
 764          node,
 765          fileHash: existingData.fileHash,
 766          lastSynced: Date.now()
 767        });
 768  
 769        // Write updated metadata back to .udd file (keeps file system in sync)
 770        await this.updateUDDFile(node);
 771        console.log(`💾 [GitDreamNodeService] Updated .udd file for ${node.name}`);
 772      }
 773  
 774      return updated;
 775    }
 776    
 777    /**
 778     * Update .udd file with node data
 779     */
 780    private async updateUDDFile(node: DreamNode): Promise<void> {
 781      const uddPath = path.join(this.vaultPath, node.repoPath, '.udd');
 782  
 783      const udd: UDDFile = {
 784        uuid: node.id,
 785        title: node.name,
 786        type: node.type,
 787        dreamTalk: node.dreamTalkMedia.length > 0 ? node.dreamTalkMedia[0].path : '',
 788        liminalWebRelationships: node.liminalWebConnections || [],
 789        submodules: [],
 790        supermodules: []
 791      };
 792  
 793      // Include contact fields only for dreamer nodes
 794      if (node.type === 'dreamer') {
 795        if (node.email) udd.email = node.email;
 796        if (node.phone) udd.phone = node.phone;
 797      }
 798  
 799      // CRITICAL: Preserve radicleId field if it exists
 800      if (node.radicleId) {
 801        udd.radicleId = node.radicleId;
 802      }
 803  
 804      // CRITICAL: Preserve GitHub URLs if they exist
 805      if (node.githubRepoUrl) {
 806        udd.githubRepoUrl = node.githubRepoUrl;
 807      }
 808      if (node.githubPagesUrl) {
 809        udd.githubPagesUrl = node.githubPagesUrl;
 810      }
 811  
 812      await fsPromises.writeFile(uddPath, JSON.stringify(udd, null, 2));
 813    }
 814    
 815    /**
 816     * Helper utilities
 817     */
 818    private async fileExists(path: string): Promise<boolean> {
 819      try {
 820        await fsPromises.access(path);
 821        return true;
 822      } catch {
 823        return false;
 824      }
 825    }
 826    
 827    /**
 828     * Sanitize title to PascalCase for folder names
 829     * Uses unified sanitization utility for consistency across all layers
 830     */
 831    private sanitizeRepoName(title: string): string {
 832      return sanitizeTitleToPascalCase(title);
 833    }
 834    
 835    private generateRepoName(title: string, _type: string, _nodeId: string): string {
 836      const sanitized = this.sanitizeRepoName(title);
 837      // For updates, we can use the same sanitization approach
 838      // If we need uniqueness, we could append a short hash of nodeId
 839      return sanitized;
 840    }
 841    
 842    
 843    /**
 844     * Auto-commit changes if they are significant
 845     */
 846    private async autoCommitChanges(node: DreamNode, changes: Partial<DreamNode>): Promise<void> {
 847      try {
 848        const repoPath = path.join(this.vaultPath, node.repoPath);
 849        
 850        // Check if there are any changes to commit
 851        const statusResult = await execAsync('git status --porcelain', { cwd: repoPath });
 852        if (statusResult.stdout.trim().length === 0) {
 853          return; // No changes to commit
 854        }
 855        
 856        // Create commit message based on changes
 857        const changeTypes = [];
 858        if (changes.name) changeTypes.push(`rename to "${changes.name}"`);
 859        if (changes.type) changeTypes.push(`change type to ${changes.type}`);
 860        if (changes.dreamTalkMedia) changeTypes.push('update media');
 861        
 862        const commitMessage = changeTypes.length > 0 
 863          ? `Update DreamNode: ${changeTypes.join(', ')}`
 864          : 'Update DreamNode metadata';
 865        
 866        // Stage and commit changes
 867        await execAsync('git add -A', { cwd: repoPath });
 868        // Escape the commit message to handle quotes and special characters
 869        const escapedMessage = commitMessage.replace(/"/g, '\\"');
 870        await execAsync(`git commit -m "${escapedMessage}"`, { cwd: repoPath });
 871        
 872        console.log(`GitDreamNodeService: Auto-committed changes for ${node.name}: ${commitMessage}`);
 873        
 874        // Refresh git status after commit
 875        const newGitStatus = await this.checkGitStatus(node.repoPath);
 876        
 877        // Update store with new git status
 878        const store = useInterBrainStore.getState();
 879        const nodeData = store.realNodes.get(node.id);
 880        if (nodeData) {
 881          store.updateRealNode(node.id, {
 882            ...nodeData,
 883            node: { ...node, gitStatus: newGitStatus },
 884            lastSynced: Date.now()
 885          });
 886        }
 887        
 888      } catch (error) {
 889        console.error(`Failed to auto-commit changes for node ${node.id}:`, error);
 890        // Don't throw - auto-commit failure shouldn't break the update
 891      }
 892    }
 893    
 894    private calculateNewNodePosition(): [number, number, number] {
 895      const sphereRadius = 5000;
 896      const theta = Math.random() * Math.PI * 2;
 897      const phi = Math.acos(2 * Math.random() - 1);
 898      
 899      const x = sphereRadius * Math.sin(phi) * Math.cos(theta);
 900      const y = sphereRadius * Math.sin(phi) * Math.sin(theta);
 901      const z = sphereRadius * Math.cos(phi);
 902      
 903      return [x, y, z];
 904    }
 905    
 906    private async fileToDataUrl(file: globalThis.File): Promise<string> {
 907      // .link files contain JSON metadata and should be read as text, not data URLs
 908      if (file.name.toLowerCase().endsWith('.link')) {
 909        return new Promise((resolve, reject) => {
 910          const reader = new globalThis.FileReader();
 911          reader.onload = () => resolve(reader.result as string);
 912          reader.onerror = reject;
 913          reader.readAsText(file);
 914        });
 915      }
 916  
 917      // Regular media files get converted to data URLs
 918      return new Promise((resolve, reject) => {
 919        const reader = new globalThis.FileReader();
 920        reader.onload = () => resolve(reader.result as string);
 921        reader.onerror = reject;
 922        reader.readAsDataURL(file);
 923      });
 924    }
 925    
 926    private async filePathToDataUrl(filePath: string): Promise<string> {
 927      // .link files contain JSON metadata and should be read as text, not data URLs
 928      if (filePath.toLowerCase().endsWith('.link')) {
 929        return await fsPromises.readFile(filePath, 'utf-8');
 930      }
 931  
 932      // Regular media files get converted to data URLs
 933      const buffer = await fsPromises.readFile(filePath);
 934      const mimeType = this.getMimeType(filePath);
 935      return `data:${mimeType};base64,${buffer.toString('base64')}`;
 936    }
 937    
 938    private getMimeType(filename: string): string {
 939      const ext = path.extname(filename).toLowerCase();
 940      const mimeTypes: Record<string, string> = {
 941        '.png': 'image/png',
 942        '.jpg': 'image/jpeg',
 943        '.jpeg': 'image/jpeg',
 944        '.gif': 'image/gif',
 945        '.webp': 'image/webp',
 946        '.mp4': 'video/mp4',
 947        '.webm': 'video/webm',
 948        '.pdf': 'application/pdf'
 949      };
 950      return mimeTypes[ext] || 'application/octet-stream';
 951    }
 952    
 953    private async calculateFileHash(file: globalThis.File): Promise<string> {
 954      const buffer = await file.arrayBuffer();
 955      const hash = crypto.createHash('sha256');
 956      hash.update(globalThis.Buffer.from(buffer));
 957      return hash.digest('hex');
 958    }
 959    
 960    private async calculateFileHashFromPath(filePath: string): Promise<string> {
 961      const buffer = await fsPromises.readFile(filePath);
 962      const hash = crypto.createHash('sha256');
 963      hash.update(buffer);
 964      return hash.digest('hex');
 965    }
 966    
 967    /**
 968     * Add files to an existing DreamNode
 969     */
 970    async addFilesToNode(nodeId: string, files: globalThis.File[]): Promise<void> {
 971      const store = useInterBrainStore.getState();
 972      const nodeData = store.realNodes.get(nodeId);
 973      
 974      if (!nodeData) {
 975        throw new Error(`DreamNode with ID ${nodeId} not found`);
 976      }
 977      
 978      const node = nodeData.node;
 979      const repoPath = path.join(this.vaultPath, node.repoPath);
 980      
 981      // Separate media and other files
 982      const mediaFiles = files.filter(f => this.isMediaFile(f));
 983      const otherFiles = files.filter(f => !this.isMediaFile(f));
 984      
 985      // Update dreamTalk if media provided
 986      if (mediaFiles.length > 0) {
 987        const primaryMedia = mediaFiles[0];
 988        const dataUrl = await this.fileToDataUrl(primaryMedia);
 989        
 990        node.dreamTalkMedia = [{
 991          path: primaryMedia.name,
 992          absolutePath: path.join(repoPath, primaryMedia.name),
 993          type: primaryMedia.type,
 994          data: dataUrl,
 995          size: primaryMedia.size
 996        }];
 997        
 998        // Write file to disk
 999        const buffer = await primaryMedia.arrayBuffer();
1000        await fsPromises.writeFile(
1001          path.join(repoPath, primaryMedia.name),
1002          globalThis.Buffer.from(buffer)
1003        );
1004        
1005        // Update file hash
1006        nodeData.fileHash = await this.calculateFileHash(primaryMedia);
1007      }
1008      
1009      // Write other files
1010      for (const file of otherFiles) {
1011        const buffer = await file.arrayBuffer();
1012        await fsPromises.writeFile(
1013          path.join(repoPath, file.name),
1014          globalThis.Buffer.from(buffer)
1015        );
1016      }
1017      
1018      // Update store
1019      store.updateRealNode(nodeId, {
1020        ...nodeData,
1021        node,
1022        lastSynced: Date.now()
1023      });
1024      
1025      // Update .udd file
1026      await this.updateUDDFile(node);
1027      
1028      console.log(`GitDreamNodeService: Added ${files.length} files to ${nodeId}`);
1029    }
1030    
1031    private isMediaFile(file: globalThis.File): boolean {
1032      const validTypes = [
1033        'image/png',
1034        'image/jpeg',
1035        'image/jpg',
1036        'image/gif',
1037        'image/webp',
1038        'video/mp4',
1039        'video/webm',
1040        'audio/mp3',
1041        'audio/wav',
1042        'audio/ogg',
1043        'application/pdf',
1044        // .link files appear as text/plain or application/octet-stream depending on system
1045        'text/plain',
1046        'application/octet-stream'
1047      ];
1048  
1049      // Also check file extension for .link files since MIME detection is unreliable
1050      const fileName = file.name.toLowerCase();
1051      if (fileName.endsWith('.link')) {
1052        return true;
1053      }
1054  
1055      return validTypes.includes(file.type);
1056    }
1057    
1058    /**
1059     * Reset all data (clears store but not disk)
1060     */
1061    reset(): void {
1062      const store = useInterBrainStore.getState();
1063      store.setRealNodes(new Map());
1064      console.log('GitDreamNodeService: Reset store data');
1065    }
1066    
1067    /**
1068     * Refresh git status for all nodes (implements IDreamNodeService)
1069     */
1070    async refreshGitStatus(): Promise<{ updated: number; errors: number }> {
1071      return await this.refreshAllGitStatus();
1072    }
1073    
1074    /**
1075     * Internal method to refresh git status for all nodes
1076     */
1077    private async refreshAllGitStatus(): Promise<{ updated: number; errors: number }> {
1078      const store = useInterBrainStore.getState();
1079      const nodes = Array.from(store.realNodes.entries());
1080      
1081      let updated = 0;
1082      let errors = 0;
1083      
1084      console.log(`GitDreamNodeService: Refreshing git status for ${nodes.length} nodes...`);
1085      
1086      for (const [nodeId, nodeData] of nodes) {
1087        try {
1088          const newGitStatus = await this.checkGitStatus(nodeData.node.repoPath);
1089          const oldGitStatus = nodeData.node.gitStatus;
1090          
1091          // Check if git status actually changed
1092          const statusChanged = !oldGitStatus || 
1093            oldGitStatus.hasUncommittedChanges !== newGitStatus.hasUncommittedChanges ||
1094            oldGitStatus.hasStashedChanges !== newGitStatus.hasStashedChanges ||
1095            oldGitStatus.hasUnpushedChanges !== newGitStatus.hasUnpushedChanges;
1096          
1097          // Check if commit hash changed (new commit detected)
1098          const oldCommitHash = oldGitStatus?.details?.commitHash;
1099          const newCommitHash = newGitStatus.details?.commitHash;
1100          const commitChanged = oldCommitHash && newCommitHash && oldCommitHash !== newCommitHash;
1101          
1102          if (statusChanged || commitChanged) {
1103            // Update the node with new git status
1104            const updatedNode = {
1105              ...nodeData.node,
1106              gitStatus: newGitStatus
1107            };
1108            
1109            store.updateRealNode(nodeId, {
1110              ...nodeData,
1111              node: updatedNode,
1112              lastSynced: Date.now()
1113            });
1114            
1115            updated++;
1116            console.log(`GitDreamNodeService: Updated git status for ${updatedNode.name}: uncommitted=${newGitStatus.hasUncommittedChanges}, stashed=${newGitStatus.hasStashedChanges}, unpushed=${newGitStatus.hasUnpushedChanges}`);
1117            
1118            // Trigger re-indexing if commit changed (meaningful content change)
1119            if (commitChanged && !newGitStatus.hasUncommittedChanges) {
1120              // Only re-index if the node is clean (committed changes)
1121              try {
1122                await indexingService.indexNode(updatedNode);
1123                console.log(`GitDreamNodeService: Re-indexed node "${updatedNode.name}" after commit change`);
1124              } catch (error) {
1125                console.error(`Failed to re-index node ${updatedNode.name}:`, error);
1126              }
1127            }
1128          }
1129        } catch (error) {
1130          console.error(`GitDreamNodeService: Failed to refresh git status for node ${nodeId}:`, error);
1131          errors++;
1132        }
1133      }
1134      
1135      console.log(`GitDreamNodeService: Git status refresh complete. Updated: ${updated}, Errors: ${errors}`);
1136      return { updated, errors };
1137    }
1138    
1139    /**
1140     * Get statistics
1141     */
1142    getStats() {
1143      const store = useInterBrainStore.getState();
1144      const nodes = Array.from(store.realNodes.values()).map(d => d.node);
1145      
1146      return {
1147        totalNodes: nodes.length,
1148        dreamNodes: nodes.filter(n => n.type === 'dream').length,
1149        dreamerNodes: nodes.filter(n => n.type === 'dreamer').length,
1150        nodesWithMedia: nodes.filter(n => n.dreamTalkMedia.length > 0).length
1151      };
1152    }
1153    
1154    /**
1155     * Check git status for a repository
1156     */
1157    private async checkGitStatus(repoPath: string): Promise<GitStatus> {
1158      try {
1159        const fullPath = path.join(this.vaultPath, repoPath);
1160        
1161        // Check if git repository exists
1162        const gitDir = path.join(fullPath, '.git');
1163        if (!await this.fileExists(gitDir)) {
1164          // No git repo yet, return clean state
1165          return {
1166            hasUncommittedChanges: false,
1167            hasStashedChanges: false,
1168            hasUnpushedChanges: false,
1169            lastChecked: Date.now()
1170          };
1171        }
1172        
1173        // Get current commit hash
1174        let commitHash: string | undefined;
1175        try {
1176          const hashResult = await execAsync('git rev-parse HEAD', { cwd: fullPath });
1177          commitHash = hashResult.stdout.trim();
1178        } catch {
1179          // No commits yet
1180          console.log(`GitDreamNodeService: No commits yet in ${repoPath}`);
1181        }
1182        
1183        // Check for uncommitted changes
1184        const statusResult = await execAsync('git status --porcelain', { cwd: fullPath });
1185        const hasUncommittedChanges = statusResult.stdout.trim().length > 0;
1186        
1187        // Check for stashed changes
1188        const stashResult = await execAsync('git stash list', { cwd: fullPath });
1189        const hasStashedChanges = stashResult.stdout.trim().length > 0;
1190        
1191        // Check for unpushed commits (ahead of remote) using git status
1192        let hasUnpushedChanges = false;
1193        let aheadCount = 0;
1194        try {
1195          // Use git status --porcelain=v1 --branch to get ahead/behind info
1196          const statusBranchResult = await execAsync('git status --porcelain=v1 --branch', { cwd: fullPath });
1197          const branchLine = statusBranchResult.stdout.split('\n')[0];
1198          
1199          // Look for "ahead N" in the branch line
1200          // Format: "## branch...origin/branch [ahead N, behind M]" or "## branch...origin/branch [ahead N]"
1201          const aheadMatch = branchLine.match(/\[ahead (\d+)/);
1202          if (aheadMatch) {
1203            aheadCount = parseInt(aheadMatch[1], 10);
1204            hasUnpushedChanges = aheadCount > 0;
1205            console.log(`GitDreamNodeService: Found ${aheadCount} unpushed commits in ${repoPath}`);
1206          } else {
1207            console.log(`GitDreamNodeService: No ahead commits detected in ${repoPath}, branch line: ${branchLine}`);
1208          }
1209        } catch (error) {
1210          // No upstream or git error, assume no unpushed commits
1211          console.log(`GitDreamNodeService: Git status error for ${repoPath}:`, error instanceof Error ? error.message : 'Unknown error');
1212        }
1213        
1214        // Count different types of changes for details
1215        let details;
1216        if (hasUncommittedChanges || hasStashedChanges || hasUnpushedChanges || commitHash) {
1217          const statusLines = statusResult.stdout.trim().split('\n').filter((line: string) => line.length > 0);
1218          const staged = statusLines.filter((line: string) => line.charAt(0) !== ' ' && line.charAt(0) !== '?').length;
1219          const unstaged = statusLines.filter((line: string) => line.charAt(1) !== ' ').length;
1220          const untracked = statusLines.filter((line: string) => line.startsWith('??')).length;
1221          const stashCount = hasStashedChanges ? stashResult.stdout.trim().split('\n').length : 0;
1222          
1223          details = { staged, unstaged, untracked, stashCount, aheadCount, commitHash };
1224        }
1225        
1226        return {
1227          hasUncommittedChanges,
1228          hasStashedChanges,
1229          hasUnpushedChanges,
1230          lastChecked: Date.now(),
1231          details
1232        };
1233        
1234      } catch (error) {
1235        console.warn(`Failed to check git status for ${repoPath}:`, error);
1236        // Return clean state on error
1237        return {
1238          hasUncommittedChanges: false,
1239          hasStashedChanges: false,
1240          hasUnpushedChanges: false,
1241          lastChecked: Date.now()
1242        };
1243      }
1244    }
1245  
1246    // Relationship management methods
1247    
1248    /**
1249     * Update relationships for a node (bidirectional)
1250     * This method enforces bidirectionality by using atomic add/remove operations
1251     */
1252    async updateRelationships(nodeId: string, relationshipIds: string[]): Promise<void> {
1253      const store = useInterBrainStore.getState();
1254      const nodeData = store.realNodes.get(nodeId);
1255  
1256      if (!nodeData) {
1257        throw new Error(`DreamNode with ID ${nodeId} not found`);
1258      }
1259  
1260      const node = nodeData.node;
1261  
1262      // Get current relationships
1263      const currentRelationships = new Set(node.liminalWebConnections || []);
1264      const newRelationships = new Set(relationshipIds);
1265  
1266      // Find added and removed relationships
1267      const added = relationshipIds.filter(id => !currentRelationships.has(id));
1268      const removed = Array.from(currentRelationships).filter(id => !newRelationships.has(id));
1269  
1270      // Use atomic operations for each change to ensure bidirectionality
1271      for (const addedId of added) {
1272        await this.addRelationship(nodeId, addedId);
1273      }
1274  
1275      for (const removedId of removed) {
1276        await this.removeRelationship(nodeId, removedId);
1277      }
1278  
1279      console.log(`GitDreamNodeService: Updated relationships for ${nodeId}:`, {
1280        added: added.length,
1281        removed: removed.length,
1282        total: relationshipIds.length
1283      });
1284    }
1285  
1286    /**
1287     * Get relationships for a node
1288     */
1289    async getRelationships(nodeId: string): Promise<string[]> {
1290      const store = useInterBrainStore.getState();
1291      const nodeData = store.realNodes.get(nodeId);
1292      
1293      if (!nodeData) {
1294        throw new Error(`DreamNode with ID ${nodeId} not found`);
1295      }
1296      
1297      return nodeData.node.liminalWebConnections || [];
1298    }
1299  
1300    /**
1301     * Add a single relationship (bidirectional)
1302     * This method enforces bidirectionality by updating BOTH nodes atomically
1303     */
1304    async addRelationship(nodeId: string, relatedNodeId: string): Promise<void> {
1305      const store = useInterBrainStore.getState();
1306      const nodeData = store.realNodes.get(nodeId);
1307      const relatedNodeData = store.realNodes.get(relatedNodeId);
1308  
1309      if (!nodeData) {
1310        throw new Error(`DreamNode with ID ${nodeId} not found`);
1311      }
1312  
1313      if (!relatedNodeData) {
1314        throw new Error(`Related DreamNode with ID ${relatedNodeId} not found`);
1315      }
1316  
1317      // Update both nodes' relationships atomically
1318      const node = nodeData.node;
1319      const relatedNode = relatedNodeData.node;
1320  
1321      // Add relationship in both directions
1322      const relationships = new Set(node.liminalWebConnections || []);
1323      relationships.add(relatedNodeId);
1324      node.liminalWebConnections = Array.from(relationships);
1325  
1326      const relatedRelationships = new Set(relatedNode.liminalWebConnections || []);
1327      relatedRelationships.add(nodeId);
1328      relatedNode.liminalWebConnections = Array.from(relatedRelationships);
1329  
1330      // Update both nodes in store
1331      store.updateRealNode(nodeId, {
1332        ...nodeData,
1333        node,
1334        lastSynced: Date.now()
1335      });
1336  
1337      store.updateRealNode(relatedNodeId, {
1338        ...relatedNodeData,
1339        node: relatedNode,
1340        lastSynced: Date.now()
1341      });
1342  
1343      // Update both .udd files
1344      await Promise.all([
1345        this.updateUDDFile(node),
1346        this.updateUDDFile(relatedNode)
1347      ]);
1348  
1349      console.log(`GitDreamNodeService: Added bidirectional relationship ${nodeId} <-> ${relatedNodeId}`);
1350    }
1351  
1352    /**
1353     * Remove a single relationship (bidirectional)
1354     * This method enforces bidirectionality by updating BOTH nodes atomically
1355     */
1356    async removeRelationship(nodeId: string, relatedNodeId: string): Promise<void> {
1357      const store = useInterBrainStore.getState();
1358      const nodeData = store.realNodes.get(nodeId);
1359      const relatedNodeData = store.realNodes.get(relatedNodeId);
1360  
1361      if (!nodeData) {
1362        throw new Error(`DreamNode with ID ${nodeId} not found`);
1363      }
1364  
1365      if (!relatedNodeData) {
1366        console.warn(`Related DreamNode with ID ${relatedNodeId} not found - removing one-way relationship only`);
1367        // Still remove from the first node even if related node is missing
1368        const node = nodeData.node;
1369        const relationships = new Set(node.liminalWebConnections || []);
1370        relationships.delete(relatedNodeId);
1371        node.liminalWebConnections = Array.from(relationships);
1372  
1373        store.updateRealNode(nodeId, {
1374          ...nodeData,
1375          node,
1376          lastSynced: Date.now()
1377        });
1378  
1379        await this.updateUDDFile(node);
1380        console.log(`GitDreamNodeService: Removed one-way relationship ${nodeId} -> ${relatedNodeId}`);
1381        return;
1382      }
1383  
1384      // Update both nodes' relationships atomically
1385      const node = nodeData.node;
1386      const relatedNode = relatedNodeData.node;
1387  
1388      // Remove relationship in both directions
1389      const relationships = new Set(node.liminalWebConnections || []);
1390      relationships.delete(relatedNodeId);
1391      node.liminalWebConnections = Array.from(relationships);
1392  
1393      const relatedRelationships = new Set(relatedNode.liminalWebConnections || []);
1394      relatedRelationships.delete(nodeId);
1395      relatedNode.liminalWebConnections = Array.from(relatedRelationships);
1396  
1397      // Update both nodes in store
1398      store.updateRealNode(nodeId, {
1399        ...nodeData,
1400        node,
1401        lastSynced: Date.now()
1402      });
1403  
1404      store.updateRealNode(relatedNodeId, {
1405        ...relatedNodeData,
1406        node: relatedNode,
1407        lastSynced: Date.now()
1408      });
1409  
1410      // Update both .udd files
1411      await Promise.all([
1412        this.updateUDDFile(node),
1413        this.updateUDDFile(relatedNode)
1414      ]);
1415  
1416      console.log(`GitDreamNodeService: Removed bidirectional relationship ${nodeId} <-> ${relatedNodeId}`);
1417    }
1418  
1419    /**
1420     * Create a DreamNode from URL metadata
1421     */
1422    async createFromUrl(
1423      title: string,
1424      type: 'dream' | 'dreamer',
1425      urlMetadata: UrlMetadata,
1426      position?: [number, number, number]
1427    ): Promise<DreamNode> {
1428      // Use provided position or calculate random position
1429      const nodePosition = position
1430        ? position // Position is already calculated in world coordinates
1431        : this.calculateNewNodePosition();
1432  
1433      // Create node using existing create method without files
1434      const node = await this.create(title, type, undefined, nodePosition);
1435  
1436      // Generate .link file name and content
1437      const linkFileName = getLinkFileName(urlMetadata, title);
1438      const linkFileContent = createLinkFileContent(urlMetadata, title);
1439  
1440      console.log(`🔗 [GitDreamNodeService] Creating link file:`, {
1441        linkFileName,
1442        linkFilePath: path.join(this.vaultPath, node.repoPath, linkFileName),
1443        contentLength: linkFileContent.length,
1444        contentPreview: linkFileContent.substring(0, 100)
1445      });
1446  
1447      // Write .link file to repository
1448      const linkFilePath = path.join(this.vaultPath, node.repoPath, linkFileName);
1449      await fsPromises.writeFile(linkFilePath, linkFileContent);
1450  
1451      console.log(`🔗 [GitDreamNodeService] Link file written successfully:`, linkFilePath);
1452  
1453      // Update dreamTalk media to reference the .link file
1454      node.dreamTalkMedia = [{
1455        path: linkFileName,
1456        absolutePath: linkFilePath,
1457        type: urlMetadata.type,
1458        data: linkFileContent, // Store link metadata as data
1459        size: linkFileContent.length
1460      }];
1461  
1462      // Create README content with URL
1463      const readmeContent = this.createUrlReadmeContent(urlMetadata, title);
1464      await this.writeReadmeFile(node.repoPath, readmeContent);
1465  
1466      // Update .udd file with .link file path
1467      await this.updateUDDFile(node);
1468  
1469      console.log(`GitDreamNodeService: Created ${type} "${title}" from URL (${urlMetadata.type})`);
1470      console.log(`GitDreamNodeService: Created .link file: ${linkFileName}`);
1471      console.log(`GitDreamNodeService: URL: ${urlMetadata.url}`);
1472      return node;
1473    }
1474  
1475    /**
1476     * Add URL to an existing DreamNode
1477     */
1478    async addUrlToNode(nodeId: string, urlMetadata: UrlMetadata): Promise<void> {
1479      const store = useInterBrainStore.getState();
1480      const nodeData = store.realNodes.get(nodeId);
1481  
1482      if (!nodeData) {
1483        throw new Error(`DreamNode with ID ${nodeId} not found`);
1484      }
1485  
1486      const node = nodeData.node;
1487  
1488      // Generate .link file name and content
1489      const linkFileName = getLinkFileName(urlMetadata, node.name);
1490      const linkFileContent = createLinkFileContent(urlMetadata, node.name);
1491  
1492      console.log(`🔗 [GitDreamNodeService] Adding link file to existing node:`, {
1493        linkFileName,
1494        linkFilePath: path.join(this.vaultPath, node.repoPath, linkFileName),
1495        contentLength: linkFileContent.length,
1496        contentPreview: linkFileContent.substring(0, 100)
1497      });
1498  
1499      // Write .link file to repository
1500      const linkFilePath = path.join(this.vaultPath, node.repoPath, linkFileName);
1501      await fsPromises.writeFile(linkFilePath, linkFileContent);
1502  
1503      console.log(`🔗 [GitDreamNodeService] Link file added successfully:`, linkFilePath);
1504  
1505      // Add .link file as additional dreamTalk media
1506      const linkMedia = {
1507        path: linkFileName,
1508        absolutePath: linkFilePath,
1509        type: urlMetadata.type,
1510        data: linkFileContent,
1511        size: linkFileContent.length
1512      };
1513  
1514      node.dreamTalkMedia.push(linkMedia);
1515  
1516      // Append URL content to README
1517      const urlContent = this.createUrlReadmeContent(urlMetadata);
1518      const readmePath = path.join(this.vaultPath, node.repoPath, 'README.md');
1519  
1520      try {
1521        // Read existing README content
1522        const existingContent = await fsPromises.readFile(readmePath, 'utf8');
1523        const newContent = existingContent + '\n\n' + urlContent;
1524        await fsPromises.writeFile(readmePath, newContent);
1525      } catch (error) {
1526        console.warn(`Failed to update README for node ${nodeId}:`, error);
1527        // Create new README if it doesn't exist
1528        await this.writeReadmeFile(node.repoPath, urlContent);
1529      }
1530  
1531      // Update .udd file
1532      await this.updateUDDFile(node);
1533  
1534      // Update store
1535      store.updateRealNode(nodeId, {
1536        ...nodeData,
1537        node,
1538        lastSynced: Date.now()
1539      });
1540  
1541      console.log(`GitDreamNodeService: Added URL (${urlMetadata.type}) to node ${nodeId}: ${urlMetadata.url}`);
1542      console.log(`GitDreamNodeService: Created .link file: ${linkFileName}`);
1543    }
1544  
1545    /**
1546     * Create README content for URLs
1547     */
1548    private createUrlReadmeContent(urlMetadata: UrlMetadata, title?: string): string {
1549      let content = '';
1550  
1551      if (title) {
1552        content += `# ${title}\n\n`;
1553      }
1554  
1555      if (urlMetadata.type === 'youtube' && urlMetadata.videoId) {
1556        // Add YouTube iframe embed for Obsidian
1557        content += generateYouTubeIframe(urlMetadata.videoId, 560, 315);
1558        content += '\n\n';
1559  
1560        // Add markdown link as backup
1561        content += `[${urlMetadata.title || 'YouTube Video'}](${urlMetadata.url})`;
1562      } else {
1563        // For other URLs, add as markdown link
1564        content += generateMarkdownLink(urlMetadata.url, urlMetadata.title);
1565      }
1566  
1567      return content;
1568    }
1569  
1570    /**
1571     * Write README.md file to node repository
1572     */
1573    private async writeReadmeFile(repoPath: string, content: string): Promise<void> {
1574      const readmePath = path.join(this.vaultPath, repoPath, 'README.md');
1575      await fsPromises.writeFile(readmePath, content);
1576    }
1577  }