@dollhousemcp/mcp-server 2.0.0-rc.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/CHANGELOG.md +52 -61
  2. package/README.github.md +2 -2
  3. package/README.md +2 -2
  4. package/README.md.backup +284 -224
  5. package/README.npm.md +2 -2
  6. package/dist/cache/LRUCache.d.ts +3 -0
  7. package/dist/cache/LRUCache.d.ts.map +1 -1
  8. package/dist/cache/LRUCache.js +36 -26
  9. package/dist/config/env.d.ts +14 -4
  10. package/dist/config/env.d.ts.map +1 -1
  11. package/dist/config/env.js +20 -6
  12. package/dist/di/Container.d.ts +21 -0
  13. package/dist/di/Container.d.ts.map +1 -1
  14. package/dist/di/Container.js +250 -53
  15. package/dist/elements/BaseElement.d.ts.map +1 -1
  16. package/dist/elements/BaseElement.js +5 -10
  17. package/dist/elements/base/BaseElementManager.d.ts +22 -0
  18. package/dist/elements/base/BaseElementManager.d.ts.map +1 -1
  19. package/dist/elements/base/BaseElementManager.js +47 -7
  20. package/dist/elements/memories/Memory.d.ts +1 -0
  21. package/dist/elements/memories/Memory.d.ts.map +1 -1
  22. package/dist/elements/memories/Memory.js +12 -8
  23. package/dist/elements/memories/MemoryManager.d.ts.map +1 -1
  24. package/dist/elements/memories/MemoryManager.js +23 -42
  25. package/dist/elements/memories/MemorySearchIndex.js +2 -2
  26. package/dist/generated/version.d.ts +2 -2
  27. package/dist/generated/version.d.ts.map +1 -1
  28. package/dist/generated/version.js +3 -3
  29. package/dist/handlers/EnhancedIndexHandler.js +6 -6
  30. package/dist/handlers/element-crud/listElements.d.ts +2 -0
  31. package/dist/handlers/element-crud/listElements.d.ts.map +1 -1
  32. package/dist/handlers/element-crud/listElements.js +3 -1
  33. package/dist/handlers/mcp-aql/Gatekeeper.d.ts.map +1 -1
  34. package/dist/handlers/mcp-aql/Gatekeeper.js +23 -17
  35. package/dist/handlers/mcp-aql/MCPAQLHandler.d.ts +14 -0
  36. package/dist/handlers/mcp-aql/MCPAQLHandler.d.ts.map +1 -1
  37. package/dist/handlers/mcp-aql/MCPAQLHandler.js +110 -14
  38. package/dist/handlers/mcp-aql/OperationRouter.d.ts.map +1 -1
  39. package/dist/handlers/mcp-aql/OperationRouter.js +13 -1
  40. package/dist/handlers/mcp-aql/OperationSchema.d.ts +7 -0
  41. package/dist/handlers/mcp-aql/OperationSchema.d.ts.map +1 -1
  42. package/dist/handlers/mcp-aql/OperationSchema.js +52 -1
  43. package/dist/handlers/mcp-aql/evaluatePermission.d.ts +53 -0
  44. package/dist/handlers/mcp-aql/evaluatePermission.d.ts.map +1 -0
  45. package/dist/handlers/mcp-aql/evaluatePermission.js +132 -0
  46. package/dist/handlers/mcp-aql/policies/ToolClassification.d.ts.map +1 -1
  47. package/dist/handlers/mcp-aql/policies/ToolClassification.js +2 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -3
  50. package/dist/logging/LogHooks.js +11 -11
  51. package/dist/logging/LogManager.d.ts +0 -2
  52. package/dist/logging/LogManager.d.ts.map +1 -1
  53. package/dist/logging/LogManager.js +1 -3
  54. package/dist/logging/sinks/MemoryLogSink.d.ts +2 -0
  55. package/dist/logging/sinks/MemoryLogSink.d.ts.map +1 -1
  56. package/dist/logging/sinks/MemoryLogSink.js +12 -3
  57. package/dist/logging/types.d.ts +0 -2
  58. package/dist/logging/types.d.ts.map +1 -1
  59. package/dist/logging/types.js +1 -1
  60. package/dist/metrics/GatekeeperMetricsTracker.d.ts +32 -0
  61. package/dist/metrics/GatekeeperMetricsTracker.d.ts.map +1 -0
  62. package/dist/metrics/GatekeeperMetricsTracker.js +42 -0
  63. package/dist/metrics/MetricsManager.d.ts +47 -0
  64. package/dist/metrics/MetricsManager.d.ts.map +1 -0
  65. package/dist/metrics/MetricsManager.js +232 -0
  66. package/dist/metrics/OperationMetricsTracker.d.ts +32 -0
  67. package/dist/metrics/OperationMetricsTracker.d.ts.map +1 -0
  68. package/dist/metrics/OperationMetricsTracker.js +53 -0
  69. package/dist/metrics/collectors/DefaultElementProviderCollector.d.ts +27 -0
  70. package/dist/metrics/collectors/DefaultElementProviderCollector.d.ts.map +1 -0
  71. package/dist/metrics/collectors/DefaultElementProviderCollector.js +69 -0
  72. package/dist/metrics/collectors/FileLockManagerCollector.d.ts +16 -0
  73. package/dist/metrics/collectors/FileLockManagerCollector.d.ts.map +1 -0
  74. package/dist/metrics/collectors/FileLockManagerCollector.js +51 -0
  75. package/dist/metrics/collectors/GatekeeperMetricsCollector.d.ts +16 -0
  76. package/dist/metrics/collectors/GatekeeperMetricsCollector.d.ts.map +1 -0
  77. package/dist/metrics/collectors/GatekeeperMetricsCollector.js +76 -0
  78. package/dist/metrics/collectors/LRUCacheCollector.d.ts +18 -0
  79. package/dist/metrics/collectors/LRUCacheCollector.d.ts.map +1 -0
  80. package/dist/metrics/collectors/LRUCacheCollector.js +95 -0
  81. package/dist/metrics/collectors/OperationMetricsCollector.d.ts +16 -0
  82. package/dist/metrics/collectors/OperationMetricsCollector.d.ts.map +1 -0
  83. package/dist/metrics/collectors/OperationMetricsCollector.js +80 -0
  84. package/dist/metrics/collectors/OperationalTelemetryCollector.d.ts +17 -0
  85. package/dist/metrics/collectors/OperationalTelemetryCollector.d.ts.map +1 -0
  86. package/dist/metrics/collectors/OperationalTelemetryCollector.js +26 -0
  87. package/dist/metrics/collectors/PerformanceMonitorCollector.d.ts +14 -0
  88. package/dist/metrics/collectors/PerformanceMonitorCollector.d.ts.map +1 -0
  89. package/dist/metrics/collectors/PerformanceMonitorCollector.js +141 -0
  90. package/dist/metrics/collectors/SecurityMonitorCollector.d.ts +21 -0
  91. package/dist/metrics/collectors/SecurityMonitorCollector.d.ts.map +1 -0
  92. package/dist/metrics/collectors/SecurityMonitorCollector.js +56 -0
  93. package/dist/metrics/collectors/SecurityTelemetryCollector.d.ts +15 -0
  94. package/dist/metrics/collectors/SecurityTelemetryCollector.d.ts.map +1 -0
  95. package/dist/metrics/collectors/SecurityTelemetryCollector.js +112 -0
  96. package/dist/metrics/collectors/TriggerMetricsTrackerCollector.d.ts +16 -0
  97. package/dist/metrics/collectors/TriggerMetricsTrackerCollector.d.ts.map +1 -0
  98. package/dist/metrics/collectors/TriggerMetricsTrackerCollector.js +26 -0
  99. package/dist/metrics/collectors/index.d.ts +11 -0
  100. package/dist/metrics/collectors/index.d.ts.map +1 -0
  101. package/dist/metrics/collectors/index.js +11 -0
  102. package/dist/metrics/sinks/MemoryMetricsSink.d.ts +22 -0
  103. package/dist/metrics/sinks/MemoryMetricsSink.d.ts.map +1 -0
  104. package/dist/metrics/sinks/MemoryMetricsSink.js +121 -0
  105. package/dist/metrics/types.d.ts +98 -0
  106. package/dist/metrics/types.d.ts.map +1 -0
  107. package/dist/metrics/types.js +24 -0
  108. package/dist/portfolio/DefaultElementProvider.d.ts.map +1 -1
  109. package/dist/portfolio/DefaultElementProvider.js +1 -7
  110. package/dist/portfolio/EnhancedIndexManager.d.ts.map +1 -1
  111. package/dist/portfolio/EnhancedIndexManager.js +18 -18
  112. package/dist/portfolio/NLPScoringManager.d.ts.map +1 -1
  113. package/dist/portfolio/NLPScoringManager.js +5 -9
  114. package/dist/portfolio/PortfolioIndexManager.js +2 -2
  115. package/dist/portfolio/RelationshipManager.js +2 -2
  116. package/dist/portfolio/VerbTriggerManager.d.ts.map +1 -1
  117. package/dist/portfolio/VerbTriggerManager.js +5 -19
  118. package/dist/portfolio/config/IndexConfig.d.ts.map +1 -1
  119. package/dist/portfolio/config/IndexConfig.js +1 -12
  120. package/dist/portfolio/enhanced-index/ElementDefinitionBuilder.d.ts.map +1 -1
  121. package/dist/portfolio/enhanced-index/ElementDefinitionBuilder.js +3 -15
  122. package/dist/portfolio/enhanced-index/SemanticRelationshipService.d.ts.map +1 -1
  123. package/dist/portfolio/enhanced-index/SemanticRelationshipService.js +2 -16
  124. package/dist/portfolio/types/RelationshipTypes.d.ts.map +1 -1
  125. package/dist/portfolio/types/RelationshipTypes.js +3 -17
  126. package/dist/security/audit/config/suppressions.d.ts.map +1 -1
  127. package/dist/security/audit/config/suppressions.js +36 -8
  128. package/dist/security/constants.d.ts.map +1 -1
  129. package/dist/security/constants.js +10 -6
  130. package/dist/security/fileLockManager.d.ts.map +1 -1
  131. package/dist/security/fileLockManager.js +8 -6
  132. package/dist/security/secureYamlParser.d.ts.map +1 -1
  133. package/dist/security/secureYamlParser.js +1 -13
  134. package/dist/security/securityMonitor.d.ts +2 -1
  135. package/dist/security/securityMonitor.d.ts.map +1 -1
  136. package/dist/security/securityMonitor.js +14 -3
  137. package/dist/security/telemetry/SecurityTelemetry.d.ts +16 -0
  138. package/dist/security/telemetry/SecurityTelemetry.d.ts.map +1 -1
  139. package/dist/security/telemetry/SecurityTelemetry.js +30 -2
  140. package/dist/security/tokenManager.d.ts +3 -0
  141. package/dist/security/tokenManager.d.ts.map +1 -1
  142. package/dist/security/tokenManager.js +13 -5
  143. package/dist/security/validation/BackgroundValidator.d.ts.map +1 -1
  144. package/dist/security/validation/BackgroundValidator.js +7 -7
  145. package/dist/server/startup.d.ts.map +1 -1
  146. package/dist/server/startup.js +8 -24
  147. package/dist/server/tools/MCPAQLTools.js +4 -1
  148. package/dist/services/ActivationStore.d.ts.map +1 -1
  149. package/dist/services/ActivationStore.js +9 -3
  150. package/dist/services/FileWatchService.d.ts +1 -0
  151. package/dist/services/FileWatchService.d.ts.map +1 -1
  152. package/dist/services/FileWatchService.js +83 -48
  153. package/dist/services/MetadataService.d.ts.map +1 -1
  154. package/dist/services/MetadataService.js +7 -2
  155. package/dist/services/query/ElementQueryService.d.ts.map +1 -1
  156. package/dist/services/query/ElementQueryService.js +1 -41
  157. package/dist/services/query/PaginationService.d.ts.map +1 -1
  158. package/dist/services/query/PaginationService.js +1 -14
  159. package/dist/services/query/SortService.d.ts.map +1 -1
  160. package/dist/services/query/SortService.js +1 -6
  161. package/dist/services/validation/ValidationService.d.ts.map +1 -1
  162. package/dist/services/validation/ValidationService.js +3 -8
  163. package/dist/storage/ElementStorageLayer.d.ts.map +1 -1
  164. package/dist/storage/ElementStorageLayer.js +5 -2
  165. package/dist/storage/MemoryStorageLayer.d.ts.map +1 -1
  166. package/dist/storage/MemoryStorageLayer.js +5 -2
  167. package/dist/telemetry/OperationalTelemetry.js +2 -2
  168. package/dist/utils/EventDeduplicator.d.ts +44 -0
  169. package/dist/utils/EventDeduplicator.d.ts.map +1 -0
  170. package/dist/utils/EventDeduplicator.js +93 -0
  171. package/dist/utils/FileLock.d.ts.map +1 -1
  172. package/dist/utils/FileLock.js +1 -9
  173. package/dist/utils/PerformanceMonitor.d.ts.map +1 -1
  174. package/dist/utils/PerformanceMonitor.js +5 -5
  175. package/dist/utils/SlidingWindowRateLimiter.d.ts +13 -0
  176. package/dist/utils/SlidingWindowRateLimiter.d.ts.map +1 -0
  177. package/dist/utils/SlidingWindowRateLimiter.js +23 -0
  178. package/dist/web/console/IngestRoutes.d.ts +84 -0
  179. package/dist/web/console/IngestRoutes.d.ts.map +1 -0
  180. package/dist/web/console/IngestRoutes.js +252 -0
  181. package/dist/web/console/LeaderElection.d.ts +89 -0
  182. package/dist/web/console/LeaderElection.d.ts.map +1 -0
  183. package/dist/web/console/LeaderElection.js +205 -0
  184. package/dist/web/console/LeaderForwardingSink.d.ts +61 -0
  185. package/dist/web/console/LeaderForwardingSink.d.ts.map +1 -0
  186. package/dist/web/console/LeaderForwardingSink.js +197 -0
  187. package/dist/web/console/SessionNames.d.ts +46 -0
  188. package/dist/web/console/SessionNames.d.ts.map +1 -0
  189. package/dist/web/console/SessionNames.js +257 -0
  190. package/dist/web/console/UnifiedConsole.d.ts +64 -0
  191. package/dist/web/console/UnifiedConsole.d.ts.map +1 -0
  192. package/dist/web/console/UnifiedConsole.js +119 -0
  193. package/dist/web/contentPipeline.d.ts +58 -0
  194. package/dist/web/contentPipeline.d.ts.map +1 -0
  195. package/dist/web/contentPipeline.js +112 -0
  196. package/dist/web/portDiscovery.d.ts +58 -0
  197. package/dist/web/portDiscovery.d.ts.map +1 -0
  198. package/dist/web/portDiscovery.js +143 -0
  199. package/dist/web/public/app.js +148 -60
  200. package/dist/web/public/logs.js +638 -0
  201. package/dist/web/public/metrics.js +682 -0
  202. package/dist/web/public/permissions.js +394 -0
  203. package/dist/web/public/sessions.js +369 -0
  204. package/dist/web/routes/healthRoutes.d.ts +16 -0
  205. package/dist/web/routes/healthRoutes.d.ts.map +1 -0
  206. package/dist/web/routes/healthRoutes.js +29 -0
  207. package/dist/web/routes/logRoutes.d.ts +18 -0
  208. package/dist/web/routes/logRoutes.d.ts.map +1 -0
  209. package/dist/web/routes/logRoutes.js +126 -0
  210. package/dist/web/routes/metricsRoutes.d.ts +17 -0
  211. package/dist/web/routes/metricsRoutes.d.ts.map +1 -0
  212. package/dist/web/routes/metricsRoutes.js +90 -0
  213. package/dist/web/routes/permissionRoutes.d.ts +16 -0
  214. package/dist/web/routes/permissionRoutes.d.ts.map +1 -0
  215. package/dist/web/routes/permissionRoutes.js +133 -0
  216. package/dist/web/routes.d.ts.map +1 -1
  217. package/dist/web/routes.js +309 -339
  218. package/dist/web/server.d.ts +21 -1
  219. package/dist/web/server.d.ts.map +1 -1
  220. package/dist/web/server.js +42 -4
  221. package/dist/web/sinks/WebSSELogSink.d.ts +15 -0
  222. package/dist/web/sinks/WebSSELogSink.d.ts.map +1 -0
  223. package/dist/web/sinks/WebSSELogSink.js +22 -0
  224. package/dist/web/sinks/WebSSEMetricsSink.d.ts +16 -0
  225. package/dist/web/sinks/WebSSEMetricsSink.d.ts.map +1 -0
  226. package/dist/web/sinks/WebSSEMetricsSink.js +23 -0
  227. package/package.json +2 -2
  228. package/server.json +2 -2
  229. package/dist/constants/version.d.ts +0 -3
  230. package/dist/constants/version.d.ts.map +0 -1
  231. package/dist/constants/version.js +0 -4
  232. package/dist/logging/sinks/SSELogSink.d.ts +0 -35
  233. package/dist/logging/sinks/SSELogSink.d.ts.map +0 -1
  234. package/dist/logging/sinks/SSELogSink.js +0 -181
  235. package/dist/logging/viewer/viewerHtml.d.ts +0 -8
  236. package/dist/logging/viewer/viewerHtml.d.ts.map +0 -1
  237. package/dist/logging/viewer/viewerHtml.js +0 -204
  238. package/dist/web/public/public/app.js +0 -1878
