@jungjaehoon/mama-os 0.8.3 → 0.9.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 (106) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agent/agent-loop.d.ts +1 -8
  3. package/dist/agent/agent-loop.d.ts.map +1 -1
  4. package/dist/agent/agent-loop.js +44 -159
  5. package/dist/agent/agent-loop.js.map +1 -1
  6. package/dist/agent/claude-cli-wrapper.d.ts +6 -0
  7. package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
  8. package/dist/agent/claude-cli-wrapper.js +6 -0
  9. package/dist/agent/claude-cli-wrapper.js.map +1 -1
  10. package/dist/agent/codex-mcp-process.d.ts +85 -0
  11. package/dist/agent/codex-mcp-process.d.ts.map +1 -0
  12. package/dist/agent/codex-mcp-process.js +357 -0
  13. package/dist/agent/codex-mcp-process.js.map +1 -0
  14. package/dist/agent/session-pool.d.ts +17 -2
  15. package/dist/agent/session-pool.d.ts.map +1 -1
  16. package/dist/agent/session-pool.js +51 -26
  17. package/dist/agent/session-pool.js.map +1 -1
  18. package/dist/agent/types.d.ts +9 -24
  19. package/dist/agent/types.d.ts.map +1 -1
  20. package/dist/agent/types.js.map +1 -1
  21. package/dist/api/graph-api.d.ts.map +1 -1
  22. package/dist/api/graph-api.js +133 -45
  23. package/dist/api/graph-api.js.map +1 -1
  24. package/dist/cli/commands/init.d.ts +1 -1
  25. package/dist/cli/commands/init.d.ts.map +1 -1
  26. package/dist/cli/commands/init.js +14 -25
  27. package/dist/cli/commands/init.js.map +1 -1
  28. package/dist/cli/commands/run.d.ts.map +1 -1
  29. package/dist/cli/commands/run.js +3 -10
  30. package/dist/cli/commands/run.js.map +1 -1
  31. package/dist/cli/commands/start.d.ts.map +1 -1
  32. package/dist/cli/commands/start.js +143 -54
  33. package/dist/cli/commands/start.js.map +1 -1
  34. package/dist/cli/commands/status.d.ts.map +1 -1
  35. package/dist/cli/commands/status.js +2 -7
  36. package/dist/cli/commands/status.js.map +1 -1
  37. package/dist/cli/config/config-manager.d.ts.map +1 -1
  38. package/dist/cli/config/config-manager.js +9 -17
  39. package/dist/cli/config/config-manager.js.map +1 -1
  40. package/dist/cli/config/types.d.ts +19 -25
  41. package/dist/cli/config/types.d.ts.map +1 -1
  42. package/dist/cli/config/types.js.map +1 -1
  43. package/dist/cli/index.js +2 -2
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/gateways/context-injector.d.ts.map +1 -1
  46. package/dist/gateways/context-injector.js +6 -3
  47. package/dist/gateways/context-injector.js.map +1 -1
  48. package/dist/gateways/discord.d.ts +4 -0
  49. package/dist/gateways/discord.d.ts.map +1 -1
  50. package/dist/gateways/discord.js +39 -16
  51. package/dist/gateways/discord.js.map +1 -1
  52. package/dist/gateways/message-router.d.ts +6 -1
  53. package/dist/gateways/message-router.d.ts.map +1 -1
  54. package/dist/gateways/message-router.js +92 -7
  55. package/dist/gateways/message-router.js.map +1 -1
  56. package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
  57. package/dist/multi-agent/agent-process-manager.js +36 -9
  58. package/dist/multi-agent/agent-process-manager.js.map +1 -1
  59. package/dist/multi-agent/runtime-process.d.ts +4 -4
  60. package/dist/multi-agent/runtime-process.d.ts.map +1 -1
  61. package/dist/multi-agent/runtime-process.js +9 -20
  62. package/dist/multi-agent/runtime-process.js.map +1 -1
  63. package/dist/multi-agent/types.d.ts +13 -8
  64. package/dist/multi-agent/types.d.ts.map +1 -1
  65. package/dist/multi-agent/types.js.map +1 -1
  66. package/dist/setup/setup-prompt.d.ts +1 -1
  67. package/dist/setup/setup-prompt.d.ts.map +1 -1
  68. package/dist/setup/setup-prompt.js +19 -0
  69. package/dist/setup/setup-prompt.js.map +1 -1
  70. package/dist/setup/setup-server.d.ts.map +1 -1
  71. package/dist/setup/setup-server.js +39 -16
  72. package/dist/setup/setup-server.js.map +1 -1
  73. package/dist/skills/skill-registry.d.ts.map +1 -1
  74. package/dist/skills/skill-registry.js +5 -2
  75. package/dist/skills/skill-registry.js.map +1 -1
  76. package/package.json +5 -3
  77. package/public/setup.html +12 -1
  78. package/public/viewer/js/modules/chat.js +1760 -1976
  79. package/public/viewer/js/modules/dashboard.js +613 -695
  80. package/public/viewer/js/modules/graph.js +857 -970
  81. package/public/viewer/js/modules/memory.js +357 -312
  82. package/public/viewer/js/modules/settings.js +1009 -1026
  83. package/public/viewer/js/modules/skills.js +336 -355
  84. package/public/viewer/js/utils/api.js +255 -255
  85. package/public/viewer/js/utils/debug-logger.js +20 -26
  86. package/public/viewer/js/utils/dom.js +73 -60
  87. package/public/viewer/js/utils/format.js +182 -228
  88. package/public/viewer/js/utils/markdown.js +40 -0
  89. package/public/viewer/src/modules/chat.ts +2258 -0
  90. package/public/viewer/src/modules/dashboard.ts +1052 -0
  91. package/public/viewer/src/modules/graph.ts +1080 -0
  92. package/public/viewer/src/modules/memory.ts +453 -0
  93. package/public/viewer/src/modules/settings.ts +1398 -0
  94. package/public/viewer/src/modules/skills.ts +457 -0
  95. package/public/viewer/src/types/global.d.ts +168 -0
  96. package/public/viewer/src/utils/api.ts +650 -0
  97. package/public/viewer/src/utils/debug-logger.ts +36 -0
  98. package/public/viewer/src/utils/dom.ts +138 -0
  99. package/public/viewer/src/utils/format.ts +331 -0
  100. package/public/viewer/src/utils/markdown.ts +46 -0
  101. package/public/viewer/tsconfig.viewer.json +18 -0
  102. package/public/viewer/viewer.html +214 -311
  103. package/dist/agent/codex-cli-wrapper.d.ts +0 -85
  104. package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
  105. package/dist/agent/codex-cli-wrapper.js +0 -295
  106. package/dist/agent/codex-cli-wrapper.js.map +0 -1
