@dollhousemcp/mcp-server
Version:
DollhouseMCP - A Model Context Protocol (MCP) server that enables dynamic AI persona management from markdown files, allowing Claude and other compatible AI assistants to activate and switch between different behavioral personas.
449 lines • 19.5 kB
TypeScript
/**
* BaseElementManager - Abstract base class for all element managers
*
* Provides common CRUD operations for all element types using the
* Template Method Pattern:
* - Defines the skeleton of operations in base class
* - Lets subclasses override specific steps without changing structure
*
* Subclasses: SkillManager, TemplateManager, AgentManager, MemoryManager
*
* SECURITY:
* 1. CRITICAL: Uses FileLockManager for atomic read/write operations
* 2. HIGH: Path validation and sanitization to prevent traversal attacks
* 3. MEDIUM: Security event logging for audit trail
* 4. MEDIUM: Input validation and sanitization throughout
*/
import { IElementManager } from '../../types/elements/IElementManager.js';
import { IElement, 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 { LRUCache } from '../../cache/LRUCache.js';
import { ElementEventDispatcher, ElementEventPayload } from '../../events/ElementEventDispatcher.js';
import { FileWatchService } from '../../services/FileWatchService.js';
import { FileOperationsService } from '../../services/FileOperationsService.js';
import { ValidationRegistry } from '../../services/validation/ValidationRegistry.js';
import { type ElementValidator } from '../../services/validation/ElementValidator.js';
import type { IStorageLayer } from '../../storage/IStorageLayer.js';
import type { ElementIndexEntry } from '../../storage/types.js';
import type { CacheMemoryBudget } from '../../cache/CacheMemoryBudget.js';
import type { BackupService } from '../../services/BackupService.js';
export interface BaseElementManagerOptions {
elementDirOverride?: string;
eventDispatcher?: ElementEventDispatcher;
elementCacheTTL?: number;
pathCacheTTL?: number;
enableFileWatcher?: boolean;
autoReloadOnExternalChange?: boolean;
fileWatchService?: FileWatchService;
memoryBudget?: CacheMemoryBudget;
backupService?: BackupService;
}
/**
* Record of an element that failed to load (Issue #708).
* Stored so callers can distinguish "file not found" from "file invalid".
*/
export interface InvalidElementRecord {
/** Relative file path within the element directory. */
filePath: string;
/** Human-readable reason the element was rejected. */
reason: string;
/** ISO timestamp of last failed load attempt. */
failedAt: string;
}
/**
* Abstract base class implementing common element management operations
* Subclasses must implement element-specific logic via abstract methods
*/
export declare abstract class BaseElementManager<T extends IElement> implements IElementManager<T> {
protected portfolioManager: PortfolioManager;
protected fileLockManager: FileLockManager;
protected fileOperations: FileOperationsService;
protected fileWatchService?: FileWatchService;
protected elementDir: string;
/**
* Specialized validator for this element type
* Obtained from ValidationRegistry during construction
*/
protected validator: ElementValidator;
protected elements: LRUCache<T>;
private filePathToId;
private readonly elementGenerations;
private cacheGenerationCounter;
private watcherCleanup?;
private readonly eventDispatcher;
private readonly autoReloadOnExternalChange;
private readonly elementType;
private readonly memoryBudget?;
protected readonly backupService?: BackupService;
/** Map plural ElementType enum values to singular ContentValidator context.
* Partial because not all element types have a content context (e.g., ensembles). */
private static readonly ELEMENT_TYPE_TO_CONTEXT;
protected readonly storageLayer: IStorageLayer;
/**
* Issue #708: Elements that exist on disk but failed validation during load.
* Keyed by relative file path for deduplication.
*/
private readonly invalidElements;
/**
* Tracks file paths whose load failure has already been logged at error level.
* Repeated failures with the same reason are demoted to debug to avoid log flooding.
* Cleared when the file changes on disk or loads successfully.
*/
private readonly suppressedLoadPaths;
/**
* Returns true if the most recent load() failure for this path was suppressed
* because it was a repeat of an already-logged error.
* Subclasses can use this to avoid duplicate security event logging.
*
* @param filePath - Relative file path within the element directory
* @returns Whether the error for this path is currently suppressed
*/
protected isLoadErrorSuppressed(filePath: string): boolean;
protected afterLoad?(element: T, filePath: string): Promise<void>;
protected beforeSave?(element: T, filePath: string): Promise<void>;
protected afterSave?(element: T, filePath: string): Promise<void>;
protected findByIdentifier?(identifier: string): Promise<T | undefined>;
protected canDelete?(element: T): Promise<{
allowed: boolean;
reason?: string;
}>;
/**
* Create a backup before overwriting an existing file.
* Subclasses can override to no-op (e.g. MemoryManager has its own backup system).
*/
protected createBackupBeforeSave(absolutePath: string): Promise<void>;
/**
* Create a backup before deleting a file (moves file to backup dir).
* Returns true if the original file was moved (caller should skip deleteFile).
* Subclasses can override to no-op (e.g. MemoryManager has its own backup system).
*/
protected createBackupBeforeDelete(absolutePath: string): Promise<boolean>;
/**
* Provides access to the event dispatcher for subclasses that need to emit custom events.
*
* BaseElementManager handles standard lifecycle events (load, save, delete) automatically.
* Subclasses should use this getter only when they need to emit additional domain-specific
* events that are not part of the standard CRUD lifecycle.
*
* @example
* // PersonaManager emits activation/deactivation events
* this.dispatcher.emit('element:activate', this.createEventPayload({...}));
*
* @returns The ElementEventDispatcher instance used by this manager
*/
protected get dispatcher(): ElementEventDispatcher;
private static readonly MAX_ELEMENT_CACHE_SIZE;
private static readonly MAX_PATH_CACHE_SIZE;
/**
* Constructor - initializes common dependencies
* @param elementType - The type of element this manager handles
* @param portfolioManager - Portfolio manager for directory resolution
* @param fileLockManager - File lock manager for atomic operations
* @param options - Configuration options including fileWatchService
* @param fileOperationsService - Service for file operations
* @param validationRegistry - Registry for obtaining type-specific validators
*/
constructor(elementType: ElementType, portfolioManager: PortfolioManager, fileLockManager: FileLockManager, options: BaseElementManagerOptions | undefined, fileOperationsService: FileOperationsService, validationRegistry: ValidationRegistry);
/**
* Factory method for creating the storage layer.
* Default returns ElementStorageLayer for .md elements.
* Subclasses (e.g. MemoryManager) can override to return a different implementation.
*/
protected createStorageLayer(fileOperationsService: FileOperationsService): IStorageLayer;
/**
* Returns the singular human-readable label for this element type (e.g., "skill", "persona").
* Used in filename generation ({name}-{label}.md) and display strings.
* Must return the singular form — not the plural ElementType value.
*/
protected abstract getElementLabel(): string;
/**
* Returns a capitalized version of the element label.
*/
protected getElementLabelCapitalized(): string;
/**
* Load an element from file
* TEMPLATE METHOD: Defines the algorithm, subclasses customize steps
*
* SECURITY FIXES (inherited from original managers):
* - this.fileLockManager.atomicReadFile() prevents race conditions
* - Path validation prevents traversal attacks
* - Security event logging for audit trail
*/
load(filePath: string): Promise<T>;
/**
* Issue #695: Pre-fill missing metadata fields with sensible defaults
* before parseMetadata() runs. This implements the "tolerant reader" pattern
* — strict on output, lenient on input for older/sparse frontmatter.
*
* Mutates `data` in place. Logs a warning for each defaulted field so
* operators know which files need updating.
*/
private migrateMetadataDefaults;
/**
* Issue #708: Returns elements that exist on disk but failed validation during load.
* Callers can use this to report invalid elements instead of silently hiding them.
*/
getInvalidElements(): InvalidElementRecord[];
/**
* Issue #708: Check if a specific file failed validation during load.
* Used by get_element to distinguish "not found" from "invalid".
*/
getInvalidElement(name: string): InvalidElementRecord | undefined;
/**
* Save an element to file
* TEMPLATE METHOD: Common save logic with hooks for customization
*
* SECURITY FIXES:
* - this.fileLockManager.atomicWriteFile() for atomic operations
* - Path validation to prevent traversal attacks
* - Security event logging
*/
save(element: T, filePath: string): Promise<void>;
/**
* Validate serialized element content before writing to disk.
* Fix #908: Mirrors the read-path validation from SecureYamlParser.parse()
* to ensure write → read symmetry. Content that fails this check would also
* fail to load, so rejecting it on write prevents permanently broken elements.
*/
private validateSerializedContent;
/**
* List all available elements
* SECURITY: Uses PortfolioManager.listElements() which filters test elements
*/
list(): Promise<T[]>;
/**
* List lightweight metadata summaries without loading full elements.
* Useful when only names/descriptions/tags are needed.
*/
listSummaries(): Promise<ElementIndexEntry[]>;
/**
* Find an element by predicate
*/
find(predicate: (element: T) => boolean): Promise<T | undefined>;
/**
* Find multiple elements by predicate
*/
findMany(predicate: (element: T) => boolean): Promise<T[]>;
/**
* Find an element by name or ID without loading all elements
*
* Issue #24 (LOW PRIORITY): Performance optimization for activation flow
*
* This method provides an optimized lookup that tries cache first, then
* attempts direct file access before falling back to full list() scan.
* This is significantly faster than list() for large portfolios.
*
* PERFORMANCE IMPROVEMENTS:
* 1. Cache lookup - O(1) if element was previously loaded
* 2. Direct file access - O(1) for name-based lookups
* 3. Full scan fallback - O(n) only if above methods fail
*
* @param identifier - Element name or ID to search for
* @returns Element if found, undefined otherwise
*/
findByName(identifier: string): Promise<T | undefined>;
/**
* Helper: Search cache for element by name or ID
* @private
*/
private findInCache;
/**
* Helper: Try loading element directly by constructing expected filename
* @private
*/
private tryDirectLoad;
/**
* Validate an element
* Delegates to element's own validate method
*
* @returns Validation result with both 'valid' and 'isValid' properties.
* 'isValid' is deprecated - use 'valid' for new code.
*/
validate(element: T): ElementValidationResult;
/**
* Delete an element
* SECURITY: Path validation to prevent deletion outside directory
* CACHE FIX: Uses filepath-based cache removal to prevent stale entries
*/
delete(filePath: string): Promise<void>;
/**
* Check if an element exists
*/
exists(filePath: string): Promise<boolean>;
/**
* Validate a file path
*/
validatePath(filePath: string): boolean;
/**
* Get the element type
*/
getElementType(): ElementType;
/**
* Resolves a file path to its absolute form for cache consistency
* Ensures consistent path handling across relative/absolute paths
*/
protected resolveAbsolutePath(filePath: string): string;
private normalizeAndValidatePath;
private getCachedElementByAbsolutePath;
/**
* Protected cache lookup by absolute path.
* Allows subclasses with custom load() overrides (e.g. MemoryManager)
* to check the LRU cache before re-reading from disk.
*/
protected getCachedByAbsolutePath(absolutePath: string): T | undefined;
private loadElementSnapshot;
/**
* Creates a standardized event payload for element lifecycle events.
*
* This helper is available to subclasses to ensure consistent event payload
* structure when emitting custom events via the dispatcher getter.
*
* @param params - Event parameters including correlation ID, element, file path, and optional error
* @returns Fully-formed ElementEventPayload ready for emission
*/
protected createEventPayload(params: {
correlationId: string;
filePath?: string;
element?: T;
error?: unknown;
}): ElementEventPayload;
private handleExternalChange;
/**
* Adds an element to both caches (bidirectional mapping)
* @param element - Element to cache
* @param filePath - File path (relative or absolute)
*/
protected cacheElement(element: T, filePath: string): void;
/**
* Force a fresh disk scan and evict any modified/removed entries from the
* in-memory LRU cache. Call before findByName() when freshness is critical
* (e.g. on ensemble activation) to pick up external file changes that
* occurred since the last scan, even if the scan cooldown is still active.
*
* Unlike list(), this does not load all elements — it only evicts stale ones.
* Fixes #1895 (ensemble activation serving stale cached element list).
*/
protected scanAndEvict(): Promise<void>;
/**
* Removes an element from both caches by file path
* This is the preferred method for deletion to avoid stale cache entries
* @param filePath - File path (relative or absolute)
*/
protected uncacheByPath(filePath: string): void;
/**
* Clear all cached elements
*/
clearCache(): void;
/**
* Get cache statistics for debugging
* @returns Object with cache size metrics
*/
protected getCacheStats(): {
elementCount: number;
pathMappings: number;
};
/**
* Expose internal LRU cache instances for metrics collection.
*/
getMetricsCaches(): Array<{
name: string;
instance: LRUCache<unknown>;
}>;
/**
* Dispose of resources and cleanup
* Subclasses should override to add their own cleanup logic
*/
dispose(): void;
/**
* Normalize a name to kebab-case for consistent filename formatting.
*
* This method provides unified filename normalization across all element managers,
* ensuring consistent naming regardless of the input format (CamelCase, spaces,
* underscores, mixed case, etc.).
*
* Transformations applied (in order):
* 1. Insert hyphens between camelCase boundaries (MyName -> My-Name)
* 2. Replace spaces and underscores with hyphens
* 3. Convert to lowercase
* 4. Strip invalid characters (keep only a-z, 0-9, -)
* 5. Collapse multiple consecutive hyphens
* 6. Trim leading/trailing hyphens
*
* @example
* normalizeFilename("CRUDV-Agent-Delta") // "crudv-agent-delta"
* normalizeFilename("Creative Writer") // "creative-writer"
* normalizeFilename("CamelCaseName") // "camel-case-name"
* normalizeFilename("my_skill_name") // "my-skill-name"
* normalizeFilename("Special@Chars!") // "special-chars"
* normalizeFilename("--leading-and-trailing--") // "leading-and-trailing"
*
* @param name - The element name to normalize
* @returns Normalized kebab-case filename (without extension)
*/
protected normalizeFilename(name: string): string;
/**
* Get the standardized filename for an element.
*
* Normalizes the element name (handling CamelCase, spaces, underscores, etc.)
* and appends the file extension. The directory structure (personas/, skills/, etc.)
* already provides type context, so the type is NOT included in the filename.
*
* @param name - The element name to convert to a filename
* @returns The standardized filename (e.g., "code-review.md")
*
* @example
* getElementFilename("Code Review") // → "code-review.md"
* getElementFilename("Debug Detective") // → "debug-detective.md"
* getElementFilename("BugReport") // → "bug-report.md" (CamelCase split)
* getElementFilename("fix-persona-helper") // → "fix-persona-helper.md" (no mangling)
*/
protected getElementFilename(name: string): string;
/**
* Parse and validate metadata from frontmatter
* Subclasses implement element-specific validation logic
*
* @param data - Raw metadata from YAML frontmatter
* @returns Validated and typed metadata
*/
protected abstract parseMetadata(data: any): Promise<T['metadata']>;
/**
* @deprecated Use ElementValidator via ValidationRegistry instead.
* Will be removed in next major version.
*/
validateMetadata(metadata: any, _strict?: boolean): Promise<import('../../utils/validation/FieldValidator.js').ValidationError[]>;
/**
* Create an element instance from metadata and content
* Subclasses implement element-specific construction
*
* @param metadata - Validated metadata
* @param content - Element content (instructions, template, etc.)
* @returns New element instance
*/
protected abstract createElement(metadata: T['metadata'], content: string): T;
/**
* Serialize an element to file content
* Subclasses can customize serialization format
*
* @param element - Element to serialize
* @returns File content (usually markdown with frontmatter)
*/
protected abstract serializeElement(element: T): Promise<string>;
/**
* Get the file extension for this element type
* Most elements use .md, but subclasses can override
*/
abstract getFileExtension(): string;
/**
* Import an element from external format
* Subclasses implement format-specific import logic
*/
abstract importElement(data: string, format?: 'json' | 'yaml' | 'markdown'): Promise<T>;
/**
* Export an element to external format
* Subclasses implement format-specific export logic
*/
abstract exportElement(element: T, format?: 'json' | 'yaml' | 'markdown'): Promise<string>;
}
//# sourceMappingURL=BaseElementManager.d.ts.map