@@ -1,1878 +0,0 @@
1
- /**
2
- * DollhouseMCP Collection Browser
3
- *
4
- * Functionality-first client-side app. No framework, no build step.
5
- * Fetches collection-index.json at runtime, renders dynamically.
6
- * All presentation is delegated to styles.css.
7
- *
8
- * Design hooks: class names follow BEM-ish conventions.
9
- * JS hooks: data-* attributes. Never use JS-hook classes for styling.
10
- */
11
-
12
- /**
13
- * Client-side YAML parser wrapper with safety hardening.
14
- *
15
- * SECURITY NOTE: This runs in the browser, not the server. SecureYamlParser
16
- * is a Node.js module and cannot be used in browser context. Instead we
17
- * harden js-yaml's load() with:
18
- * - Explicit CORE_SCHEMA (safe schema — standard types only, no custom
19
- * types, no binary, no merge keys. Matches SecureYamlParser behavior)
20
- * - Size limit to prevent YAML bomb / amplification attacks
21
- * - Error swallowing (malformed YAML returns null, never throws)
22
- *
23
- * The data being parsed is served from our own localhost server or fetched
24
- * from the GitHub collection API — both trusted sources.
25
- */
26
- const YAML_MAX_SIZE = 1024 * 512; // 512KB — generous but bounded
27
- function safeParseYaml(content) {
28
- if (!globalThis.jsyaml) return null;
29
- if (typeof content !== 'string' || content.length > YAML_MAX_SIZE) return null;
30
- try {
31
- return globalThis.jsyaml.load(content, { schema: globalThis.jsyaml.CORE_SCHEMA }) || null;
32
- } catch {
33
- return null;
34
- }
35
- }
36
-
37
- (() => {
38
- const REPO = 'DollhouseMCP/collection';
39
- const BRANCH = 'main';
40
- // Portfolio web UI always fetches collection content from GitHub
41
- const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}`;
42
- const GITHUB_BASE = `https://github.com/${REPO}/blob/${BRANCH}`;
43
-
44
- // ── Constants ──────────────────────────────────────────────────────────────
45
- const BRANCH_CHECK_CONCURRENCY = 8; // max parallel HEAD requests
46
- const SEARCH_DEBOUNCE_MS = 150; // ms delay before search fires
47
- const PORTFOLIO_MAX_DEPTH = 3; // max directory recursion depth
48
- const FILE_READ_CONCURRENCY = 20; // parallel file reads for portfolio loading
49
- const PAGE_SIZE = 50; // cards per page
50
-
51
- // ── State ──────────────────────────────────────────────────────────────────
52
-
53
- let collectionElements = []; // from collection-index.json
54
- let localElements = []; // loaded from local portfolio (~/.dollhouse/portfolio/)
55
- let allElements = []; // collectionElements + localElements
56
- let filteredElements = []; // currently displayed after search + type filter
57
- let currentPage = 1; // pagination — reset on every filter/search change
58
- let activeTypes = new Set(); // empty = show all; multi-select
59
- let openElementIndex = -1; // index of currently open modal element in filteredElements
60
- let modalShowRaw = false; // sticky raw/rendered toggle — persists across prev/next navigation
61
- let activeTopic = 'all';
62
- let highlightedCardIndex = -1; // keyboard-highlighted card in the grid
63
-
64
- // Normalize plural index keys → singular CSS/display type names
65
- const SINGULAR_TYPE = {
66
- agents: 'agent', personas: 'persona', skills: 'skill',
67
- templates: 'template', memories: 'memory', ensembles: 'ensemble',
68
- prompts: 'prompt', tools: 'tool',
69
- };
70
- let activeSort = 'date-desc';
71
- let activeSource = 'all'; // 'all' | 'collection' | 'portfolio'
72
- let searchQuery = '';
73
-
74
- // ── Bootstrap ──────────────────────────────────────────────────────────────
75
-
76
- function mergeCollectionData(data) {
77
- const CANONICAL_TYPES = new Set(['agents','personas','skills','templates','memories','ensembles']);
78
- collectionElements = Object.entries(data.index)
79
- .filter(([type]) => CANONICAL_TYPES.has(type))
80
- .flatMap(([type, elements]) =>
81
- elements.map(el => ({ ...el, type: SINGULAR_TYPE[type] || type }))
82
- );
83
- allElements = [...localElements, ...collectionElements];
84
- renderTypeFilters();
85
- renderTopicFilters();
86
- applyFilters();
87
- const statsEl = document.getElementById('stats');
88
- if (statsEl) statsEl.innerHTML = `
89
- <span class="stat"><strong>${localElements.length}</strong> portfolio</span>
90
- <span class="stat"><strong>${collectionElements.length}</strong> collection</span>
91
- `;
92
- }
93
-
94
- async function init() {
95
- try {
96
- showGridMessage('loading', 'Loading portfolio…');
97
-
98
- // Load portfolio from local API
99
- const portfolioRes = await fetch('/api/elements');
100
- if (!portfolioRes.ok) throw new Error(`HTTP ${portfolioRes.status} fetching portfolio`);
101
- const portfolioData = await portfolioRes.json();
102
-
103
- localElements = Object.entries(portfolioData.elements).flatMap(([type, elements]) =>
104
- elements.map(el => ({
105
- ...el,
106
- type: SINGULAR_TYPE[type] || type,
107
- _local: true,
108
- path: `${type}/${el.filename || el.name}`,
109
- }))
110
- );
111
-
112
- allElements = [...localElements];
113
- renderStats({ total_elements: portfolioData.totalCount, index: portfolioData.elements });
114
- renderTypeFilters();
115
- renderTopicFilters();
116
- applyFilters();
117
-
118
- // Load community collection (non-blocking — portfolio shows immediately)
119
- fetch('/api/collection')
120
- .then(r => r.ok ? r.json() : Promise.reject('not available'))
121
- .then(mergeCollectionData)
122
- .catch(() => { /* collection not available — portfolio-only mode */ });
123
-
124
- const updated = document.getElementById('footer-updated');
125
- if (updated) {
126
- updated.textContent = `Portfolio: ${localElements.length} elements`;
127
- }
128
-
129
- } catch (err) {
130
- showGridMessage('error', `Could not load portfolio: ${err.message}`);
131
- console.error('[DollhouseMCP]', {
132
- error: err.message,
133
- context: 'portfolioLoad',
134
- timestamp: new Date().toISOString(),
135
- });
136
- }
137
- }
138
-
139
- // ── Branch availability check ──────────────────────────────────────────────
140
-
141
- async function checkBranchAvailability() {
142
- // Probe each element's path; mark unavailable ones so the grid can show them dimmed.
143
- // Uses HEAD requests in parallel, capped at 8 concurrent to avoid rate limits.
144
- const CONCURRENCY = BRANCH_CHECK_CONCURRENCY;
145
- const queue = allElements.filter(el => !el._local);
146
- let dirty = false;
147
-
148
- async function probe(el) {
149
- try {
150
- const res = await fetch(`${RAW_BASE}/${el.path}`, { method: 'HEAD' });
151
- if (!res.ok) { el._unavailable = true; dirty = true; }
152
- } catch { el._unavailable = true; dirty = true; }
153
- }
154
-
155
- while (queue.length) {
156
- await Promise.all(queue.splice(0, CONCURRENCY).map(probe));
157
- }
158
-
159
- if (dirty) renderResults(); // re-render with unavailable badges applied
160
- }
161
-
162
- // ── Stats bar ──────────────────────────────────────────────────────────────
163
-
164
- function renderStats(data) {
165
- const el = document.getElementById('stats');
166
- if (!el) return;
167
- const types = Object.keys(data.index).length;
168
- el.innerHTML = `
169
- <span class="stat"><strong>${data.total_elements}</strong> elements</span>
170
- <span class="stat"><strong>${types}</strong> types</span>
171
- `;
172
- }
173
-
174
- // ── Type filter chips ──────────────────────────────────────────────────────
175
-
176
- function renderTypeFilters() {
177
- const container = document.getElementById('type-filters');
178
- if (!container) return;
179
-
180
- const typeCounts = allElements.reduce((acc, el) => {
181
- acc[el.type] = (acc[el.type] || 0) + 1;
182
- return acc;
183
- }, {});
184
-
185
- const types = ['all', ...Object.keys(typeCounts).sort((a, b) => a.localeCompare(b))];
186
-
187
- const isAllActive = activeTypes.size === 0;
188
- container.innerHTML = types.map(type => {
189
- const count = type === 'all' ? allElements.length : typeCounts[type];
190
- const isActive = type === 'all' ? isAllActive : activeTypes.has(type);
191
- return `<button
192
- class="type-filter${isActive ? ' active' : ''}"
193
- data-type="${escapeAttr(type)}"
194
- aria-pressed="${isActive}"
195
- >${capitalize(type)} <span class="filter-count">${count}</span></button>`;
196
- }).join('');
197
-
198
- // Replace listener (clone node removes old listeners)
199
- const fresh = container.cloneNode(true);
200
- container.parentNode.replaceChild(fresh, container);
201
- fresh.addEventListener('click', e => {
202
- const btn = e.target.closest('[data-type]');
203
- if (!btn) return;
204
- const t = btn.dataset.type;
205
- if (t === 'all') {
206
- activeTypes.clear();
207
- } else if (activeTypes.has(t)) {
208
- activeTypes.delete(t);
209
- } else {
210
- activeTypes.add(t);
211
- }
212
- fresh.querySelectorAll('.type-filter').forEach(b => {
213
- const isAll = b.dataset.type === 'all';
214
- const active = isAll ? activeTypes.size === 0 : activeTypes.has(b.dataset.type);
215
- b.classList.toggle('active', active);
216
- b.setAttribute('aria-pressed', active);
217
- });
218
- applyFilters();
219
- });
220
- }
221
-
222
- // ── Topic filter chips ─────────────────────────────────────────────────────
223
-
224
- // Map raw tags → normalized topic buckets
225
- const TOPIC_MAP = {
226
- 'professional': 'Professional',
227
- 'business': 'Business', 'strategy': 'Business', 'consulting': 'Business', 'finance': 'Business',
228
- 'development': 'Development', 'programming': 'Development', 'code': 'Development',
229
- 'software-engineering': 'Development', 'code-review': 'Development', 'code-quality': 'Development',
230
- 'security': 'Security', 'vulnerability': 'Security', 'compliance': 'Security',
231
- 'code-security': 'Security', 'codeql': 'Security', 'security-analysis': 'Security',
232
- 'writing': 'Writing', 'creative-writing': 'Writing', 'storytelling': 'Writing',
233
- 'content': 'Writing', 'copywriting': 'Writing', 'narrative': 'Writing',
234
- 'research': 'Research', 'academic': 'Research', 'analysis': 'Research',
235
- 'literature-review': 'Research', 'data-analysis': 'Research',
236
- 'productivity': 'Productivity', 'task-management': 'Productivity', 'organization': 'Productivity',
237
- 'workflow': 'Productivity', 'efficiency': 'Productivity',
238
- 'education': 'Education', 'learning': 'Education', 'teaching': 'Education', 'tutorial': 'Education',
239
- 'creative': 'Creative', 'design': 'Creative', 'art': 'Creative',
240
- 'personal': 'Personal',
241
- };
242
-
243
- function getTopicForElement(el) {
244
- if (!el.tags?.length) return el.category ? capitalize(el.category) : null;
245
- for (const tag of el.tags) {
246
- const t = tag.toLowerCase();
247
- if (TOPIC_MAP[t]) return TOPIC_MAP[t];
248
- }
249
- return null;
250
- }
251
-
252
- function renderTopicFilters() {
253
- const container = document.getElementById('topic-filters');
254
- if (!container) return;
255
-
256
- const topicCounts = {};
257
- allElements.forEach(el => {
258
- const topic = getTopicForElement(el);
259
- if (topic) topicCounts[topic] = (topicCounts[topic] || 0) + 1;
260
- });
261
-
262
- const topics = ['all', ...Object.keys(topicCounts).sort((a, b) => a.localeCompare(b))];
263
- if (topics.length <= 2) { container.hidden = true; return; } // not enough to be useful
264
-
265
- container.hidden = false;
266
- container.innerHTML = topics.map(topic => {
267
- const count = topic === 'all' ? allElements.length : topicCounts[topic];
268
- const isActive = topic === activeTopic;
269
- return `<button
270
- class="topic-filter${isActive ? ' active' : ''}"
271
- data-topic="${escapeAttr(topic)}"
272
- aria-pressed="${isActive}"
273
- >${escapeHtml(topic === 'all' ? 'All topics' : topic)} <span class="filter-count">${count}</span></button>`;
274
- }).join('');
275
-
276
- container.addEventListener('click', e => {
277
- const btn = e.target.closest('[data-topic]');
278
- if (!btn) return;
279
- activeTopic = btn.dataset.topic;
280
- container.querySelectorAll('.topic-filter').forEach(b => {
281
- const active = b.dataset.topic === activeTopic;
282
- b.classList.toggle('active', active);
283
- b.setAttribute('aria-pressed', active);
284
- });
285
- applyFilters();
286
- });
287
- }
288
-
289
- // ── Search ─────────────────────────────────────────────────────────────────
290
-
291
- let searchTimer;
292
- function onSearch(e) {
293
- clearTimeout(searchTimer);
294
- searchTimer = setTimeout(() => {
295
- searchQuery = e.target.value.trim().toLowerCase();
296
- applyFilters();
297
- }, SEARCH_DEBOUNCE_MS);
298
- }
299
-
300
- // ── Filter + render pipeline ───────────────────────────────────────────────
301
-
302
- function applyFilters() {
303
- currentPage = 1;
304
- filteredElements = allElements.filter(el => {
305
- if (activeTypes.size > 0 && !activeTypes.has(el.type)) return false;
306
- if (activeTopic !== 'all' && getTopicForElement(el) !== activeTopic) return false;
307
- if (activeSource === 'collection' && el._local) return false;
308
- if (activeSource === 'portfolio' && !el._local) return false;
309
- if (!searchQuery) return true;
310
- const searchable = [
311
- el.name, el.description, el.author, el.category,
312
- ...(Array.isArray(el.tags) ? el.tags : []),
313
- ...(Array.isArray(el.keywords) ? el.keywords : []),
314
- ].filter(Boolean).join(' ').toLowerCase();
315
- return searchable.includes(searchQuery);
316
- });
317
- renderResults();
318
- }
319
-
320
- // ── Card grid ──────────────────────────────────────────────────────────────
321
-
322
- function sortElements(elements) {
323
- const sorted = [...elements];
324
- switch (activeSort) {
325
- case 'name-asc': return sorted.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
326
- case 'name-desc': return sorted.sort((a, b) => (b.name || '').localeCompare(a.name || ''));
327
- case 'date-asc': return sorted.sort((a, b) => {
328
- const da = a.created ? new Date(a.created).getTime() : 0;
329
- const db = b.created ? new Date(b.created).getTime() : 0;
330
- return da - db;
331
- });
332
- case 'date-desc': return sorted.sort((a, b) => {
333
- const da = a.created ? new Date(a.created).getTime() : 0;
334
- const db = b.created ? new Date(b.created).getTime() : 0;
335
- return db - da;
336
- });
337
- case 'type-asc': return sorted.sort((a, b) => (a.type || '').localeCompare(b.type || '') || (a.name || '').localeCompare(b.name || ''));
338
- default: return sorted;
339
- }
340
- }
341
-
342
- function renderResults() {
343
- const grid = document.getElementById('elements-grid');
344
- const countEl = document.getElementById('results-count');
345
- const announcer = document.getElementById('results-announcer');
346
- if (!grid) return;
347
-
348
- filteredElements = sortElements(filteredElements);
349
- highlightedCardIndex = -1; // reset grid keyboard selection on re-render
350
-
351
- const total = filteredElements.length;
352
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
353
- if (currentPage > totalPages) currentPage = totalPages;
354
- const pageStart = (currentPage - 1) * PAGE_SIZE;
355
- const pageEnd = Math.min(pageStart + PAGE_SIZE, total);
356
- const pageItems = filteredElements.slice(pageStart, pageEnd);
357
-
358
- if (countEl) {
359
- const base = total === allElements.length
360
- ? `${allElements.length} elements`
361
- : `${total} of ${allElements.length} elements`;
362
- const pageNote = totalPages > 1 ? ` · page ${currentPage} of ${totalPages}` : '';
363
- countEl.textContent = base + pageNote;
364
- }
365
-
366
- if (announcer) {
367
- announcer.textContent = total === allElements.length
368
- ? `Showing all ${allElements.length} elements`
369
- : `Found ${total} of ${allElements.length} elements`;
370
- }
371
-
372
- if (total === 0) {
373
- showGridMessage('empty-state', searchQuery
374
- ? `No elements match "${searchQuery}".`
375
- : 'No elements found.');
376
- renderPagination(0, 1);
377
- return;
378
- }
379
-
380
- grid.innerHTML = pageItems.map((el, i) => {
381
- const idx = pageStart + i; // absolute index into filteredElements
382
- const unavailable = el._unavailable;
383
- const compSummary = renderComponentSummary(el);
384
- return `
385
- <article
386
- class="element-card"
387
- data-index="${idx}"
388
- data-type="${escapeAttr(el.type)}"
389
- ${unavailable ? 'data-unavailable=""' : ''}
390
- role="listitem button"
391
- tabindex="0"
392
- aria-label="${unavailable ? 'Unavailable: ' : 'View '}${escapeHtml(el.name)}"
393
- >
394
- <div class="card-header">
395
- <h3 class="card-title">${escapeHtml(el.name)}</h3>
396
- <div class="card-badges">
397
- <span class="type-badge" data-type="${escapeAttr(el.type)}">${capitalize(el.type)}</span>
398
- ${el._local ? '<span class="source-badge">LOCAL</span>' : ''}
399
- ${unavailable ? '<span class="unavailable-badge">unavailable</span>' : ''}
400
- </div>
401
- <span class="card-expand-icon" aria-hidden="true">▾</span>
402
- </div>
403
- ${el.description
404
- ? `<p class="card-description">${escapeHtml(el.description)}</p>`
405
- : ''}
406
- ${compSummary}
407
- <footer class="card-footer">
408
- <div class="card-meta">
409
- ${el.author ? `<span class="meta-author">${escapeHtml(el.author)}</span>` : ''}
410
- ${el.version ? `<span class="meta-version">v${escapeHtml(el.version)}</span>` : ''}
411
- ${el.category ? `<span class="meta-category">${escapeHtml(el.category)}</span>` : ''}
412
- ${el.created ? `<span class="meta-date">${formatDate(el.created)}</span>` : ''}
413
- </div>
414
- <div class="card-actions">
415
- <button class="card-download-btn" data-action="download" aria-label="Download ${escapeHtml(el.name)}">⤓</button>
416
- </div>
417
- ${el.tags?.length
418
- ? `<ul class="card-tags" aria-label="Tags">${
419
- el.tags.slice(0, 5).map(t =>
420
- `<li class="tag">${escapeHtml(t)}</li>`
421
- ).join('')
422
- }</ul>`
423
- : ''}
424
- </footer>
425
- <div class="card-inline-detail"></div>
426
- </article>
427
- `}).join('');
428
-
429
- // Single delegated listener for the grid
430
- grid.onclick = handleCardClick;
431
- grid.onkeydown = e => {
432
- if (e.key === 'Enter' || e.key === ' ') {
433
- e.preventDefault();
434
- handleCardClick(e);
435
- }
436
- };
437
-
438
- renderPagination(total, totalPages);
439
- }
440
-
441
- function renderPagination(total, totalPages) {
442
- const nav = document.getElementById('pagination');
443
- const prevBtn = document.getElementById('btn-prev-page');
444
- const nextBtn = document.getElementById('btn-next-page');
445
- const info = document.getElementById('page-info');
446
- if (!nav) return;
447
-
448
- if (totalPages <= 1) { nav.hidden = true; return; }
449
-
450
- nav.hidden = false;
451
- if (prevBtn) {
452
- prevBtn.disabled = currentPage <= 1;
453
- prevBtn.onclick = () => { currentPage--; renderResults(); window.scrollTo({ top: 0, behavior: 'smooth' }); };
454
- }
455
- if (nextBtn) {
456
- nextBtn.disabled = currentPage >= totalPages;
457
- nextBtn.onclick = () => { currentPage++; renderResults(); window.scrollTo({ top: 0, behavior: 'smooth' }); };
458
- }
459
- if (info) {
460
- const pageStart = (currentPage - 1) * PAGE_SIZE + 1;
461
- const pageEnd = Math.min(currentPage * PAGE_SIZE, total);
462
- info.textContent = `${pageStart}–${pageEnd} of ${total}`;
463
- }
464
- }
465
-
466
- function showGridMessage(cls, text) {
467
- const grid = document.getElementById('elements-grid');
468
- if (grid) grid.innerHTML = `<p class="${escapeAttr(cls)}">${escapeHtml(text)}</p>`;
469
- }
470
-
471
- // ── Modal ──────────────────────────────────────────────────────────────────
472
-
473
- function handleCardClick(e) {
474
- // Download button — fetch and save without opening modal/expand
475
- if (e.target.closest('[data-action="download"]')) {
476
- e.stopPropagation();
477
- const card = e.target.closest('[data-index]');
478
- if (!card) return;
479
- const el = filteredElements[Number.parseInt(card.dataset.index, 10)];
480
- const btn = e.target.closest('[data-action="download"]');
481
- const prev = btn.textContent;
482
- if (el._local && el._content) {
483
- downloadFile(el.name, el._content);
484
- } else {
485
- btn.textContent = '…';
486
- fetch(`${RAW_BASE}/${el.path}`)
487
- .then(r => r.ok ? r.text() : Promise.reject(r.status))
488
- .then(content => { downloadFile(el.name, content); btn.textContent = prev; })
489
- .catch(() => { btn.textContent = '✗'; setTimeout(() => { btn.textContent = prev; }, 1500); });
490
- }
491
- return;
492
- }
493
-
494
- const card = e.target.closest('[data-index]');
495
- if (!card) return;
496
- const idx = Number.parseInt(card.dataset.index, 10);
497
- const el = filteredElements[idx];
498
- const grid = document.getElementById('elements-grid');
499
- const isListView = grid?.dataset.view === 'list';
500
-
501
- if (isListView) {
502
- // Don't collapse when clicking inside expanded content
503
- if (e.target.closest('.card-inline-detail')) return;
504
- toggleInlineExpand(card, el);
505
- } else if (!card.dataset.unavailable) {
506
- openModal(el, idx);
507
- }
508
- }
509
-
510
- async function toggleInlineExpand(card, el) {
511
- const detail = card.querySelector('.card-inline-detail');
512
- if (!detail) return;
513
-
514
- if (card.dataset.expanded !== undefined) {
515
- delete card.dataset.expanded;
516
- detail.innerHTML = '';
517
- return;
518
- }
519
-
520
- card.dataset.expanded = '';
521
- if (!el._local) {
522
- detail.innerHTML = '<p class="loading" style="font-size:0.8rem;padding:0.4rem 0">Loading…</p>';
523
- }
524
-
525
- try {
526
- let content;
527
- if (el._content) {
528
- content = el._content;
529
- } else if (el._local) {
530
- const res = await fetch(`/api/elements/${el.path}`);
531
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
532
- content = await res.text();
533
- } else {
534
- const res = await fetch(`https://raw.githubusercontent.com/DollhouseMCP/collection/main/${el.path}`);
535
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
536
- content = await res.text();
537
- }
538
- detail.innerHTML = '';
539
-
540
- // Action bar at TOP of expanded content
541
- const actions = document.createElement('div');
542
- actions.className = 'inline-detail-actions';
543
- const copyBtn = document.createElement('button');
544
- copyBtn.className = 'modal-action-btn';
545
- copyBtn.textContent = '⎘ Copy';
546
- copyBtn.onclick = e => { e.stopPropagation(); copyToClipboard(content, copyBtn); };
547
-
548
- const dlBtn = document.createElement('button');
549
- dlBtn.className = 'modal-action-btn';
550
- dlBtn.textContent = '⤓ Download';
551
- dlBtn.onclick = e => { e.stopPropagation(); downloadFile(el.name, content); };
552
-
553
- actions.appendChild(copyBtn);
554
- actions.appendChild(dlBtn);
555
-
556
- if (el._local) {
557
- const submitBtn2 = document.createElement('button');
558
- submitBtn2.className = 'modal-action-btn modal-action-btn--submit';
559
- submitBtn2.type = 'button';
560
- submitBtn2.textContent = '↑ Submit';
561
- submitBtn2.onclick = e => { e.stopPropagation(); openSubmitIssue(el.name, el.type, content); };
562
- actions.appendChild(submitBtn2);
563
- } else {
564
- const ghLink = document.createElement('a');
565
- ghLink.className = 'modal-action-btn';
566
- ghLink.href = `${GITHUB_BASE}/${el.path}`;
567
- ghLink.target = '_blank';
568
- ghLink.rel = 'noopener noreferrer';
569
- ghLink.textContent = '↗ GitHub';
570
- actions.appendChild(ghLink);
571
- }
572
- detail.appendChild(actions);
573
-
574
- const contentDiv = document.createElement('div');
575
- contentDiv.innerHTML = renderDetailView(content, el.type);
576
- contentDiv.querySelectorAll('pre code').forEach(block => {
577
- if (globalThis.hljs) hljs.highlightElement(block);
578
- });
579
- detail.appendChild(contentDiv);
580
-
581
- } catch (err) {
582
- detail.innerHTML = `<p class="error" style="font-size:0.8rem">Could not load: ${escapeHtml(err.message)}</p>`;
583
- }
584
- }
585
-
586
- function setupModalNav(index) {
587
- const prevElBtn = document.getElementById('btn-prev-element');
588
- const nextElBtn = document.getElementById('btn-next-element');
589
- const navCount = document.getElementById('modal-nav-count');
590
- if (prevElBtn) {
591
- prevElBtn.disabled = index <= 0;
592
- prevElBtn.onclick = () => {
593
- if (openElementIndex > 0) openModal(filteredElements[openElementIndex - 1], openElementIndex - 1);
594
- };
595
- }
596
- if (nextElBtn) {
597
- nextElBtn.disabled = index < 0 || index >= filteredElements.length - 1;
598
- nextElBtn.onclick = () => {
599
- if (openElementIndex < filteredElements.length - 1) openModal(filteredElements[openElementIndex + 1], openElementIndex + 1);
600
- };
601
- }
602
- if (navCount) {
603
- navCount.textContent = index >= 0 ? `${index + 1} / ${filteredElements.length}` : '';
604
- }
605
- }
606
-
607
- function setupModalMeta(element, modal) {
608
- modal.querySelector('.modal-title').textContent = element.name;
609
- modal.querySelector('.modal-type').textContent = capitalize(element.type);
610
- modal.querySelector('.modal-author').textContent = element.author ? `by ${element.author}` : '';
611
- modal.querySelector('.modal-version').textContent = element.version ? `v${element.version}` : '';
612
- const modalDate = modal.querySelector('.modal-date');
613
- const modalSource = modal.querySelector('.modal-source');
614
- if (modalDate) modalDate.textContent = element.created ? formatDate(element.created) : '';
615
- if (modalSource) modalSource.textContent = element._local ? 'LOCAL' : '';
616
- }
617
-
618
- async function installElement(element, btn) {
619
- const prev = btn.textContent;
620
- btn.textContent = '⏳ Installing…';
621
- btn.disabled = true;
622
- try {
623
- const res = await fetch('/api/install', {
624
- method: 'POST',
625
- headers: { 'Content-Type': 'application/json' },
626
- body: JSON.stringify({ path: element.path, name: element.name, type: element.type }),
627
- });
628
- const data = await res.json();
629
- if (res.ok && data.success) {
630
- btn.textContent = '✅ Installed!';
631
- setTimeout(() => { btn.textContent = prev; btn.disabled = false; }, 3000);
632
- } else {
633
- btn.textContent = `✗ ${data.error || 'Failed'}`;
634
- setTimeout(() => { btn.textContent = prev; btn.disabled = false; }, 3000);
635
- }
636
- } catch (err) {
637
- btn.textContent = '✗ Error';
638
- setTimeout(() => { btn.textContent = prev; btn.disabled = false; }, 3000);
639
- }
640
- }
641
-
642
- function setupModalLinks(element, modal) {
643
- const ghLink = modal.querySelector('#btn-github');
644
- if (ghLink) {
645
- if (element._local) {
646
- ghLink.style.display = 'none';
647
- } else {
648
- ghLink.style.display = '';
649
- ghLink.href = `${GITHUB_BASE}/${element.path}`;
650
- }
651
- }
652
- // Install button for collection elements
653
- let installBtn = modal.querySelector('#btn-install');
654
- if (!installBtn) {
655
- // Create install button if it doesn't exist in the HTML
656
- const toolbar = modal.querySelector('#modal-toolbar');
657
- if (toolbar) {
658
- installBtn = document.createElement('button');
659
- installBtn.className = 'modal-action-btn modal-action-btn--submit';
660
- installBtn.id = 'btn-install';
661
- installBtn.type = 'button';
662
- toolbar.insertBefore(installBtn, toolbar.querySelector('#modal-nav'));
663
- }
664
- }
665
- if (installBtn) {
666
- if (!element._local) {
667
- installBtn.style.display = '';
668
- installBtn.textContent = '⤓ Install';
669
- installBtn.onclick = (e) => { e.preventDefault(); installElement(element, installBtn); };
670
- } else {
671
- installBtn.style.display = 'none';
672
- }
673
- }
674
- const submitBtn = modal.querySelector('#btn-submit');
675
- if (submitBtn) {
676
- if (element._local) {
677
- submitBtn.style.display = '';
678
- submitBtn.dataset.elementName = element.name;
679
- submitBtn.dataset.elementType = element.type;
680
- } else {
681
- submitBtn.style.display = 'none';
682
- }
683
- }
684
- return submitBtn;
685
- }
686
-
687
- async function openModal(element, index = -1) {
688
- const modal = document.getElementById('element-modal');
689
- if (!modal) return;
690
-
691
- openElementIndex = index;
692
- setupModalNav(index);
693
- setupModalMeta(element, modal);
694
- const submitBtn = setupModalLinks(element, modal);
695
-
696
- // Reset action buttons
697
- const copyBtn = modal.querySelector('#btn-copy');
698
- const downloadBtn = modal.querySelector('#btn-download');
699
- copyBtn.onclick = null;
700
- downloadBtn.onclick = null;
701
- copyBtn.textContent = '⎘ Copy';
702
-
703
- // Show modal with loading state
704
- const body = document.getElementById('modal-body');
705
- body.innerHTML = '<p class="loading">Loading content…</p>';
706
- body.tabIndex = -1; // make scrollable body focusable for keyboard scrolling
707
- modal.showModal();
708
- document.body.classList.add('modal-open');
709
- body.focus(); // focus body so arrow/Page/Home/End keys scroll content natively
710
-
711
- // Fetch full content as raw text — local API for portfolio, GitHub for collection
712
- try {
713
- let content;
714
- if (element._content) {
715
- content = element._content;
716
- } else if (element._local) {
717
- const res = await fetch(`/api/elements/${element.path}`);
718
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
719
- content = await res.text();
720
- } else {
721
- const res = await fetch(`https://raw.githubusercontent.com/DollhouseMCP/collection/main/${element.path}`);
722
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
723
- content = await res.text();
724
- }
725
-
726
- const renderBtn = modal.querySelector('#btn-render');
727
-
728
- function renderModalBody() {
729
- if (modalShowRaw) {
730
- body.innerHTML = `<pre class="element-source"><code class="element-code language-yaml">${escapeHtml(content)}</code></pre>`;
731
- if (globalThis.hljs) body.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b));
732
- } else {
733
- body.innerHTML = renderDetailView(content, element.type);
734
- body.querySelectorAll('pre code').forEach(b => {
735
- if (globalThis.hljs) hljs.highlightElement(b);
736
- });
737
- }
738
- }
739
-
740
- renderModalBody();
741
-
742
- if (renderBtn) {
743
- // Reflect current sticky state so button label matches on navigation
744
- renderBtn.textContent = modalShowRaw ? '⇄ Rendered' : '⇄ Raw';
745
- renderBtn.dataset.mode = modalShowRaw ? 'raw' : 'rendered';
746
- renderBtn.onclick = () => {
747
- modalShowRaw = !modalShowRaw;
748
- renderBtn.textContent = modalShowRaw ? '⇄ Rendered' : '⇄ Raw';
749
- renderBtn.dataset.mode = modalShowRaw ? 'raw' : 'rendered';
750
- renderModalBody();
751
- };
752
- }
753
-
754
- copyBtn.onclick = () => copyToClipboard(content, copyBtn);
755
- downloadBtn.onclick = () => downloadFile(element.name, content);
756
-
757
- if (element._local && submitBtn) {
758
- submitBtn.onclick = e => { e.preventDefault(); openSubmitIssue(element.name, element.type, content); };
759
- }
760
-
761
- } catch (err) {
762
- body.innerHTML = `<p class="error">Could not load content: ${escapeHtml(err.message)}</p>
763
- <p class="error-hint">
764
- <a href="${GITHUB_BASE}/${element.path}" target="_blank" rel="noopener noreferrer">
765
- View on GitHub directly
766
- </a>
767
- </p>`;
768
- console.error('[DollhouseMCP]', {
769
- error: err.message,
770
- context: 'modalLoad',
771
- element: element.path,
772
- timestamp: new Date().toISOString(),
773
- });
774
- }
775
- }
776
-
777
- // ── Detail view renderer ───────────────────────────────────────────────────
778
-
779
- function parseFrontmatter(raw) {
780
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
781
- if (!match) return { frontmatter: {}, body: raw };
782
- let fm = {};
783
- try {
784
- fm = safeParseYaml(match[1]) || {};
785
- } catch {
786
- fm = {};
787
- }
788
- const body = raw.slice(match[0].length).trim();
789
- return { frontmatter: fm, body };
790
- }
791
-
792
- function renderComponentSummary(el) {
793
- // Only for ensembles — show component type counts from index metadata
794
- if (el.type !== 'ensemble' && el.type !== 'ensembles') return '';
795
- const counts = ['personas','skills','tools','templates','prompts','memories']
796
- .filter(k => Array.isArray(el[k]) && el[k].length)
797
- .map(k => `${el[k].length} ${k}`);
798
- if (!counts.length) return '';
799
- return `<p class="card-components">${counts.join(' · ')}</p>`;
800
- }
801
-
802
- // Fields whose string values are assumed to contain markdown content
803
- const MEMORY_MARKDOWN_FIELDS = new Set([
804
- 'content', 'body', 'text', 'notes', 'summary', 'context', 'observations',
805
- 'insights', 'instructions', 'thoughts', 'analysis', 'reflection', 'outcome',
806
- 'details', 'log', 'data', 'value', 'message', 'description',
807
- ]);
808
-
809
- // Heuristic: does a multi-line string look like it has markdown syntax?
810
- function looksLikeMarkdown(str) {
811
- if (typeof str !== 'string' || !str.includes('\n')) return false;
812
- return /^(?:#{1,6}\s|\s{0,3}[-*+]\s|\s{0,3}\d+\.\s|>\s|```|\*\*|__|!\[)/m.test(str);
813
- }
814
-
815
- // Render a single memory entry object — markdown fields prominent, scalars in meta footer.
816
- function renderMemoryEntry(item) {
817
- if (typeof item !== 'object' || item === null) {
818
- return `<li>${escapeHtml(String(item))}</li>`;
819
- }
820
- let entryBody = '';
821
- const metaParts = [];
822
- for (const [k, v] of Object.entries(item)) {
823
- if (typeof v === 'string' && (MEMORY_MARKDOWN_FIELDS.has(k) || looksLikeMarkdown(v))) {
824
- entryBody += globalThis.marked
825
- ? `<div class="element-rendered memory-entry-content">${sanitizeHtml(marked.parse(v))}</div>`
826
- : `<pre class="detail-multiline">${escapeHtml(v)}</pre>`;
827
- } else if (Array.isArray(v)) {
828
- if (v.length) metaParts.push(`<span class="memory-meta-key">${escapeHtml(k)}</span> ${v.map(i => escapeHtml(String(i))).join(', ')}`);
829
- } else if (typeof v !== 'object' && v != null && v !== '') {
830
- metaParts.push(`<span class="memory-meta-key">${escapeHtml(k.replaceAll('_', ' '))}</span> ${escapeHtml(String(v))}`);
831
- }
832
- }
833
- const metaRow = metaParts.length ? `<div class="memory-entry-meta">${metaParts.join(' · ')}</div>` : '';
834
- return `<li class="memory-entry">${entryBody}${metaRow}</li>`;
835
- }
836
-
837
- function renderMemoryField(key, value) {
838
- const label = key.replaceAll('_', ' ');
839
- if (typeof value === 'string') {
840
- const isMarkdown = MEMORY_MARKDOWN_FIELDS.has(key) || looksLikeMarkdown(value);
841
- if (isMarkdown && globalThis.marked) {
842
- return detailSection(label, `<div class="element-rendered">${sanitizeHtml(marked.parse(value))}</div>`);
843
- }
844
- if (value.includes('\n')) {
845
- return detailSection(label, `<pre class="detail-multiline">${escapeHtml(value)}</pre>`);
846
- }
847
- return detailSection(label, `<p class="detail-prose">${escapeHtml(value)}</p>`);
848
- }
849
- if (Array.isArray(value)) {
850
- const items = value.map(item => renderMemoryEntry(item)).join('');
851
- return detailSection(label, `<ul class="memory-entries-list">${items}</ul>`);
852
- }
853
- if (typeof value === 'object' && value !== null) {
854
- const rows = Object.entries(value).map(([k, v]) =>
855
- detailField(k.replaceAll('_', ' '), typeof v === 'object' ? JSON.stringify(v) : String(v))
856
- ).filter(Boolean).join('');
857
- return rows ? detailSection(label, rows) : '';
858
- }
859
- if (value != null && value !== '') {
860
- return detailSection(label, `<p class="detail-prose">${escapeHtml(String(value))}</p>`);
861
- }
862
- return '';
863
- }
864
-
865
- // Render a pure-YAML memory file: parse each field, detect markdown, render appropriately
866
- function renderMemoryView(content) {
867
- let parsed;
868
- parsed = safeParseYaml(content);
869
- if (!parsed || typeof parsed !== 'object') {
870
- return `<pre class="element-source"><code class="element-code">${escapeHtml(content)}</code></pre>`;
871
- }
872
-
873
- let html = '';
874
-
875
- // Standard metadata at top
876
- const createdVal = parsed.created || parsed.created_date;
877
- if (createdVal) {
878
- html += `<div class="detail-created"><span class="detail-created-label">Created</span><span class="detail-created-value">${escapeHtml(formatDate(createdVal))}</span></div>`;
879
- }
880
- const meta = [detailField('Author', parsed.author), detailField('ID', parsed.unique_id || parsed.id)].filter(Boolean).join('');
881
- if (meta) html += detailSection('Details', meta);
882
- if (Array.isArray(parsed.tags) && parsed.tags.length) {
883
- html += detailSection('Tags', detailPillList(parsed.tags, 'pill-tag'));
884
- }
885
-
886
- // Render all remaining fields
887
- const SKIP = new Set(['name','type','created','created_date','updated','author','version','tags','unique_id','id']);
888
- for (const [key, value] of Object.entries(parsed)) {
889
- if (SKIP.has(key)) continue;
890
- html += renderMemoryField(key, value);
891
- }
892
- return html || `<pre class="element-source"><code class="element-code">${escapeHtml(content)}</code></pre>`;
893
- }
894
-
895
- function renderGoalSection(goal) {
896
- let goalHtml = '';
897
- if (goal.template) {
898
- const tplHtml = escapeHtml(String(goal.template))
899
- .replaceAll(/\{([^}]{1,100})\}/g, '<span class="detail-template-param">{$1}</span>');
900
- goalHtml += `<div class="detail-goal-template">${tplHtml}</div>`;
901
- }
902
- if (Array.isArray(goal.successCriteria) && goal.successCriteria.length) {
903
- const criteriaItems = goal.successCriteria.map(c => `<li>${escapeHtml(c)}</li>`).join('');
904
- goalHtml += `<h5 class="detail-subsection-title">Success criteria</h5>
905
- <ul class="detail-list">${criteriaItems}</ul>`;
906
- }
907
- if (Array.isArray(goal.parameters) && goal.parameters.length) {
908
- goalHtml += `<h5 class="detail-subsection-title">Parameters</h5>`;
909
- goalHtml += goal.parameters.map(p =>
910
- `<div class="detail-param">
911
- <div class="detail-param-header">
912
- <span class="detail-param-name">${escapeHtml(p.name || '')}</span>
913
- ${p.type ? `<span class="detail-pill pill-meta">${escapeHtml(p.type)}</span>` : ''}
914
- ${p.required ? `<span class="detail-pill pill-required">required</span>` : '<span class="detail-pill">optional</span>'}
915
- </div>
916
- ${p.description ? `<span class="detail-param-desc">${escapeHtml(p.description)}</span>` : ''}
917
- </div>`
918
- ).join('');
919
- }
920
- return goalHtml ? detailSection('Goal', goalHtml) : '';
921
- }
922
-
923
- function renderAutonomySection(a) {
924
- let aHtml = ['maxSteps','maxAutonomousSteps','safetyTier','riskTolerance']
925
- .filter(k => a[k] != null)
926
- .map(k => detailField(k.replaceAll(/([A-Z])/g, ' $1').toLowerCase().trim(), String(a[k])))
927
- .join('');
928
- if (Array.isArray(a.autoApprove) && a.autoApprove.length) {
929
- 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>`;
930
- }
931
- if (Array.isArray(a.requiresApproval) && a.requiresApproval.length) {
932
- 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>`;
933
- }
934
- return aHtml ? detailSection('Autonomy', aHtml) : '';
935
- }
936
-
937
- function renderGatekeeperSection(g) {
938
- let gHtml = '';
939
- 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>`;
940
- 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>`;
941
- 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>`;
942
- return gHtml ? detailSection('Gatekeeper', gHtml) : '';
943
- }
944
-
945
- // ── Agent sub-section helpers (extracted for cognitive complexity) ──────
946
-
947
- function renderLegacyGoals(fm) {
948
- if (!fm.goals || typeof fm.goals !== 'object') return '';
949
- let goalsHtml = '';
950
- if (fm.goals.primary) goalsHtml += `<p class="detail-prose">${escapeHtml(String(fm.goals.primary))}</p>`;
951
- if (Array.isArray(fm.goals.secondary) && fm.goals.secondary.length) {
952
- const items = fm.goals.secondary.map(g => `<li>${escapeHtml(g)}</li>`).join('');
953
- goalsHtml += `<ul class="detail-list">${items}</ul>`;
954
- }
955
- return goalsHtml ? detailSection('Goals', goalsHtml) : '';
956
- }
957
-
958
- function renderStateSection(fm) {
959
- if (!fm.state || typeof fm.state !== 'object') return '';
960
- let stateHtml = '';
961
- for (const [k, v] of Object.entries(fm.state)) {
962
- if (Array.isArray(v)) {
963
- stateHtml += `<div class="detail-field"><span class="detail-label">${escapeHtml(k.replaceAll('_', ' '))}</span><span class="detail-value">${detailPillList(v)}</span></div>`;
964
- } else {
965
- stateHtml += detailField(k.replaceAll('_', ' '), String(v));
966
- }
967
- }
968
- return stateHtml ? detailSection('State', stateHtml) : '';
969
- }
970
-
971
- function renderAgentToolsSection(fm) {
972
- if (!fm.tools || typeof fm.tools !== 'object') return '';
973
- let toolsHtml = '';
974
- if (Array.isArray(fm.tools.allowed) && fm.tools.allowed.length) {
975
- 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>`;
976
- }
977
- if (Array.isArray(fm.tools.denied) && fm.tools.denied.length) {
978
- 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>`;
979
- }
980
- return toolsHtml ? detailSection('Tools', toolsHtml) : '';
981
- }
982
-
983
- function renderAgentV1Config(fm) {
984
- const agentConfig = ['decisionFramework','riskTolerance','learningEnabled','maxConcurrentGoals']
985
- .map(k => detailField(k.replaceAll(/([A-Z])/g, ' $1').toLowerCase().trim(), fm[k] == null ? null : String(fm[k])))
986
- .filter(Boolean).join('');
987
- return agentConfig ? detailSection('Configuration', agentConfig) : '';
988
- }
989
-
990
- function renderMarkdownOrPre(text) {
991
- return globalThis.marked
992
- ? `<div class="element-rendered">${sanitizeHtml(marked.parse(String(text)))}</div>`
993
- : `<pre class="detail-multiline">${escapeHtml(String(text))}</pre>`;
994
- }
995
-
996
- /**
997
- * Render instructions text with smart segmentation.
998
- *
999
- * Detects directive-style instructions (lines starting with command-voice
1000
- * keywords like ALWAYS, NEVER, WHEN, YOU ARE, PREFER, etc.) and renders
1001
- * each directive as a visually separated block. Falls back to standard
1002
- * markdown rendering for non-directive content.
1003
- */
1004
- const DIRECTIVE_KEYWORDS = [
1005
- 'YOU','ALWAYS','NEVER','WHEN','PREFER','DO',"DON'T",'DONT','MUST','SHOULD',
1006
- 'IF','FOR','ENSURE','MAINTAIN','USE','AVOID','FOLLOW','PRIORITIZE','FOCUS',
1007
- 'REMEMBER','NOTE','IMPORTANT','CRITICAL',
1008
- ];
1009
- const DIRECTIVE_PATTERN = new RegExp(
1010
- `^(${DIRECTIVE_KEYWORDS.join('|')})(\\s)`, 'i'
1011
- );
1012
-
1013
- function renderInstructions(text) {
1014
- if (!text) return '';
1015
- const str = String(text);
1016
-
1017
- // Split on double newlines (paragraph boundaries)
1018
- const paragraphs = str.split(/\n\n+/).filter(p => p.trim());
1019
- if (paragraphs.length === 0) return '';
1020
-
1021
- // Detect directives in a single pass — cache match results
1022
- const parsed = paragraphs.map(p => {
1023
- const trimmed = p.trim();
1024
- const match = trimmed.match(DIRECTIVE_PATTERN);
1025
- return { trimmed, match };
1026
- });
1027
-
1028
- const directiveCount = parsed.filter(p => p.match).length;
1029
- const isDirectiveStyle = directiveCount >= 2 && directiveCount >= paragraphs.length * 0.3;
1030
-
1031
- if (!isDirectiveStyle) {
1032
- return renderMarkdownOrPre(str);
1033
- }
1034
-
1035
- // Render each paragraph as a segmented directive block.
1036
- // DOMPurify sanitizes all rendered HTML to prevent XSS.
1037
- // CSP headers provide an additional layer of protection.
1038
- const blocks = parsed.map(({ trimmed, match }) => {
1039
- if (match) {
1040
- const keyword = escapeHtml(match[1]);
1041
- const rest = trimmed.slice(match[0].length);
1042
- const rendered = globalThis.marked
1043
- ? sanitizeHtml(marked.parseInline(rest))
1044
- : escapeHtml(rest);
1045
- return `<div class="directive-block"><span class="directive-keyword">${keyword}</span> ${rendered}</div>`;
1046
- }
1047
- // Non-directive paragraph — render as markdown (DOMPurify sanitizes output)
1048
- return globalThis.marked
1049
- ? `<div class="directive-block directive-block--prose">${sanitizeHtml(marked.parse(trimmed))}</div>`
1050
- : `<div class="directive-block directive-block--prose">${escapeHtml(trimmed)}</div>`;
1051
- }).join('');
1052
-
1053
- return `<div class="directive-list">${blocks}</div>`;
1054
- }
1055
-
1056
- function renderActivatesSection(fm) {
1057
- if (!fm.activates || typeof fm.activates !== 'object') return '';
1058
- const entries = Object.entries(fm.activates)
1059
- .filter(([, v]) => Array.isArray(v) && v.length)
1060
- .map(([k, v]) => `<div class="detail-field"><span class="detail-label">${escapeHtml(k)}</span><span class="detail-value">${detailPillList(v)}</span></div>`)
1061
- .join('');
1062
- return entries ? detailSection('Activates', entries) : '';
1063
- }
1064
-
1065
- function renderResilienceSection(fm) {
1066
- if (!fm.resilience || typeof fm.resilience !== 'object') return '';
1067
- const fields = Object.entries(fm.resilience)
1068
- .map(([k, v]) => detailField(k.replaceAll(/([A-Z])/g, ' $1').toLowerCase().trim(), String(v)))
1069
- .filter(Boolean).join('');
1070
- return fields ? detailSection('Resilience', fields) : '';
1071
- }
1072
-
1073
- function renderRiskThresholds(fm) {
1074
- if (!fm.risk_thresholds || typeof fm.risk_thresholds !== 'object') return '';
1075
- const thresholds = Object.entries(fm.risk_thresholds)
1076
- .map(([k, v]) => detailField(k.replaceAll('_', ' '), String(v)))
1077
- .filter(Boolean).join('');
1078
- return thresholds ? detailSection('Risk thresholds', thresholds) : '';
1079
- }
1080
-
1081
- function renderAgentSection(fm) {
1082
- let html = '';
1083
- if (fm.instructions) html += detailSection('Instructions', renderInstructions(fm.instructions));
1084
- if (fm.goal && typeof fm.goal === 'object') html += renderGoalSection(fm.goal);
1085
- html += renderLegacyGoals(fm);
1086
- if (fm.autonomy && typeof fm.autonomy === 'object') html += renderAutonomySection(fm.autonomy);
1087
- if (fm.gatekeeper && typeof fm.gatekeeper === 'object') html += renderGatekeeperSection(fm.gatekeeper);
1088
- if (fm.systemPrompt) html += detailSection('System prompt', renderMarkdownOrPre(fm.systemPrompt));
1089
- html += renderActivatesSection(fm);
1090
- html += renderAgentToolsSection(fm);
1091
- html += renderResilienceSection(fm);
1092
- if (Array.isArray(fm.capabilities) && fm.capabilities.length) {
1093
- html += detailSection('Capabilities', detailPillList(fm.capabilities.map(c => String(c).replaceAll('_', ' ')), 'pill-tag'));
1094
- }
1095
- if (fm.decision_framework && typeof fm.decision_framework === 'object') html += renderDecisionFramework(fm.decision_framework);
1096
- html += renderStateSection(fm);
1097
- html += renderRiskThresholds(fm);
1098
- html += renderAgentV1Config(fm);
1099
- return html;
1100
- }
1101
-
1102
- // ── Ensemble-specific rendering ──────────────────────────────────────────
1103
-
1104
- function buildElementPills(el) {
1105
- const elType = el.element_type || el.type || '';
1106
- const pills = [];
1107
- if (elType) pills.push(detailPill(elType, 'pill-meta'));
1108
- if (el.role) {
1109
- const cls = (el.role === 'primary' || el.role === 'core') ? 'pill-required' : 'pill-tag';
1110
- pills.push(detailPill(el.role, cls));
1111
- }
1112
- if (el.activation) {
1113
- const cls = el.activation === 'always' ? 'pill-trigger' : '';
1114
- pills.push(detailPill(el.activation, cls));
1115
- }
1116
- if (el.priority != null) {
1117
- pills.push(detailPill(`priority ${el.priority}`));
1118
- }
1119
- return pills.join(' ');
1120
- }
1121
-
1122
- function renderEnsembleElementRow(el) {
1123
- if (typeof el !== 'object' || el === null) return '';
1124
- const elName = el.element_name || el.name || '(unnamed)';
1125
- const pills = buildElementPills(el);
1126
- const purposeLine = el.purpose ? `<div class="detail-param-desc">${escapeHtml(el.purpose)}</div>` : '';
1127
- const condLine = el.condition ? `<div class="detail-param-desc"><em>when:</em> <code>${escapeHtml(el.condition)}</code></div>` : '';
1128
- const deps = Array.isArray(el.dependencies) && el.dependencies.length;
1129
- const depsLine = deps ? `<div class="detail-param-desc"><em>depends on:</em> ${el.dependencies.map(d => detailPill(d)).join(' ')}</div>` : '';
1130
- return `<div class="detail-param">
1131
- <div class="detail-param-header"><span class="detail-param-name">${escapeHtml(elName)}</span>${pills}</div>
1132
- ${purposeLine}${condLine}${depsLine}
1133
- </div>`;
1134
- }
1135
-
1136
- function renderEnsembleElements(elements) {
1137
- if (!Array.isArray(elements) || !elements.length) return '';
1138
- const rows = elements.map(renderEnsembleElementRow).filter(Boolean).join('');
1139
- return rows ? detailSection('Elements', rows) : '';
1140
- }
1141
-
1142
- function renderResourceLimits(fm) {
1143
- const limits = fm.resource_limits || fm.resourceLimits;
1144
- if (!limits || typeof limits !== 'object') return '';
1145
- const fields = Object.entries(limits)
1146
- .map(([k, v]) => detailField(k.replaceAll(/([A-Z])/g, ' $1').replaceAll('_', ' ').toLowerCase().trim(), String(v)))
1147
- .filter(Boolean).join('');
1148
- return fields ? detailSection('Resource limits', fields) : '';
1149
- }
1150
-
1151
- function renderEnsembleSection(fm) {
1152
- let html = '';
1153
- if (fm.instructions) html += detailSection('Instructions', renderInstructions(fm.instructions));
1154
- const configFields = [
1155
- detailField('Activation strategy', fm.activation_strategy || fm.activationStrategy),
1156
- detailField('Conflict resolution', fm.conflict_resolution || fm.conflictResolution),
1157
- detailField('Context sharing', fm.context_sharing || fm.contextSharing),
1158
- detailField('Allow nested', fm.allowNested == null ? null : String(fm.allowNested)),
1159
- detailField('Max nesting depth', fm.maxNestingDepth == null ? null : String(fm.maxNestingDepth)),
1160
- ].filter(Boolean).join('');
1161
- if (configFields) html += detailSection('Ensemble configuration', configFields);
1162
- html += renderEnsembleElements(fm.elements);
1163
- html += renderResourceLimits(fm);
1164
- if (fm.gatekeeper && typeof fm.gatekeeper === 'object') html += renderGatekeeperSection(fm.gatekeeper);
1165
- return html;
1166
- }
1167
-
1168
- function renderNestedDfObject(obj) {
1169
- let nested = '';
1170
- for (const [sk, sv] of Object.entries(obj)) {
1171
- if (Array.isArray(sv)) {
1172
- nested += `<div class="detail-field"><span class="detail-label">${escapeHtml(sk.replaceAll('_', ' '))}</span><span class="detail-value">${detailPillList(sv.map(i => String(i).replaceAll('_', ' ')))}</span></div>`;
1173
- } else {
1174
- nested += detailField(sk.replaceAll('_', ' '), String(sv));
1175
- }
1176
- }
1177
- return nested;
1178
- }
1179
-
1180
- function renderDecisionFramework(df) {
1181
- let html = '';
1182
- if (df.type) html += detailField('Type', String(df.type).replaceAll(/[-_]/g, ' '));
1183
-
1184
- // Render any array fields as pill lists (rules_engine, ml_components, evaluation_criteria, etc.)
1185
- for (const [k, v] of Object.entries(df)) {
1186
- if (k === 'type') continue;
1187
- if (Array.isArray(v)) {
1188
- html += `<div class="detail-field"><span class="detail-label">${escapeHtml(k.replaceAll('_', ' '))}</span><span class="detail-value">${detailPillList(v.map(i => String(i).replaceAll('_', ' ')))}</span></div>`;
1189
- } else if (typeof v === 'object' && v !== null) {
1190
- const nested = renderNestedDfObject(v);
1191
- if (nested) html += detailSection(k.replaceAll('_', ' '), nested);
1192
- } else {
1193
- html += detailField(k.replaceAll('_', ' '), String(v));
1194
- }
1195
- }
1196
- return html ? detailSection('Decision framework', html) : '';
1197
- }
1198
-
1199
- function renderDetailParameters(fm) {
1200
- if (!fm.parameters || typeof fm.parameters !== 'object' || Array.isArray(fm.parameters)) return '';
1201
- const paramRows = Object.entries(fm.parameters).map(([name, def]) => {
1202
- const d = typeof def === 'object' && def !== null ? def : {};
1203
- const enumValues = Array.isArray(d.enum) && d.enum.length
1204
- ? `<div class="detail-param-enum">${d.enum.map(v => detailPill(v, 'pill-meta')).join(' ')}</div>`
1205
- : '';
1206
- const defaultVal = d.default === undefined ? ''
1207
- : Array.isArray(d.default)
1208
- ? `<span class="detail-pill">default: ${escapeHtml(d.default.join(', '))}</span>`
1209
- : `<span class="detail-pill">default: ${escapeHtml(String(d.default))}</span>`;
1210
- return `<div class="detail-param">
1211
- <div class="detail-param-header">
1212
- <span class="detail-param-name">${escapeHtml(name)}</span>
1213
- ${d.type ? `<span class="detail-pill pill-meta">${escapeHtml(d.type)}</span>` : ''}
1214
- ${d.required ? `<span class="detail-pill pill-required">required</span>` : ''}
1215
- ${defaultVal}
1216
- </div>
1217
- ${d.description ? `<span class="detail-param-desc">${escapeHtml(d.description)}</span>` : ''}
1218
- ${enumValues}
1219
- </div>`;
1220
- }).join('');
1221
- return paramRows ? detailSection('Parameters', paramRows) : '';
1222
- }
1223
-
1224
- function renderDetailVariables(fm) {
1225
- if (!Array.isArray(fm.variables) || !fm.variables.length) return '';
1226
- const rows = fm.variables.map(v => {
1227
- if (typeof v === 'string') return `<div class="detail-param"><span class="detail-param-name">${escapeHtml(v)}</span></div>`;
1228
- if (typeof v !== 'object' || v === null) return '';
1229
- return `<div class="detail-param">
1230
- <div class="detail-param-header">
1231
- <span class="detail-param-name">${escapeHtml(v.name || '')}</span>
1232
- ${v.type ? `<span class="detail-pill pill-meta">${escapeHtml(v.type)}</span>` : ''}
1233
- ${v.required ? `<span class="detail-pill pill-required">required</span>` : ''}
1234
- ${v.default === undefined ? '' : `<span class="detail-pill">default: ${escapeHtml(String(v.default))}</span>`}
1235
- </div>
1236
- ${v.description ? `<span class="detail-param-desc">${escapeHtml(v.description)}</span>` : ''}
1237
- </div>`;
1238
- }).filter(Boolean).join('');
1239
- return rows ? detailSection('Variables', rows) : '';
1240
- }
1241
-
1242
- function renderExtraValue(key, value) {
1243
- const label = key.replaceAll('_', ' ');
1244
- if (value == null || value === '') return '';
1245
- if (typeof value === 'boolean') return detailField(label, value ? 'Yes' : 'No');
1246
- if (typeof value === 'number' || typeof value === 'string') return detailField(label, String(value));
1247
- if (Array.isArray(value)) {
1248
- if (value.length === 0) return '';
1249
- // Array of simple values → pill list
1250
- if (value.every(v => typeof v === 'string' || typeof v === 'number')) {
1251
- return `<div class="detail-field"><span class="detail-label">${escapeHtml(label)}</span><span class="detail-value">${detailPillList(value.map(String))}</span></div>`;
1252
- }
1253
- // Array of objects → render each as a sub-block
1254
- const items = value.map(item => {
1255
- if (typeof item !== 'object' || item === null) return `<li>${escapeHtml(String(item))}</li>`;
1256
- const fields = Object.entries(item)
1257
- .map(([k, v]) => `<strong>${escapeHtml(k.replaceAll('_', ' '))}</strong>: ${escapeHtml(String(v))}`)
1258
- .join(' · ');
1259
- return `<li class="detail-prose">${fields}</li>`;
1260
- }).join('');
1261
- return detailSection(label, `<ul class="detail-list">${items}</ul>`);
1262
- }
1263
- if (typeof value === 'object') {
1264
- // Object → render as field list
1265
- const fields = Object.entries(value)
1266
- .map(([k, v]) => {
1267
- if (typeof v === 'object' && v !== null) return detailField(k.replaceAll('_', ' '), JSON.stringify(v));
1268
- return detailField(k.replaceAll('_', ' '), String(v));
1269
- })
1270
- .filter(Boolean).join('');
1271
- return fields ? detailSection(label, fields) : '';
1272
- }
1273
- return detailField(label, String(value));
1274
- }
1275
-
1276
- function renderDetailExtra(fm, body) {
1277
- const knownFields = new Set([
1278
- 'name','type','description','author','version','category','license','age_rating',
1279
- 'created','created_date','updated','modified','tags','triggers','use_cases','parameters',
1280
- 'proficiency_levels','coordination_strategy','variables',
1281
- 'personas','skills','tools','templates','prompts','memories',
1282
- 'instructions','goal','goals','autonomy','gatekeeper',
1283
- 'systemPrompt','activates','tools','resilience',
1284
- 'capabilities','decision_framework','state','risk_thresholds',
1285
- 'decisionFramework','riskTolerance','learningEnabled','maxConcurrentGoals',
1286
- 'specializations','ruleEngineConfig',
1287
- 'activation_strategy','activationStrategy','conflict_resolution','conflictResolution',
1288
- 'context_sharing','contextSharing','resource_limits','resourceLimits',
1289
- 'elements','allowNested','maxNestingDepth','components',
1290
- 'unique_id','content_flags','system_prompt','systemPrompt',
1291
- ]);
1292
-
1293
- const extraFields = Object.entries(fm)
1294
- .filter(([k]) => !knownFields.has(k))
1295
- .map(([k, v]) => renderExtraValue(k, v))
1296
- .filter(Boolean).join('');
1297
- let html = extraFields ? detailSection('Additional metadata', extraFields) : '';
1298
- if (body) {
1299
- const rendered = globalThis.marked
1300
- ? `<div class="element-rendered">${sanitizeHtml(marked.parse(body))}</div>`
1301
- : `<pre class="element-source"><code class="element-code">${escapeHtml(body)}</code></pre>`;
1302
- html += detailSection('Content', rendered);
1303
- }
1304
- return html;
1305
- }
1306
-
1307
- function renderDetailView(content, type) { // NOSONAR - sequential independent metadata sections; complexity score is inflated by &&-guards on array existence checks
1308
- if (type === 'memory') {
1309
- // Portfolio memories are pure YAML — jsyaml.load succeeds.
1310
- // Collection memories are markdown-with-frontmatter — jsyaml.load throws on the
1311
- // second `---` document marker, so we fall through to the standard renderer below.
1312
- try {
1313
- const parsed = safeParseYaml(content);
1314
- if (parsed && typeof parsed === 'object') return renderMemoryView(content);
1315
- } catch { /* not pure YAML — fall through */ }
1316
- }
1317
-
1318
- const { frontmatter: fm, body } = parseFrontmatter(content);
1319
- let html = '';
1320
-
1321
- // ── Created date — prominent header line ──
1322
- const createdVal = fm.created || fm.created_date;
1323
- if (createdVal) {
1324
- html += `<div class="detail-created"><span class="detail-created-label">Created</span><span class="detail-created-value">${escapeHtml(formatDate(createdVal))}</span></div>`;
1325
- }
1326
-
1327
- // ── Core metadata ──
1328
- const coreFields = [
1329
- detailField('Author', fm.author),
1330
- detailField('Version', fm.version ? `v${fm.version}` : null),
1331
- detailField('Category', fm.category),
1332
- detailField('License', fm.license),
1333
- detailField('Age rating', fm.age_rating),
1334
- detailField('Modified', fm.modified ? formatDate(fm.modified) : null),
1335
- ].filter(Boolean).join('');
1336
- if (coreFields) html += detailSection('Details', coreFields);
1337
-
1338
- // ── Tags ──
1339
- if (Array.isArray(fm.tags) && fm.tags.length) {
1340
- html += detailSection('Tags', detailPillList(fm.tags, 'pill-tag'));
1341
- }
1342
-
1343
- // ── Triggers (personas) ──
1344
- if (Array.isArray(fm.triggers) && fm.triggers.length) {
1345
- html += detailSection('Trigger words', detailPillList(fm.triggers, 'pill-trigger'));
1346
- }
1347
-
1348
- // ── Components (ensembles) ──
1349
- const compTypes = ['personas','skills','tools','templates','prompts','memories'];
1350
- const compEntries = compTypes
1351
- .filter(k => Array.isArray(fm[k]) && fm[k].length)
1352
- .map(k => `<div class="detail-field"><span class="detail-label">${capitalize(k)}</span><span class="detail-value">${detailPillList(fm[k])}</span></div>`)
1353
- .join('');
1354
- if (compEntries) html += detailSection('Components', compEntries);
1355
-
1356
- // ── Ensemble coordination ──
1357
- if (fm.coordination_strategy) html += detailSection('Coordination', `<p class="detail-prose">${escapeHtml(fm.coordination_strategy)}</p>`);
1358
-
1359
- // ── Use cases ──
1360
- if (Array.isArray(fm.use_cases) && fm.use_cases.length) {
1361
- const useCaseItems = fm.use_cases.map(u => `<li>${escapeHtml(u)}</li>`).join('');
1362
- html += detailSection('Use cases', `<ul class="detail-list">${useCaseItems}</ul>`);
1363
- }
1364
-
1365
- // ── Instructions (all element types) ──
1366
- if (fm.instructions && type !== 'agent' && type !== 'ensemble') {
1367
- // Agents and ensembles render instructions in their own sections
1368
- html += detailSection('Instructions', renderInstructions(fm.instructions));
1369
- }
1370
-
1371
- // ── Gatekeeper (all element types) ──
1372
- if (fm.gatekeeper && typeof fm.gatekeeper === 'object' && type !== 'agent' && type !== 'ensemble') {
1373
- html += renderGatekeeperSection(fm.gatekeeper);
1374
- }
1375
-
1376
- // ── Parameters (skills/tools) ──
1377
- html += renderDetailParameters(fm);
1378
-
1379
- // ── Variables (templates) ──
1380
- html += renderDetailVariables(fm);
1381
-
1382
- // ── Proficiency levels (skills) ──
1383
- if (fm.proficiency_levels && typeof fm.proficiency_levels === 'object') {
1384
- const levels = Object.entries(fm.proficiency_levels)
1385
- .map(([lvl, desc]) => detailField(capitalize(lvl), desc)).join('');
1386
- if (levels) html += detailSection('Proficiency levels', levels);
1387
- }
1388
-
1389
- // ── Agent fields ──
1390
- if (type === 'agent') {
1391
- html += renderAgentSection(fm);
1392
- }
1393
-
1394
- // ── Ensemble fields ──
1395
- if (type === 'ensemble') {
1396
- html += renderEnsembleSection(fm);
1397
- }
1398
-
1399
- // ── Catch-all + body ──
1400
- html += renderDetailExtra(fm, body);
1401
-
1402
- return html || `<pre class="element-source"><code class="element-code">${escapeHtml(content)}</code></pre>`;
1403
- }
1404
-
1405
- function closeModal() {
1406
- const modal = document.getElementById('element-modal');
1407
- if (!modal) return;
1408
- modal.close();
1409
- document.body.classList.remove('modal-open');
1410
- }
1411
-
1412
- // ── Grid keyboard navigation ───────────────────────────────────────────────
1413
-
1414
- function highlightCard(index) {
1415
- const grid = document.getElementById('elements-grid');
1416
- if (!grid) return;
1417
- const cards = grid.querySelectorAll('.element-card');
1418
- // Clear previous highlight
1419
- cards.forEach(c => c.classList.remove('keyboard-focus'));
1420
- if (index < 0 || index >= cards.length) return;
1421
- highlightedCardIndex = index;
1422
- const card = cards[index];
1423
- card.classList.add('keyboard-focus');
1424
- card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1425
- }
1426
-
1427
- function getVisibleCardCount() {
1428
- const grid = document.getElementById('elements-grid');
1429
- return grid ? grid.querySelectorAll('.element-card').length : 0;
1430
- }
1431
-
1432
- function getGridColumns() {
1433
- const grid = document.getElementById('elements-grid');
1434
- if (!grid) return 1;
1435
- // For list view, it's always a single column
1436
- if (grid.dataset.view === 'list') return 1;
1437
- // Read actual computed column count from the CSS grid
1438
- const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
1439
- return Math.max(1, cols);
1440
- }
1441
-
1442
- function openHighlightedCard() {
1443
- const grid = document.getElementById('elements-grid');
1444
- if (!grid || highlightedCardIndex < 0) return;
1445
- const card = grid.querySelectorAll('.element-card')[highlightedCardIndex];
1446
- if (!card) return;
1447
- const idx = Number.parseInt(card.dataset.index, 10);
1448
- const el = filteredElements[idx];
1449
- if (!el) return;
1450
- const isListView = grid.dataset.view === 'list';
1451
- if (isListView) {
1452
- toggleInlineExpand(card, el);
1453
- } else if (!card.dataset.unavailable) {
1454
- openModal(el, idx);
1455
- }
1456
- }
1457
-
1458
- function getModalNavTarget(key) {
1459
- const last = filteredElements.length - 1;
1460
- if ((key === 'ArrowLeft' || key === 'k') && openElementIndex > 0) return openElementIndex - 1;
1461
- if ((key === 'ArrowRight' || key === 'j') && openElementIndex < last) return openElementIndex + 1;
1462
- return -1;
1463
- }
1464
-
1465
- function handleModalKeyboard(e, modal) {
1466
- if (e.key === 'r' || e.key === 'R') {
1467
- const renderBtn = modal.querySelector('#btn-render');
1468
- if (renderBtn?.onclick) { e.preventDefault(); renderBtn.onclick(); }
1469
- return;
1470
- }
1471
- const target = getModalNavTarget(e.key);
1472
- if (target >= 0) {
1473
- e.preventDefault();
1474
- openModal(filteredElements[target], target);
1475
- }
1476
- }
1477
-
1478
- function handleGridKeyboard(e, sInput) {
1479
- const cardCount = getVisibleCardCount();
1480
- if (!cardCount) return;
1481
- const last = cardCount - 1;
1482
- const cols = getGridColumns();
1483
- let target = highlightedCardIndex;
1484
- switch (e.key) {
1485
- case 'ArrowRight': target = Math.min(last, target + 1); break;
1486
- case 'ArrowLeft': target = Math.max(0, target <= 0 ? 0 : target - 1); break;
1487
- case 'j': case 'ArrowDown': target = Math.min(last, (target < 0 ? -cols : target) + cols); break;
1488
- case 'k': case 'ArrowUp': target = Math.max(0, target - cols); break;
1489
- case 'Home': target = 0; break;
1490
- case 'End': target = last; break;
1491
- case 'PageDown': target = Math.min(last, target + cols * 3); break;
1492
- case 'PageUp': target = Math.max(0, target - cols * 3); break;
1493
- case 'Enter': case ' ':
1494
- if (highlightedCardIndex >= 0) { e.preventDefault(); openHighlightedCard(); }
1495
- return;
1496
- case '/':
1497
- e.preventDefault();
1498
- sInput?.focus();
1499
- return;
1500
- default: return;
1501
- }
1502
- e.preventDefault();
1503
- highlightCard(target);
1504
- }
1505
-
1506
- // ── Actions ────────────────────────────────────────────────────────────────
1507
-
1508
- async function copyToClipboard(text, btn) {
1509
- const original = btn.textContent;
1510
- try {
1511
- await navigator.clipboard.writeText(text);
1512
- btn.textContent = 'Copied!';
1513
- } catch {
1514
- // Fallback for non-https contexts
1515
- try {
1516
- const ta = Object.assign(document.createElement('textarea'), {
1517
- value: text,
1518
- style: 'position:fixed;opacity:0;top:-9999px'
1519
- });
1520
- document.body.appendChild(ta);
1521
- ta.select();
1522
- // NOSONAR - Intentional fallback for non-HTTPS contexts where Clipboard API is unavailable
1523
- document.execCommand('copy');
1524
- ta.remove();
1525
- btn.textContent = 'Copied!';
1526
- } catch {
1527
- btn.textContent = 'Copy failed';
1528
- }
1529
- }
1530
- setTimeout(() => { btn.textContent = original; }, 2000);
1531
- }
1532
-
1533
- function downloadFile(name, content) {
1534
- const slug = name.toLowerCase().split(/[^a-z0-9]/).filter(Boolean).join('-');
1535
- const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1536
- const url = URL.createObjectURL(blob);
1537
- const a = Object.assign(document.createElement('a'), { href: url, download: `${slug}.md` });
1538
- document.body.appendChild(a);
1539
- a.click();
1540
- a.remove();
1541
- URL.revokeObjectURL(url);
1542
- }
1543
-
1544
- // Open a GitHub "new issue" submission for a local element.
1545
- // Content is copied to clipboard; only metadata goes in the URL (avoids URL-too-long errors).
1546
- function openSubmitIssue(name, type, content) {
1547
- // Copy raw content to clipboard — no fencing until frontmatter detection is reliable.
1548
- navigator.clipboard.writeText(content).catch(() => {});
1549
- const body = `✅ Your element content has already been copied to your clipboard, wrapped in a code block. Just paste (Cmd+V / Ctrl+V) to replace this line.`;
1550
- const issueTitle = `Submit: ${name}`;
1551
- const url = `https://github.com/DollhouseMCP/collection/issues/new`
1552
- + `?title=${encodeURIComponent(issueTitle)}`
1553
- + `&labels=submission`
1554
- + `&body=${encodeURIComponent(body)}`;
1555
- globalThis.open(url, '_blank', 'noopener,noreferrer');
1556
- }
1557
-
1558
- // ── Helpers ────────────────────────────────────────────────────────────────
1559
-
1560
- function escapeHtml(str) {
1561
- if (str == null) return '';
1562
- return String(str)
1563
- .replaceAll('&', '&amp;')
1564
- .replaceAll('<', '&lt;')
1565
- .replaceAll('>', '&gt;')
1566
- .replaceAll('"', '&quot;')
1567
- .replaceAll("'", '&#x27;');
1568
- }
1569
-
1570
- /** Sanitize HTML through DOMPurify (falls back to escapeHtml if DOMPurify is unavailable). */
1571
- function sanitizeHtml(html) {
1572
- return globalThis.DOMPurify ? DOMPurify.sanitize(html) : escapeHtml(html);
1573
- }
1574
-
1575
- function escapeAttr(str) {
1576
- return String(str || '').replaceAll('"', '&quot;').replaceAll("'", '&#x27;');
1577
- }
1578
-
1579
- function capitalize(str) {
1580
- if (!str) return '';
1581
- return str.charAt(0).toUpperCase() + str.slice(1);
1582
- }
1583
-
1584
- function formatDate(iso) {
1585
- if (!iso) return '';
1586
- try {
1587
- return new Date(iso).toLocaleDateString('en-US', {
1588
- month: 'short', day: 'numeric', year: 'numeric'
1589
- });
1590
- } catch {
1591
- return iso;
1592
- }
1593
- }
1594
-
1595
- // ── Shared detail-view building blocks ────────────────────────────────────
1596
-
1597
- const detailSection = (label, html) =>
1598
- `<section class="detail-section"><h4 class="detail-section-title">${escapeHtml(label)}</h4><div class="detail-section-body">${html}</div></section>`;
1599
-
1600
- const detailPill = (text, cls = '') => {
1601
- const clsSuffix = cls ? ` ${cls}` : '';
1602
- return `<span class="detail-pill${clsSuffix}">${escapeHtml(String(text))}</span>`;
1603
- };
1604
-
1605
- const detailField = (label, value) =>
1606
- value != null && value !== '' ? `<div class="detail-field"><span class="detail-label">${escapeHtml(label)}</span><span class="detail-value">${escapeHtml(String(value))}</span></div>` : '';
1607
-
1608
- const detailPillList = (items, cls) =>
1609
- Array.isArray(items) && items.length
1610
- ? `<div class="detail-pills">${items.map(t => detailPill(t, cls)).join('')}</div>`
1611
- : '';
1612
-
1613
- // ── Local portfolio ────────────────────────────────────────────────────────
1614
-
1615
- // Skip hidden files, index/meta files, and backup entries
1616
- function isPortfolioSkip(name) {
1617
- const lower = name.toLowerCase();
1618
- return name.startsWith('.') || // hidden (.DS_Store, etc.)
1619
- name.startsWith('_') || // meta (_index.json, etc.)
1620
- lower.includes('backup') || // backup dirs/files
1621
- name.endsWith('.backup'); // explicit backup extension
1622
- }
1623
-
1624
- // Extract a YYYY-MM-DD date string from a relative file path.
1625
- // Handles both slash-separated dirs (2026/01/15/) and hyphen-prefixed filenames (2026-01-15_topic.yaml).
1626
- function dateFromPath(path) {
1627
- const m = path.match(/(\d{4})[-/](\d{2})[-/](\d{2})/);
1628
- return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
1629
- }
1630
-
1631
- // Recursively collect files matching extensions from a directory handle.
1632
- // Returns { name, handle, path } where path is relative to the type subdir (e.g. "2026/01/15/note.yaml").
1633
- async function collectLocalFiles(dirHandle, extensions, maxDepth = PORTFOLIO_MAX_DEPTH, prefix = '') {
1634
- const results = [];
1635
- try {
1636
- for await (const [name, handle] of dirHandle.entries()) {
1637
- if (isPortfolioSkip(name)) continue;
1638
- if (handle.kind === 'file' && extensions.some(ext => name.endsWith(ext))) {
1639
- results.push({ name, handle, path: prefix + name });
1640
- } else if (handle.kind === 'directory' && maxDepth > 0) {
1641
- const sub = await collectLocalFiles(handle, extensions, maxDepth - 1, prefix + name + '/');
1642
- results.push(...sub);
1643
- }
1644
- }
1645
- } catch (err) {
1646
- console.warn('[DollhouseMCP] Portfolio directory read error:', err.message);
1647
- }
1648
- return results;
1649
- }
1650
-
1651
- // Parse file content — pure YAML files vs frontmatter markdown
1652
- function parseLocalFile(content, name) {
1653
- if (name.endsWith('.yaml') || name.endsWith('.yml')) {
1654
- try {
1655
- const fm = safeParseYaml(content) || {};
1656
- return { frontmatter: typeof fm === 'object' && fm !== null ? fm : {}, body: '' };
1657
- } catch {
1658
- return { frontmatter: {}, body: '' };
1659
- }
1660
- }
1661
- return parseFrontmatter(content);
1662
- }
1663
-
1664
- function finalizePortfolioUI(btn) {
1665
- try { renderTypeFilters(); } catch (err) { console.warn('[DollhouseMCP] renderTypeFilters error:', err.message); }
1666
- try { renderTopicFilters(); } catch (err) { console.warn('[DollhouseMCP] renderTopicFilters error:', err.message); }
1667
- applyFilters();
1668
- if (btn) {
1669
- btn.textContent = localElements.length > 0
1670
- ? `📁 Portfolio (${localElements.length})`
1671
- : '📁 Portfolio (empty)';
1672
- btn.dataset.loaded = 'true';
1673
- }
1674
- }
1675
-
1676
- function handlePortfolioError(err, btn, prevText) {
1677
- if (err.name === 'AbortError') {
1678
- if (btn) btn.textContent = prevText;
1679
- } else {
1680
- console.error('[DollhouseMCP] Portfolio load error:', err.message);
1681
- if (btn) btn.textContent = '📁 Portfolio (error)';
1682
- }
1683
- }
1684
-
1685
- async function loadTypeDirectory(dirHandle, subdirName, type, extensions, btn) {
1686
- try {
1687
- const subdir = await dirHandle.getDirectoryHandle(subdirName);
1688
- const fileEntries = await collectLocalFiles(subdir, extensions, PORTFOLIO_MAX_DEPTH);
1689
- // Sort descending so newest date-prefixed dirs/files load into page 1 first
1690
- fileEntries.sort((a, b) => b.path.localeCompare(a.path));
1691
-
1692
- // Read files in parallel batches; update UI every batch so progress is visible
1693
- for (let i = 0; i < fileEntries.length; i += FILE_READ_CONCURRENCY) {
1694
- const batch = fileEntries.slice(i, i + FILE_READ_CONCURRENCY);
1695
- await Promise.all(batch.map(async ({ name, handle, path }) => {
1696
- try {
1697
- const file = await handle.getFile();
1698
- const content = await file.text();
1699
- const { frontmatter: fm } = parseLocalFile(content, name);
1700
- localElements.push({
1701
- name: fm.name || name.replace(/\.(md|yaml|yml)$/, ''),
1702
- type,
1703
- description: fm.description || '',
1704
- author: fm.author || '',
1705
- version: fm.version ? String(fm.version) : '',
1706
- tags: Array.isArray(fm.tags) ? fm.tags : [],
1707
- created: fm.created_date || fm.created || fm.date || dateFromPath(path) || null,
1708
- _local: true,
1709
- _content: content,
1710
- path,
1711
- });
1712
- } catch { /* skip unreadable file */ }
1713
- }));
1714
-
1715
- // Update UI after each batch so user sees content appear progressively
1716
- allElements = [...collectionElements, ...localElements];
1717
- if (btn) btn.textContent = `📁 Loading… (${localElements.length})`;
1718
- try { applyFilters(); } catch { /* non-fatal */ }
1719
- }
1720
- } catch { /* subdir may not exist — skip silently */ }
1721
- }
1722
-
1723
- async function loadLocalPortfolio() {
1724
- if (!globalThis.showDirectoryPicker) {
1725
- alert('Your browser does not support the File System Access API.\nTry Chrome or Edge on desktop.');
1726
- return;
1727
- }
1728
-
1729
- const btn = document.getElementById('btn-portfolio');
1730
- const prevText = btn?.textContent;
1731
- if (btn) btn.textContent = '…';
1732
-
1733
- try {
1734
- const dirHandle = await globalThis.showDirectoryPicker({ mode: 'read' });
1735
-
1736
- const TYPE_EXTENSIONS = {
1737
- agents: ['.md'], personas: ['.md'], skills: ['.md'],
1738
- templates: ['.md'], ensembles: ['.md'], prompts: ['.md'],
1739
- memories: ['.yaml', '.yml'], tools: ['.md'],
1740
- };
1741
-
1742
- localElements = [];
1743
-
1744
- for (const [subdirName, type] of Object.entries(SINGULAR_TYPE)) {
1745
- const extensions = TYPE_EXTENSIONS[subdirName] || ['.md'];
1746
- await loadTypeDirectory(dirHandle, subdirName, type, extensions, btn);
1747
- }
1748
-
1749
- finalizePortfolioUI(btn);
1750
- } catch (err) {
1751
- handlePortfolioError(err, btn, prevText);
1752
- }
1753
- }
1754
-
1755
- // ── Event wiring ───────────────────────────────────────────────────────────
1756
-
1757
- document.addEventListener('DOMContentLoaded', () => {
1758
-
1759
- // Theme toggle
1760
- const themeToggleBtn = document.getElementById('theme-toggle');
1761
- const themeToggleIcon = document.getElementById('theme-toggle-icon');
1762
- const themeToggleLbl = document.getElementById('theme-toggle-label');
1763
- const html = document.documentElement;
1764
-
1765
- function applyTheme(theme) {
1766
- html.dataset.theme = theme;
1767
- const isDark = theme === 'dark';
1768
- if (themeToggleIcon) themeToggleIcon.textContent = isDark ? '☀' : '☾';
1769
- if (themeToggleLbl) themeToggleLbl.textContent = isDark ? 'Switch to light mode' : 'Switch to dark mode';
1770
- if (themeToggleBtn) themeToggleBtn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
1771
- // Sync highlight.js theme
1772
- const hljsLight = document.getElementById('hljs-theme-light');
1773
- const hljsDark = document.getElementById('hljs-theme-dark');
1774
- if (hljsLight) hljsLight.disabled = isDark;
1775
- if (hljsDark) hljsDark.disabled = !isDark;
1776
- try { localStorage.setItem('color-scheme', theme); } catch {}
1777
- }
1778
-
1779
- // Restore saved preference; fall back to OS preference
1780
- const saved = (() => { try { return localStorage.getItem('color-scheme'); } catch {} })();
1781
- const preferred = saved || (globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
1782
- applyTheme(preferred);
1783
-
1784
- if (themeToggleBtn) {
1785
- themeToggleBtn.addEventListener('click', () => {
1786
- applyTheme(html.dataset.theme === 'dark' ? 'light' : 'dark');
1787
- });
1788
- }
1789
-
1790
- // View toggle
1791
- const viewToggle = document.getElementById('view-toggle');
1792
- const elemGrid = document.getElementById('elements-grid');
1793
- let activeView = (() => { try { return localStorage.getItem('collection-view') || 'grid'; } catch { return 'grid'; } })();
1794
-
1795
- function applyView(view) {
1796
- activeView = view;
1797
- if (elemGrid) elemGrid.dataset.view = view;
1798
- viewToggle?.querySelectorAll('.view-btn').forEach(btn => {
1799
- const on = btn.dataset.view === view;
1800
- btn.classList.toggle('active', on);
1801
- btn.setAttribute('aria-pressed', on);
1802
- });
1803
- try { localStorage.setItem('collection-view', view); } catch {}
1804
- }
1805
-
1806
- applyView(activeView);
1807
-
1808
- viewToggle?.addEventListener('click', e => {
1809
- const btn = e.target.closest('[data-view]');
1810
- if (btn) applyView(btn.dataset.view);
1811
- });
1812
-
1813
- // Sort
1814
- const sortSelect = document.getElementById('sort-select');
1815
- if (sortSelect) {
1816
- sortSelect.value = activeSort;
1817
- sortSelect.addEventListener('change', e => {
1818
- activeSort = e.target.value;
1819
- applyFilters();
1820
- });
1821
- }
1822
-
1823
- // Search
1824
- const searchInput = document.getElementById('search-input');
1825
- if (searchInput) searchInput.addEventListener('input', onSearch);
1826
-
1827
- // Modal close
1828
- document.getElementById('modal-close')?.addEventListener('click', closeModal);
1829
- document.getElementById('modal-overlay')?.addEventListener('click', closeModal);
1830
-
1831
- // Keyboard shortcuts
1832
- document.addEventListener('keydown', e => {
1833
- const inInput = ['INPUT','TEXTAREA'].includes(document.activeElement?.tagName);
1834
- const modal = document.getElementById('element-modal');
1835
- const modalOpen = modal?.open;
1836
-
1837
- // ── Escape: close modal or clear search ──
1838
- if (e.key === 'Escape') {
1839
- if (modalOpen) { closeModal(); return; }
1840
- if (inInput && searchInput) { searchInput.blur(); return; }
1841
- return;
1842
- }
1843
-
1844
- // ── Modal-open shortcuts ──
1845
- if (modalOpen && !inInput) {
1846
- handleModalKeyboard(e, modal);
1847
- return;
1848
- }
1849
-
1850
- // ── Grid navigation (modal closed) ──
1851
- if (!inInput) {
1852
- handleGridKeyboard(e, searchInput);
1853
- }
1854
- });
1855
-
1856
- // Source toggle
1857
- const sourceToggle = document.getElementById('source-toggle');
1858
- if (sourceToggle) {
1859
- sourceToggle.addEventListener('click', e => {
1860
- const btn = e.target.closest('[data-source]');
1861
- if (!btn) return;
1862
- activeSource = btn.dataset.source;
1863
- sourceToggle.querySelectorAll('[data-source]').forEach(b => {
1864
- const on = b.dataset.source === activeSource;
1865
- b.classList.toggle('active', on);
1866
- b.setAttribute('aria-pressed', on);
1867
- });
1868
- applyFilters();
1869
- });
1870
- }
1871
-
1872
- // Portfolio button
1873
- document.getElementById('btn-portfolio')?.addEventListener('click', loadLocalPortfolio);
1874
-
1875
- init();
1876
- });
1877
-
1878
- })();