Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
RelationshipManager.tsโ€ข20.7 kB
/** * Relationship Manager - Discovers and manages cross-element relationships * * Implements GraphRAG-style relationship tracking between elements: * - similar_to: Semantic similarity (Jaccard-based) * - used_by / uses: Usage dependencies * - prerequisite_for / depends_on: Learning paths * - helps_debug / debugged_by: Debugging relationships * - contradicts / supports: Conflicting or supporting elements * * Features: * - Automatic relationship discovery from content * - Graph traversal for relationship paths * - Relationship strength scoring * - Bidirectional relationship tracking * * FIXES IMPLEMENTED (Issue #1099): * - Uses centralized element ID parsing utilities * - Consistent ID format handling */ import { logger } from '../utils/logger.js'; import { EnhancedIndex, ElementDefinition } from './EnhancedIndexManager.js'; import { NLPScoringManager } from './NLPScoringManager.js'; import { VerbTriggerManager } from './VerbTriggerManager.js'; import { UnicodeValidator } from '../security/validators/unicodeValidator.js'; import { parseElementId } from '../utils/elementId.js'; import { BaseRelationship, ParsedRelationship, createRelationship, parseRelationship, isParsedRelationship, deduplicateRelationships, filterRelationshipsByStrength, RelationshipTypes } from './types/RelationshipTypes.js'; // Use BaseRelationship instead of importing Relationship from EnhancedIndexManager type Relationship = BaseRelationship; /** * Relationship types and their inverse mappings */ export const RELATIONSHIP_TYPES = { // Similarity relationships similar_to: 'similar_to', // Bidirectional // Usage relationships uses: 'used_by', used_by: 'uses', // Dependency relationships prerequisite_for: 'depends_on', depends_on: 'prerequisite_for', requires: 'required_by', required_by: 'requires', // Debugging relationships helps_debug: 'debugged_by', debugged_by: 'helps_debug', // Support/conflict relationships supports: 'supported_by', supported_by: 'supports', contradicts: 'contradicts', // Bidirectional complements: 'complements', // Bidirectional // Hierarchical relationships parent_of: 'child_of', child_of: 'parent_of', contains: 'contained_by', contained_by: 'contains', // Temporal relationships follows: 'preceded_by', preceded_by: 'follows', // Example relationships example_of: 'has_example', has_example: 'example_of' } as const; export type RelationshipType = keyof typeof RELATIONSHIP_TYPES; /** * Relationship discovery configuration */ export interface RelationshipConfig { // Minimum confidence to establish relationship minConfidence?: number; // Maximum relationships per element maxRelationshipsPerElement?: number; // Enable automatic discovery enableAutoDiscovery?: boolean; // Custom relationship patterns customPatterns?: RelationshipPattern[]; } /** * Pattern for discovering relationships */ export interface RelationshipPattern { type: RelationshipType; pattern: RegExp; confidence: number; bidirectional?: boolean; } /** * Graph traversal options */ export interface TraversalOptions { maxDepth?: number; relationshipTypes?: RelationshipType[]; minStrength?: number; visited?: Set<string>; } /** * Path between elements */ export interface ElementPath { path: string[]; relationships: RelationshipType[]; totalStrength: number; } export class RelationshipManager { private static instance: RelationshipManager | null = null; private _nlpScoring: NLPScoringManager; private verbTriggers: VerbTriggerManager; private config: RelationshipConfig; // Default patterns for relationship discovery private readonly defaultPatterns: RelationshipPattern[] = [ // Usage patterns { type: 'uses', pattern: /uses?\s+(\w+[-\w]*)/gi, confidence: 0.8 }, { type: 'uses', pattern: /requires?\s+(\w+[-\w]*)/gi, confidence: 0.7 }, { type: 'uses', pattern: /depends?\s+on\s+(\w+[-\w]*)/gi, confidence: 0.7 }, // Prerequisite patterns { type: 'prerequisite_for', pattern: /prerequisite\s+for\s+(\w+[-\w]*)/gi, confidence: 0.9 }, { type: 'depends_on', pattern: /after\s+(\w+[-\w]*)/gi, confidence: 0.6 }, // Debug patterns { type: 'helps_debug', pattern: /debug(?:s|ging)?\s+(\w+[-\w]*)/gi, confidence: 0.7 }, { type: 'helps_debug', pattern: /troubleshoot(?:s|ing)?\s+(\w+[-\w]*)/gi, confidence: 0.7 }, // Support patterns { type: 'supports', pattern: /supports?\s+(\w+[-\w]*)/gi, confidence: 0.8 }, { type: 'complements', pattern: /complements?\s+(\w+[-\w]*)/gi, confidence: 0.8 }, { type: 'contradicts', pattern: /contradicts?\s+(\w+[-\w]*)/gi, confidence: 0.9 }, // Example patterns { type: 'example_of', pattern: /example\s+of\s+(\w+[-\w]*)/gi, confidence: 0.9 }, { type: 'has_example', pattern: /see\s+(\w+[-\w]*)\s+for\s+example/gi, confidence: 0.7 } ]; private constructor(config: RelationshipConfig = {}) { this.config = { minConfidence: config.minConfidence || 0.5, maxRelationshipsPerElement: config.maxRelationshipsPerElement || 20, enableAutoDiscovery: config.enableAutoDiscovery !== false, customPatterns: config.customPatterns || [] }; this._nlpScoring = new NLPScoringManager(); this.verbTriggers = VerbTriggerManager.getInstance(); logger.debug('RelationshipManager initialized', { config: this.config }); } public static getInstance(config?: RelationshipConfig): RelationshipManager { if (!this.instance) { this.instance = new RelationshipManager(config); } return this.instance; } /** * Discover relationships for all elements in the index */ public async discoverRelationships(index: EnhancedIndex): Promise<void> { const startTime = Date.now(); let relationshipsFound = 0; // FIX: Add timeout to prevent infinite loops const MAX_DISCOVERY_TIME = 3000; // 3 seconds max logger.info('Starting relationship discovery'); // First, ensure semantic relationships are calculated // (This is already done in EnhancedIndexManager) // Then discover other relationship types for (const [type, elements] of Object.entries(index.elements)) { for (const [name, element] of Object.entries(elements)) { // FIX: Check timeout if (Date.now() - startTime > MAX_DISCOVERY_TIME) { logger.warn('Relationship discovery timeout', { elapsed: Date.now() - startTime, relationshipsFound }); return; } const discovered = await this.discoverElementRelationships( element, `${type}:${name}`, index ); // Merge discovered relationships with existing ones if (discovered.length > 0) { if (!element.relationships) { element.relationships = {}; } for (const rel of discovered) { const relType = rel.type?.split('_').join('_') || 'related'; if (!element.relationships[relType]) { element.relationships[relType] = []; } // Check if relationship already exists const existing = element.relationships[relType].find( r => r.element === rel.element ); if (!existing) { element.relationships[relType].push(rel); relationshipsFound++; // Add inverse relationship if needed await this.addInverseRelationship(rel, `${type}:${name}`, index); } } } } } const duration = Date.now() - startTime; logger.info('Relationship discovery completed', { duration: `${duration}ms`, relationshipsFound }); } /** * Discover relationships for a single element */ private async discoverElementRelationships( element: ElementDefinition, elementId: string, index: EnhancedIndex ): Promise<Relationship[]> { const relationships: Relationship[] = []; if (!this.config.enableAutoDiscovery) { return relationships; } // Combine text from various fields for analysis let text = this.getElementText(element); // FIX: Prevent ReDoS attacks by limiting input length const MAX_TEXT_LENGTH = 50000; // 50KB limit if (text.length > MAX_TEXT_LENGTH) { logger.warn('Content too large for relationship extraction, truncating', { originalLength: text.length, elementId, truncatedTo: MAX_TEXT_LENGTH }); text = text.substring(0, MAX_TEXT_LENGTH); } // Apply patterns to discover relationships const patterns = [...this.defaultPatterns, ...(this.config.customPatterns || [])]; for (const pattern of patterns) { const matches = text.matchAll(pattern.pattern); for (const match of matches) { const targetName = match[1]; // Find matching element const targetElement = this.findElementByName(targetName, index); if (targetElement && targetElement !== elementId) { relationships.push({ element: targetElement, type: pattern.type, strength: pattern.confidence, metadata: { discoveryMethod: 'pattern', pattern: pattern.pattern.source } }); } } } // Discover verb-based relationships (fixed: now passes index to avoid circular dependency) const verbRelationships = this.discoverVerbRelationships(element, elementId, index); relationships.push(...verbRelationships); // Filter by confidence and limit const filtered = relationships .filter(r => (r.strength || 0) >= this.config.minConfidence!) .sort((a, b) => (b.strength || 0) - (a.strength || 0)) .slice(0, this.config.maxRelationshipsPerElement); return filtered; } /** * Discover relationships based on verb associations */ private discoverVerbRelationships( _element: ElementDefinition, elementId: string, index: EnhancedIndex ): Relationship[] { const relationships: Relationship[] = []; try { // FIX: Use centralized element ID parsing const parsed = parseElementId(elementId); if (!parsed) { return []; } // Get verbs associated with this element const verbs = this.verbTriggers.getVerbsForElement(parsed.name, index); for (const verb of verbs) { // Find other elements with same verb const matches = this.verbTriggers.getElementsForVerb(verb, index); for (const match of matches) { if (match.name !== parsed.name) { // Determine relationship type based on verb category const category = this.verbTriggers.getVerbCategory(verb); let relType: RelationshipType = 'similar_to'; switch (category) { case 'debugging': relType = 'helps_debug'; break; case 'creation': relType = 'complements'; break; case 'explanation': relType = 'supports'; break; case 'analysis': relType = 'complements'; break; } relationships.push({ element: `${match.type}:${match.name}`, type: relType, strength: match.confidence * 0.7, // Slightly lower confidence for verb-based metadata: { discoveryMethod: 'verb', verb, category } }); } } } } catch (error) { // If verb trigger fails, just continue without verb-based relationships logger.debug('Verb-based relationship discovery failed', { elementId, error }); } return relationships; } /** * Add inverse relationship if applicable */ private async addInverseRelationship( relationship: Relationship, sourceElement: string, index: EnhancedIndex ): Promise<void> { const relType = relationship.type as RelationshipType; const inverseType = RELATIONSHIP_TYPES[relType]; if (!inverseType || inverseType === relType) { // Bidirectional or no inverse return; } // FIX: Use centralized element ID parsing const parsed = parseElementId(relationship.element); if (!parsed) { return; } const targetElement = index.elements[parsed.type]?.[parsed.name]; if (!targetElement) { return; } if (!targetElement.relationships) { targetElement.relationships = {}; } if (!targetElement.relationships[inverseType]) { targetElement.relationships[inverseType] = []; } // Check if inverse already exists const existing = targetElement.relationships[inverseType].find( r => r.element === sourceElement ); if (!existing) { targetElement.relationships[inverseType].push({ element: sourceElement, type: inverseType, strength: relationship.strength, metadata: { ...relationship.metadata, inverse: true } }); } } /** * Find element by partial name match */ private findElementByName(name: string, index: EnhancedIndex): string | null { const nameLower = name.toLowerCase().replaceAll(/[_-]/g, ''); for (const [type, elements] of Object.entries(index.elements)) { for (const [elementName, element] of Object.entries(elements)) { const elementNameLower = elementName.toLowerCase().replaceAll(/[_-]/g, ''); // Check exact match first if (elementNameLower === nameLower) { return `${type}:${elementName}`; } // Check if element name contains the search term if (elementNameLower.includes(nameLower) || nameLower.includes(elementNameLower)) { return `${type}:${elementName}`; } // Check display name const displayNameLower = element.core.name?.toLowerCase().replaceAll(/[_-]/g, ''); if (displayNameLower && (displayNameLower === nameLower || displayNameLower.includes(nameLower))) { return `${type}:${elementName}`; } } } return null; } /** * Get combined text from element for analysis */ private getElementText(element: ElementDefinition): string { const parts: string[] = []; // Core fields if (element.core.name) parts.push(element.core.name); if (element.core.description) parts.push(element.core.description); // Keywords and tags from search optimization if (element.search?.keywords) parts.push(...element.search.keywords); if (element.search?.tags) parts.push(...element.search.tags); // Custom fields (might contain relevant text) if (element.custom) { const customText = this.extractTextFromObject(element.custom); if (customText) parts.push(customText); } // Normalize Unicode for security (DMCP-SEC-004) const combinedText = parts.join(' '); const validation = UnicodeValidator.normalize(combinedText); if (validation.detectedIssues && validation.detectedIssues.length > 0) { logger.warn('Unicode issues in relationship text', { issues: validation.detectedIssues }); } return validation.normalizedContent; } /** * Extract text from nested object */ private extractTextFromObject(obj: any): string { const texts: string[] = []; for (const value of Object.values(obj)) { if (typeof value === 'string') { texts.push(value); } else if (Array.isArray(value)) { texts.push(...value.filter(v => typeof v === 'string')); } else if (typeof value === 'object' && value !== null) { const nested = this.extractTextFromObject(value); if (nested) texts.push(nested); } } return texts.join(' '); } /** * Find shortest path between two elements */ public findPath( fromElement: string, toElement: string, index: EnhancedIndex, options: TraversalOptions = {} ): ElementPath | null { const { maxDepth = 5, relationshipTypes, minStrength = 0, visited = new Set() } = options; // BFS to find shortest path const queue: ElementPath[] = [{ path: [fromElement], relationships: [], totalStrength: 1.0 }]; visited.add(fromElement); while (queue.length > 0) { const current = queue.shift()!; if (current.path.length > maxDepth) { continue; } const currentElement = current.path[current.path.length - 1]; if (currentElement === toElement) { return current; } // FIX: Use centralized element ID parsing const parsed = parseElementId(currentElement); if (!parsed) continue; const element = index.elements[parsed.type]?.[parsed.name]; if (!element?.relationships) { continue; } // Explore all relationships for (const [relType, relations] of Object.entries(element.relationships)) { // Filter by relationship types if specified if (relationshipTypes && !relationshipTypes.includes(relType as RelationshipType)) { continue; } for (const rel of relations) { // Filter by strength if ((rel.strength || 0) < minStrength) { continue; } if (!visited.has(rel.element)) { visited.add(rel.element); queue.push({ path: [...current.path, rel.element], relationships: [...current.relationships, relType as RelationshipType], totalStrength: current.totalStrength * (rel.strength || 0.5) }); } } } } return null; } /** * Get all connected elements within a certain depth */ public getConnectedElements( element: string, index: EnhancedIndex, options: TraversalOptions = {} ): Map<string, ElementPath> { const { maxDepth = 2, relationshipTypes, minStrength = 0 } = options; const connected = new Map<string, ElementPath>(); const visited = new Set<string>([element]); const queue: ElementPath[] = [{ path: [element], relationships: [], totalStrength: 1.0 }]; while (queue.length > 0) { const current = queue.shift()!; if (current.path.length > maxDepth + 1) { continue; } const currentElement = current.path[current.path.length - 1]; // FIX: Use centralized element ID parsing const parsed = parseElementId(currentElement); if (!parsed) continue; const elementDef = index.elements[parsed.type]?.[parsed.name]; if (!elementDef?.relationships) { continue; } for (const [relType, relations] of Object.entries(elementDef.relationships)) { if (relationshipTypes && !relationshipTypes.includes(relType as RelationshipType)) { continue; } for (const rel of relations) { if ((rel.strength || 0) < minStrength) { continue; } if (!visited.has(rel.element)) { visited.add(rel.element); const path: ElementPath = { path: [...current.path, rel.element], relationships: [...current.relationships, relType as RelationshipType], totalStrength: current.totalStrength * (rel.strength || 0.5) }; connected.set(rel.element, path); if (current.path.length < maxDepth) { queue.push(path); } } } } } return connected; } /** * Get relationship statistics for the index */ public getRelationshipStats(index: EnhancedIndex): Record<string, number> { const stats: Record<string, number> = { totalRelationships: 0, elementsWithRelationships: 0 }; // Count by relationship type for (const relType of Object.keys(RELATIONSHIP_TYPES)) { stats[relType] = 0; } for (const elements of Object.values(index.elements)) { for (const element of Object.values(elements)) { if (element.relationships) { stats.elementsWithRelationships++; for (const [relType, relations] of Object.entries(element.relationships)) { const count = relations.length; stats.totalRelationships += count; stats[relType] = (stats[relType] || 0) + count; } } } } return stats; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server