UNPKG

@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.

1,183 lines (1,058 loc) 105 kB
/** * DollhouseMCP Collection Browser * * Functionality-first client-side app. No framework, no build step. * Fetches collection-index.json at runtime, renders dynamically. * All presentation is delegated to styles.css. * * Design hooks: class names follow BEM-ish conventions. * JS hooks: data-* attributes. Never use JS-hook classes for styling. */ /** * Client-side YAML parser wrapper with safety hardening. * * SECURITY NOTE: This runs in the browser, not the server. SecureYamlParser * is a Node.js module and cannot be used in browser context. Instead we * harden js-yaml's load() with: * - Explicit CORE_SCHEMA (safe schema — standard types only, no custom * types, no binary, no merge keys. Matches SecureYamlParser behavior) * - Size limit to prevent YAML bomb / amplification attacks * - Error swallowing (malformed YAML returns null, never throws) * * The data being parsed is served from our own localhost server or fetched * from the GitHub collection API — both trusted sources. */ const YAML_MAX_SIZE = 1024 * 512; // 512KB — generous but bounded function safeParseYaml(content) { if (!globalThis.jsyaml) return null; if (typeof content !== 'string' || content.length > YAML_MAX_SIZE) return null; try { return globalThis.jsyaml.load(content, { schema: globalThis.jsyaml.CORE_SCHEMA }) || null; } catch { return null; } } globalThis.DollhouseConsoleUI = globalThis.DollhouseConsoleUI || {}; /** * Show or update a visible error banner within a tab panel. * * Creates the banner lazily on first use, then reuses it for later updates. * * @param {string} targetId - DOM id of the tab panel or container that owns the banner * @param {string} bannerId - Stable DOM id for the banner element * @param {string} message - User-visible message to render inside the banner */ globalThis.DollhouseConsoleUI.showBanner = function(targetId, bannerId, message) { const target = document.getElementById(targetId); if (!target) return; let banner = document.getElementById(bannerId); if (!banner) { banner = document.createElement('div'); banner.id = bannerId; banner.className = 'tab-error-banner'; target.prepend(banner); } banner.textContent = message; banner.hidden = false; }; /** * Hide an existing tab-level error banner without removing its DOM node. * * @param {string} bannerId - Stable DOM id for the banner element */ globalThis.DollhouseConsoleUI.clearBanner = function(bannerId) { const banner = document.getElementById(bannerId); if (banner) banner.hidden = true; }; (() => { const REPO = 'DollhouseMCP/collection'; const BRANCH = 'main'; // Portfolio web UI always fetches collection content from GitHub const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}`; const GITHUB_BASE = `https://github.com/${REPO}/blob/${BRANCH}`; // ── Constants ────────────────────────────────────────────────────────────── const BRANCH_CHECK_CONCURRENCY = 8; // max parallel HEAD requests const SEARCH_DEBOUNCE_MS = 150; // ms delay before search fires const PORTFOLIO_MAX_DEPTH = 3; // max directory recursion depth const FILE_READ_CONCURRENCY = 20; // parallel file reads for portfolio loading const PAGE_SIZE = 50; // cards per page // ── State ────────────────────────────────────────────────────────────────── let collectionElements = []; // from collection-index.json let localElements = []; // loaded from local portfolio (~/.dollhouse/portfolio/) let allElements = []; // collectionElements + localElements let filteredElements = []; // currently displayed after search + type filter let currentPage = 1; // pagination — reset on every filter/search change let activeTypes = new Set(); // empty = show all; multi-select let openElementIndex = -1; // index of currently open modal element in filteredElements let modalShowRaw = false; // sticky raw/rendered toggle — persists across prev/next navigation let activeTopic = 'all'; let highlightedCardIndex = -1; // keyboard-highlighted card in the grid // Normalize plural index keys → singular CSS/display type names const SINGULAR_TYPE = { agents: 'agent', personas: 'persona', skills: 'skill', templates: 'template', memories: 'memory', ensembles: 'ensemble', prompts: 'prompt', tools: 'tool', }; let activeSort = 'date-desc'; let activeSource = 'all'; // 'all' | 'collection' | 'portfolio' let searchQuery = ''; const DOLLHOUSE_SERVER_VERSION = document.querySelector('meta[name="dollhouse-server-version"]')?.content || ''; const DOLLHOUSE_SESSION_ID = document.querySelector('meta[name="dollhouse-session-id"]')?.content || ''; const DOLLHOUSE_RUNTIME_SESSION_ID = document.querySelector('meta[name="dollhouse-runtime-session-id"]')?.content || ''; function escHtml(value) { return String(value) .replaceAll('&', '&amp;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;') .replaceAll('"', '&quot;') .replaceAll("'", '&#39;'); } function renderHeaderStatsMarkup(primaryMarkup) { const sessionTitle = DOLLHOUSE_RUNTIME_SESSION_ID && DOLLHOUSE_RUNTIME_SESSION_ID !== DOLLHOUSE_SESSION_ID ? `Stable session ${DOLLHOUSE_SESSION_ID}; runtime ${DOLLHOUSE_RUNTIME_SESSION_ID}` : `Stable session ${DOLLHOUSE_SESSION_ID}`; const sessionMarkup = DOLLHOUSE_SESSION_ID ? ` <span class="stat stat--session" title="${escHtml(sessionTitle)}"> <strong>${escHtml(DOLLHOUSE_SESSION_ID)}</strong> session </span>` : ''; return `${primaryMarkup}${sessionMarkup}`; } function updateFooterVersion() { const footerVersion = document.getElementById('footer-version'); if (!footerVersion) return; footerVersion.textContent = `Version: ${DOLLHOUSE_SERVER_VERSION || 'unknown'}`; } // ── Bootstrap ────────────────────────────────────────────────────────────── function mergeCollectionData(data) { globalThis.DollhouseConsoleUI?.clearBanner?.('collection-error-banner'); const CANONICAL_TYPES = new Set(['agents','personas','skills','templates','memories','ensembles']); collectionElements = Object.entries(data.index) .filter(([type]) => CANONICAL_TYPES.has(type)) .flatMap(([type, elements]) => elements.map(el => ({ ...el, type: SINGULAR_TYPE[type] || type })) ); allElements = [...localElements, ...collectionElements]; renderTypeFilters(); renderTopicFilters(); applyFilters(); const statsEl = document.getElementById('stats'); if (statsEl) statsEl.innerHTML = renderHeaderStatsMarkup(` <span class="stat"><strong>${localElements.length}</strong> portfolio</span> <span class="stat"><strong>${collectionElements.length}</strong> collection</span> `); } async function init() { try { showGridMessage('loading', 'Loading portfolio…'); // Load portfolio from local API const portfolioRes = await DollhouseAuth.apiFetch('/api/elements'); if (!portfolioRes.ok) throw new Error(`HTTP ${portfolioRes.status} fetching portfolio`); const portfolioData = await portfolioRes.json(); localElements = Object.entries(portfolioData.elements).flatMap(([type, elements]) => elements.map(el => ({ ...el, type: SINGULAR_TYPE[type] || type, _local: true, path: `${type}/${el.filename || el.name}`, })) ); allElements = [...localElements]; renderStats({ total_elements: portfolioData.totalCount, index: portfolioData.elements }); renderTypeFilters(); renderTopicFilters(); applyFilters(); // Load community collection (non-blocking — portfolio shows immediately) DollhouseAuth.apiFetch('/api/collection') .then(r => r.ok ? r.json() : Promise.reject(new Error('collection request failed'))) .then(mergeCollectionData) .catch((err) => { console.warn('[App] Collection fetch unavailable:', err); globalThis.DollhouseConsoleUI?.showBanner?.( 'tab-portfolio', 'collection-error-banner', 'Community collection unavailable — showing local portfolio only.' ); }); const updated = document.getElementById('footer-updated'); if (updated) { updated.textContent = `Portfolio: ${localElements.length} elements`; } } catch (err) { showGridMessage('error', `Could not load portfolio: ${err.message}`); console.error('[DollhouseMCP]', { error: err.message, context: 'portfolioLoad', timestamp: new Date().toISOString(), }); } } // ── Branch availability check ────────────────────────────────────────────── async function checkBranchAvailability() { // Probe each element's path; mark unavailable ones so the grid can show them dimmed. // Uses HEAD requests in parallel, capped at 8 concurrent to avoid rate limits. const CONCURRENCY = BRANCH_CHECK_CONCURRENCY; const queue = allElements.filter(el => !el._local); let dirty = false; async function probe(el) { try { const res = await fetch(`${RAW_BASE}/${el.path}`, { method: 'HEAD' }); if (!res.ok) { el._unavailable = true; dirty = true; } } catch { el._unavailable = true; dirty = true; } } while (queue.length) { await Promise.all(queue.splice(0, CONCURRENCY).map(probe)); } if (dirty) renderResults(); // re-render with unavailable badges applied } // ── Stats bar ────────────────────────────────────────────────────────────── function renderStats(data) { const el = document.getElementById('stats'); if (!el) return; const types = Object.keys(data.index).length; el.innerHTML = renderHeaderStatsMarkup(` <span class="stat"><strong>${data.total_elements}</strong> elements</span> <span class="stat"><strong>${types}</strong> types</span> `); } // ── Type filter chips ────────────────────────────────────────────────────── /** Elements filtered by source toggle only (no type/topic/search). */ function getSourceFilteredElements() { if (activeSource === 'collection') return allElements.filter(el => !el._local); if (activeSource === 'portfolio') return allElements.filter(el => el._local); return allElements; } function renderTypeFilters() { const container = document.getElementById('type-filters'); if (!container) return; const sourceFiltered = getSourceFilteredElements(); const typeCounts = sourceFiltered.reduce((acc, el) => { acc[el.type] = (acc[el.type] || 0) + 1; return acc; }, {}); const types = ['all', ...Object.keys(typeCounts).sort((a, b) => a.localeCompare(b))]; const isAllActive = activeTypes.size === 0; container.innerHTML = types.map(type => { const count = type === 'all' ? sourceFiltered.length : typeCounts[type]; const isActive = type === 'all' ? isAllActive : activeTypes.has(type); return `<button class="type-filter${isActive ? ' active' : ''}" data-type="${escapeAttr(type)}" aria-pressed="${isActive}" >${capitalize(type)} <span class="filter-count">${count}</span></button>`; }).join(''); // Replace listener (clone node removes old listeners) const fresh = container.cloneNode(true); container.parentNode.replaceChild(fresh, container); fresh.addEventListener('click', e => { const btn = e.target.closest('[data-type]'); if (!btn) return; const t = btn.dataset.type; if (t === 'all') { activeTypes.clear(); } else if (activeTypes.has(t)) { activeTypes.delete(t); } else { activeTypes.add(t); } fresh.querySelectorAll('.type-filter').forEach(b => { const isAll = b.dataset.type === 'all'; const active = isAll ? activeTypes.size === 0 : activeTypes.has(b.dataset.type); b.classList.toggle('active', active); b.setAttribute('aria-pressed', active); }); applyFilters(); }); } // ── Topic filter chips ───────────────────────────────────────────────────── // Map raw tags → normalized topic buckets const TOPIC_MAP = { 'professional': 'Professional', 'business': 'Business', 'strategy': 'Business', 'consulting': 'Business', 'finance': 'Business', 'development': 'Development', 'programming': 'Development', 'code': 'Development', 'software-engineering': 'Development', 'code-review': 'Development', 'code-quality': 'Development', 'security': 'Security', 'vulnerability': 'Security', 'compliance': 'Security', 'code-security': 'Security', 'codeql': 'Security', 'security-analysis': 'Security', 'writing': 'Writing', 'creative-writing': 'Writing', 'storytelling': 'Writing', 'content': 'Writing', 'copywriting': 'Writing', 'narrative': 'Writing', 'research': 'Research', 'academic': 'Research', 'analysis': 'Research', 'literature-review': 'Research', 'data-analysis': 'Research', 'productivity': 'Productivity', 'task-management': 'Productivity', 'organization': 'Productivity', 'workflow': 'Productivity', 'efficiency': 'Productivity', 'education': 'Education', 'learning': 'Education', 'teaching': 'Education', 'tutorial': 'Education', 'creative': 'Creative', 'design': 'Creative', 'art': 'Creative', 'personal': 'Personal', }; function getTopicForElement(el) { if (!el.tags?.length) return el.category ? capitalize(el.category) : null; for (const tag of el.tags) { const t = tag.toLowerCase(); if (TOPIC_MAP[t]) return TOPIC_MAP[t]; } return null; } function renderTopicFilters() { const container = document.getElementById('topic-filters'); if (!container) return; const sourceFiltered = getSourceFilteredElements(); const topicCounts = {}; sourceFiltered.forEach(el => { const topic = getTopicForElement(el); if (topic) topicCounts[topic] = (topicCounts[topic] || 0) + 1; }); const topics = ['all', ...Object.keys(topicCounts).sort((a, b) => a.localeCompare(b))]; if (topics.length <= 2) { container.hidden = true; return; } // not enough to be useful container.hidden = false; container.innerHTML = topics.map(topic => { const count = topic === 'all' ? sourceFiltered.length : topicCounts[topic]; const isActive = topic === activeTopic; return `<button class="topic-filter${isActive ? ' active' : ''}" data-topic="${escapeAttr(topic)}" aria-pressed="${isActive}" >${escapeHtml(topic === 'all' ? 'All topics' : topic)} <span class="filter-count">${count}</span></button>`; }).join(''); container.addEventListener('click', e => { const btn = e.target.closest('[data-topic]'); if (!btn) return; activeTopic = btn.dataset.topic; container.querySelectorAll('.topic-filter').forEach(b => { const active = b.dataset.topic === activeTopic; b.classList.toggle('active', active); b.setAttribute('aria-pressed', active); }); applyFilters(); }); } // ── Search ───────────────────────────────────────────────────────────────── let searchTimer; function onSearch(e) { clearTimeout(searchTimer); searchTimer = setTimeout(() => { searchQuery = e.target.value.trim().toLowerCase(); applyFilters(); }, SEARCH_DEBOUNCE_MS); } // ── Filter + render pipeline ─────────────────────────────────────────────── function applyFilters() { currentPage = 1; filteredElements = allElements.filter(el => { if (activeTypes.size > 0 && !activeTypes.has(el.type)) return false; if (activeTopic !== 'all' && getTopicForElement(el) !== activeTopic) return false; if (activeSource === 'collection' && el._local) return false; if (activeSource === 'portfolio' && !el._local) return false; if (!searchQuery) return true; const searchable = [ el.name, el.description, el.author, el.category, ...(Array.isArray(el.tags) ? el.tags : []), ...(Array.isArray(el.keywords) ? el.keywords : []), ].filter(Boolean).join(' ').toLowerCase(); return searchable.includes(searchQuery); }); renderResults(); } // ── Card grid ────────────────────────────────────────────────────────────── function sortElements(elements) { const sorted = [...elements]; switch (activeSort) { case 'name-asc': return sorted.sort((a, b) => (a.name || '').localeCompare(b.name || '')); case 'name-desc': return sorted.sort((a, b) => (b.name || '').localeCompare(a.name || '')); case 'date-asc': return sorted.sort((a, b) => { const da = a.created ? new Date(a.created).getTime() : 0; const db = b.created ? new Date(b.created).getTime() : 0; return da - db; }); case 'date-desc': return sorted.sort((a, b) => { const da = a.created ? new Date(a.created).getTime() : 0; const db = b.created ? new Date(b.created).getTime() : 0; return db - da; }); case 'type-asc': return sorted.sort((a, b) => (a.type || '').localeCompare(b.type || '') || (a.name || '').localeCompare(b.name || '')); default: return sorted; } } function renderResults() { const grid = document.getElementById('elements-grid'); const countEl = document.getElementById('results-count'); const announcer = document.getElementById('results-announcer'); if (!grid) return; filteredElements = sortElements(filteredElements); highlightedCardIndex = -1; // reset grid keyboard selection on re-render const total = filteredElements.length; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); if (currentPage > totalPages) currentPage = totalPages; const pageStart = (currentPage - 1) * PAGE_SIZE; const pageEnd = Math.min(pageStart + PAGE_SIZE, total); const pageItems = filteredElements.slice(pageStart, pageEnd); if (countEl) { const sourceTotal = getSourceFilteredElements().length; const base = total === sourceTotal ? `${sourceTotal} elements` : `${total} of ${sourceTotal} elements`; const pageNote = totalPages > 1 ? ` · page ${currentPage} of ${totalPages}` : ''; countEl.textContent = base + pageNote; } if (announcer) { announcer.textContent = total === allElements.length ? `Showing all ${allElements.length} elements` : `Found ${total} of ${allElements.length} elements`; } if (total === 0) { showGridMessage('empty-state', searchQuery ? `No elements match "${searchQuery}".` : 'No elements found.'); renderPagination(0, 1); return; } grid.innerHTML = pageItems.map((el, i) => { const idx = pageStart + i; // absolute index into filteredElements const unavailable = el._unavailable; const compSummary = renderComponentSummary(el); return ` <article class="element-card" data-index="${idx}" data-type="${escapeAttr(el.type)}" ${unavailable ? 'data-unavailable=""' : ''} role="listitem button" tabindex="0" aria-label="${unavailable ? 'Unavailable: ' : 'View '}${escapeHtml(el.name)}" > <div class="card-header"> <h3 class="card-title">${escapeHtml(el.name)}</h3> <div class="card-badges"> <span class="type-badge" data-type="${escapeAttr(el.type)}">${capitalize(el.type)}</span> ${el._local ? '<span class="source-badge">LOCAL</span>' : ''} ${unavailable ? '<span class="unavailable-badge">unavailable</span>' : ''} </div> <span class="card-expand-icon" aria-hidden="true">▾</span> </div> ${el.description ? `<p class="card-description">${escapeHtml(el.description)}</p>` : ''} ${compSummary} <footer class="card-footer"> <div class="card-meta"> ${el.author ? `<span class="meta-author">${escapeHtml(el.author)}</span>` : ''} ${el.version ? `<span class="meta-version">v${escapeHtml(el.version)}</span>` : ''} ${el.category ? `<span class="meta-category">${escapeHtml(el.category)}</span>` : ''} ${el.created ? `<span class="meta-date">${formatDate(el.created)}</span>` : ''} </div> <div class="card-actions"> <button class="card-download-btn" data-action="download" aria-label="Download ${escapeHtml(el.name)}">⤓</button> </div> ${el.tags?.length ? `<ul class="card-tags" aria-label="Tags">${ el.tags.slice(0, 5).map(t => `<li class="tag">${escapeHtml(t)}</li>` ).join('') }</ul>` : ''} </footer> <div class="card-inline-detail"></div> </article> `}).join(''); // Single delegated listener for the grid grid.onclick = handleCardClick; grid.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardClick(e); } }; renderPagination(total, totalPages); } function renderPagination(total, totalPages) { const nav = document.getElementById('pagination'); const prevBtn = document.getElementById('btn-prev-page'); const nextBtn = document.getElementById('btn-next-page'); const info = document.getElementById('page-info'); if (!nav) return; if (totalPages <= 1) { nav.hidden = true; return; } nav.hidden = false; if (prevBtn) { prevBtn.disabled = currentPage <= 1; prevBtn.onclick = () => { currentPage--; renderResults(); window.scrollTo({ top: 0, behavior: 'smooth' }); }; } if (nextBtn) { nextBtn.disabled = currentPage >= totalPages; nextBtn.onclick = () => { currentPage++; renderResults(); window.scrollTo({ top: 0, behavior: 'smooth' }); }; } if (info) { const pageStart = (currentPage - 1) * PAGE_SIZE + 1; const pageEnd = Math.min(currentPage * PAGE_SIZE, total); info.textContent = `${pageStart}–${pageEnd} of ${total}`; } } function showGridMessage(cls, text) { const grid = document.getElementById('elements-grid'); if (grid) grid.innerHTML = `<p class="${escapeAttr(cls)}">${escapeHtml(text)}</p>`; } // ── Modal ────────────────────────────────────────────────────────────────── function handleCardClick(e) { // Download button — fetch and save without opening modal/expand if (e.target.closest('[data-action="download"]')) { e.stopPropagation(); const card = e.target.closest('[data-index]'); if (!card) return; const el = filteredElements[Number.parseInt(card.dataset.index, 10)]; const btn = e.target.closest('[data-action="download"]'); const prev = btn.textContent; if (el._local && el._content) { downloadFile(el.name, el._content); } else { btn.textContent = '…'; fetch(`${RAW_BASE}/${el.path}`) .then(r => r.ok ? r.text() : Promise.reject(r.status)) .then(content => { downloadFile(el.name, content); btn.textContent = prev; }) .catch(() => { btn.textContent = '✗'; setTimeout(() => { btn.textContent = prev; }, 1500); }); } return; } const card = e.target.closest('[data-index]'); if (!card) return; const idx = Number.parseInt(card.dataset.index, 10); const el = filteredElements[idx]; const grid = document.getElementById('elements-grid'); const isListView = grid?.dataset.view === 'list'; if (isListView) { // Don't collapse when clicking inside expanded content if (e.target.closest('.card-inline-detail')) return; toggleInlineExpand(card, el); } else if (!card.dataset.unavailable) { openModal(el, idx); } } async function toggleInlineExpand(card, el) { const detail = card.querySelector('.card-inline-detail'); if (!detail) return; if (card.dataset.expanded !== undefined) { delete card.dataset.expanded; detail.innerHTML = ''; return; } card.dataset.expanded = ''; if (!el._local) { detail.innerHTML = '<p class="loading" style="font-size:0.8rem;padding:0.4rem 0">Loading…</p>'; } try { let content; if (el._content) { content = el._content; } else if (el._local) { const res = await DollhouseAuth.apiFetch(`/api/elements/${el.path}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); content = await res.text(); } else { const res = await fetch(`https://raw.githubusercontent.com/DollhouseMCP/collection/main/${el.path}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); content = await res.text(); } detail.innerHTML = ''; // Action bar at TOP of expanded content const actions = document.createElement('div'); actions.className = 'inline-detail-actions'; const copyBtn = document.createElement('button'); copyBtn.className = 'modal-action-btn'; copyBtn.textContent = '⎘ Copy'; copyBtn.onclick = e => { e.stopPropagation(); copyToClipboard(content, copyBtn); }; const dlBtn = document.createElement('button'); dlBtn.className = 'modal-action-btn'; dlBtn.textContent = '⤓ Download'; dlBtn.onclick = e => { e.stopPropagation(); downloadFile(el.name, content); }; actions.appendChild(copyBtn); actions.appendChild(dlBtn); if (el._local) { const submitBtn2 = document.createElement('button'); submitBtn2.className = 'modal-action-btn modal-action-btn--submit'; submitBtn2.type = 'button'; submitBtn2.textContent = '↑ Submit'; submitBtn2.onclick = e => { e.stopPropagation(); openSubmitIssue(el.name, el.type, content); }; actions.appendChild(submitBtn2); } else { const ghLink = document.createElement('a'); ghLink.className = 'modal-action-btn'; ghLink.href = `${GITHUB_BASE}/${el.path}`; ghLink.target = '_blank'; ghLink.rel = 'noopener noreferrer'; ghLink.textContent = '↗ GitHub'; actions.appendChild(ghLink); } detail.appendChild(actions); const contentDiv = document.createElement('div'); contentDiv.innerHTML = renderDetailView(content, el.type); contentDiv.querySelectorAll('pre code').forEach(block => { if (globalThis.hljs) hljs.highlightElement(block); }); detail.appendChild(contentDiv); } catch (err) { detail.innerHTML = `<p class="error" style="font-size:0.8rem">Could not load: ${escapeHtml(err.message)}</p>`; } } function setupModalNav(index) { const prevElBtn = document.getElementById('btn-prev-element'); const nextElBtn = document.getElementById('btn-next-element'); const navCount = document.getElementById('modal-nav-count'); if (prevElBtn) { prevElBtn.disabled = index <= 0; prevElBtn.onclick = () => { if (openElementIndex > 0) openModal(filteredElements[openElementIndex - 1], openElementIndex - 1); }; } if (nextElBtn) { nextElBtn.disabled = index < 0 || index >= filteredElements.length - 1; nextElBtn.onclick = () => { if (openElementIndex < filteredElements.length - 1) openModal(filteredElements[openElementIndex + 1], openElementIndex + 1); }; } if (navCount) { navCount.textContent = index >= 0 ? `${index + 1} / ${filteredElements.length}` : ''; } } function setupModalMeta(element, modal) { modal.querySelector('.modal-title').textContent = element.name; modal.querySelector('.modal-type').textContent = capitalize(element.type); modal.querySelector('.modal-author').textContent = element.author ? `by ${element.author}` : ''; modal.querySelector('.modal-version').textContent = element.version ? `v${element.version}` : ''; const modalDate = modal.querySelector('.modal-date'); const modalSource = modal.querySelector('.modal-source'); if (modalDate) modalDate.textContent = element.created ? formatDate(element.created) : ''; if (modalSource) modalSource.textContent = element._local ? 'LOCAL' : ''; } async function installElement(element, btn) { const prev = btn.textContent; btn.textContent = '⏳ Installing…'; btn.disabled = true; try { const res = await DollhouseAuth.apiFetch('/api/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: element.path, name: element.name, type: element.type }), }); const data = await res.json(); if (res.ok && data.success) { btn.textContent = '✅ Installed!'; setTimeout(() => { btn.textContent = prev; btn.disabled = false; }, 3000); } else { btn.textContent = `✗ ${data.error || 'Failed'}`; setTimeout(() => { btn.textContent = prev; btn.disabled = false; }, 3000); } } catch (err) { btn.textContent = '✗ Error'; setTimeout(() => { btn.textContent = prev; btn.disabled = false; }, 3000); } } function setupModalLinks(element, modal) { const ghLink = modal.querySelector('#btn-github'); if (ghLink) { if (element._local) { ghLink.style.display = 'none'; } else { ghLink.style.display = ''; ghLink.href = `${GITHUB_BASE}/${element.path}`; } } // Install button for collection elements let installBtn = modal.querySelector('#btn-install'); if (!installBtn) { // Create install button if it doesn't exist in the HTML const toolbar = modal.querySelector('#modal-toolbar'); if (toolbar) { installBtn = document.createElement('button'); installBtn.className = 'modal-action-btn modal-action-btn--submit'; installBtn.id = 'btn-install'; installBtn.type = 'button'; toolbar.insertBefore(installBtn, toolbar.querySelector('#modal-nav')); } } if (installBtn) { if (!element._local) { installBtn.style.display = ''; installBtn.textContent = '⤓ Install'; installBtn.onclick = (e) => { e.preventDefault(); installElement(element, installBtn); }; } else { installBtn.style.display = 'none'; } } const submitBtn = modal.querySelector('#btn-submit'); if (submitBtn) { if (element._local) { submitBtn.style.display = ''; submitBtn.dataset.elementName = element.name; submitBtn.dataset.elementType = element.type; } else { submitBtn.style.display = 'none'; } } return submitBtn; } async function openModal(element, index = -1) { const modal = document.getElementById('element-modal'); if (!modal) return; openElementIndex = index; setupModalNav(index); setupModalMeta(element, modal); const submitBtn = setupModalLinks(element, modal); // Reset action buttons const copyBtn = modal.querySelector('#btn-copy'); const downloadBtn = modal.querySelector('#btn-download'); copyBtn.onclick = null; downloadBtn.onclick = null; copyBtn.textContent = '⎘ Copy'; // Show modal with loading state const body = document.getElementById('modal-body'); body.innerHTML = '<p class="loading">Loading content…</p>'; body.tabIndex = -1; // make scrollable body focusable for keyboard scrolling modal.showModal(); document.body.classList.add('modal-open'); body.focus(); // focus body so arrow/Page/Home/End keys scroll content natively // Fetch element content — structured JSON for local, raw text for collection try { let content; // raw file content (for Raw view, Copy, Download) let structured; // { metadata, body, type, validation } when available if (element._content) { content = element._content; } else if (element._local) { const res = await DollhouseAuth.apiFetch(`/api/elements/${element.path}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); structured = data; content = data.raw; } else { const res = await DollhouseAuth.apiFetch(`/api/collection/content/${element.path}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); structured = data; content = data.raw; } const renderBtn = modal.querySelector('#btn-render'); function renderModalBody() { if (modalShowRaw) { body.innerHTML = `<pre class="element-source"><code class="element-code language-yaml">${escapeHtml(content)}</code></pre>`; if (globalThis.hljs) body.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b)); } else if (structured) { body.innerHTML = renderStructuredDetail(structured); body.querySelectorAll('pre code').forEach(b => { if (globalThis.hljs) hljs.highlightElement(b); }); } else { body.innerHTML = renderDetailView(content, element.type); body.querySelectorAll('pre code').forEach(b => { if (globalThis.hljs) hljs.highlightElement(b); }); } } renderModalBody(); if (renderBtn) { // Reflect current sticky state so button label matches on navigation renderBtn.textContent = modalShowRaw ? '⇄ Rendered' : '⇄ Raw'; renderBtn.dataset.mode = modalShowRaw ? 'raw' : 'rendered'; renderBtn.onclick = () => { modalShowRaw = !modalShowRaw; renderBtn.textContent = modalShowRaw ? '⇄ Rendered' : '⇄ Raw'; renderBtn.dataset.mode = modalShowRaw ? 'raw' : 'rendered'; renderModalBody(); }; } copyBtn.onclick = () => copyToClipboard(content, copyBtn); downloadBtn.onclick = () => downloadFile(element.name, content); if (element._local && submitBtn) { submitBtn.onclick = e => { e.preventDefault(); openSubmitIssue(element.name, element.type, content); }; } } catch (err) { body.innerHTML = `<p class="error">Could not load content: ${escapeHtml(err.message)}</p> <p class="error-hint"> <a href="${GITHUB_BASE}/${element.path}" target="_blank" rel="noopener noreferrer"> View on GitHub directly </a> </p>`; console.error('[DollhouseMCP]', { error: err.message, context: 'modalLoad', element: element.path, timestamp: new Date().toISOString(), }); } } // ── Detail view renderer ─────────────────────────────────────────────────── function parseFrontmatter(raw) { const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return { frontmatter: {}, body: raw }; let fm = {}; try { fm = safeParseYaml(match[1]) || {}; } catch { fm = {}; } const body = raw.slice(match[0].length).trim(); return { frontmatter: fm, body }; } function renderComponentSummary(el) { // Only for ensembles — show component type counts from index metadata if (el.type !== 'ensemble' && el.type !== 'ensembles') return ''; const counts = ['personas','skills','tools','templates','prompts','memories'] .filter(k => Array.isArray(el[k]) && el[k].length) .map(k => `${el[k].length} ${k}`); if (!counts.length) return ''; return `<p class="card-components">${counts.join(' · ')}</p>`; } // Fields whose string values are assumed to contain markdown content const MEMORY_MARKDOWN_FIELDS = new Set([ 'content', 'body', 'text', 'notes', 'summary', 'context', 'observations', 'insights', 'instructions', 'thoughts', 'analysis', 'reflection', 'outcome', 'details', 'log', 'data', 'value', 'message', 'description', ]); // Heuristic: does a multi-line string look like it has markdown syntax? function looksLikeMarkdown(str) { if (typeof str !== 'string' || !str.includes('\n')) return false; return /^(?:#{1,6}\s|\s{0,3}[-*+]\s|\s{0,3}\d+\.\s|>\s|```|\*\*|__|!\[)/m.test(str); } // Render a single memory entry object — markdown fields prominent, scalars in meta footer. function renderMemoryEntry(item) { if (typeof item !== 'object' || item === null) { return `<li>${escapeHtml(String(item))}</li>`; } let entryBody = ''; const metaParts = []; for (const [k, v] of Object.entries(item)) { if (typeof v === 'string' && (MEMORY_MARKDOWN_FIELDS.has(k) || looksLikeMarkdown(v))) { entryBody += globalThis.marked ? `<div class="element-rendered memory-entry-content">${sanitizeHtml(marked.parse(v))}</div>` : `<pre class="detail-multiline">${escapeHtml(v)}</pre>`; } else if (Array.isArray(v)) { if (v.length) metaParts.push(`<span class="memory-meta-key">${escapeHtml(k)}</span> ${v.map(i => escapeHtml(String(i))).join(', ')}`); } else if (typeof v !== 'object' && v != null && v !== '') { metaParts.push(`<span class="memory-meta-key">${escapeHtml(k.replaceAll('_', ' '))}</span> ${escapeHtml(String(v))}`); } } const metaRow = metaParts.length ? `<div class="memory-entry-meta">${metaParts.join(' · ')}</div>` : ''; return `<li class="memory-entry">${entryBody}${metaRow}</li>`; } function renderMemoryField(key, value) { const label = key.replaceAll('_', ' '); if (typeof value === 'string') { const isMarkdown = MEMORY_MARKDOWN_FIELDS.has(key) || looksLikeMarkdown(value); if (isMarkdown && globalThis.marked) { return detailSection(label, `<div class="element-rendered">${sanitizeHtml(marked.parse(value))}</div>`); } if (value.includes('\n')) { return detailSection(label, `<pre class="detail-multiline">${escapeHtml(value)}</pre>`); } return detailSection(label, `<p class="detail-prose">${escapeHtml(value)}</p>`); } if (Array.isArray(value)) { const items = value.map(item => renderMemoryEntry(item)).join(''); return detailSection(label, `<ul class="memory-entries-list">${items}</ul>`); } if (typeof value === 'object' && value !== null) { const rows = Object.entries(value).map(([k, v]) => detailField(k.replaceAll('_', ' '), typeof v === 'object' ? JSON.stringify(v) : String(v)) ).filter(Boolean).join(''); return rows ? detailSection(label, rows) : ''; } if (value != null && value !== '') { return detailSection(label, `<p class="detail-prose">${escapeHtml(String(value))}</p>`); } return ''; } // Render a pure-YAML memory file: parse each field, detect markdown, render appropriately function renderMemoryView(content) { let parsed; parsed = safeParseYaml(content); if (!parsed || typeof parsed !== 'object') { return `<pre class="element-source"><code class="element-code">${escapeHtml(content)}</code></pre>`; } let html = ''; // Standard metadata at top const createdVal = parsed.created || parsed.created_date; if (createdVal) { html += `<div class="detail-created"><span class="detail-created-label">Created</span><span class="detail-created-value">${escapeHtml(formatDate(createdVal))}</span></div>`; } const meta = [detailField('Author', parsed.author), detailField('ID', parsed.unique_id || parsed.id)].filter(Boolean).join(''); if (meta) html += detailSection('Details', meta); if (Array.isArray(parsed.tags) && parsed.tags.length) { html += detailSection('Tags', detailPillList(parsed.tags, 'pill-tag')); } // Render all remaining fields const SKIP = new Set(['name','type','created','created_date','updated','author','version','tags','unique_id','id']); for (const [key, value] of Object.entries(parsed)) { if (SKIP.has(key)) continue; html += renderMemoryField(key, value); } return html || `<pre class="element-source"><code class="element-code">${escapeHtml(content)}</code></pre>`; } function renderGoalSection(goal) { let goalHtml = ''; if (goal.template) { const tplHtml = escapeHtml(String(goal.template)) .replaceAll(/\{([^}]{1,100})\}/g, '<span class="detail-template-param">{$1}</span>'); goalHtml += `<div class="detail-goal-template">${tplHtml}</div>`; } if (Array.isArray(goal.successCriteria) && goal.successCriteria.length) { const criteriaItems = goal.successCriteria.map(c => `<li>${escapeHtml(c)}</li>`).join(''); goalHtml += `<h5 class="detail-subsection-title">Success criteria</h5> <ul class="detail-list">${criteriaItems}</ul>`; } if (Array.isArray(goal.parameters) && goal.parameters.length) { goalHtml += `<h5 class="detail-subsection-title">Parameters</h5>`; goalHtml += goal.parameters.map(p => `<div class="detail-param"> <div class="detail-param-header"> <span class="detail-param-name">${escapeHtml(p.name || '')}</span> ${p.type ? `<span class="detail-pill pill-meta">${escapeHtml(p.type)}</span>` : ''} ${p.required ? `<span class="detail-pill pill-required">required</span>` : '<span class="detail-pill">optional</span>'} </div> ${p.description ? `<span class="detail-param-desc">${escapeHtml(p.description)}</span>` : ''} </div>` ).join(''); } return goalHtml ? detailSection('Goal', goalHtml) : ''; } function renderAutonomySection(a) { let aHtml = ['maxSteps','maxAutonomousSteps','safetyTier','riskTolerance'] .filter(k => a[k] != null) .map(k => detailField(k.replaceAll(/([A-Z])/g, ' $1').toLowerCase().trim(), String(a[k]))) .join(''); if (Array.isArray(a.autoApprove) && a.autoApprove.length) { aHtml += `<div class="detail-field"><span class="detail-label">auto approve</span><span class="detail-value">${a.autoApprove.map(v => detailPill(v, 'pill-tag')).join(' ')}</span></div>`; } if (Array.isArray(a.requiresApproval) && a.requiresApproval.length) { aHtml += `<div class="detail-field"><span class="detail-label">requires approval</span><span class="detail-value">${a.requiresApproval.map(v => detailPill(v, 'pill-required')).join(' ')}</span></div>`; } return aHtml ? detailSection('Autonomy', aHtml) : ''; } function renderGatekeeperSection(g) { let gHtml = ''; if (Array.isArray(g.allow) && g.allow.length) gHtml += `<div class="detail-field"><span class="detail-label">allow</span><span class="detail-value">${g.allow.map(v => detailPill(v, 'pill-tag')).join(' ')}</span></div>`; if (Array.isArray(g.confirm) && g.confirm.length) gHtml += `<div class="detail-field"><span class="detail-label">confirm</span><span class="detail-value">${g.confirm.map(v => detailPill(v, 'pill-meta')).join(' ')}</span></div>`; if (Array.isArray(g.deny) && g.deny.length) gHtml += `<div class="detail-field"><span class="detail-label">deny</span><span class="detail-value">${g.deny.map(v => detailPill(v, 'pill-required')).join(' ')}</span></div>`; return gHtml ? detailSection('Gatekeeper', gHtml) : ''; } // ── Agent sub-section helpers (extracted for cognitive complexity) ────── function renderLegacyGoals(fm) { if (!fm.goals || typeof fm.goals !== 'object') return ''; let goalsHtml = ''; if (fm.goals.primary) goalsHtml += `<p class="detail-prose">${escapeHtml(String(fm.goals.primary))}</p>`; if (Array.isArray(fm.goals.secondary) && fm.goals.secondary.length) { const items = fm.goals.secondary.map(g => `<li>${escapeHtml(g)}</li>`).join(''); goalsHtml += `<ul class="detail-list">${items}</ul>`; } return goalsHtml ? detailSection('Goals', goalsHtml) : ''; } function renderStateSection(fm) { if (!fm.state || typeof fm.state !== 'object') return ''; let stateHtml = ''; for (const [k, v] of Object.entries(fm.state)) { if (Array.isArray(v)) { stateHtml += `<div class="detail-field"><span class="detail-label">${escapeHtml(k.replaceAll('_', ' '))}</span><span class="detail-value">${detailPillList(v)}</span></div>`; } else { stateHtml += detailField(k.replaceAll('_', ' '), String(v)); } } return stateHtml ? detailSection('State', stateHtml) : ''; } function renderAgentToolsSection(fm) { if (!fm.tools || typeof fm.tools !== 'object') return ''; let toolsHtml = ''; if (Array.isArray(fm.tools.allowed) && fm.tools.allowed.length) { toolsHtml += `<div class="detail-field"><span class="detail-label">allowed</span><span class="detail-value">${fm.tools.allowed.map(v => detailPill(v, 'pill-tag')).join(' ')}</span></div>`; } if (Array.isArray(fm.tools.denied) && fm.tools.denied.length) { toolsHtml += `<div class="detail-field"><span class="detail-label">denied</span><span class="detail-value">${fm.tools.denied.map(v => detailPill(v, 'pill-required')).join(' ')}</span></div>`; } return toolsHtml ? detailSection('Tools', toolsHtml) : ''; } function renderAgentV1Config(fm) { const agentConfig = ['decisionFramework','riskTolerance','learningEnabled','maxConcurrentGoals'] .map(k => detailField(k.replaceAll(/([A-Z])/g, ' $1').toLowerCase().trim(), fm[k] == null ? null : String(fm[k]))) .filter(Boolean).join(''); return agentConfig ? detailSection('Configuration', agentConfig) : ''; } function renderMarkdownOrPre(text) { return globalThis.marked ? `<div class="element-rendered">${sanitizeHtml(marked.parse(String(text)))}</div>` : `<pre class="detail-multiline">${escapeHtml(String(text))}</pre>`; } /** * Render instructions text with smart segmentation. * * Detects directive-style instructions (lines starting with command-voice * keywords like ALWAYS, NEVER, WHEN, YOU ARE, PREFER, etc.) and renders * each directive as a visually separated block. Falls back to standard * markdown rendering for non-directive content. */ const DIRECTIVE_KEYWORDS = [ 'YOU','ALWAYS','NEVER','WHEN','PREFER','DO',"DON'T",'DONT','MUST','SHOULD', 'IF','FOR','ENSURE','MAINTAIN','USE','AVOID','FOLLOW','PRIORITIZE','FOCUS', 'REMEMBER','NOTE','IMPORTANT','CRITICAL', ]; const DIRECTIVE_PATTERN = new RegExp( `^(${DIRECTIVE_KEYWORDS.join('|')})(\\s)`, 'i' ); function renderInstructions(text) { if (!text) return ''; const str = String(text); // Split on double newlines (paragraph boundaries) const paragraphs = str.split(/\n\n+/).filter(p => p.trim()); if (paragraphs.length === 0) return ''; // Detect directives in a single pass — cache match results const parsed = paragraphs.map(p => { const trimmed = p.trim(); const match = trimmed.match(DIRECTIVE_PATTERN); return { trimmed, match }; }); const directiveCount = parsed.filter(p => p.match).length; const isDirectiveStyle = directiveCount >= 2 && directiveCount >= paragraphs.length * 0.3; if (!isDirectiveStyle) { return renderMarkdownOrPre(str); } // Render each paragraph as a segmented directive block. // DOMPurify sanitizes all rendered HTML to prevent XSS. // CSP headers provide an additional layer of protection. const blocks = parsed.map(({ trimmed, match }) => { if (match) { const keyword = escapeHtml(match[1]); const rest = trimmed.slice(match[0].length); const rendered = globalThis.marked ? sanitizeHtml(marked.parseInline(rest)) : escapeHtml(rest); return `<div class="directive-block"><span class="directive-keyword">${keyword}</span> ${rendered}</div>`; } // Non-directive paragraph — render as markdown (DOMPurify sanitizes output) return globalThis.marked ? `<div class="directive-block directive-block--prose">${sanitizeHtml(marked.parse(trimmed))}</div>` : `<div class="directive-block directive-block--prose">${escapeHtml(trimmed)}</div>`; }).join(''); return `<div class="directive-list">${blocks}</div>`; } function renderActivatesSection(fm) { if (!fm.activates || typeof fm.activates !== 'object') return ''; const entries = Object.entries(fm.activates) .filter(([, v]) => Array.isArray(v) && v.length) .map(([k, v]) => `<div class="detail-field"><span class="detail-label">${escapeHtml(k)}</span><span class="detail-value">${detailPillList(v)}</span></div>`) .join(''); return entries ? detailSection('Activates', entries) : ''; } function renderResilienceSection(fm) { if (!fm.resilience || typeof fm.resilience !== 'object') return ''; const fields = Object.entries(fm.resilience) .map(([k, v]) => detailField(k.replaceAll(/([A-Z])/g, ' $1').toLowerCase().trim(), String(v))) .filter(Boolean).join(''); return fields ? detailSection('Resilience', fields) : ''; } function renderRiskThresholds(fm) { if (!fm.risk_thresholds || typeof fm.risk_thresholds !== 'object') return ''; const thresholds = Object.entries(fm.risk_thresholds) .map(([k, v]) => detailField(k.replaceAll('_', ' '), String(v))) .filter(Boolean).join(''); return thresholds ? detailSection('Risk thresholds', thresholds) : ''; } function renderAgentSection(fm) { let html = ''; if (fm.instructions) html += detailSection('Instructions', renderInstructions(fm.instructions)); if (fm.goal && typeof fm.goal === 'object') html += renderGoalSection(fm.goal); html += renderLegacyGoals(fm); if (fm.autonomy && typeof fm.autonomy === 'object') html += renderAutonomySection(fm.autonomy); if (fm.gatekeeper && typeof fm.gatekeeper === 'object') html += renderGatekeeperSection(fm.gatekeeper); if (fm.systemPrompt) html += detailSection('System prompt'