@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.
923 lines ⢠155 kB
JavaScript
import { ElementType } from '../portfolio/PortfolioManager.js';
import { SecureErrorHandler } from '../security/errorHandler.js';
import { validateUsername } from '../security/InputValidator.js';
import { getPortfolioRepositoryName } from '../config/portfolioConfig.js';
import { logger } from '../utils/logger.js';
import { ErrorHandler } from '../utils/ErrorHandler.js';
import * as path from 'path';
import { getElementIcon, getSourceIcon } from '../utils/index.js';
import { SecurityMonitor } from '../security/securityMonitor.js';
import { normalizeElementType, formatElementTypesList } from '../utils/elementTypeNormalization.js';
/**
* PortfolioHandler - Manages portfolio operations, status, configuration, and search
*
* Uses dependency injection for all services:
* - InitializationService for setup tasks
* - PersonaIndicatorService for persona indicator formatting
* - GitHubAuthManager for authentication
* - PortfolioManager for portfolio operations
*
* FIX: DMCP-SEC-006 - Security audit suppression
* This handler delegates operations to PortfolioManager and related services.
* Audit logging happens in the underlying services (GitHubAuthManager, PortfolioRepoManager).
* @security-audit-suppress DMCP-SEC-006
*/
export class PortfolioHandler {
githubAuthManager;
portfolioManager;
portfolioPullHandler;
portfolioIndexManager;
unifiedIndexManager;
initService;
indicatorService;
configManager;
fileOperations;
tokenManager;
portfolioRepoManager;
constructor(githubAuthManager, portfolioManager, portfolioPullHandler, portfolioIndexManager, unifiedIndexManager, initService, indicatorService, configManager, fileOperations, tokenManager, portfolioRepoManager) {
this.githubAuthManager = githubAuthManager;
this.portfolioManager = portfolioManager;
this.portfolioPullHandler = portfolioPullHandler;
this.portfolioIndexManager = portfolioIndexManager;
this.unifiedIndexManager = unifiedIndexManager;
this.initService = initService;
this.indicatorService = indicatorService;
this.configManager = configManager;
// Validation moved to constructor parameters with readonly
if (!portfolioPullHandler) {
throw new Error('PortfolioHandler requires a PortfolioPullHandler instance');
}
if (!portfolioIndexManager) {
throw new Error('PortfolioHandler requires a PortfolioIndexManager instance');
}
if (!unifiedIndexManager) {
throw new Error('PortfolioHandler requires a UnifiedIndexManager instance');
}
if (!portfolioRepoManager) {
throw new Error('PortfolioHandler requires a PortfolioRepoManager instance');
}
// Initialize services
this.fileOperations = fileOperations;
this.tokenManager = tokenManager;
this.portfolioRepoManager = portfolioRepoManager;
}
async countElementsInDir(dirPath) {
try {
const exists = await this.fileOperations.exists(dirPath);
if (!exists) {
return 0;
}
const files = await this.fileOperations.listDirectory(dirPath);
return files.filter(file => file.endsWith('.md') ||
file.endsWith('.json') ||
file.endsWith('.yaml')).length;
}
catch {
return 0;
}
}
async getElementsList(elementType) {
const elementTypeEnum = normalizeElementType(elementType);
if (!elementTypeEnum) {
throw new Error(`Invalid element type: '${elementType}'. Valid types are: ${formatElementTypesList()}`);
}
const dirPath = this.portfolioManager.getElementDir(elementTypeEnum);
try {
const exists = await this.fileOperations.exists(dirPath);
if (!exists) {
logger.debug(`[PortfolioHandler] Element directory doesn't exist yet: ${dirPath}`);
return [];
}
const files = await this.fileOperations.listDirectory(dirPath);
return files
.filter(file => file.endsWith('.md') || file.endsWith('.json') || file.endsWith('.yaml'))
.map(file => file.replace(/\.(md|json|yaml)$/i, ''));
}
catch (error) {
if (error.code === 'ENOENT') {
logger.debug(`[PortfolioHandler] Element directory doesn't exist yet: ${dirPath}`);
return [];
}
logger.warn('Error in getElementsList', {
elementType,
error: error.message,
code: error.code,
});
throw new Error(error.code === 'ENOENT'
? `Element directory not found for type '${elementType}'. Directory may not exist yet.`
: `Failed to read elements directory for type '${elementType}': ${error.message || 'Unknown file system error'}`);
}
}
async loadElementByType(elementName, elementType) {
const sanitizedName = path.basename(elementName);
const elementTypeEnum = normalizeElementType(elementType);
if (!elementTypeEnum) {
throw new Error(`Invalid element type: '${elementType}'. Valid types are: ${formatElementTypesList()}`);
}
const dirPath = this.portfolioManager.getElementDir(elementTypeEnum);
const extensions = ['.md', '.json', '.yaml', '.yml'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
let content = null;
let foundFile = null;
for (const ext of extensions) {
const filePath = path.join(dirPath, `${sanitizedName}${ext}`);
try {
const stats = await this.fileOperations.stat(filePath);
if (stats.size > MAX_FILE_SIZE) {
throw new Error(`File size exceeds limit of 10MB: ${stats.size} bytes`);
}
content = await this.fileOperations.readFile(filePath, {
encoding: 'utf-8',
source: 'PortfolioHandler.loadElementByType'
});
foundFile = filePath;
break;
}
catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
}
if (!foundFile) {
throw Object.assign(new Error('File not found'), { code: 'ENOENT' });
}
return {
id: `${elementType}_${sanitizedName}_${Date.now()}`,
type: elementTypeEnum,
version: '1.0.0',
metadata: {
name: sanitizedName,
description: `Loaded from ${path.basename(foundFile)}`,
author: 'unknown',
created: new Date().toISOString(),
modified: new Date().toISOString(),
tags: [],
},
validate: () => ({
isValid: true,
errors: [],
warnings: [],
}),
serialize: () => content || '',
deserialize: () => { },
getStatus: () => ({ status: 'active' }),
content: content || '',
filename: path.basename(foundFile),
};
}
async portfolioStatus(username) {
try {
// FIX: DMCP-SEC-006 - Add security audit logging for portfolio access
SecurityMonitor.logSecurityEvent({
type: 'PORTFOLIO_FETCH_SUCCESS',
severity: 'LOW',
source: 'PortfolioHandler.portfolioStatus',
details: `Portfolio status check for ${username || 'current user'}`
});
// Validate username parameter if provided
if (username && typeof username === 'string') {
try {
validateUsername(username);
}
catch (error) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Invalid username: ${error instanceof Error ? error.message : 'Validation failed'}`
}]
};
}
}
// Get current user if username not provided
let targetUsername = username;
if (!targetUsername) {
const authStatus = await this.githubAuthManager.getAuthStatus();
if (!authStatus.isAuthenticated || !authStatus.username) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā **GitHub Authentication Required**\n\n` +
`š **Quick Setup**:\n` +
`1. Run: \`setup_github_auth\` to authenticate\n` +
`2. Or use: \`gh auth login --web\` if you have GitHub CLI\n\n` +
`š **What this enables**:\n` +
`⢠Upload elements to your GitHub portfolio\n` +
`⢠Sync your local portfolio with GitHub\n` +
`⢠Share elements with the community\n\n` +
`š **Need help?** Visit: https://docs.anthropic.com/en/docs/claude-code/oauth-setup`
}]
};
}
targetUsername = authStatus.username;
}
// Check if portfolio exists
const portfolioExists = await this.portfolioRepoManager.checkPortfolioExists(targetUsername);
let statusText = `${this.indicatorService.getPersonaIndicator()}š **Portfolio Status for ${targetUsername}**\n\n`;
if (portfolioExists) {
statusText += `ā
**Repository**: ${this.portfolioRepoManager.getRepositoryName()} exists\n`;
statusText += `š **URL**: https://github.com/${targetUsername}/${this.portfolioRepoManager.getRepositoryName()}\n\n`;
// Get local elements count
const personasPath = this.portfolioManager.getElementDir(ElementType.PERSONA);
const skillsPath = this.portfolioManager.getElementDir(ElementType.SKILL);
const templatesPath = this.portfolioManager.getElementDir(ElementType.TEMPLATE);
const agentsPath = this.portfolioManager.getElementDir(ElementType.AGENT);
const memoriesPath = this.portfolioManager.getElementDir(ElementType.MEMORY);
const ensemblesPath = this.portfolioManager.getElementDir(ElementType.ENSEMBLE);
const [personas, skills, templates, agents, memories, ensembles] = await Promise.all([
this.countElementsInDir(personasPath),
this.countElementsInDir(skillsPath),
this.countElementsInDir(templatesPath),
this.countElementsInDir(agentsPath),
this.countElementsInDir(memoriesPath),
this.countElementsInDir(ensemblesPath)
]);
const totalElements = personas + skills + templates + agents + memories + ensembles;
statusText += `š **Local Elements**:\n`;
statusText += ` ⢠Personas: ${personas}\n`;
statusText += ` ⢠Skills: ${skills}\n`;
statusText += ` ⢠Templates: ${templates}\n`;
statusText += ` ⢠Agents: ${agents}\n`;
statusText += ` ⢠Memories: ${memories}\n`;
statusText += ` ⢠Ensembles: ${ensembles}\n`;
statusText += ` ⢠**Total**: ${totalElements}\n\n`;
statusText += `š **Sync Status**: Use sync_portfolio to update GitHub\n`;
}
else {
statusText += `ā **Repository**: No portfolio found\n`;
statusText += `š” **Next Step**: Use init_portfolio to create one\n\n`;
statusText += `š **What you'll get**:\n`;
statusText += ` ⢠GitHub repository for your elements\n`;
statusText += ` ⢠Organized folder structure\n`;
statusText += ` ⢠README with usage instructions\n`;
statusText += ` ⢠Easy sharing and backup\n`;
}
return {
content: [{
type: "text",
text: statusText
}]
};
}
catch (error) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Failed to check portfolio status: ${SecureErrorHandler.sanitizeError(error).message}`
}]
};
}
}
async initPortfolio(_options) {
try {
// Check authentication
const authStatus = await this.githubAuthManager.getAuthStatus();
if (!authStatus.isAuthenticated || !authStatus.username) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā **GitHub Authentication Required**\n\n` +
`š **Quick Setup**:\n` +
`1. Run: \`setup_github_auth\` to authenticate\n` +
`2. Or use: \`gh auth login --web\` if you have GitHub CLI\n\n` +
`š **What this enables**:\n` +
`⢠Upload elements to your GitHub portfolio\n` +
`⢠Sync your local portfolio with GitHub\n` +
`⢠Share elements with the community\n\n` +
`š **Need help?** Visit: https://docs.anthropic.com/en/docs/claude-code/oauth-setup`
}]
};
}
const username = authStatus.username;
// Check if portfolio already exists
const portfolioExists = await this.portfolioRepoManager.checkPortfolioExists(username);
if (portfolioExists) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā
Portfolio already exists at https://github.com/${username}/${this.portfolioRepoManager.getRepositoryName()}\n\nUse portfolio_status to see details or sync_portfolio to update it.`
}]
};
}
// Create portfolio with explicit consent
await this.portfolioRepoManager.createPortfolio(username, true);
// FIX: DMCP-SEC-006 - Add security audit logging for portfolio initialization
SecurityMonitor.logSecurityEvent({
type: 'PORTFOLIO_INITIALIZATION',
severity: 'LOW',
source: 'PortfolioHandler.initPortfolio',
details: `Portfolio created for user ${username}`,
additionalData: { username }
});
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}š **Portfolio Created Successfully!**\n\n` +
`ā
**Repository**: https://github.com/${username}/${this.portfolioRepoManager.getRepositoryName()}\n` +
`š **Structure**: Organized folders for all element types\n` +
`š **README**: Usage instructions included\n` +
`š **Next Step**: Use sync_portfolio to upload your elements\n\n` +
`Your portfolio is ready for sharing your DollhouseMCP creations!`
}]
};
}
catch (error) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Failed to initialize portfolio: ${SecureErrorHandler.sanitizeError(error).message}`
}]
};
}
}
async portfolioConfig(options) {
try {
// FIX: DMCP-SEC-006 - Add security audit logging for configuration changes
SecurityMonitor.logSecurityEvent({
type: 'CONFIG_UPDATED',
severity: 'LOW',
source: 'PortfolioHandler.portfolioConfig',
details: `Portfolio configuration updated: ${Object.keys(options).join(', ')}`,
additionalData: {
autoSync: options.autoSync,
defaultVisibility: options.defaultVisibility,
autoSubmit: options.autoSubmit,
repositoryName: options.repositoryName,
}
});
const configManager = this.configManager;
await configManager.initialize();
let statusText = `${this.indicatorService.getPersonaIndicator()}āļø **Portfolio Configuration**\n\n`;
// Update settings if provided
if (options.autoSync !== undefined) {
// This would be implemented when auto-sync feature is added
statusText += `š Auto-sync: ${options.autoSync ? 'Enabled' : 'Disabled'} (Coming soon)\n`;
}
if (options.defaultVisibility) {
statusText += `š Default visibility: ${options.defaultVisibility}\n`;
}
if (options.autoSubmit !== undefined) {
// Set the environment variable for auto-submit
if (options.autoSubmit) {
process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION = 'true';
}
else {
delete process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION;
}
statusText += `š¤ Auto-submit to collection: ${options.autoSubmit ? 'Enabled' : 'Disabled'}\n`;
}
if (options.repositoryName) {
statusText += `š Repository name: ${options.repositoryName} (Custom names coming soon)\n`;
}
// Show current configuration
statusText += `\nš **Current Settings**:\n`;
const autoSubmitEnabled = process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION === 'true';
statusText += ` ⢠Auto-submit: ${autoSubmitEnabled ? 'Enabled' : 'Disabled'}\n`;
statusText += ` ⢠Repository name: ${getPortfolioRepositoryName()}\n`;
statusText += ` ⢠Default visibility: public\n`;
return {
content: [{
type: "text",
text: statusText
}],
data: {
config: {
autoSubmit: process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION === 'true',
repositoryName: getPortfolioRepositoryName(),
defaultVisibility: 'public'
}
}
};
}
catch (error) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Failed to configure portfolio: ${SecureErrorHandler.sanitizeError(error).message}`
}]
};
}
}
async syncPortfolio(options) {
try {
// Check authentication
const authStatus = await this.githubAuthManager.getAuthStatus();
if (!authStatus.isAuthenticated || !authStatus.username) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā **GitHub Authentication Required**\n\n` +
`š **Quick Setup**:\n` +
`1. Run: \`setup_github_auth\` to authenticate\n` +
`2. Or use: \`gh auth login --web\` if you have GitHub CLI\n\n` +
`š **What this enables**:\n` +
`⢠Upload elements to your GitHub portfolio\n` +
`⢠Sync your local portfolio with GitHub\n` +
`⢠Share elements with the community\n\n` +
`š **Need help?** Visit: https://docs.anthropic.com/en/docs/claude-code/oauth-setup`
}]
};
}
const username = authStatus.username;
// Check if portfolio exists (PortfolioRepoManager is injected with TokenManager)
const portfolioExists = await this.portfolioRepoManager.checkPortfolioExists(username);
if (!portfolioExists) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā **No Portfolio Repository Found**\n\n` +
`š **Quick Setup**:\n` +
`1. Run: \`init_portfolio\` to create your GitHub portfolio\n` +
`2. This creates: https://github.com/[username]/${this.portfolioRepoManager.getRepositoryName()}\n\n` +
`š **What you'll get**:\n` +
`⢠Public repository to showcase your AI elements\n` +
`⢠Organized structure for personas, skills, templates, and agents\n` +
`⢠Automatic syncing of your local portfolio\n` +
`⢠Community sharing capabilities\n\n` +
`š **After setup**: Use \`sync_portfolio\` to upload your content!`
}]
};
}
if (options.dryRun) {
// Show what would be synced
const elementTypeCounts = {};
const elementTypeErrors = [];
// Get element counts with better error handling
for (const elementType of ['personas', 'skills', 'templates', 'agents']) {
try {
const elements = await this.getElementsList(elementType);
elementTypeCounts[elementType] = elements.length;
}
catch (error) {
elementTypeCounts[elementType] = 'ERROR';
elementTypeErrors.push(`${elementType}: ${error.message || 'Unknown error'}`);
}
}
let dryRunText = `${this.indicatorService.getPersonaIndicator()}š **Dry Run - Portfolio Sync Preview**\n\n`;
dryRunText += `š¤ **Elements to sync** (${options.direction}):\n`;
dryRunText += ` ⢠Personas: ${elementTypeCounts.personas}\n`;
dryRunText += ` ⢠Skills: ${elementTypeCounts.skills}\n`;
dryRunText += ` ⢠Templates: ${elementTypeCounts.templates}\n`;
dryRunText += ` ⢠Agents: ${elementTypeCounts.agents}\n\n`;
// Include any errors encountered during dry run
if (elementTypeErrors.length > 0) {
dryRunText += `ā ļø **Errors found during preview:**\n`;
for (const error of elementTypeErrors) {
dryRunText += ` ⢠${error}\n`;
}
dryRunText += `\n`;
}
dryRunText += `šÆ **Target**: https://github.com/${username}/${this.portfolioRepoManager.getRepositoryName()}\n`;
dryRunText += `ā ļø **Note**: This is a preview. Remove dry_run=true to perform actual sync.`;
return {
content: [{
type: "text",
text: dryRunText
}]
};
}
// For now, implement basic push functionality
if (options.direction === 'push' || options.direction === 'both') {
let syncCount = 0;
let totalElements = 0;
let syncText = `${this.indicatorService.getPersonaIndicator()}š **Syncing Portfolio...**\n\n`;
// UX IMPROVEMENT: Calculate total elements for progress tracking
const elementTypes = ['personas', 'skills', 'templates', 'agents'];
const elementCounts = {};
const failedElements = [];
// Pre-calculate totals for better progress indicators
try {
syncText += `š **Calculating sync scope...**\n`;
for (const elementType of elementTypes) {
try {
const elements = await this.getElementsList(elementType);
elementCounts[elementType] = elements.length;
totalElements += elements.length;
}
catch (error) {
elementCounts[elementType] = 0;
logger.warn(`Failed to count ${elementType}`, { error: error.message });
}
}
syncText += `\nšÆ **Ready to sync ${totalElements} elements:**\n`;
for (const [type, count] of Object.entries(elementCounts)) {
const icon = count > 0 ? 'ā
' : 'āŖ';
syncText += ` ${icon} ${type}: ${count} elements\n`;
}
syncText += `\nš **Starting sync process...**\n\n`;
}
catch (error) {
syncText += `\nā ļø **Warning**: Could not calculate sync scope: ${error.message}\n\n`;
}
// UX IMPROVEMENT: Process each element type with progress tracking
for (const elementType of elementTypes) {
const typeCount = elementCounts[elementType] || 0;
if (typeCount === 0) {
syncText += `ā© **Skipping ${elementType}** (no elements found)\n`;
continue;
}
syncText += `š **Processing ${elementType}** (${typeCount} elements):\n`;
let typeSuccessCount = 0;
try {
const elements = await this.getElementsList(elementType);
for (let i = 0; i < elements.length; i++) {
const elementName = elements[i];
const progress = `[${i + 1}/${elements.length}]`;
try {
// UX IMPROVEMENT: Show individual element progress
syncText += ` ${progress} š Syncing \"${elementName}\"...`;
// Load element and save to portfolio
const element = await this.loadElementByType(elementName, elementType);
if (element) {
await this.portfolioRepoManager.saveElement(element, true); // Explicit consent
syncCount++;
typeSuccessCount++;
syncText += ` ā
\n`;
logger.debug(`Successfully synced ${elementType}/${elementName}`);
}
else {
syncText += ` ā (null element)\n`;
failedElements.push({
type: elementType,
name: elementName,
error: 'Element loaded as null/undefined'
});
}
}
catch (elementError) {
// Extract error code if present
const errorCode = elementError.code || (elementError.message?.match(/([A-Z_]+_\d+)/)?.[1]) || '';
const errorMessage = elementError.message || 'Unknown error during element sync';
// Clean up error message for display (remove code if already extracted)
const displayMessage = errorCode
? errorMessage.replace(/([A-Z_]+_\d+)\s*/, '')
: errorMessage;
// Show error code in output for better diagnostics
const errorOutput = errorCode
? `${errorCode}: ${displayMessage}`
: displayMessage;
syncText += ` ā (${errorOutput})\n`;
failedElements.push({
type: elementType,
name: elementName,
error: errorOutput
});
logger.warn(`Failed to sync ${elementType}/${elementName}`, {
error: errorMessage,
errorCode,
elementName,
elementType
});
}
}
// UX IMPROVEMENT: Show completion summary for each type
const successRate = elements.length > 0 ? Math.round((typeSuccessCount / elements.length) * 100) : 0;
const statusIcon = successRate === 100 ? 'š' : successRate > 50 ? 'ā ļø' : 'ā';
syncText += ` ${statusIcon} **${elementType} complete**: ${typeSuccessCount}/${elements.length} synced (${successRate}%)\n\n`;
}
catch (listError) {
// UX IMPROVEMENT: Better error reporting for list failures
const errorMessage = listError.message || 'Failed to get elements list';
syncText += ` ā **Failed to list ${elementType}**: ${errorMessage}\n\n`;
failedElements.push({
type: elementType,
name: 'ALL',
error: `Failed to list ${elementType}: ${errorMessage}`
});
logger.warn(`Failed to get ${elementType} list`, { error: errorMessage });
}
}
// UX IMPROVEMENT: Enhanced final summary with actionable insights
const successRate = totalElements > 0 ? Math.round((syncCount / totalElements) * 100) : 0;
const summaryIcon = successRate === 100 ? 'š' : successRate >= 80 ? 'ā
' : successRate >= 50 ? 'ā ļø' : 'ā';
syncText += `${summaryIcon} **Sync Complete!**\n`;
syncText += `š **Overall Results**: ${syncCount}/${totalElements} elements synced (${successRate}%)\n`;
syncText += `š **Portfolio**: https://github.com/${username}/${this.portfolioRepoManager.getRepositoryName()}\n\n`;
// Include failed elements information with actionable suggestions
if (failedElements.length > 0) {
syncText += `ā ļø **Issues Encountered** (${failedElements.length} problems):\n\n`;
// Group failures by type for better organization
const failuresByType = {};
for (const failed of failedElements) {
if (!failuresByType[failed.type]) {
failuresByType[failed.type] = [];
}
failuresByType[failed.type].push({ name: failed.name, error: failed.error });
}
for (const [type, failures] of Object.entries(failuresByType)) {
syncText += `š **${type}** (${failures.length} issues):\n`;
for (const failure of failures) {
if (failure.name === 'ALL') {
syncText += ` ā ${failure.error}\n`;
}
else {
syncText += ` ā \"${failure.name}\": ${failure.error}\n`;
}
}
syncText += `\n`;
}
// UX IMPROVEMENT: Add helpful suggestions based on error codes found
syncText += `š” **Troubleshooting Tips**:\n`;
// Check for specific error codes and provide targeted advice
const errorCodes = failedElements.map(f => f.error.match(/^([A-Z_]+_\d+):/)?.[1]).filter(Boolean);
const uniqueErrorCodes = [...new Set(errorCodes)];
if (uniqueErrorCodes.includes('PORTFOLIO_SYNC_001')) {
syncText += ` ⢠š **Auth Error**: Run \`setup_github_auth\` to re-authenticate\n`;
}
if (uniqueErrorCodes.includes('PORTFOLIO_SYNC_002')) {
syncText += ` ⢠š **Repo Missing**: Run \`init_portfolio\` to create your repository\n`;
}
if (uniqueErrorCodes.includes('PORTFOLIO_SYNC_004')) {
syncText += ` ⢠š§ **API Error**: GitHub response format issue - please report this bug\n`;
}
if (uniqueErrorCodes.includes('PORTFOLIO_SYNC_006')) {
syncText += ` ⢠Ⳡ**Rate Limited**: Wait a few minutes and try again\n`;
}
// General tips
syncText += ` ⢠Check element file formats and metadata\n`;
syncText += ` ⢠Try syncing individual elements with \`portfolio_element_manager\` (upload operation)\n`;
syncText += ` ⢠Use \`sync_portfolio\` with \`dry_run=true\` to preview issues\n\n`;
// Add error code legend if we found any
if (uniqueErrorCodes.length > 0) {
syncText += `š **Error Codes Detected**:\n`;
for (const code of uniqueErrorCodes) {
const errorDescriptions = {
'PORTFOLIO_SYNC_001': 'Authentication failure',
'PORTFOLIO_SYNC_002': 'Repository not found',
'PORTFOLIO_SYNC_003': 'File creation failed',
'PORTFOLIO_SYNC_004': 'API response parsing error',
'PORTFOLIO_SYNC_005': 'Network error',
'PORTFOLIO_SYNC_006': 'Rate limit exceeded'
};
const description = errorDescriptions[code] || 'Unknown error';
syncText += ` ⢠${code}: ${description}\n`;
}
syncText += `\n`;
}
}
else {
syncText += `š **Perfect Sync!** All elements uploaded successfully!\n\n`;
}
// UX IMPROVEMENT: Add next steps and helpful links
if (syncCount > 0) {
syncText += `š **Next Steps**:\n`;
syncText += ` ⢠View your portfolio: https://github.com/${username}/${this.portfolioRepoManager.getRepositoryName()}\n`;
syncText += ` ⢠Share individual elements using \`submit_collection_content <name>\`\n`;
syncText += ` ⢠Keep portfolio updated with \`sync_portfolio\` regularly\n\n`;
}
syncText += `Your elements are now available on GitHub!`;
return {
content: [{
type: "text",
text: syncText
}]
};
}
if (options.direction === 'pull' || options.direction === 'both') {
return this.portfolioPullHandler.executePull(options, this.indicatorService.getPersonaIndicator());
}
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Invalid sync direction. Use 'push', 'pull', or 'both'.`
}]
};
}
catch (error) {
// IMPROVED ERROR HANDLING: Ensure we always have a meaningful error message
const sanitizedError = SecureErrorHandler.sanitizeError(error);
const errorMessage = sanitizedError?.message || error?.message || String(error) || 'Unknown error occurred';
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Failed to sync portfolio: ${errorMessage}`
}]
};
}
}
async searchPortfolio(options) {
try {
// Validate the query parameter
if (!options.query || typeof options.query !== 'string' || options.query.trim().length === 0) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Search query is required and must be a non-empty string.`
}]
};
}
// Parse element type if provided (Issue #433: accept singular forms)
let elementType;
if (options.elementType) {
const normalized = normalizeElementType(options.elementType);
if (!normalized) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Invalid element type '${options.elementType}'. Valid types: ${formatElementTypesList()}`
}]
};
}
elementType = normalized;
}
// Build search options
const searchOptions = {
elementType,
fuzzyMatch: options.fuzzyMatch !== false, // Default to true
maxResults: options.maxResults || 20,
includeKeywords: options.includeKeywords !== false,
includeTags: options.includeTags !== false,
includeTriggers: options.includeTriggers !== false,
includeDescriptions: options.includeDescriptions !== false
};
// Perform the search
const results = await this.portfolioIndexManager.search(options.query, searchOptions);
// Format the results
let text = `${this.indicatorService.getPersonaIndicator()}š **Portfolio Search Results**\n\n`;
text += `**Query**: \"${options.query}\"\n`;
if (elementType) {
text += `**Type Filter**: ${elementType}\n`;
}
text += `**Found**: ${results.length} element${results.length === 1 ? '' : 's'}\n\n`;
if (results.length === 0) {
text += `No elements found matching your search criteria.\n\n`;
text += `**Tips for better results:**\n`;
text += `⢠Try different keywords or partial names\n`;
text += `⢠Remove the type filter to search all element types\n`;
text += `⢠Check spelling and try synonyms\n`;
text += `⢠Use the list_elements tool to see all available content`;
}
else {
text += `**Results:**\n\n`;
for (const result of results) {
const { entry, matchType } = result;
const icon = getElementIcon(entry.elementType);
text += `${icon} **${entry.metadata.name}**\n`;
text += ` š Type: ${entry.elementType}\n`;
text += ` šÆ Match: ${matchType}\n`;
if (entry.metadata.description) {
const desc = entry.metadata.description.length > 100
? entry.metadata.description.substring(0, 100) + '...'
: entry.metadata.description;
text += ` š ${desc}\n`;
}
if (entry.metadata.tags && entry.metadata.tags.length > 0) {
text += ` š·ļø Tags: ${entry.metadata.tags.slice(0, 5).join(', ')}${entry.metadata.tags.length > 5 ? '...' : ''}\n`;
}
// FIX (#1213): Use correct file extension based on element type
// Previously: Hardcoded .md for all types (wrong for memories which are .yaml)
// Now: Get correct extension from PortfolioManager
const fileExtension = this.portfolioManager.getFileExtension(entry.elementType);
text += ` š File: ${entry.filename}${fileExtension}\n\n`;
}
if (results.length >= searchOptions.maxResults) {
text += `ā ļø Results limited to ${searchOptions.maxResults}. Refine your search for more specific results.\n\n`;
}
text += `š” **Next steps:**\n`;
text += `⢠Use get_element_details to see full content\n`;
text += `⢠Use activate_element to activate elements\n`;
text += `⢠Use submit_collection_content to share with the community`;
}
return {
content: [{
type: "text",
text
}]
};
}
catch (error) {
ErrorHandler.logError('PortfolioHandler.searchPortfolio', error, {
query: options.query,
elementType: options.elementType
});
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Search failed: ${SecureErrorHandler.sanitizeError(error).message}`
}]
};
}
}
async searchAll(options) {
try {
// Validate the query parameter
if (!options.query || typeof options.query !== 'string' || options.query.trim().length === 0) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Search query is required and must be a non-empty string.`
}]
};
}
// Parse element type if provided (Issue #433: accept singular forms)
let elementType;
if (options.elementType) {
const normalized = normalizeElementType(options.elementType);
if (!normalized) {
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Invalid element type '${options.elementType}'. Valid types: ${formatElementTypesList()}`
}]
};
}
elementType = normalized;
}
// Parse sources (default to local and github)
const sources = options.sources || ['local', 'github'];
const includeLocal = sources.includes('local');
const includeGitHub = sources.includes('github');
const includeCollection = sources.includes('collection');
// Build search options
const searchOptions = {
query: options.query,
includeLocal,
includeGitHub,
includeCollection,
elementType,
page: options.page || 1,
pageSize: options.pageSize || 20,
sortBy: options.sortBy || 'relevance'
};
// Perform the unified search
const results = await this.unifiedIndexManager.search(searchOptions);
// Format the results
let text = `${this.indicatorService.getPersonaIndicator()}š **Unified Search Results**\n\n`;
text += `**Query**: \"${options.query}\"\n`;
text += `**Sources**: ${sources.join(', ')}
`;
if (elementType) {
text += `**Type Filter**: ${elementType}\n`;
}
text += `**Found**: ${results.length} element${results.length === 1 ? '' : 's'}\n\n`;
if (results.length === 0) {
text += `No elements found matching your search criteria.\n\n`;
text += `**Tips for better results:**\n`;
text += `⢠Try different keywords or partial names\n`;
text += `⢠Remove the type filter to search all element types\n`;
text += `⢠Include more sources: local, github, collection\n`;
text += `⢠Check spelling and try synonyms\n`;
text += `⢠Use browse_collection to explore available content`;
}
else {
text += `**Results:**\n\n`;
for (const result of results) {
const { entry, source, matchType, score, isDuplicate, versionConflict } = result;
const icon = getElementIcon(entry.elementType);
const sourceIcon = getSourceIcon(source);
text += `${icon} **${entry.name}** ${sourceIcon}\n`;
text += ` š Type: ${entry.elementType} | Source: ${source}\n`;
text += ` šÆ Match: ${matchType} | Score: ${score.toFixed(2)}
`;
if (entry.description) {
const desc = entry.description.length > 100
? entry.description.substring(0, 100) + '...'
: entry.description;
text += ` š ${desc}\n`;
}
if (entry.version) {
text += ` š·ļø Version: ${entry.version}\n`;
}
// Show duplicate information
if (isDuplicate) {
text += ` ā ļø **Duplicate detected across sources**\n`;
if (versionConflict) {
text += ` š Version conflict - Recommended: ${versionConflict.recommended} (${versionConflict.reason})\n`;
}
}
text += `\n`;
}
const hasMore = results.length >= searchOptions.pageSize;
if (hasMore) {
const nextPage = searchOptions.page + 1;
text += `ā ļø Results limited to ${searchOptions.pageSize}. Use page=${nextPage} for more results.\n\n`;
}
text += `š” **Next steps:**\n`;
text += `⢠Use get_element_details to see full content\n`;
text += `⢠Use install_collection_content for collection items\n`;
text += `⢠Use activate_element for local elements\n`;
text += `⢠Check for duplicates before submitting new content`;
}
return {
content: [{
type: "text",
text
}]
};
}
catch (error) {
ErrorHandler.logError('PortfolioHandler.searchAll', error, {
query: options.query,
sources: options.sources,
elementType: options.elementType
});
return {
content: [{
type: "text",
text: `${this.indicatorService.getPersonaIndicator()}ā Unified search failed: ${SecureErrorHandler.sanitizeError(error).message}`
}]
};
}
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiUG9ydGZvbGlvSGFuZGxlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9oYW5kbGVycy9Qb3J0Zm9saW9IYW5kbGVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUVBLE9BQU8sRUFBb0IsV0FBVyxFQUFFLE1BQU0sa0NBQWtDLENBQUM7QUFFakYsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sNkJBQTZCLENBQUM7QUFDakUsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0sK0JBQStCLENBQUM7QUFFakUsT0FBTyxFQUFFLDBCQUEwQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFLMUUsT0FBTyxFQUFFLE1BQU0sRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBQzVDLE9BQU8sRUFBRSxZQUFZLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQztBQUN4RCxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUM3QixPQUFPLEVBQUUsY0FBYyxFQUFFLGFBQWEsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBR2xFLE9BQU8s