@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.
- package/CHANGELOG.md +10 -0
- package/dist/agent/agent-loop.d.ts +1 -8
- package/dist/agent/agent-loop.d.ts.map +1 -1
- package/dist/agent/agent-loop.js +44 -159
- package/dist/agent/agent-loop.js.map +1 -1
- package/dist/agent/claude-cli-wrapper.d.ts +6 -0
- package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
- package/dist/agent/claude-cli-wrapper.js +6 -0
- package/dist/agent/claude-cli-wrapper.js.map +1 -1
- package/dist/agent/codex-mcp-process.d.ts +85 -0
- package/dist/agent/codex-mcp-process.d.ts.map +1 -0
- package/dist/agent/codex-mcp-process.js +357 -0
- package/dist/agent/codex-mcp-process.js.map +1 -0
- package/dist/agent/session-pool.d.ts +17 -2
- package/dist/agent/session-pool.d.ts.map +1 -1
- package/dist/agent/session-pool.js +51 -26
- package/dist/agent/session-pool.js.map +1 -1
- package/dist/agent/types.d.ts +9 -24
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/api/graph-api.d.ts.map +1 -1
- package/dist/api/graph-api.js +133 -45
- package/dist/api/graph-api.js.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +14 -25
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +3 -10
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +143 -54
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -7
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/config/config-manager.d.ts.map +1 -1
- package/dist/cli/config/config-manager.js +9 -17
- package/dist/cli/config/config-manager.js.map +1 -1
- package/dist/cli/config/types.d.ts +19 -25
- package/dist/cli/config/types.d.ts.map +1 -1
- package/dist/cli/config/types.js.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/gateways/context-injector.d.ts.map +1 -1
- package/dist/gateways/context-injector.js +6 -3
- package/dist/gateways/context-injector.js.map +1 -1
- package/dist/gateways/discord.d.ts +4 -0
- package/dist/gateways/discord.d.ts.map +1 -1
- package/dist/gateways/discord.js +39 -16
- package/dist/gateways/discord.js.map +1 -1
- package/dist/gateways/message-router.d.ts +6 -1
- package/dist/gateways/message-router.d.ts.map +1 -1
- package/dist/gateways/message-router.js +92 -7
- package/dist/gateways/message-router.js.map +1 -1
- package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
- package/dist/multi-agent/agent-process-manager.js +36 -9
- package/dist/multi-agent/agent-process-manager.js.map +1 -1
- package/dist/multi-agent/runtime-process.d.ts +4 -4
- package/dist/multi-agent/runtime-process.d.ts.map +1 -1
- package/dist/multi-agent/runtime-process.js +9 -20
- package/dist/multi-agent/runtime-process.js.map +1 -1
- package/dist/multi-agent/types.d.ts +13 -8
- package/dist/multi-agent/types.d.ts.map +1 -1
- package/dist/multi-agent/types.js.map +1 -1
- package/dist/setup/setup-prompt.d.ts +1 -1
- package/dist/setup/setup-prompt.d.ts.map +1 -1
- package/dist/setup/setup-prompt.js +19 -0
- package/dist/setup/setup-prompt.js.map +1 -1
- package/dist/setup/setup-server.d.ts.map +1 -1
- package/dist/setup/setup-server.js +39 -16
- package/dist/setup/setup-server.js.map +1 -1
- package/dist/skills/skill-registry.d.ts.map +1 -1
- package/dist/skills/skill-registry.js +5 -2
- package/dist/skills/skill-registry.js.map +1 -1
- package/package.json +5 -3
- package/public/setup.html +12 -1
- package/public/viewer/js/modules/chat.js +1760 -1976
- package/public/viewer/js/modules/dashboard.js +613 -695
- package/public/viewer/js/modules/graph.js +857 -970
- package/public/viewer/js/modules/memory.js +357 -312
- package/public/viewer/js/modules/settings.js +1009 -1026
- package/public/viewer/js/modules/skills.js +336 -355
- package/public/viewer/js/utils/api.js +255 -255
- package/public/viewer/js/utils/debug-logger.js +20 -26
- package/public/viewer/js/utils/dom.js +73 -60
- package/public/viewer/js/utils/format.js +182 -228
- package/public/viewer/js/utils/markdown.js +40 -0
- package/public/viewer/src/modules/chat.ts +2258 -0
- package/public/viewer/src/modules/dashboard.ts +1052 -0
- package/public/viewer/src/modules/graph.ts +1080 -0
- package/public/viewer/src/modules/memory.ts +453 -0
- package/public/viewer/src/modules/settings.ts +1398 -0
- package/public/viewer/src/modules/skills.ts +457 -0
- package/public/viewer/src/types/global.d.ts +168 -0
- package/public/viewer/src/utils/api.ts +650 -0
- package/public/viewer/src/utils/debug-logger.ts +36 -0
- package/public/viewer/src/utils/dom.ts +138 -0
- package/public/viewer/src/utils/format.ts +331 -0
- package/public/viewer/src/utils/markdown.ts +46 -0
- package/public/viewer/tsconfig.viewer.json +18 -0
- package/public/viewer/viewer.html +214 -311
- package/dist/agent/codex-cli-wrapper.d.ts +0 -85
- package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
- package/dist/agent/codex-cli-wrapper.js +0 -295
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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, '"');
|
|
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:  ā 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 >)
|
|
228
|
-
// Match patterns: A) text, **A)** text, > A) text, etc.
|
|
229
|
-
formatted = formatted.replace(
|
|
230
|
-
/^(?:>\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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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, '"');
|
|
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:  ā 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 >)
|
|
199
|
+
// Match patterns: A) text, **A)** text, > A) text, etc.
|
|
200
|
+
formatted = formatted.replace(/^(?:>\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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|