UNPKG

mcp-memory-ts

Version:

Cloud-based vector MCP memory service for Claude.ai - TypeScript implementation

622 lines (618 loc) 27.7 kB
/** * Contacts Sync Command - True Bidirectional Sync with LLM-based Deduplication * Implements comprehensive sync logic with UUID matching, conflict resolution, and ChatGPT-4 deduplication */ import { exec } from 'child_process'; import { promisify } from 'util'; import { DatabaseConnection } from '../../database/connection.js'; import { DatabaseOperations } from '../../database/operations.js'; import { entityToVCard } from '../../vcard/mapper.js'; import { parseVCard } from '../../vcard/parser.js'; import { vcardToEntity, validateVCard } from '../../vcard/mapper.js'; import { createEntity } from '../../models/index.js'; import { EntityType, ImportanceLevel } from '../../types/enums.js'; import { colors, icons } from '../colors.js'; import { loadUserConfig } from '../claude-desktop.js'; import { DEFAULT_SYNC_CONFIG } from '../../types/sync-config.js'; // Import sync utilities import { matchContacts, extractMcpUuid, findPotentialDuplicates, } from '../../utils/contact-matching.js'; import { batchCheckDuplicates, entityToContactInfo, vcardToContactInfo, } from '../../utils/deduplication.js'; import { resolveConflict, hasConflict, } from '../../utils/conflict-resolution.js'; const execAsync = promisify(exec); /** * Check if running on macOS */ function isMacOS() { return process.platform === 'darwin'; } /** * Ensure Contacts.app is running and ready * Uses a simple, permission-free approach that directly tests app functionality * This avoids System Events which requires accessibility permissions */ async function ensureContactsAppRunning() { try { // First check if Contacts is already running and accessible const checkScript = ` tell application "Contacts" if running then try count of people return "already_running" on error return "running_but_not_ready" end try else return "not_running" end if end tell `; const { stdout: status } = await execAsync(`osascript -e '${checkScript}'`, { timeout: 5000, maxBuffer: 1024 * 1024, }); const appStatus = status.trim(); // If app is already running and accessible, we're done if (appStatus === 'already_running') { console.log(colors.dim(' Contacts.app already running')); return; } // Launch Contacts.app using open command (more reliable than AppleScript launch) console.log(colors.dim(' Launching Contacts.app...')); await execAsync('open -a Contacts', { timeout: 5000, }); // Wait for app to be ready with retry logic const waitScript = ` tell application "Contacts" -- Launch if not running if not running then launch delay 2 end if -- Test if app is ready set maxAttempts to 15 set attemptCount to 0 set appReady to false repeat while attemptCount < maxAttempts try -- Try to access the app's data - this proves it's functional set personCount to count of people set appReady to true exit repeat on error errMsg -- App not ready yet, wait and retry delay 0.5 set attemptCount to attemptCount + 1 end try end repeat if not appReady then error "Contacts app did not become ready within 7.5 seconds" end if return "ready" end tell `; await execAsync(`osascript -e '${waitScript}'`, { timeout: 15000, // 15 seconds total timeout maxBuffer: 1024 * 1024, }); console.log(colors.dim(' Contacts.app ready')); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to launch Contacts.app: ${errorMsg}\n` + `Troubleshooting steps:\n` + ` 1) Manually open Contacts.app and verify it works\n` + ` 2) Try running: osascript -e 'tell application "Contacts" to count of people'\n` + ` 3) Check Console.app for any permission errors\n` + ` 4) Ensure Contacts.app is installed in /Applications/`); } } /** * Count contacts with retry logic * Retries up to maxRetries times if Contacts.app fails to respond */ async function countContactsWithRetry(maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const countScript = ` tell application "Contacts" launch -- Run in background (no activate) delay 0.5 count people end tell `; const { stdout } = await execAsync(`osascript -e '${countScript}'`, { maxBuffer: 10 * 1024 * 1024, timeout: 30000, }); return parseInt(stdout.trim(), 10); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); if (attempt === maxRetries) { throw new Error(`Failed to count contacts after ${maxRetries} attempts: ${errorMsg}`); } console.log(colors.dim(` Retry ${attempt}/${maxRetries}...`)); // eslint-disable-next-line no-undef await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); } } throw new Error('Failed to count contacts'); } /** * Get all contacts from macOS Contacts as vCards (with batch loading) */ async function getMacOSContacts() { console.log(`${icons.cycle} Counting macOS contacts...`); // Ensure Contacts.app is launched and ready await ensureContactsAppRunning(); // Use retry logic for counting const totalCount = await countContactsWithRetry(); if (totalCount === 0) { return []; } console.log(` Found ${totalCount} contacts, loading in batches...`); const batchSize = 50; // Process 50 contacts at a time (optimized for performance) const results = []; // Load in batches for (let i = 1; i <= totalCount; i += batchSize) { const endIndex = Math.min(i + batchSize - 1, totalCount); const batchScript = ` tell application "Contacts" set vcardData to "" repeat with j from ${i} to ${endIndex} set vcardData to vcardData & vcard of person j end repeat return vcardData end tell `; try { const { stdout } = await execAsync(`osascript -e '${batchScript}'`, { maxBuffer: 10 * 1024 * 1024, // 10MB buffer timeout: 300000, // 5 minute timeout for large batches }); if (stdout && stdout.trim().length > 0) { const batch = parseVCard(stdout); results.push(...batch); } // Progress indicator process.stdout.write(`\r Loaded ${results.length}/${totalCount} contacts...`); } catch (error) { console.error(`\n Warning: Failed to load batch ${i}-${endIndex}: ${error}`); // Continue with next batch } } console.log(''); // New line after progress console.log(`${icons.success} Loaded ${results.length} contacts from macOS`); return results; } /** * Batch export contacts to macOS Contacts (more efficient than individual exports) */ async function batchExportToMacOS(vcards, batchSize = 50) { const totalCount = vcards.length; for (let i = 0; i < totalCount; i += batchSize) { const batch = vcards.slice(i, i + batchSize); // Process batch in parallel (up to a limit) const promises = batch.map(vcard => upsertMacOSContact(vcard)); await Promise.all(promises); // Progress indicator const processed = Math.min(i + batchSize, totalCount); process.stdout.write(`\r Exported ${processed}/${totalCount} contacts...`); } console.log(''); // New line after progress } /** * Create or update contact in macOS Contacts via AppleScript */ async function upsertMacOSContact(vcard, existingUuid) { const firstName = vcard.n?.givenName || vcard.fn.split(' ')[0] || ''; const lastName = vcard.n?.familyName || vcard.fn.split(' ').slice(1).join(' ') || ''; const email = vcard.email?.[0] || ''; const phone = vcard.tel?.[0] || ''; const org = vcard.org || ''; const title = vcard.title || ''; // Build notes with MCP-UUID let notes = vcard.note || ''; const mcpUuid = vcard['x-mcp-uuid']; if (mcpUuid && !notes.includes(`[MCP-UUID: ${mcpUuid}]`)) { notes = notes ? `${notes}\n\n[MCP-UUID: ${mcpUuid}]` : `[MCP-UUID: ${mcpUuid}]`; } // If existingUuid, try to update existing contact // Note: macOS Contacts doesn't have a great API for updating by UUID // We'll create a new contact and rely on macOS's built-in duplicate detection const script = ` tell application "Contacts" launch -- Run in background (no activate) delay 0.5 set newPerson to make new person with properties {first name:"${escapeAppleScript(firstName)}", last name:"${escapeAppleScript(lastName)}"} ${email ? `make new email at end of emails of newPerson with properties {value:"${escapeAppleScript(email)}", label:"work"}` : ''} ${phone ? `make new phone at end of phones of newPerson with properties {value:"${escapeAppleScript(phone)}", label:"work"}` : ''} ${org ? `set organization of newPerson to "${escapeAppleScript(org)}"` : ''} ${title ? `set job title of newPerson to "${escapeAppleScript(title)}"` : ''} ${notes ? `set note of newPerson to "${escapeAppleScript(notes)}"` : ''} save end tell `; await execAsync(`osascript -e '${script}'`, { maxBuffer: 10 * 1024 * 1024, // 10MB buffer timeout: 60000, // 1 minute timeout for individual contact operations }); } /** * Escape special characters for AppleScript embedded in shell single quotes * * When AppleScript is embedded in osascript -e '...', we must use proper shell escaping. * For single quotes in the data, we use '\'' which: * 1. Ends the single-quoted string with ' * 2. Adds an escaped single quote \' * 3. Starts a new single-quoted string with ' * * Order matters: backslashes first, then quotes, then newlines */ function escapeAppleScript(text) { if (!text) return ''; return text // Escape backslashes first (must be first to avoid double-escaping!) .replace(/\\/g, '\\\\') // Escape double quotes (for property values in AppleScript) .replace(/"/g, '\\"') // Escape single quotes for shell single-quote context // CRITICAL FIX: Use '\'' instead of \' to properly escape in shell single quotes .replace(/'/g, "'\\''") // Escape newlines for multi-line strings .replace(/\n/g, '\\n') // Escape carriage returns .replace(/\r/g, '\\r'); } /** * Sync matched contact pairs (compare timestamps and update older) */ async function syncMatchedPair(pair, dbOps, config, dryRun) { // Check if there's a conflict if (!hasConflict(pair.mcp, pair.external)) { return { action: 'no_change', updated: false }; } // Resolve conflict const conflict = { mcpEntity: pair.mcp, externalContact: pair.external, mcpModified: pair.mcp.updatedAt ? new Date(pair.mcp.updatedAt) : undefined, // External contacts don't have modification timestamps in vCard standard // We'll rely on the conflict resolution strategy }; const syncConfig = config.sync || DEFAULT_SYNC_CONFIG; const resolution = resolveConflict(conflict, syncConfig.conflictResolution); if (resolution.action === 'skip') { console.log(`${colors.dim('⊘')} Skipped (manual resolution required): ${pair.mcp.name}`); return { action: 'no_change', updated: false }; } if (dryRun) { // Reduced logging - no per-contact output in dry run for matched pairs return { action: resolution.action, updated: true }; } if (resolution.action === 'use_mcp') { // Update external contact from MCP const vcard = entityToVCard(pair.mcp); await upsertMacOSContact(vcard, pair.mcp.id ? String(pair.mcp.id) : undefined); return { action: 'updated_external', updated: true }; } else if (resolution.action === 'use_external') { // Update MCP entity from external const entityData = vcardToEntity(pair.external, pair.mcp.userId, { entityType: pair.mcp.entityType, importance: pair.mcp.importance, tags: pair.mcp.tags, }); // Update existing entity if (pair.mcp.id) { await dbOps.updateEntity(String(pair.mcp.id), entityData, pair.mcp.userId); } return { action: 'updated_mcp', updated: true }; } else if (resolution.action === 'merge' && resolution.mergedData) { // Merge both sides // Update MCP with merged data if (pair.mcp.id) { await dbOps.updateEntity(String(pair.mcp.id), resolution.mergedData, pair.mcp.userId); } // Update external with merged data const mergedVCard = entityToVCard({ ...pair.mcp, ...resolution.mergedData }); await upsertMacOSContact(mergedVCard, pair.mcp.id ? String(pair.mcp.id) : undefined); return { action: 'merged', updated: true }; } return { action: 'no_change', updated: false }; } /** * Process duplicate pairs found by LLM */ async function processDuplicates(potentialPairs, userId, dbOps, config, dryRun, autoMerge) { const result = { merged: 0, failed: 0, skipped: 0 }; if (potentialPairs.length === 0) { return result; } console.log(`\n${icons.magnify} ${colors.title(`Checking ${potentialPairs.length} potential duplicates with LLM...`)}`); // Convert to ContactPairs for LLM check const contactPairs = potentialPairs.map(p => ({ contact1: entityToContactInfo(p.mcp), contact2: vcardToContactInfo(p.external), })); const syncConfig = config.sync || DEFAULT_SYNC_CONFIG; // Batch check with LLM const llmResults = await batchCheckDuplicates(contactPairs, syncConfig.deduplication, config.openaiApiKey, (current, total) => { process.stdout.write(`\r Progress: ${current}/${total} checked...`); }); console.log('\n'); // New line after progress // Process results (with reduced logging) let notDuplicates = 0; let duplicatesFound = 0; for (let i = 0; i < potentialPairs.length; i++) { const pair = potentialPairs[i]; const llmResult = llmResults[i]; if (!llmResult.isDuplicate || llmResult.confidence < syncConfig.deduplication.threshold) { notDuplicates++; result.skipped++; continue; } duplicatesFound++; if (!autoMerge) { result.skipped++; continue; } try { if (dryRun) { result.merged++; } else { // Merge: Update external contact with MCP UUID const mcpVCard = entityToVCard(pair.mcp); await upsertMacOSContact(mcpVCard, pair.mcp.id ? String(pair.mcp.id) : undefined); // Also update MCP entity with any additional info from external const conflict = { mcpEntity: pair.mcp, externalContact: pair.external, }; const resolution = resolveConflict(conflict, syncConfig.conflictResolution); if (resolution.action === 'merge' && resolution.mergedData && pair.mcp.id) { await dbOps.updateEntity(String(pair.mcp.id), resolution.mergedData, pair.mcp.userId); } result.merged++; } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); result.failed++; } } // Summary logging instead of per-contact console.log(` ${icons.success} Duplicates identified: ${duplicatesFound}`); console.log(` ${colors.dim('○')} Not duplicates: ${notDuplicates}`); return result; } /** * Main sync function with true bidirectional sync logic */ export async function syncContacts(options) { const { userId, direction, dryRun = false, autoMerge, threshold, noLlm } = options; // Check if running on macOS if (!isMacOS()) { throw new Error('Contacts sync is only available on macOS'); } console.log(`\n${icons.robot} ${colors.title('macOS Contacts Sync - Bidirectional')}`); console.log(`${colors.dim('User:')} ${userId}`); console.log(`${colors.dim('Direction:')} ${direction}`); console.log(`${colors.dim('Dry run:')} ${dryRun ? 'YES' : 'NO'}`); const result = { success: true, exported: 0, imported: 0, updated: 0, merged: 0, failed: 0, skipped: 0, errors: [], duplicatesFound: 0, }; // Load config for sync settings const config = loadUserConfig(); if (!config) { throw new Error('Configuration not found. Run "mcp-memory init" first.'); } // Override config with command-line options const syncConfig = config.sync || DEFAULT_SYNC_CONFIG; if (autoMerge !== undefined) { syncConfig.conflictResolution.autoMerge = autoMerge; } if (threshold !== undefined) { syncConfig.deduplication.threshold = threshold; } if (noLlm) { syncConfig.deduplication.enableLLMDeduplication = false; } console.log(`${colors.dim('Deduplication:')} ${syncConfig.deduplication.enableLLMDeduplication ? 'LLM-based' : 'Rule-based'} (threshold: ${syncConfig.deduplication.threshold}%)`); console.log(`${colors.dim('Conflict resolution:')} ${syncConfig.conflictResolution.strategy} ${syncConfig.conflictResolution.autoMerge ? '(auto-merge)' : ''}`); // Connect to database const dbUrl = process.env.TURSO_URL || config.tursoUrl; const authToken = process.env.TURSO_AUTH_TOKEN || config.tursoAuthToken; if (!dbUrl || !authToken) { throw new Error('TURSO_URL and TURSO_AUTH_TOKEN are required'); } const db = new DatabaseConnection({ url: dbUrl, authToken, }); await db.connect(); const dbOps = new DatabaseOperations(db); try { // Get user let user = await dbOps.getUserByEmail(userId); if (!user) { user = await dbOps.getUserById(userId); } if (!user) { throw new Error(`User not found: ${userId}`); } console.log(`${icons.success} Found user: ${user.email} (${user.id})\n`); // PHASE 1: Load contacts from both sides console.log(`${icons.cycle} ${colors.title('Loading contacts...')}`); const mcpEntities = await dbOps.getEntitiesByUserId(user.id, 10000); const peopleEntities = mcpEntities.filter(e => e.entityType === EntityType.PERSON); console.log(` MCP entities: ${peopleEntities.length}`); const macContacts = direction === 'export' ? [] : await getMacOSContacts(); console.log(` macOS contacts: ${macContacts.length}`); // PHASE 2: Match by UUID and other identifiers if (direction === 'both' || direction === 'import') { console.log(`\n${icons.cycle} ${colors.title('Matching contacts...')}`); const matchResult = matchContacts(peopleEntities, macContacts); console.log(` Matched (by UID): ${matchResult.matched.filter(m => m.matchType === 'uid').length}`); console.log(` Matched (by email): ${matchResult.matched.filter(m => m.matchType === 'email').length}`); console.log(` Matched (by phone): ${matchResult.matched.filter(m => m.matchType === 'phone').length}`); console.log(` Matched (by name): ${matchResult.matched.filter(m => m.matchType === 'name').length}`); console.log(` Unmatched MCP: ${matchResult.mcpUnmatched.length}`); console.log(` Unmatched macOS: ${matchResult.externalUnmatched.length}`); // PHASE 3: Sync matched contacts if (matchResult.matched.length > 0) { console.log(`\n${icons.cycle} ${colors.title('Syncing matched contacts...')}`); let processed = 0; for (const pair of matchResult.matched) { try { const syncResult = await syncMatchedPair(pair, dbOps, config, dryRun); if (syncResult.updated) { result.updated++; } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); result.failed++; result.errors.push(`${pair.mcp.name}: ${errorMsg}`); } // Progress indicator every 50 contacts processed++; if (processed % 50 === 0) { process.stdout.write(`\r Synced ${processed}/${matchResult.matched.length} contacts...`); } } if (matchResult.matched.length >= 50) { console.log(''); // New line after progress } console.log(` ${icons.success} Synced ${matchResult.matched.length} matched contacts`); } // PHASE 4: Find and merge duplicates using LLM if (syncConfig.deduplication.enableLLMDeduplication && (matchResult.mcpUnmatched.length > 0 || matchResult.externalUnmatched.length > 0)) { console.log(`\n${icons.cycle} ${colors.title('Finding duplicates...')}`); const potentialDuplicates = findPotentialDuplicates(matchResult.mcpUnmatched, matchResult.externalUnmatched, 3 // max 3 candidates per contact ); console.log(` Found ${potentialDuplicates.length} potential duplicate pairs`); result.duplicatesFound = potentialDuplicates.length; if (potentialDuplicates.length > 0) { const dedupResult = await processDuplicates(potentialDuplicates, user.id, dbOps, config, dryRun, syncConfig.conflictResolution.autoMerge); result.merged += dedupResult.merged; result.failed += dedupResult.failed; result.skipped += dedupResult.skipped; } } // PHASE 5: Create new contacts console.log(`\n${icons.cycle} ${colors.title('Creating new contacts...')}`); // Import new contacts from macOS (that weren't matched or merged) const unmatchedExternal = macContacts.filter(ext => { const uuid = extractMcpUuid(ext); return !matchResult.matched.some(m => extractMcpUuid(m.external) === uuid); }); let importProcessed = 0; for (const vcard of unmatchedExternal) { try { const validation = validateVCard(vcard); if (!validation.valid) { result.failed++; result.errors.push(`${vcard.fn}: ${validation.errors.join(', ')}`); continue; } const entityData = vcardToEntity(vcard, user.id, { entityType: EntityType.PERSON, importance: ImportanceLevel.MEDIUM, tags: ['imported-from-contacts'], }); if (dryRun) { result.imported++; } else { const entity = createEntity(entityData); await dbOps.createEntity(entity); result.imported++; } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); result.failed++; result.errors.push(`${vcard.fn}: ${errorMsg}`); } // Progress indicator every 50 contacts importProcessed++; if (importProcessed % 50 === 0) { process.stdout.write(`\r Imported ${importProcessed}/${unmatchedExternal.length} contacts...`); } } if (unmatchedExternal.length >= 50) { console.log(''); // New line after progress } console.log(` ${icons.success} Imported ${result.imported} new contacts from macOS`); } // PHASE 6: Export to macOS (for unmatched MCP entities) if (direction === 'export' || direction === 'both') { console.log(`\n${icons.cycle} ${colors.title('Exporting to macOS Contacts...')}`); const toExport = direction === 'export' ? peopleEntities : peopleEntities.filter(e => !macContacts.some(m => extractMcpUuid(m) === String(e.id) || m.email?.[0] === e.email)); if (toExport.length > 0) { if (dryRun) { result.exported = toExport.length; console.log(` ${colors.dim('[DRY RUN]')} Would export ${toExport.length} contacts`); } else { // Use batch export for better performance const vcards = toExport.map(entity => entityToVCard(entity)); try { await batchExportToMacOS(vcards, 50); result.exported = toExport.length; console.log(` ${icons.success} Exported ${toExport.length} contacts to macOS`); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); result.failed += toExport.length; result.errors.push(`Batch export failed: ${errorMsg}`); console.error(`${icons.error} Batch export failed: ${errorMsg}`); } } } } // Print summary console.log(`\n${colors.title('=== Sync Summary ===')}`); console.log(`${colors.dim('Matched & synced:')} ${result.updated}`); console.log(`${colors.dim('Duplicates merged:')} ${result.merged}`); console.log(`${colors.dim('Imported from macOS:')} ${result.imported}`); console.log(`${colors.dim('Exported to macOS:')} ${result.exported}`); console.log(`${colors.dim('Skipped:')} ${result.skipped}`); console.log(`${colors.dim('Failed:')} ${result.failed}`); if (result.errors.length > 0) { console.log(`\n${colors.warning('Errors:')}`); for (const error of result.errors.slice(0, 10)) { console.log(` ${colors.error('•')} ${error}`); } if (result.errors.length > 10) { console.log(` ${colors.dim(`... and ${result.errors.length - 10} more`)}`); } } result.success = result.failed === 0; } finally { await db.disconnect(); } return result; } //# sourceMappingURL=contacts-sync.js.map