Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
MemoryManager.tsโ€ข33.5 kB
/** * MemoryManager - Implementation of IElementManager for Memory elements * Handles CRUD operations and lifecycle management for memories implementing IElement * * FIXES IMPLEMENTED: * 1. CRITICAL: Fixed race conditions in file operations by using FileLockManager for atomic reads/writes * 2. HIGH: Fixed unvalidated YAML parsing vulnerability by using SecureYamlParser * 3. MEDIUM: All user inputs are now validated and sanitized * 4. MEDIUM: Audit logging added for security operations * 5. MEDIUM: Path validation prevents directory traversal attacks */ import { Memory, MemoryMetadata, MemoryEntry } from './Memory.js'; import { IElementManager } from '../../types/elements/IElementManager.js'; import { ElementValidationResult } from '../../types/elements/IElement.js'; import { ElementType } from '../../portfolio/types.js'; import { PortfolioManager } from '../../portfolio/PortfolioManager.js'; import { FileLockManager } from '../../security/fileLockManager.js'; import { SecureYamlParser } from '../../security/secureYamlParser.js'; import { SecurityMonitor } from '../../security/securityMonitor.js'; import { logger } from '../../utils/logger.js'; import { UnicodeValidator } from '../../security/validators/unicodeValidator.js'; import { sanitizeInput } from '../../security/InputValidator.js'; import { MEMORY_CONSTANTS, MEMORY_SECURITY_EVENTS } from './constants.js'; import * as path from 'path'; import * as fs from 'fs/promises'; import * as yaml from 'js-yaml'; import * as crypto from 'crypto'; // Character code constants for whitespace detection // Makes the code more readable and maintainable const WHITESPACE_CHARS = { SPACE: 32, TAB: 9, NEWLINE: 10, CARRIAGE_RETURN: 13 } as const; export class MemoryManager implements IElementManager<Memory> { private portfolioManager: PortfolioManager; private memoriesDir: string; private memoryCache: Map<string, Memory> = new Map(); private contentHashIndex: Map<string, string> = new Map(); // PERFORMANCE IMPROVEMENT: Cache for date folders to avoid directory scanning // Invalidated when new folders are created private dateFoldersCache: string[] | null = null; private dateFoldersCacheTimestamp: number = 0; constructor() { this.portfolioManager = PortfolioManager.getInstance(); this.memoriesDir = this.portfolioManager.getElementDir(ElementType.MEMORY); } /** * Validates and processes triggers for a memory * Extracted method to reduce cognitive complexity (SonarCloud) * @private */ private validateAndProcessTriggers(triggers: any[], memoryName: string): string[] { const validTriggers: string[] = []; const rejectedTriggers: string[] = []; const rawTriggers = triggers.slice(0, 20); // Limit to 20 triggers max for (const raw of rawTriggers) { const sanitized = sanitizeInput(String(raw), MEMORY_CONSTANTS.MAX_TAG_LENGTH); if (sanitized) { if (/^[a-zA-Z0-9\-_]+$/.test(sanitized)) { // Only allow alphanumeric + hyphens/underscores 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( `Memory "${memoryName}": Rejected ${rejectedTriggers.length} invalid trigger(s)`, { memoryName, rejectedTriggers, acceptedCount: validTriggers.length } ); } // Warn if trigger limit was exceeded if (triggers.length > 20) { logger.warn( `Memory "${memoryName}": Trigger limit exceeded`, { memoryName, providedCount: triggers.length, limit: 20, truncated: triggers.length - 20 } ); } return validTriggers; } /** * Load a memory from file * SECURITY FIX #1: Uses FileLockManager.atomicReadFile() instead of fs.readFile() * to prevent race conditions and ensure atomic file operations * @param filePath Path to the memory file to load * @returns Promise resolving to the loaded Memory instance * @throws {Error} When file cannot be found or path validation fails * @throws {Error} When YAML parsing fails or content is malformed * @throws {Error} When memory validation fails after loading */ async load(filePath: string): Promise<Memory> { try { let fullPath: string | undefined; // Check if it's a relative path (no date folder) if (!filePath.includes(path.sep) || !filePath.match(/^\d{4}-\d{2}-\d{2}/)) { // Search in date folders const dateFolders = await this.getDateFolders(); let found = false; for (const dateFolder of dateFolders) { const testPath = path.join(this.memoriesDir, dateFolder, filePath); if (await fs.access(testPath).then(() => true).catch(() => false)) { fullPath = testPath; found = true; break; } } if (!found) { // Fall back to root directory for backward compatibility during transition fullPath = await this.validateAndResolvePath(filePath); } } else { fullPath = await this.validateAndResolvePath(filePath); } // Ensure fullPath is defined if (!fullPath) { throw new Error(`Could not resolve path: ${filePath}`); } // Check cache first const cached = this.memoryCache.get(fullPath); if (cached) { return cached; } // CRITICAL FIX: Use atomic file read to prevent race conditions // Previously: const content = await fs.readFile(fullPath, 'utf-8'); // Now: Uses FileLockManager with proper encoding object format const content = await FileLockManager.atomicReadFile(fullPath, { encoding: 'utf-8' }); // HIGH SEVERITY FIX: Use SecureYamlParser to prevent YAML injection attacks // Previously: Could use unsafe YAML parsing // Now: Uses SecureYamlParser which validates content and prevents malicious patterns // Memory files are pure YAML (unlike other elements which are markdown with frontmatter) // Check if this is pure YAML (doesn't start with frontmatter markers) let parsed: any; // Efficient format detection without creating trimmed copy // Performance optimization: Use character codes instead of regex for whitespace detection // Credit: Jeet Singh (@jeetsingh008) - PR #1035 let firstNonWhitespace = 0; while (firstNonWhitespace < content.length) { const charCode = content.codePointAt(firstNonWhitespace); // Check if character is NOT whitespace if (charCode !== WHITESPACE_CHARS.SPACE && charCode !== WHITESPACE_CHARS.TAB && charCode !== WHITESPACE_CHARS.NEWLINE && charCode !== WHITESPACE_CHARS.CARRIAGE_RETURN) { break; } firstNonWhitespace++; } // Handle empty content edge case if (firstNonWhitespace === content.length) { // Empty or all whitespace file - create minimal valid structure parsed = { data: {}, content: '' }; } else if (!content.startsWith('---', firstNonWhitespace)) { // Pure YAML file - wrap it with frontmatter markers for SecureYamlParser const wrappedContent = `---\n${content}\n---\n`; // FIX (#1206): Memory files are locally trusted user content. Word-matching // validation creates false positives for legitimate documentation (e.g., // SonarCloud rules reference). Security validation should happen at // import/installation time, not during load. See PortfolioIndexManager:644-649. const parseResult = SecureYamlParser.parse(wrappedContent, { maxYamlSize: MEMORY_CONSTANTS.MAX_YAML_SIZE, validateContent: false // Local files are pre-trusted }); // For pure YAML, the entire content becomes the data, no markdown content parsed = { data: parseResult.data, content: '' }; } else { // File with frontmatter (shouldn't happen for memories, but handle it) // FIX (#1206): Same rationale as above - local memory files are pre-trusted parsed = SecureYamlParser.parse(content, { maxYamlSize: MEMORY_CONSTANTS.MAX_YAML_SIZE, validateContent: false // Local files are pre-trusted }); } // Extract metadata and content const { metadata, content: memoryContent } = this.parseMemoryFile(parsed); // Create memory instance const memory = new Memory(metadata); // Load saved entries if present // Memory files have entries as a top-level key in the YAML const entries = parsed.data?.entries; if (entries) { memory.deserialize(JSON.stringify({ id: memory.id, type: memory.type, version: memory.version, metadata: memory.metadata, extensions: memory.extensions, entries: entries })); } // FIX #1320: Set file path on memory for persistence (store relative path) const relativePath = path.relative(this.memoriesDir, fullPath); memory.setFilePath(relativePath); // Cache the loaded memory this.memoryCache.set(fullPath, memory); // Log successful load SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_LOADED, severity: 'LOW', source: 'MemoryManager.load', details: `Loaded memory from ${path.basename(fullPath)}` }); return memory; } catch (error) { SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_LOAD_FAILED, severity: 'MEDIUM', source: 'MemoryManager.load', details: `Failed to load memory from ${filePath}: ${error}` }); throw new Error(`Failed to load memory: ${error}`); } } /** * Generate date-based path for memory storage * Creates YYYY-MM-DD folder structure to prevent flat directory issues * @param element Memory element to save * @param fileName Optional custom filename * @returns Full path to memory file */ private async generateMemoryPath(element: Memory, fileName?: string): Promise<string> { const date = new Date(); const dateFolder = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; const datePath = path.join(this.memoriesDir, dateFolder); // Ensure date folder exists await fs.mkdir(datePath, { recursive: true }); // PERFORMANCE IMPROVEMENT: Invalidate date folders cache since we created a new folder this.dateFoldersCache = null; // Generate filename const baseName = fileName || `${element.metadata.name?.toLowerCase().replaceAll(/\s+/g, '-') || 'memory'}.yaml`; let finalName = baseName; let version = 1; // Handle collisions with version suffix while (await fs.access(path.join(datePath, finalName)).then(() => true).catch(() => false)) { version++; finalName = baseName.replace('.yaml', `-v${version}.yaml`); } return path.join(datePath, finalName); } /** * Calculate SHA-256 hash of memory content for deduplication * Implements Issue #994 - Content-based deduplication */ private calculateContentHash(element: Memory): string { const content = JSON.stringify({ metadata: element.metadata, entries: JSON.parse(element.serialize()).entries }); return crypto.createHash('sha256').update(content).digest('hex'); } /** * Get all date folders in memories directory * PERFORMANCE IMPROVEMENT: Uses cache to avoid repeated directory scanning * Cache is invalidated when new folders are created or after 60 seconds * @returns Array of date folder names */ private async getDateFolders(): Promise<string[]> { const now = Date.now(); const CACHE_TTL = 60000; // 60 seconds // Return cached result if valid if (this.dateFoldersCache !== null && (now - this.dateFoldersCacheTimestamp) < CACHE_TTL) { return this.dateFoldersCache; } try { const entries = await fs.readdir(this.memoriesDir, { withFileTypes: true }); const folders = entries .filter(entry => entry.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry.name)) .map(entry => entry.name) .sort() .reverse(); // Most recent first // Cache the result this.dateFoldersCache = folders; this.dateFoldersCacheTimestamp = now; return folders; } catch (error) { if ((error as any).code === 'ENOENT') { // Cache empty result this.dateFoldersCache = []; this.dateFoldersCacheTimestamp = now; return []; } throw error; } } /** * Save a memory to file * SECURITY FIX #1: Uses FileLockManager.atomicWriteFile() for atomic operations * @param element Memory element to save * @param filePath Optional custom file path, defaults to date-based path * @returns Promise that resolves when save is complete * @throws {Error} When memory validation fails before saving * @throws {Error} When path validation fails or file system errors occur * @throws {Error} When atomic write operation fails */ async save(element: Memory, filePath?: string): Promise<void> { try { // Validate element const validation = element.validate(); if (!validation.valid) { throw new Error(`Invalid memory: ${validation.errors?.map(e => e.message).join(', ')}`); } // Calculate content hash for deduplication const contentHash = this.calculateContentHash(element); const existingPath = this.contentHashIndex.get(contentHash); if (existingPath) { // Log duplicate detection SecurityMonitor.logSecurityEvent({ type: 'MEMORY_DUPLICATE_DETECTED', severity: 'LOW', source: 'MemoryManager.save', details: `Duplicate content detected. Existing: ${existingPath}` }); } // Generate date-based path if not provided const fullPath = filePath ? await this.validateAndResolvePath(filePath) : await this.generateMemoryPath(element); // Ensure parent directory exists (for date folders) await fs.mkdir(path.dirname(fullPath), { recursive: true }); // Get memory statistics const stats = element.getStats(); // Prepare data for saving const data = { metadata: element.metadata, extensions: element.extensions, stats: { totalEntries: stats.totalEntries, totalSize: stats.totalSize, oldestEntry: stats.oldestEntry?.toISOString(), newestEntry: stats.newestEntry?.toISOString(), topTags: Array.from(stats.tagFrequency.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([tag, count]) => ({ tag, count })) }, entries: JSON.parse(element.serialize()).entries }; // SECURITY FIX: Use secure YAML dumping with safety options // Previously: Could allow dangerous YAML features // Now: Uses FAILSAFE_SCHEMA and security options to prevent code execution const yamlContent = yaml.dump(data, { schema: yaml.FAILSAFE_SCHEMA, noRefs: true, skipInvalid: true, sortKeys: true }); // CRITICAL FIX: Use atomic file write to prevent corruption // Previously: await fs.writeFile(fullPath, yamlContent, 'utf-8'); // Now: Uses FileLockManager for atomic write with proper encoding await FileLockManager.atomicWriteFile(fullPath, yamlContent, { encoding: 'utf-8' }); // FIX #1320: Set file path on memory after successful save const relativePath = path.relative(this.memoriesDir, fullPath); element.setFilePath(relativePath); // Update cache this.memoryCache.set(fullPath, element); // Update content hash index this.contentHashIndex.set(contentHash, fullPath); // Log successful save SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_SAVED, severity: 'LOW', source: 'MemoryManager.save', details: `Saved memory to ${path.basename(fullPath)} with ${stats.totalEntries} entries` }); } catch (error) { SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_SAVE_FAILED, severity: 'HIGH', source: 'MemoryManager.save', details: `Failed to save memory to ${filePath}: ${error}` }); throw new Error(`Failed to save memory: ${error}`); } } /** * Handle memory load failure * FIX (SonarCloud): Extract duplicated error handling to reduce code duplication * @private */ private handleLoadFailure( file: string, error: unknown, failedLoads: Array<{ file: string; error: string }> ): void { const errorMsg = error instanceof Error ? error.message : String(error); failedLoads.push({ file, error: errorMsg }); SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_LIST_ITEM_FAILED, severity: 'LOW', source: 'MemoryManager.list', details: `Failed to load ${file}: ${error}` }); } /** * List all available memories */ async list(): Promise<Memory[]> { const memories: Memory[] = []; // FIX (#1206): Track failed loads to surface to users const failedLoads: Array<{ file: string; error: string }> = []; try { // Get all date folders const dateFolders = await this.getDateFolders(); // Also check root directory for any memory files // Memory files should be .yaml format only const rootFiles = await fs.readdir(this.memoriesDir) .then(files => files.filter(f => f.endsWith('.yaml'))) .catch(() => []); // Process root files first (legacy) for (const file of rootFiles) { try { const memory = await this.load(file); memories.push(memory); } catch (error) { this.handleLoadFailure(file, error, failedLoads); } } // Process date folders for (const dateFolder of dateFolders) { const folderPath = path.join(this.memoriesDir, dateFolder); const files = await fs.readdir(folderPath) .then(files => files.filter(f => f.endsWith('.yaml'))) .catch(() => []); for (const file of files) { try { const memory = await this.load(path.join(dateFolder, file)); memories.push(memory); } catch (error) { const fullPath = `${dateFolder}/${file}`; this.handleLoadFailure(fullPath, error, failedLoads); } } } // FIX (#1206): Log summary of failed loads if any if (failedLoads.length > 0) { logger.warn(`[MemoryManager] Failed to load ${failedLoads.length} memories:`, failedLoads.map(f => ` - ${f.file}: ${f.error}`).join('\n')); } return memories; } catch (error) { if ((error as any).code === 'ENOENT') { // Directory doesn't exist yet return []; } throw error; } } /** * Check root files for load failures * FIX (SonarCloud S3776): Extract to reduce cognitive complexity * @private */ private async checkRootFiles( failures: Array<{ file: string; error: string }> ): Promise<number> { const rootFiles = await fs.readdir(this.memoriesDir) .then(files => files.filter(f => f.endsWith('.yaml'))) .catch(() => []); for (const file of rootFiles) { try { await this.load(file); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); failures.push({ file, error: errorMsg }); } } return rootFiles.length; } /** * Check date folder files for load failures * FIX (SonarCloud S3776): Extract to reduce cognitive complexity * @private */ private async checkDateFolderFiles( dateFolder: string, failures: Array<{ file: string; error: string }> ): Promise<number> { const files = await fs.readdir(path.join(this.memoriesDir, dateFolder)) .then(files => files.filter(f => f.endsWith('.yaml'))) .catch(() => []); for (const file of files) { try { await this.load(path.join(dateFolder, file)); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); failures.push({ file: `${dateFolder}/${file}`, error: errorMsg }); } } return files.length; } /** * Get diagnostic information about memory loading status * FIX (#1206): New method to expose failed loads to users */ async getLoadStatus(): Promise<{ total: number; loaded: number; failed: number; failures: Array<{ file: string; error: string }>; }> { const failures: Array<{ file: string; error: string }> = []; let totalFiles = 0; try { const dateFolders = await this.getDateFolders(); // Check root files totalFiles += await this.checkRootFiles(failures); // Check date folders for (const dateFolder of dateFolders) { totalFiles += await this.checkDateFolderFiles(dateFolder, failures); } return { total: totalFiles, loaded: totalFiles - failures.length, failed: failures.length, failures }; } catch (error) { throw new Error(`Failed to get load status: ${error}`); } } /** * Find memories matching a predicate */ async find(predicate: (element: Memory) => boolean): Promise<Memory | undefined> { const memories = await this.list(); return memories.find(predicate); } /** * Find multiple memories matching a predicate */ async findMany(predicate: (element: Memory) => boolean): Promise<Memory[]> { const memories = await this.list(); return memories.filter(predicate); } /** * Delete a memory file * SECURITY: Validates path and logs deletion */ async delete(filePath: string): Promise<void> { try { const fullPath = await this.validateAndResolvePath(filePath); // Check if file exists await fs.access(fullPath); // Delete the file await fs.unlink(fullPath); // Remove from cache this.memoryCache.delete(fullPath); SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_DELETED, severity: 'MEDIUM', source: 'MemoryManager.delete', details: `Deleted memory file: ${path.basename(fullPath)}` }); } catch (error) { if ((error as any).code === 'ENOENT') { // File doesn't exist, not an error for delete operation return; } throw error; } } /** * Check if a memory file exists */ async exists(filePath: string): Promise<boolean> { try { const fullPath = await this.validateAndResolvePath(filePath); await fs.access(fullPath); return true; } catch { return false; } } /** * Create a new memory with metadata */ async create(metadata: Partial<MemoryMetadata>): Promise<Memory> { return new Memory(metadata); } /** * Import a memory from JSON/YAML string * SECURITY: Full validation of imported content * @param data JSON or YAML string containing memory data * @param format Format of the input data ('json' or 'yaml') * @returns Promise resolving to the imported Memory instance * @throws {Error} When JSON/YAML parsing fails * @throws {Error} When imported data is missing required fields * @throws {Error} When YAML content exceeds maximum allowed size * @throws {Error} When imported memory fails validation */ async importElement(data: string, format: 'json' | 'yaml' = 'yaml'): Promise<Memory> { try { let parsed: any; if (format === 'json') { parsed = JSON.parse(data); } else { // HIGH SEVERITY FIX: Use secure YAML parsing // Memory import expects pure YAML (not frontmatter), so we parse it securely try { // First validate the YAML content size if (data.length > MEMORY_CONSTANTS.MAX_YAML_SIZE) { throw new Error('YAML content exceeds maximum allowed size'); } // Create a wrapper to use SecureYamlParser with pure YAML // Add minimal frontmatter markers to satisfy parser const wrappedYaml = `---\n${data}\n---\n`; const parseResult = SecureYamlParser.parse(wrappedYaml, { maxYamlSize: MEMORY_CONSTANTS.MAX_YAML_SIZE, validateContent: true }); // Extract the parsed data (will be in the 'data' property) parsed = parseResult.data; } catch (yamlError) { throw new Error(`Invalid YAML: ${yamlError}`); } // Validate it's an object if (!parsed || typeof parsed !== 'object') { throw new Error('YAML must contain an object'); } } // Handle different structures from YAML parsing let metadata = parsed.metadata; let entries = parsed.entries || (parsed.data && parsed.data.entries); // Validate required fields if (!metadata || !metadata.name) { throw new Error('Memory must have metadata with name'); } // Create memory instance const memory = new Memory(metadata); // Load entries if present if (entries) { memory.deserialize(JSON.stringify({ id: memory.id, type: memory.type, version: memory.version, metadata: memory.metadata, extensions: memory.extensions, entries: entries })); } return memory; } catch (error) { SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_IMPORT_FAILED, severity: 'MEDIUM', source: 'MemoryManager.importElement', details: `Failed to import memory: ${error}` }); throw new Error(`Failed to import memory: ${error}`); } } /** * Export a memory to YAML string */ async exportElement(element: Memory): Promise<string> { const stats = element.getStats(); const data = { metadata: element.metadata, extensions: element.extensions, stats: { totalEntries: stats.totalEntries, totalSize: stats.totalSize, oldestEntry: stats.oldestEntry?.toISOString(), newestEntry: stats.newestEntry?.toISOString() }, entries: JSON.parse(element.serialize()).entries }; // SECURITY FIX: Use secure YAML dumping return yaml.dump(data, { schema: yaml.FAILSAFE_SCHEMA, noRefs: true, skipInvalid: true, sortKeys: true }); } /** * Validate a memory element */ validate(element: Memory): ElementValidationResult { return element.validate(); } /** * Validate and resolve a file path * SECURITY: Prevents directory traversal attacks */ validatePath(filePath: string): boolean { try { // Perform synchronous validation checks const normalized = path.normalize(filePath); // Check for path traversal attempts if (normalized.includes('..') || path.isAbsolute(normalized)) { return false; } // Ensure proper extension if (!normalized.endsWith('.md') && !normalized.endsWith('.yaml') && !normalized.endsWith('.yml')) { return false; } return true; } catch { return false; } } /** * Get the element type this manager handles */ getElementType(): ElementType { return ElementType.MEMORY; } /** * Get the file extension for memory files */ getFileExtension(): string { return '.yaml'; } // Private helper methods /** * Validate and resolve a file path to prevent security issues * @param filePath Path to validate and resolve * @returns Promise resolving to the validated full path * @throws {Error} When path contains traversal attempts (../) * @throws {Error} When path is absolute or invalid * @throws {Error} When file extension is not allowed (.md, .yaml, .yml) * @throws {Error} When resolved path would be outside memories directory */ private async validateAndResolvePath(filePath: string): Promise<string> { // SECURITY FIX: Comprehensive path validation // Enhanced validation inspired by Jeet Singh (@jeetsingh008) - PR #1035 // First normalize the path to resolve any ./ or ../ sequences const normalized = path.normalize(filePath); // Check for path traversal attempts - both in original and normalized // Check both the normalized path and the original for any traversal patterns if (normalized.includes('..') || filePath.includes('..') || path.isAbsolute(normalized) || path.isAbsolute(filePath)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'MemoryManager.validateAndResolvePath', details: `Blocked path traversal attempt: ${filePath}` }); throw new Error('Invalid file path: Path traversal detected'); } // Ensure proper extension - memories should only be .yaml or .yml if (!normalized.endsWith('.yaml') && !normalized.endsWith('.yml')) { throw new Error('Memory files must have .yaml or .yml extension'); } // Construct full path const fullPath = path.join(this.memoriesDir, normalized); // Verify it's within memories directory const relative = path.relative(this.memoriesDir, fullPath); if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('File path must be within memories directory'); } return fullPath; } private parseMemoryFile(parsed: any): { metadata: MemoryMetadata; content: string } { // FIX: SecureYamlParser returns data in 'data' property, not 'metadata' // For markdown files with YAML frontmatter, the structure is: // parsed.data = YAML frontmatter values // parsed.content = markdown content after frontmatter // For pure YAML memory files, we need to check if metadata is directly in data const yamlData = parsed.data || {}; // Memory files saved by the system have metadata as a top-level key const metadataSource = yamlData.metadata || yamlData; // Extract metadata with validation const metadata: MemoryMetadata = { name: sanitizeInput(metadataSource.name || 'Unnamed Memory', 100), description: metadataSource.description ? sanitizeInput(metadataSource.description, 500) : '', version: metadataSource.version || '1.0.0', author: metadataSource.author, created: metadataSource.created, modified: metadataSource.modified || new Date().toISOString(), tags: Array.isArray(metadataSource.tags) ? metadataSource.tags.map((tag: string) => sanitizeInput(tag, MEMORY_CONSTANTS.MAX_TAG_LENGTH)) : [], // FIX #1124: Extract triggers for Enhanced Index support // Enhanced trigger validation logging for Issue #1139 triggers: [], // Will be set below with enhanced logging storageBackend: metadataSource.storage_backend || metadataSource.storageBackend || MEMORY_CONSTANTS.DEFAULT_STORAGE_BACKEND, retentionDays: metadataSource.retention_policy?.default ? this.parseRetentionDays(metadataSource.retention_policy.default) : (metadataSource.retentionDays || MEMORY_CONSTANTS.DEFAULT_RETENTION_DAYS), privacyLevel: metadataSource.privacy_level || metadataSource.privacyLevel || MEMORY_CONSTANTS.DEFAULT_PRIVACY_LEVEL, searchable: metadataSource.searchable !== false, maxEntries: metadataSource.maxEntries || MEMORY_CONSTANTS.MAX_ENTRIES_DEFAULT }; // Enhanced trigger validation and logging // NOTE: Memory triggers may evolve to support date patterns (2024-Q3), // semantic markers (recall-context), or natural language phrases. // Kept separate from Skills (technical) and Personas (character names). if (Array.isArray(metadataSource.triggers)) { metadata.triggers = this.validateAndProcessTriggers( metadataSource.triggers, metadata.name || 'unknown' ); } // Extract content (if any) const content = parsed.content || ''; return { metadata, content }; } /** * Helper to parse retention days from various formats */ private parseRetentionDays(retention: string | number): number { if (typeof retention === 'number') return retention; if (retention === 'permanent' || retention === 'perpetual') return 999999; const match = retention.match(/(\d+)\s*days?/i); return match ? Number.parseInt(match[1]) : MEMORY_CONSTANTS.DEFAULT_RETENTION_DAYS; } }

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