/ src / tools / pop-out.ts
pop-out.ts
  1  /**
  2   * Pop-Out to Sovereign MCP Tool
  3   *
  4   * Promotes a local file inside a DreamNode to its own sovereign DreamNode.
  5   * The file is moved to the new DreamNode, which is then imported back as a submodule.
  6   * DreamSong references are updated to point into the submodule.
  7   *
  8   * Remote topology (enforced by this tool and SubmoduleService):
  9   *   Radicle ← sovereign repo (vault root) ← submodule clone
 10   *
 11   * - Sovereign DreamNode at vault root has Radicle as its remote
 12   * - Submodule clones inside parent DreamNodes point to the local sovereign as origin
 13   * - This ensures fast local operations while Radicle handles network distribution
 14   *
 15   * SubmoduleService.addSubmodule already uses the sovereign's absolute local path
 16   * when running `git submodule add`, so .gitmodules URLs naturally point to the
 17   * local sovereign. No additional remote configuration needed.
 18   *
 19   * This is how knowledge gardens grow — not in size, but in interconnectedness.
 20   */
 21  
 22  import { DreamNodeService, SubmoduleService, UDDService, DEFAULT_VAULT_PATH, commitAllChanges } from '../services/standalone-adapter.js';
 23  import { exec } from 'child_process';
 24  import { promisify } from 'util';
 25  import * as path from 'path';
 26  import * as fs from 'fs/promises';
 27  import * as fsSync from 'fs';
 28  
 29  const execAsync = promisify(exec);
 30  
 31  /**
 32   * Tool: pop_out_to_sovereign
 33   * Promote a local file to its own sovereign DreamNode with submodule replacement
 34   */
 35  export async function popOutToSovereign(args: {
 36    parent_identifier: string;
 37    file_path: string;
 38    dreamnode_name: string;
 39    dreamnode_type?: 'dream' | 'dreamer';
 40    context_branch?: boolean;
 41  }): Promise<{
 42    success: boolean;
 43    sovereign_node?: {
 44      uuid: string;
 45      title: string;
 46      path: string;
 47      radicleId?: string;
 48    };
 49    submodule_name?: string;
 50    context_branch_name?: string;
 51    dreamsong_updated?: boolean;
 52    error?: string;
 53  }> {
 54    try {
 55      // 1. Find the parent DreamNode
 56      const parent = await DreamNodeService.getDreamNode(args.parent_identifier);
 57      if (!parent) {
 58        return {
 59          success: false,
 60          error: `Parent DreamNode not found: ${args.parent_identifier}`
 61        };
 62      }
 63  
 64      // 2. Resolve and validate the file path
 65      // file_path can be relative to the parent DreamNode or absolute
 66      const absoluteFilePath = path.isAbsolute(args.file_path)
 67        ? args.file_path
 68        : path.join(parent.path, args.file_path);
 69  
 70      if (!fsSync.existsSync(absoluteFilePath)) {
 71        return {
 72          success: false,
 73          error: `File not found: ${absoluteFilePath}`
 74        };
 75      }
 76  
 77      const fileName = path.basename(absoluteFilePath);
 78      const fileRelativeToParent = path.relative(parent.path, absoluteFilePath);
 79  
 80      // 3. Check that the file is directly inside the parent (not in a submodule)
 81      if (fileRelativeToParent.startsWith('..')) {
 82        return {
 83          success: false,
 84          error: `File is not inside the parent DreamNode: ${absoluteFilePath}`
 85        };
 86      }
 87  
 88      // 4. Create the sovereign DreamNode at vault root
 89      const nodeType = args.dreamnode_type || 'dream';
 90      const newNode = await DreamNodeService.createDreamNode(
 91        parent.vaultPath,
 92        args.dreamnode_name,
 93        nodeType
 94      );
 95  
 96      // 5. Move the file into the new DreamNode
 97      const destPath = path.join(newNode.path, fileName);
 98      await fs.copyFile(absoluteFilePath, destPath);
 99  
100      // 6. Update the new DreamNode's .udd with dreamTalk
101      const udd = await UDDService.readUDD(newNode.path);
102      udd.dreamTalk = fileName;
103      await UDDService.writeUDD(newNode.path, udd);
104  
105      // 7. Commit the file addition to the sovereign DreamNode
106      await commitAllChanges(newNode.path, `Add ${fileName} as DreamTalk from pop-out`);
107  
108      // 8. Remove the original file from the parent (git rm)
109      try {
110        await execAsync(`git rm -f "${fileRelativeToParent}"`, { cwd: parent.path });
111      } catch {
112        // If git rm fails (file might not be tracked), just delete it
113        await fs.unlink(absoluteFilePath);
114      }
115  
116      // 9. Commit the removal before adding submodule
117      try {
118        await commitAllChanges(parent.path, `Remove ${fileName} (popping out to sovereign DreamNode)`);
119      } catch {
120        // May fail if nothing to commit (git rm already staged)
121      }
122  
123      // 10. Add the new DreamNode as a submodule of the parent
124      const submoduleName = args.dreamnode_name;
125      const subResult = await SubmoduleService.addSubmodule(
126        parent.path,
127        newNode.path,
128        submoduleName
129      );
130  
131      if (!subResult.success) {
132        return {
133          success: false,
134          error: `Failed to add submodule: ${subResult.error}. Sovereign DreamNode was created at ${newNode.path} but not linked.`
135        };
136      }
137  
138      // 11. Update DreamSong.canvas if it exists
139      let dreamsongUpdated = false;
140      const canvasPath = path.join(parent.path, 'DreamSong.canvas');
141      if (fsSync.existsSync(canvasPath)) {
142        const canvasContent = await fs.readFile(canvasPath, 'utf-8');
143  
144        // The DreamSong references files relative to the vault, not the DreamNode
145        // e.g., "SpringLaunch/Website.png" needs to become "SpringLaunch/Website/Website.png"
146        const parentDirName = path.basename(parent.path);
147        const oldRef = `${parentDirName}/${fileRelativeToParent}`;
148        const newRef = `${parentDirName}/${submoduleName}/${fileName}`;
149  
150        if (canvasContent.includes(oldRef)) {
151          const updatedCanvas = canvasContent.split(oldRef).join(newRef);
152          await fs.writeFile(canvasPath, updatedCanvas, 'utf-8');
153          dreamsongUpdated = true;
154          await commitAllChanges(parent.path, `Update DreamSong: ${fileName} now references submodule`);
155        }
156      }
157  
158      // 12. Create context branch in the submodule clone if requested
159      let contextBranchName: string | undefined;
160      const createBranch = args.context_branch !== false; // default true
161      if (createBranch) {
162        const submodulePath = path.join(parent.path, submoduleName);
163        // Derive branch name from parent title, kebab-case
164        contextBranchName = parent.title
165          .toLowerCase()
166          .replace(/[^a-z0-9]+/g, '-')
167          .replace(/^-|-$/g, '');
168  
169        try {
170          await execAsync(`git checkout -b "${contextBranchName}"`, { cwd: submodulePath });
171          // Commit the submodule to parent at this new branch
172          await commitAllChanges(parent.path, `Set submodule ${submoduleName} to context branch: ${contextBranchName}`);
173        } catch (branchError) {
174          // Non-fatal — branch creation is a convenience
175          contextBranchName = undefined;
176          console.error('Context branch creation failed (non-fatal):', branchError);
177        }
178      }
179  
180      return {
181        success: true,
182        sovereign_node: {
183          uuid: newNode.uuid,
184          title: newNode.title,
185          path: newNode.path,
186          radicleId: newNode.radicleId
187        },
188        submodule_name: submoduleName,
189        context_branch_name: contextBranchName,
190        dreamsong_updated: dreamsongUpdated
191      };
192    } catch (error) {
193      return {
194        success: false,
195        error: error instanceof Error ? error.message : 'Unknown error'
196      };
197    }
198  }
199  
200  /**
201   * Export tool definitions for MCP registration
202   */
203  export const popOutTools = {
204    pop_out_to_sovereign: {
205      name: 'pop_out_to_sovereign',
206      description: 'Promote a local file inside a DreamNode to its own sovereign DreamNode. The file becomes the new DreamNode\'s DreamTalk, the original is replaced by a submodule import, and DreamSong references are updated. Creates a context branch in the submodule clone by default.',
207      inputSchema: {
208        type: 'object' as const,
209        properties: {
210          parent_identifier: {
211            type: 'string',
212            description: 'UUID or title of the parent DreamNode containing the file'
213          },
214          file_path: {
215            type: 'string',
216            description: 'Path to the file to pop out (relative to parent DreamNode, or absolute)'
217          },
218          dreamnode_name: {
219            type: 'string',
220            description: 'Name/title for the new sovereign DreamNode'
221          },
222          dreamnode_type: {
223            type: 'string',
224            enum: ['dream', 'dreamer'],
225            description: 'Type of DreamNode (default: dream)'
226          },
227          context_branch: {
228            type: 'boolean',
229            description: 'Create a context branch in the submodule clone named after the parent (default: true)'
230          }
231        },
232        required: ['parent_identifier', 'file_path', 'dreamnode_name']
233      },
234      handler: popOutToSovereign
235    }
236  };