@@ -3,119 +3,119 @@
3
3
  * @module utils/format
4
4
  * @version 1.1.0
5
5
  */
6
-
7
6
  /* eslint-env browser */
8
-
9
7
  /**
10
8
  * Known Claude model name mappings
9
+ * https://platform.claude.com/docs/en/about-claude/models/overview
11
10
  */
12
11
  const MODEL_NAMES = {
13
- 'claude-sonnet-4-20250514': 'Claude 4 Sonnet',
14
- 'claude-opus-4-20250514': 'Claude 4 Opus',
15
- 'claude-opus-4-5-20251101': 'Claude 4.5 Opus',
16
- 'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
17
- 'claude-3-opus-20240229': 'Claude 3 Opus',
18
- 'claude-3-sonnet-20240229': 'Claude 3 Sonnet',
19
- 'claude-3-haiku-20240307': 'Claude 3 Haiku',
12
+ // Latest models
13
+ 'claude-opus-4-6': 'Claude Opus 4.6',
14
+ 'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5',
15
+ 'claude-haiku-4-5-20251001': 'Claude Haiku 4.5',
16
+ // Legacy models
17
+ 'claude-opus-4-5-20251101': 'Claude Opus 4.5',
18
+ 'claude-sonnet-4-20250514': 'Claude Sonnet 4',
19
+ 'claude-opus-4-20250514': 'Claude Opus 4',
20
+ 'claude-3-7-sonnet-20250219': 'Claude Sonnet 3.7',
21
+ 'claude-3-haiku-20240307': 'Claude Haiku 3',
22
+ // GPT models
23
+ 'gpt-5.3-codex': 'GPT-5.3 Codex',
24
+ 'gpt-5.2': 'GPT-5.2',
25
+ 'gpt-5.1': 'GPT-5.1',
26
+ 'gpt-4.1': 'GPT-4.1',
27
+ 'gpt-4o': 'GPT-4o',
28
+ 'gpt-4o-mini': 'GPT-4o Mini',
29
+ 'o1': 'o1',
30
+ 'o1-mini': 'o1 Mini',
31
+ 'o3-mini': 'o3 Mini',
20
32
  };
