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 };