Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
PersonaLoader.ts8.99 kB
/** * Persona loading and file management */ import * as fs from 'fs/promises'; import * as path from 'path'; import matter from 'gray-matter'; import { Persona, PersonaMetadata } from '../types/persona.js'; import { ensureDirectory, generateUniqueId } from '../utils/filesystem.js'; import { SecureYamlParser } from '../security/secureYamlParser.js'; import { SecurityError } from '../errors/SecurityError.js'; import { logger } from '../utils/logger.js'; import { PortfolioManager, ElementType } from '../portfolio/PortfolioManager.js'; import { FileLockManager } from '../security/fileLockManager.js'; import { sanitizeInput } from '../security/InputValidator.js'; // Trigger validation constants // NOTE: These are intentionally NOT shared across element types. // Each element type has domain-specific requirements: // - Personas: Character names, aliases, multi-word triggers // - Skills: Technical terms, version numbers, command patterns // - Memories: Date-based, semantic, natural language triggers // - Templates: Format indicators, hierarchical paths // - Agents: Goal-oriented, role-based, mention patterns // Future element types will likely have unique validation needs. // Premature abstraction would limit flexibility. const MAX_TRIGGER_LENGTH = 50; const MAX_TRIGGERS = 20; const TRIGGER_VALIDATION_REGEX = /^[a-zA-Z0-9\-_]+$/; export class PersonaLoader { private personasDir: string; private portfolioManager: PortfolioManager; constructor(personasDir?: string) { // Use PortfolioManager for new portfolio structure this.portfolioManager = PortfolioManager.getInstance(); // If personasDir is provided, it's for legacy compatibility // Otherwise use the portfolio personas directory this.personasDir = personasDir || this.portfolioManager.getElementDir(ElementType.PERSONA); } /** * Validates and processes triggers for a persona * Extracted method to reduce cognitive complexity (SonarCloud) * @private */ private validateAndProcessTriggers(triggers: any[], personaName: string): string[] { const validTriggers: string[] = []; const rejectedTriggers: string[] = []; const originalCount = triggers.length; const rawTriggers = triggers.slice(0, MAX_TRIGGERS); for (const raw of rawTriggers) { const sanitized = sanitizeInput(String(raw), MAX_TRIGGER_LENGTH); if (sanitized) { if (TRIGGER_VALIDATION_REGEX.test(sanitized)) { validTriggers.push(sanitized); } else { rejectedTriggers.push(`"${sanitized}" (invalid format - must be alphanumeric with hyphens/underscores only)`); } } else { rejectedTriggers.push(`"${raw}" (empty after sanitization)`); } } // Enhanced logging for debugging if (rejectedTriggers.length > 0) { logger.warn( `Persona "${personaName}": Rejected ${rejectedTriggers.length} invalid trigger(s)`, { personaName, rejectedTriggers, acceptedCount: validTriggers.length } ); } // Warn if trigger limit was exceeded if (originalCount > MAX_TRIGGERS) { logger.warn( `Persona "${personaName}": Trigger limit exceeded`, { personaName, providedCount: originalCount, limit: MAX_TRIGGERS, truncated: originalCount - MAX_TRIGGERS } ); } return validTriggers; } /** * Load all personas from the personas directory */ async loadAll(getCurrentUser: () => string | null): Promise<Map<string, Persona>> { // Ensure directory exists await ensureDirectory(this.personasDir); const personas = new Map<string, Persona>(); try { const files = await fs.readdir(this.personasDir); const markdownFiles = files.filter(file => file.endsWith('.md')); for (const file of markdownFiles) { try { const persona = await this.loadPersona(file, getCurrentUser); if (persona) { personas.set(file, persona); logger.debug(`Loaded persona: ${persona.metadata.name} (${persona.unique_id})`); } } catch (error) { logger.error(`Error loading persona ${file}:`, error); } } } catch (error) { logger.error(`Error reading personas directory:`, error); } return personas; } /** * Load a single persona from file */ async loadPersona(filename: string, getCurrentUser: () => string | null): Promise<Persona | null> { try { const filePath = path.join(this.personasDir, filename); const fileContent = await fs.readFile(filePath, 'utf-8'); // DIAGNOSTIC: Log file content size logger.debug(`[CONTENT-TRACE] Loading ${filename} - file size: ${fileContent.length} chars`); // Use secure YAML parser instead of direct gray-matter let parsed; try { parsed = SecureYamlParser.safeMatter(fileContent); } catch (error) { if (error instanceof SecurityError) { logger.error(`Security threat detected in persona ${filename}: ${error.message}`); return null; } throw error; } const metadata = parsed.data as PersonaMetadata; const content = parsed.content; // DIAGNOSTIC: Log parsed content size logger.debug(`[CONTENT-TRACE] Parsed ${filename} - content size: ${content.length} chars`); if (!metadata.name) { metadata.name = path.basename(filename, '.md'); } // Generate unique ID if not present let uniqueId = metadata.unique_id; if (!uniqueId) { const authorForId = metadata.author || getCurrentUser() || undefined; uniqueId = generateUniqueId(metadata.name, authorForId); logger.debug(`Generated unique ID for ${metadata.name}: ${uniqueId}`); } // Set default values for metadata fields this.setDefaultMetadata(metadata); // Enhanced trigger validation and logging for Issue #1139 if (metadata.triggers && Array.isArray(metadata.triggers)) { metadata.triggers = this.validateAndProcessTriggers( metadata.triggers, metadata.name ); } const persona: Persona = { metadata, content, filename, unique_id: uniqueId, }; return persona; } catch (error) { logger.error(`Error loading persona ${filename}:`, error); return null; } } /** * Save a persona to file */ async savePersona(persona: Persona): Promise<void> { const filePath = path.join(this.personasDir, persona.filename); // DIAGNOSTIC: Log content size before save logger.debug(`[CONTENT-TRACE] Saving ${persona.filename} - content size: ${persona.content.length} chars`); // Use secure YAML stringification const secureParser = SecureYamlParser.createSecureMatterParser(); const fileContent = secureParser.stringify(persona.content, persona.metadata); // DIAGNOSTIC: Log stringified content size logger.debug(`[CONTENT-TRACE] Stringified ${persona.filename} - file content size: ${fileContent.length} chars`); // SECURITY FIX: Replace direct file write with atomic operation // FIXED: CVE-2025-XXXX - Non-atomic file write in persona save operation // Original issue: Line 120 used direct fs.writeFile instead of atomic operation // Security impact: Race conditions could cause data corruption or partial writes // Fix: Replaced with FileLockManager.atomicWriteFile for guaranteed atomicity await FileLockManager.atomicWriteFile(filePath, fileContent, { encoding: 'utf-8' }); // DIAGNOSTIC: Verify file was written correctly const writtenContent = await fs.readFile(filePath, 'utf-8'); logger.debug(`[CONTENT-TRACE] Verified ${persona.filename} - actual file size: ${writtenContent.length} chars`); } /** * Delete a persona file */ async deletePersona(filename: string): Promise<void> { const filePath = path.join(this.personasDir, filename); await fs.unlink(filePath); } /** * Check if a persona file exists */ async personaExists(filename: string): Promise<boolean> { try { const filePath = path.join(this.personasDir, filename); await fs.access(filePath); return true; } catch { return false; } } /** * Set default metadata values */ private setDefaultMetadata(metadata: PersonaMetadata): void { if (!metadata.category) metadata.category = 'general'; if (!metadata.age_rating) metadata.age_rating = 'all'; if (!metadata.content_flags) metadata.content_flags = []; if (metadata.ai_generated === undefined) metadata.ai_generated = false; if (!metadata.generation_method) metadata.generation_method = 'human'; if (!metadata.price) metadata.price = 'free'; if (!metadata.license) metadata.license = 'CC-BY-SA-4.0'; } }

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