21
-
22
33
  /**
23
34
  * Get human-friendly model name from model ID
24
35
  * @param {string} model - Model ID (e.g., 'claude-sonnet-4-20250514')
25
36
  * @returns {string} Human-friendly name (e.g., 'Claude 4 Sonnet')
26
37
  */
27
38
  export function formatModelName(model) {
28
- if (!model || model === 'default') {
29
- return 'Default';
30
- }
31
-
32
- // Check known model mappings
33
- if (MODEL_NAMES[model]) {
34
- return MODEL_NAMES[model];
35
- }
36
-
37
- // Try to extract friendly name from model string
38
- if (model.includes('opus')) {
39
- return 'Claude Opus';
40
- }
41
- if (model.includes('sonnet')) {
42
- return 'Claude Sonnet';
43
- }
44
- if (model.includes('haiku')) {
45
- return 'Claude Haiku';
46
- }
47
-
48
- return model;
39
+ if (!model || model === 'default') {
40
+ return 'Default';
41
+ }
42
+ // Check known model mappings
43
+ if (MODEL_NAMES[model]) {
44
+ return MODEL_NAMES[model];
45
+ }
46
+ // Try to extract friendly name from model string
47
+ if (model.includes('opus')) {
48
+ return 'Claude Opus';
49
+ }
50
+ if (model.includes('sonnet')) {
51
+ return 'Claude Sonnet';
52
+ }
53
+ if (model.includes('haiku')) {
54
+ return 'Claude Haiku';
55
+ }
56
+ return model;
49
57
  }
50
-
51
58
  /**
52
59
  * Format message timestamp
53
60
  * @param {Date} date - Date object
54
61
  * @returns {string} Formatted time (HH:MM)
55
62
  */
56
63
  export function formatMessageTime(date) {
57
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
64
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
58
65
  }
59
-
60
66
  /**
61
67
  * Format checkpoint timestamp
62
68
  * @param {string|Date} timestamp - Timestamp
63
69
  * @returns {string} Formatted relative time
64
70
  */
