social-resonance.ts
1 /** 2 * Social Resonance MCP Tools - Radicle operations, peer sync, collaboration memory 3 * 4 * Handles the P2P layer: publishing/cloning DreamNodes via Radicle, 5 * following peers, fetching new commits, and tracking acceptance/rejection 6 * history in collaboration-memory.json (stored in Dreamer nodes). 7 */ 8 9 import { 10 findDreamNode, 11 DreamNodeService, 12 UDDService, 13 commitAllChanges, 14 DEFAULT_VAULT_PATH, 15 type DreamNodeInfo, 16 } from '../services/standalone-adapter.js'; 17 import { exec } from 'child_process'; 18 import { promisify } from 'util'; 19 import * as fs from 'fs'; 20 import * as path from 'path'; 21 22 const execAsync = promisify(exec); 23 24 // ============================================================================ 25 // COLLABORATION MEMORY SERVICE 26 // Stored in Dreamer nodes as collaboration-memory.json 27 // Tracks which commits from which DreamNodes have been accepted/rejected 28 // ============================================================================ 29 30 export interface CollaborationMemoryEntry { 31 originalHash: string; 32 appliedHash?: string; 33 relayedBy: string[]; 34 subject: string; 35 timestamp: number; 36 } 37 38 export interface CollaborationMemoryFile { 39 version: 1; 40 dreamNodes: Record<string, { 41 accepted: CollaborationMemoryEntry[]; 42 rejected: CollaborationMemoryEntry[]; 43 }>; 44 } 45 46 export class CollaborationMemoryService { 47 static read(dreamerPath: string): CollaborationMemoryFile { 48 const filePath = path.join(dreamerPath, 'collaboration-memory.json'); 49 try { 50 const content = fs.readFileSync(filePath, 'utf-8'); 51 return JSON.parse(content) as CollaborationMemoryFile; 52 } catch { 53 return { version: 1, dreamNodes: {} }; 54 } 55 } 56 57 static write(dreamerPath: string, data: CollaborationMemoryFile): void { 58 const filePath = path.join(dreamerPath, 'collaboration-memory.json'); 59 fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); 60 } 61 62 static recordAcceptance(dreamerPath: string, nodeUuid: string, entry: CollaborationMemoryEntry): void { 63 const data = this.read(dreamerPath); 64 if (!data.dreamNodes[nodeUuid]) { 65 data.dreamNodes[nodeUuid] = { accepted: [], rejected: [] }; 66 } 67 data.dreamNodes[nodeUuid].accepted.push(entry); 68 this.write(dreamerPath, data); 69 } 70 71 static recordRejection(dreamerPath: string, nodeUuid: string, entry: CollaborationMemoryEntry): void { 72 const data = this.read(dreamerPath); 73 if (!data.dreamNodes[nodeUuid]) { 74 data.dreamNodes[nodeUuid] = { accepted: [], rejected: [] }; 75 } 76 data.dreamNodes[nodeUuid].rejected.push(entry); 77 this.write(dreamerPath, data); 78 } 79 80 static isProcessed(dreamerPath: string, nodeUuid: string, commitHash: string): 'accepted' | 'rejected' | null { 81 const data = this.read(dreamerPath); 82 const nodeData = data.dreamNodes[nodeUuid]; 83 if (!nodeData) return null; 84 85 if (nodeData.accepted.some(e => e.originalHash === commitHash)) return 'accepted'; 86 if (nodeData.rejected.some(e => e.originalHash === commitHash)) return 'rejected'; 87 return null; 88 } 89 } 90 91 // ============================================================================ 92 // HELPER: Check if rad CLI is available 93 // ============================================================================ 94 95 async function isRadicleAvailable(): Promise<boolean> { 96 try { 97 await execAsync('which rad'); 98 return true; 99 } catch { 100 return false; 101 } 102 } 103 104 // ============================================================================ 105 // TOOL HANDLERS 106 // ============================================================================ 107 108 /** 109 * Tool: radicle_clone 110 * Clone a DreamNode from Radicle network into the vault 111 */ 112 async function radicleClone(args: { 113 rid: string; 114 vault_path?: string; 115 }): Promise<{ 116 success: boolean; 117 node?: { uuid: string; title: string; type: string; path: string; rid: string }; 118 error?: string; 119 }> { 120 try { 121 if (!(await isRadicleAvailable())) { 122 return { success: false, error: 'Radicle CLI (rad) is not installed or not in PATH' }; 123 } 124 125 const vaultPath = args.vault_path || DEFAULT_VAULT_PATH; 126 127 // Clone from Radicle network 128 const { stdout } = await execAsync( 129 `RAD_PASSPHRASE="" rad clone ${args.rid} --scope all`, 130 { cwd: vaultPath, timeout: 60000 } 131 ); 132 133 // rad clone creates a directory named after the repo 134 // Extract the directory name from stdout or find the newest directory 135 const dirNameMatch = stdout.match(/Cloning into '([^']+)'/); 136 let clonedDir: string; 137 138 if (dirNameMatch) { 139 clonedDir = path.join(vaultPath, dirNameMatch[1]); 140 } else { 141 // Fallback: find most recently created directory 142 const entries = fs.readdirSync(vaultPath, { withFileTypes: true }); 143 let newest = ''; 144 let newestTime = 0; 145 for (const entry of entries) { 146 if (!entry.isDirectory() || entry.name.startsWith('.')) continue; 147 const fullPath = path.join(vaultPath, entry.name); 148 const stat = fs.statSync(fullPath); 149 if (stat.mtimeMs > newestTime) { 150 newestTime = stat.mtimeMs; 151 newest = fullPath; 152 } 153 } 154 clonedDir = newest; 155 } 156 157 if (!clonedDir || !fs.existsSync(clonedDir)) { 158 return { success: false, error: 'Clone appeared to succeed but could not locate cloned directory' }; 159 } 160 161 // Read .udd to get DreamNode info 162 const uddPath = path.join(clonedDir, '.udd'); 163 if (!fs.existsSync(uddPath)) { 164 return { success: false, error: `Cloned repo at ${clonedDir} is not a DreamNode (no .udd file)` }; 165 } 166 167 const udd = await UDDService.readUDD(clonedDir); 168 169 return { 170 success: true, 171 node: { 172 uuid: udd.uuid, 173 title: udd.title, 174 type: udd.type, 175 path: clonedDir, 176 rid: args.rid, 177 }, 178 }; 179 } catch (error) { 180 return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; 181 } 182 } 183 184 /** 185 * Tool: radicle_publish 186 * Publish a local DreamNode to the Radicle network 187 */ 188 async function radiclePublish(args: { 189 identifier: string; 190 }): Promise<{ 191 success: boolean; 192 node?: { title: string; uuid: string; rid?: string }; 193 error?: string; 194 }> { 195 try { 196 if (!(await isRadicleAvailable())) { 197 return { success: false, error: 'Radicle CLI (rad) is not installed or not in PATH' }; 198 } 199 200 const node = await findDreamNode(args.identifier); 201 if (!node) { 202 return { success: false, error: `DreamNode not found: ${args.identifier}` }; 203 } 204 205 // Publish to Radicle network (makes the repo publicly available) 206 await execAsync('RAD_PASSPHRASE="" rad publish', { cwd: node.path, timeout: 30000 }); 207 208 // Sync and announce to seeders 209 try { 210 await execAsync('RAD_PASSPHRASE="" rad sync --announce', { cwd: node.path, timeout: 30000 }); 211 } catch { 212 // Non-fatal: publish succeeded even if announce fails 213 } 214 215 return { 216 success: true, 217 node: { title: node.title, uuid: node.uuid, rid: node.radicleId }, 218 }; 219 } catch (error) { 220 return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; 221 } 222 } 223 224 /** 225 * Tool: radicle_follow_peer 226 * Follow a peer's DID and optionally add them as delegate to a DreamNode 227 */ 228 async function radicleFollowPeer(args: { 229 did: string; 230 node_identifier?: string; 231 add_delegate?: boolean; 232 }): Promise<{ 233 success: boolean; 234 followed: boolean; 235 delegated?: boolean; 236 error?: string; 237 }> { 238 try { 239 if (!(await isRadicleAvailable())) { 240 return { success: false, followed: false, error: 'Radicle CLI (rad) is not installed or not in PATH' }; 241 } 242 243 // Follow the peer 244 await execAsync(`RAD_PASSPHRASE="" rad follow ${args.did}`, { timeout: 15000 }); 245 246 let delegated = false; 247 248 // Optionally add as delegate to a specific DreamNode 249 if (args.node_identifier && args.add_delegate) { 250 const node = await findDreamNode(args.node_identifier); 251 if (!node) { 252 return { success: true, followed: true, delegated: false, error: `Followed peer, but DreamNode not found: ${args.node_identifier}` }; 253 } 254 255 try { 256 await execAsync( 257 `RAD_PASSPHRASE="" rad id update --delegate ${args.did} --threshold 1`, 258 { cwd: node.path, timeout: 15000 } 259 ); 260 delegated = true; 261 } catch (error) { 262 return { success: true, followed: true, delegated: false, error: `Followed peer, but failed to add as delegate: ${error instanceof Error ? error.message : 'Unknown'}` }; 263 } 264 } 265 266 return { success: true, followed: true, delegated }; 267 } catch (error) { 268 return { success: false, followed: false, error: error instanceof Error ? error.message : 'Unknown error' }; 269 } 270 } 271 272 /** 273 * Tool: fetch_peer_commits 274 * Fetch new commits from all peer remotes for a DreamNode 275 */ 276 export interface PendingCommit { 277 hash: string; 278 subject: string; 279 author: string; 280 date: string; 281 offeredBy: string[]; 282 remoteName: string; 283 beaconData?: Record<string, unknown>; 284 } 285 286 async function fetchPeerCommits(args: { 287 identifier: string; 288 }): Promise<{ 289 success: boolean; 290 node?: { title: string; uuid: string }; 291 pending_commits?: PendingCommit[]; 292 error?: string; 293 }> { 294 try { 295 const node = await findDreamNode(args.identifier); 296 if (!node) { 297 return { success: false, error: `DreamNode not found: ${args.identifier}` }; 298 } 299 300 // Sync from Radicle network first (non-fatal) 301 try { 302 await execAsync('RAD_PASSPHRASE="" rad sync --inventory', { cwd: node.path, timeout: 30000 }); 303 } catch { 304 // Non-fatal: may not be a Radicle repo or network unavailable 305 } 306 307 // Fetch from all remotes 308 try { 309 await execAsync('git fetch --all', { cwd: node.path, timeout: 30000 }); 310 } catch { 311 // Non-fatal: may have no remotes 312 } 313 314 // Find commits on remote branches not on local main 315 const pending: PendingCommit[] = []; 316 const seenHashes = new Set<string>(); 317 318 // List all remote branches 319 let remoteBranches: string[] = []; 320 try { 321 const { stdout } = await execAsync('git branch -r --format="%(refname:short)"', { cwd: node.path }); 322 remoteBranches = stdout.trim().split('\n').filter(b => b.length > 0 && !b.includes('HEAD')); 323 } catch { 324 // No remotes 325 } 326 327 for (const remoteBranch of remoteBranches) { 328 try { 329 // Find commits on this remote branch not on local main 330 const { stdout } = await execAsync( 331 `git log main..${remoteBranch} --format="%H|%s|%an|%aI" --no-merges`, 332 { cwd: node.path } 333 ); 334 335 const lines = stdout.trim().split('\n').filter(l => l.length > 0); 336 const remoteName = remoteBranch.split('/')[0] || remoteBranch; 337 338 for (const line of lines) { 339 const [hash, subject, author, date] = line.split('|'); 340 if (!hash || seenHashes.has(hash)) continue; 341 seenHashes.add(hash); 342 343 // Check for beacon data in commit message 344 let beaconData: Record<string, unknown> | undefined; 345 try { 346 const { stdout: fullMsg } = await execAsync( 347 `git log -1 --format="%B" ${hash}`, 348 { cwd: node.path } 349 ); 350 const beaconMatch = fullMsg.match(/COHERENCE_BEACON:\s*(\{.*\})/); 351 if (beaconMatch) { 352 beaconData = JSON.parse(beaconMatch[1]); 353 } 354 } catch { 355 // Non-fatal 356 } 357 358 pending.push({ 359 hash, 360 subject: subject || '', 361 author: author || '', 362 date: date || '', 363 offeredBy: [remoteName], 364 remoteName, 365 beaconData, 366 }); 367 } 368 } catch { 369 // Skip branches that can't be compared 370 } 371 } 372 373 // Merge offeredBy for duplicate hashes (shouldn't happen due to seenHashes, but defensive) 374 return { 375 success: true, 376 node: { title: node.title, uuid: node.uuid }, 377 pending_commits: pending, 378 }; 379 } catch (error) { 380 return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; 381 } 382 } 383 384 /** 385 * Tool: list_pending_commits 386 * List commits offered by peers that haven't been accepted or rejected yet 387 */ 388 async function listPendingCommits(args: { 389 identifier: string; 390 dreamer_identifier: string; 391 }): Promise<{ 392 success: boolean; 393 node?: { title: string; uuid: string }; 394 pending_commits?: PendingCommit[]; 395 error?: string; 396 }> { 397 try { 398 const node = await findDreamNode(args.identifier); 399 if (!node) { 400 return { success: false, error: `DreamNode not found: ${args.identifier}` }; 401 } 402 403 const dreamer = await findDreamNode(args.dreamer_identifier); 404 if (!dreamer) { 405 return { success: false, error: `Dreamer not found: ${args.dreamer_identifier}` }; 406 } 407 if (dreamer.type !== 'dreamer') { 408 return { success: false, error: `${dreamer.title} is a ${dreamer.type}, not a dreamer. Collaboration memory lives in Dreamer nodes.` }; 409 } 410 411 // Fetch peer commits first 412 const fetchResult = await fetchPeerCommits({ identifier: args.identifier }); 413 if (!fetchResult.success || !fetchResult.pending_commits) { 414 return { success: false, error: fetchResult.error || 'Failed to fetch peer commits' }; 415 } 416 417 // Filter out already-processed commits 418 const unprocessed = fetchResult.pending_commits.filter(commit => { 419 const status = CollaborationMemoryService.isProcessed(dreamer.path, node.uuid, commit.hash); 420 return status === null; 421 }); 422 423 return { 424 success: true, 425 node: { title: node.title, uuid: node.uuid }, 426 pending_commits: unprocessed, 427 }; 428 } catch (error) { 429 return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; 430 } 431 } 432 433 /** 434 * Tool: read_collaboration_memory 435 * Read the full collaboration history for a Dreamer 436 */ 437 async function readCollaborationMemory(args: { 438 dreamer_identifier: string; 439 node_filter?: string; 440 }): Promise<{ 441 success: boolean; 442 dreamer?: { title: string; uuid: string }; 443 memory?: CollaborationMemoryFile; 444 filtered_node?: { title: string; uuid: string }; 445 error?: string; 446 }> { 447 try { 448 const dreamer = await findDreamNode(args.dreamer_identifier); 449 if (!dreamer) { 450 return { success: false, error: `Dreamer not found: ${args.dreamer_identifier}` }; 451 } 452 if (dreamer.type !== 'dreamer') { 453 return { success: false, error: `${dreamer.title} is a ${dreamer.type}, not a dreamer. Collaboration memory lives in Dreamer nodes.` }; 454 } 455 456 const memory = CollaborationMemoryService.read(dreamer.path); 457 458 // Optionally filter by a specific DreamNode 459 if (args.node_filter) { 460 const filterNode = await findDreamNode(args.node_filter); 461 if (!filterNode) { 462 return { success: false, error: `Filter node not found: ${args.node_filter}` }; 463 } 464 465 const filtered: CollaborationMemoryFile = { 466 version: 1, 467 dreamNodes: {}, 468 }; 469 if (memory.dreamNodes[filterNode.uuid]) { 470 filtered.dreamNodes[filterNode.uuid] = memory.dreamNodes[filterNode.uuid]; 471 } 472 473 return { 474 success: true, 475 dreamer: { title: dreamer.title, uuid: dreamer.uuid }, 476 memory: filtered, 477 filtered_node: { title: filterNode.title, uuid: filterNode.uuid }, 478 }; 479 } 480 481 return { 482 success: true, 483 dreamer: { title: dreamer.title, uuid: dreamer.uuid }, 484 memory, 485 }; 486 } catch (error) { 487 return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; 488 } 489 } 490 491 // ============================================================================ 492 // TOOL EXPORTS 493 // ============================================================================ 494 495 export const socialResonanceTools = { 496 radicle_clone: { 497 name: 'radicle_clone', 498 description: 'Clone a DreamNode from the Radicle network into the vault by its Radicle ID (RID). Returns the cloned node\'s metadata.', 499 inputSchema: { 500 type: 'object' as const, 501 properties: { 502 rid: { 503 type: 'string', 504 description: 'Radicle ID of the DreamNode to clone (e.g., "rad:z...")', 505 }, 506 vault_path: { 507 type: 'string', 508 description: 'Path to vault where the clone should be placed (defaults to primary vault)', 509 }, 510 }, 511 required: ['rid'], 512 }, 513 handler: radicleClone, 514 }, 515 516 radicle_publish: { 517 name: 'radicle_publish', 518 description: 'Publish a local DreamNode to the Radicle network, making it shareable with peers. Also announces to seeders.', 519 inputSchema: { 520 type: 'object' as const, 521 properties: { 522 identifier: { 523 type: 'string', 524 description: 'UUID or title of the DreamNode to publish', 525 }, 526 }, 527 required: ['identifier'], 528 }, 529 handler: radiclePublish, 530 }, 531 532 radicle_follow_peer: { 533 name: 'radicle_follow_peer', 534 description: 'Follow a peer\'s DID on the Radicle network. Optionally add them as a delegate to a specific DreamNode, allowing them to push changes.', 535 inputSchema: { 536 type: 'object' as const, 537 properties: { 538 did: { 539 type: 'string', 540 description: 'The peer\'s DID (Decentralized Identifier) to follow', 541 }, 542 node_identifier: { 543 type: 'string', 544 description: 'UUID or title of a DreamNode to add the peer as delegate (optional)', 545 }, 546 add_delegate: { 547 type: 'boolean', 548 description: 'Whether to add the peer as a delegate to the specified DreamNode (default: false)', 549 }, 550 }, 551 required: ['did'], 552 }, 553 handler: radicleFollowPeer, 554 }, 555 556 fetch_peer_commits: { 557 name: 'fetch_peer_commits', 558 description: 'Fetch new commits from all peer remotes for a DreamNode. Syncs from Radicle network, fetches all remotes, and returns commits not yet on local main.', 559 inputSchema: { 560 type: 'object' as const, 561 properties: { 562 identifier: { 563 type: 'string', 564 description: 'UUID or title of the DreamNode to fetch commits for', 565 }, 566 }, 567 required: ['identifier'], 568 }, 569 handler: fetchPeerCommits, 570 }, 571 572 list_pending_commits: { 573 name: 'list_pending_commits', 574 description: 'List peer commits that haven\'t been accepted or rejected yet. Filters fetch_peer_commits results through the Dreamer\'s collaboration memory.', 575 inputSchema: { 576 type: 'object' as const, 577 properties: { 578 identifier: { 579 type: 'string', 580 description: 'UUID or title of the DreamNode to check', 581 }, 582 dreamer_identifier: { 583 type: 'string', 584 description: 'UUID or title of the Dreamer whose collaboration memory to check against', 585 }, 586 }, 587 required: ['identifier', 'dreamer_identifier'], 588 }, 589 handler: listPendingCommits, 590 }, 591 592 read_collaboration_memory: { 593 name: 'read_collaboration_memory', 594 description: 'Read the full collaboration history (accepted/rejected commits) for a Dreamer. Optionally filter by a specific DreamNode.', 595 inputSchema: { 596 type: 'object' as const, 597 properties: { 598 dreamer_identifier: { 599 type: 'string', 600 description: 'UUID or title of the Dreamer whose collaboration memory to read', 601 }, 602 node_filter: { 603 type: 'string', 604 description: 'UUID or title of a DreamNode to filter by (optional)', 605 }, 606 }, 607 required: ['dreamer_identifier'], 608 }, 609 handler: readCollaborationMemory, 610 }, 611 };