/ src / tools / social-resonance.ts
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  };