65
71
  export function formatCheckpointTime(timestamp) {
66
- const date = new Date(timestamp);
67
- const now = new Date();
68
- const diff = now - date;
69
-
70
- if (diff < 3600000) {
71
- const mins = Math.floor(diff / 60000);
72
- return `${mins}m ago`;
73
- }
74
- if (diff < 86400000) {
75
- const hours = Math.floor(diff / 3600000);
76
- return `${hours}h ago`;
77
- }
78
-
79
- return (
80
- date.toLocaleDateString() +
81
- ' ' +
82
- date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
83
- );
72
+ const date = new Date(timestamp);
73
+ const now = new Date();
74
+ const diff = now.getTime() - date.getTime();
75
+ if (diff < 3600000) {
76
+ const mins = Math.floor(diff / 60000);
77
+ return `${mins}m ago`;
78
+ }
79
+ if (diff < 86400000) {
80
+ const hours = Math.floor(diff / 3600000);
81
+ return `${hours}h ago`;
82
+ }
83
+ return (date.toLocaleDateString() +
84
+ ' ' +
85
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
84
86
  }
85
-
86
87
  /**
87
88
  * Format relative time
88
89
  * @param {string|Date} timestamp - Timestamp
89
90
  * @returns {string} Relative time string
90
91
  */
91
92
  export function formatRelativeTime(timestamp) {
92
- if (!timestamp) {
93
- return '';
94
- }
95
-
96
- const date = new Date(timestamp);
97
- const now = new Date();
98
- const diff = now - date;
99
-
100
- if (diff < 60000) {
101
- return 'Just now';
102
- }
103
- if (diff < 3600000) {
104
- const mins = Math.floor(diff / 60000);
105
- return `${mins}m ago`;
106
- }
107
- if (diff < 86400000) {
108
- const hours = Math.floor(diff / 3600000);
109
- return `${hours}h ago`;
110
- }
111
- if (diff < 604800000) {
112
- const days = Math.floor(diff / 86400000);
113
- return `${days}d ago`;
114
- }
115
-
116
- return date.toLocaleDateString();
93
+ if (!timestamp) {
94
+ return 'Never';
95
+ }
96
+ const date = new Date(timestamp);
97
+ if (Number.isNaN(date.getTime())) {
98
+ return 'Never';
99
+ }
100
+ const now = new Date();
101
+ const diff = now.getTime() - date.getTime();
102
+ if (diff < 60000) {
103
+ return 'Just now';
104
+ }
105
+ if (diff < 3600000) {
106
+ const mins = Math.floor(diff / 60000);
107
+ return `${mins}m ago`;
108
+ }
109
+ if (diff < 86400000) {
110
+ const hours = Math.floor(diff / 3600000);
111
+ return `${hours}h ago`;
112
+ }
113
+ if (diff < 604800000) {
114
+ const days = Math.floor(diff / 86400000);
115
+ return `${days}d ago`;
116
+ }
117
+ return date.toLocaleDateString();
117
118
  }
118
-
119
119
  /**
120
120
  * Truncate text with ellipsis
121
121
  * @param {string} text - Text to truncate
@@ -123,141 +123,103 @@ export function formatRelativeTime(timestamp) {
123
123
  * @returns {string} Truncated text
124
124
  */
125
125
  export function truncateText(text, maxLength) {
126
- if (!text) {
127
- return '';
128
- }
129
- if (text.length <= maxLength) {
130
- return text;
131
- }
132
- return text.substring(0, maxLength) + '...';
126
+ if (!text) {
127
+ return '';
128
+ }
129
+ if (text.length <= maxLength) {
130
+ return text;
131
+ }
132
+ return text.substring(0, maxLength) + '...';
133
133
  }
134
-
135
134
  /**
136
135
  * Extract first meaningful line from text
137
136
  * @param {string} text - Text to extract from
138
137
  * @returns {string} First meaningful line
139
138
  */
140
139
  export function extractFirstLine(text) {
141
- if (!text) {
142
- return 'No summary';
143
- }
144
- const lines = text.split('\n').filter((l) => l.trim() && !l.startsWith('**'));
145
- return lines[0] || text.substring(0, 100);
140
+ if (!text) {
141
+ return 'No summary';
142
+ }
143
+ const lines = text.split('\n').filter((l) => l.trim() && !l.startsWith('**'));
144
+ return lines[0] || text.substring(0, 100);
146
145
  }
147
-
148
146
  /**
149
147
  * Format assistant message with markdown support
150
148
  * @param {string} text - Text to format
151
149
  * @returns {string} Formatted HTML
152
150
  */
153
151
  export function formatAssistantMessage(text) {
154
- if (!text) {
155
- return '';
156
- }
157
-
158
- // First escape HTML to prevent XSS
159
- let formatted = escapeHtmlForMarkdown(text);
160
-
161
- // Detect and wrap checkpoint/context sections in collapsible
162
- formatted = wrapCheckpointSections(formatted);
163
-
164
- // Code blocks with optional language (```js ... ```)
165
- formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
166
- const langClass = lang ? ` class="language-${lang}"` : '';
167
- return `<pre class="code-block"><code${langClass}>${code.trim()}</code></pre>`;
168
- });
169
-
170
- // Inline code
171
- formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
172
-
173
- // Bold
174
- formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
175
-
176
- // Italic (avoiding conflicts with bold) - Safari-compatible without lookbehind
177
- // Process after bold, match single asterisks not part of ** sequences
178
- formatted = formatted.replace(/([^*]|^)\*([^*]+)\*([^*]|$)/g, '$1<em>$2</em>$3');
179
-
180
- // Helper: build safe media HTML from captured filename
181
- // Note: filename may contain HTML entities from prior escaping, so decode first
182
- const buildMediaHtml = (filename) => {
183
- const decodedName = decodeHtmlEntities(filename);
184
- const safeName = encodeURIComponent(decodedName);
185
- const safeAlt = escapeHtmlForMarkdown(decodedName).replace(/"/g, '&quot;');
186
- const ext = decodedName.split('.').pop()?.toLowerCase() || '';
187
- const imgExts = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
188
- if (imgExts.includes(ext)) {
189
- return `<div class="media-inline"><img src="/api/media/${safeName}" class="max-w-[300px] rounded-lg my-1 cursor-pointer" data-lightbox="/api/media/${safeName}" alt="${safeAlt}"/><a href="/api/media/download/${safeName}" class="text-xs text-blue-500 hover:underline block">Download ${safeAlt}</a></div>`;
190
- }
191
- return `<a href="/api/media/download/${safeName}" class="text-blue-500 hover:underline">Download ${safeAlt}</a>`;
192
- };
193
-
194
- // Markdown images: ![alt](media-path) — render as inline images
195
- // Note: exclude /api/media/download/ paths (they're already download links)
196
- formatted = formatted.replace(
197
- /!\[([^\]]*)\]\((?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/api\/media\/(?!download\/))([^)]+)\)/gi,
198
- (_match, _alt, filename) => buildMediaHtml(filename)
199
- );
200
-
201
- // First: strip any <a> wrappers around media paths (from markdown link handler)
202
- formatted = formatted.replace(
203
- /<a\s+href="(?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/)([^"]+)"[^>]*>[^<]*<\/a>/gi,
204
- (_match, filename) => buildMediaHtml(filename)
205
- );
206
- // Then: handle bare media paths not already inside HTML tags
207
- // Safari-compatible: use capture group instead of lookbehind
208
- formatted = formatted.replace(
209
- /(^|[^"'])(?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/)([^\s<"']+\.(png|jpg|jpeg|gif|webp|svg|pdf))/gi,
210
- (_, prefix, filename) => prefix + buildMediaHtml(filename)
211
- );
212
-
213
- // Headers (## and ###)
214
- formatted = formatted.replace(
215
- /^### (.+)$/gm,
216
- '<h4 class="text-sm font-semibold mt-2 mb-1">$1</h4>'
217
- );
218
- formatted = formatted.replace(
219
- /^## (.+)$/gm,
220
- '<h3 class="text-base font-semibold mt-3 mb-1">$1</h3>'
221
- );
222
-
223
- // Bullet lists (- item)
224
- formatted = formatted.replace(/^- (.+)$/gm, '<li class="ml-4">• $1</li>');
225
-
226
- // Quiz choices as buttons - patterns like **A)** text or A) text
227
- // Also handles blockquote prefix (> or &gt;)
228
- // Match patterns: A) text, **A)** text, > A) text, etc.
229
- formatted = formatted.replace(
230
- /^(?:&gt;\s*)?(?:<strong>)?([A-D])\)(?:<\/strong>)?\s*(.+)$/gim,
231
- (match, letter, text) => {
232
- const upperLetter = letter.toUpperCase();
233
- return `<button class="quiz-choice-btn" data-choice="${upperLetter}" onclick="window.sendQuizChoice('${upperLetter}')">${upperLetter}) ${text.trim()}</button>`;
152
+ if (!text) {
153
+ return '';
234
154
  }
235
- );
236
-
237
- // Line breaks
238
- formatted = formatted.replace(/\n/g, '<br>');
239
-
240
- // Clean up multiple <br> in lists
241
- formatted = formatted.replace(/<\/li><br><li/g, '</li><li');
242
-
243
- // Clean up <br> before/after quiz buttons
244
- formatted = formatted.replace(/<br>(<button class="quiz-choice-btn")/g, '$1');
245
- formatted = formatted.replace(/(<\/button>)<br>/g, '$1');
246
-
247
- return formatted;
155
+ // First escape HTML to prevent XSS
156
+ let formatted = escapeHtmlForMarkdown(text);
157
+ // Detect and wrap checkpoint/context sections in collapsible
158
+ formatted = wrapCheckpointSections(formatted);
159
+ // Code blocks with optional language (```js ... ```)
160
+ formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
161
+ const langClass = lang ? ` class="language-${lang}"` : '';
162
+ return `<pre class="code-block"><code${langClass}>${code.trim()}</code></pre>`;
163
+ });
164
+ // Inline code
165
+ formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
166
+ // Bold
167
+ formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
168
+ // Italic (avoiding conflicts with bold) - Safari-compatible without lookbehind
169
+ // Process after bold, match single asterisks not part of ** sequences
170
+ formatted = formatted.replace(/([^*]|^)\*([^*]+)\*([^*]|$)/g, '$1<em>$2</em>$3');
171
+ // Helper: build safe media HTML from captured filename
172
+ // Note: filename may contain HTML entities from prior escaping, so decode first
173
+ const buildMediaHtml = (filename) => {
174
+ const decodedName = decodeHtmlEntities(filename);
175
+ const safeName = encodeURIComponent(decodedName);
176
+ const safeAlt = escapeHtmlForMarkdown(decodedName).replace(/"/g, '&quot;');
177
+ const ext = decodedName.split('.').pop()?.toLowerCase() || '';
178
+ const imgExts = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
179
+ if (imgExts.includes(ext)) {
180
+ return `<div class="media-inline"><img src="/api/media/${safeName}" class="max-w-[300px] rounded-lg my-1 cursor-pointer" data-lightbox="/api/media/${safeName}" alt="${safeAlt}"/><a href="/api/media/download/${safeName}" class="text-xs text-blue-500 hover:underline block">Download ${safeAlt}</a></div>`;
181
+ }
182
+ return `<a href="/api/media/download/${safeName}" class="text-blue-500 hover:underline">Download ${safeAlt}</a>`;
183
+ };
184
+ // Markdown images: ![alt](media-path) — render as inline images
185
+ // Note: exclude /api/media/download/ paths (they're already download links)
186
+ formatted = formatted.replace(/!\[([^\]]*)\]\((?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/api\/media\/(?!download\/))([^)]+)\)/gi, (_match, _alt, filename) => buildMediaHtml(filename));
187
+ // First: strip any <a> wrappers around media paths (from markdown link handler)
188
+ formatted = formatted.replace(/<a\s+href="(?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/)([^"]+)"[^>]*>[^<]*<\/a>/gi, (_match, filename) => buildMediaHtml(filename));
189
+ // Then: handle bare media paths not already inside HTML tags
190
+ // Safari-compatible: use capture group instead of lookbehind
191
+ formatted = formatted.replace(/(^|[^"'])(?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/)([^\s<"']+\.(png|jpg|jpeg|gif|webp|svg|pdf))/gi, (_, prefix, filename) => prefix + buildMediaHtml(filename));
192
+ // Headers (## and ###)
193
+ formatted = formatted.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold mt-2 mb-1">$1</h4>');
194
+ formatted = formatted.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold mt-3 mb-1">$1</h3>');
195
+ // Bullet lists (- item)
196
+ formatted = formatted.replace(/^- (.+)$/gm, '<li class="ml-4">• $1</li>');
197
+ // Quiz choices as buttons - patterns like **A)** text or A) text
198
+ // Also handles blockquote prefix (> or &gt;)
199
+ // Match patterns: A) text, **A)** text, > A) text, etc.
200
+ formatted = formatted.replace(/^(?:&gt;\s*)?(?:<strong>)?([A-D])\)(?:<\/strong>)?\s*(.+)$/gim, (match, letter, text) => {
201
+ const upperLetter = letter.toUpperCase();
202
+ return `<button class="quiz-choice-btn" type="button" data-choice="${upperLetter}">${upperLetter}) ${text.trim()}</button>`;
203
+ });
204
+ // Line breaks
205
+ formatted = formatted.replace(/\n/g, '<br>');
206
+ // Clean up multiple <br> in lists
207
+ formatted = formatted.replace(/<\/li><br><li/g, '</li><li');
208
+ // Clean up <br> before/after quiz buttons
209
+ formatted = formatted.replace(/<br>(<button class="quiz-choice-btn")/g, '$1');
210
+ formatted = formatted.replace(/(<\/button>)<br>/g, '$1');
211
+ return formatted;
248
212
  }
249
-
250
213
  /**
251
214
  * Escape HTML for markdown processing
252
215
  * @param {string} text - Text to escape
253
216
  * @returns {string} Escaped text
254
217
  */
255
218
  function escapeHtmlForMarkdown(text) {
256
- const div = document.createElement('div');
257
- div.textContent = text;
258
- return div.innerHTML;
219
+ const div = document.createElement('div');
220
+ div.textContent = text;
221
+ return div.innerHTML;
259
222
  }
260
-
261
223
  /**
262
224
  * Decode HTML entities back to plain text
263
225
  * Used to fix double-encoding when building URLs from already-escaped content
@@ -265,11 +227,10 @@ function escapeHtmlForMarkdown(text) {
265
227
  * @returns {string} Decoded plain text
266
228
  */
267
229
  function decodeHtmlEntities(text) {
268
- const div = document.createElement('div');
269
- div.innerHTML = text;
270
- return div.textContent || div.innerText || '';
230
+ const div = document.createElement('div');
231
+ div.innerHTML = text;
232
+ return div.textContent || div.innerText || '';
271
233
  }
272
-
273
234
  /**
274
235
  * Wrap checkpoint/context sections in collapsible elements
275
236
  * Detects patterns like "šŸ“ Summary", "šŸŽÆ Goal", "Recent decisions", etc.
@@ -277,37 +238,30 @@ function decodeHtmlEntities(text) {
277
238
  * @returns {string} Text with collapsible sections
278
239
  */
279
240
  function wrapCheckpointSections(text) {
280
- // Pattern to detect checkpoint section start
281
- // Matches: "šŸ“ Summary", "šŸŽÆ Goal", "šŸ“ **Last Checkpoint**", etc.
282
- const checkpointStartPatterns = [
283
- /^(šŸ“\s*\*?\*?Summary of past work|šŸ“\s*\*?\*?Last Checkpoint)/m,
284
- /^(šŸŽÆ\s*Goal)/m,
285
- ];
286
-
287
- // Check if this message contains a checkpoint section
288
- const hasCheckpoint = checkpointStartPatterns.some((p) => p.test(text));
289
- if (!hasCheckpoint) {
290
- return text;
291
- }
292
-
293
- // Find the checkpoint section boundaries
294
- // It typically starts with "šŸ“" and ends before "---" or end of message
295
- const checkpointMatch = text.match(/(šŸ“[\s\S]*?)(?=\n---\nšŸš€|\n---\n\nšŸš€|---\n\nšŸš€|$)/);
296
-
297
- if (!checkpointMatch) {
298
- return text;
299
- }
300
-
301
- const checkpointContent = checkpointMatch[1];
302
- const uniqueId = 'cp-' + Math.random().toString(36).substring(2, 11);
303
-
304
- // Create collapsible wrapper - summary must be first child (no newlines before it)
305
- const collapsibleHtml =
306
- `<details class="checkpoint-collapse" id="${uniqueId}">` +
307
- `<summary class="checkpoint-summary">šŸ“ Session Context <span class="collapse-hint">(tap to expand)</span></summary>` +
308
- `<div class="checkpoint-content">${checkpointContent}</div>` +
309
- `</details>`;
310
-
311
- // Replace the checkpoint content with collapsible version
312
- return text.replace(checkpointContent, collapsibleHtml);
241
+ // Pattern to detect checkpoint section start
242
+ // Matches: "šŸ“ Summary", "šŸŽÆ Goal", "šŸ“ **Last Checkpoint**", etc.
243
+ const checkpointStartPatterns = [
244
+ /^(šŸ“\s*\*?\*?Summary of past work|šŸ“\s*\*?\*?Last Checkpoint)/m,
245
+ /^(šŸŽÆ\s*Goal)/m,
246
+ ];
247
+ // Check if this message contains a checkpoint section
248
+ const hasCheckpoint = checkpointStartPatterns.some((p) => p.test(text));
249
+ if (!hasCheckpoint) {
250
+ return text;
251
+ }
252
+ // Find the checkpoint section boundaries
253
+ // It typically starts with "šŸ“" and ends before "---" or end of message
254
+ const checkpointMatch = text.match(/(šŸ“[\s\S]*?)(?=\n---\nšŸš€|\n---\n\nšŸš€|---\n\nšŸš€|$)/);
255
+ if (!checkpointMatch) {
256
+ return text;
257
+ }
258
+ const checkpointContent = checkpointMatch[1];
259
+ const uniqueId = 'cp-' + Math.random().toString(36).substring(2, 11);
260
+ // Create collapsible wrapper - summary must be first child (no newlines before it)
261
+ const collapsibleHtml = `<details class="checkpoint-collapse" id="${uniqueId}">` +
262
+ `<summary class="checkpoint-summary">šŸ“ Session Context <span class="collapse-hint">(tap to expand)</span></summary>` +
263
+ `<div class="checkpoint-content">${checkpointContent}</div>` +
264
+ `</details>`;
265
+ // Replace the checkpoint content with collapsible version
266
+ return text.replace(checkpointContent, collapsibleHtml);
313
267
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Safe markdown rendering utilities.
3
+ */
4
+ /* eslint-env browser */
5
+ import { escapeHtml } from './dom.js';
6
+ const MARKDOWN_PARSE_OPTIONS = {};
7
+ /**
8
+ * Render markdown string to HTML and sanitize.
9
+ *
10
+ * - Uses `marked.parse` when available.
11
+ * - Falls back to plain text rendering when markdown parser is unavailable.
12
+ * - Applies DOMPurify when available; otherwise returns parser output as-is.
13
+ */
14
+ export function renderSafeMarkdown(markdown) {
15
+ if (!markdown) {
16
+ return '';
17
+ }
18
+ const markdownText = String(markdown);
19
+ let html;
20
+ try {
21
+ if (typeof marked !== 'undefined' && typeof marked.parse === 'function') {
22
+ html = marked.parse(markdownText, MARKDOWN_PARSE_OPTIONS);
23
+ }
24
+ else {
25
+ throw new Error('marked not available');
26
+ }
27
+ }
28
+ catch {
29
+ const fallback = escapeHtml(markdownText);
30
+ html = fallback.replace(/\n/g, '<br/>');
31
+ }
32
+ if (typeof DOMPurify !== 'undefined' && typeof DOMPurify.sanitize === 'function') {
33
+ return DOMPurify.sanitize(html);
34
+ }
35
+ if (typeof console !== 'undefined') {
36
+ console.warn('[SafeMarkdown] DOMPurify is unavailable. Rendering escaped content as fallback.');
37
+ }
38
+ const safeFallback = escapeHtml(markdownText).replace(/\\n/g, '<br/>');
39
+ return safeFallback;
40
+ }