amalgm 0.1.99 → 0.1.100

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.99",
3
+ "version": "0.1.100",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Email embed widgets — the email-safe mirror of the UI's shared rendering
3
+ * primitives: FileEmbed (media), EmbedChrome (the floating name/options
4
+ * pills on expanded embeds), and the tile:/full: entity renders.
5
+ *
6
+ * Email clients strip <style>, position:absolute, and most interactivity,
7
+ * so the chrome pills sit in a header row inside the same rounded-16
8
+ * stone-900 card instead of floating over the media — visually the same
9
+ * language, structurally email-safe. Gmail/Outlook drop <video> but render
10
+ * its fallback content; Apple Mail and iOS Mail play video inline.
11
+ *
12
+ * Media table mirrors amalgm-ui/lib/rendering/media.ts; visual values
13
+ * mirror EMAIL_TOKENS in email-md.js / lib/rendering/tokens.ts. Keep all
14
+ * three in sync.
15
+ */
16
+
17
+ const COLOR = {
18
+ text: '#ffffff',
19
+ textMuted: '#a8a29e', // stone-400
20
+ border: '#1c1917', // stone-900 — container borders
21
+ pillBorder: '#292524', // stone-800
22
+ bg: '#000000',
23
+ bgChip: '#0c0a09', // stone-950
24
+ link: '#60a5fa', // blue-400
25
+ };
26
+
27
+ const EXTENSION_MIME = {
28
+ // Images
29
+ png: 'image/png',
30
+ jpg: 'image/jpeg',
31
+ jpeg: 'image/jpeg',
32
+ gif: 'image/gif',
33
+ webp: 'image/webp',
34
+ svg: 'image/svg+xml',
35
+ avif: 'image/avif',
36
+ bmp: 'image/bmp',
37
+ ico: 'image/x-icon',
38
+ // Video
39
+ webm: 'video/webm',
40
+ mp4: 'video/mp4',
41
+ m4v: 'video/mp4',
42
+ mov: 'video/quicktime',
43
+ mkv: 'video/x-matroska',
44
+ ogv: 'video/ogg',
45
+ // Audio
46
+ mp3: 'audio/mpeg',
47
+ wav: 'audio/wav',
48
+ m4a: 'audio/mp4',
49
+ ogg: 'audio/ogg',
50
+ flac: 'audio/flac',
51
+ // Documents
52
+ pdf: 'application/pdf',
53
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
54
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
55
+ xls: 'application/vnd.ms-excel',
56
+ csv: 'text/csv',
57
+ tsv: 'text/tab-separated-values',
58
+ };
59
+
60
+ function escapeAttr(value) {
61
+ return String(value)
62
+ .replace(/&/g, '&amp;')
63
+ .replace(/</g, '&lt;')
64
+ .replace(/>/g, '&gt;')
65
+ .replace(/"/g, '&quot;');
66
+ }
67
+
68
+ function escapeText(value) {
69
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
70
+ }
71
+
72
+ function mediaExtensionOf(nameOrUrl) {
73
+ const withoutQuery = String(nameOrUrl || '').split(/[?#]/)[0];
74
+ const base = withoutQuery.split('/').pop() || '';
75
+ const dot = base.lastIndexOf('.');
76
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
77
+ }
78
+
79
+ function mediaMimeOf(nameOrUrl) {
80
+ return EXTENSION_MIME[mediaExtensionOf(nameOrUrl)] || null;
81
+ }
82
+
83
+ function mediaKindOf(nameOrUrl) {
84
+ const mime = mediaMimeOf(nameOrUrl);
85
+ if (!mime) return 'unknown';
86
+ if (mime.startsWith('image/')) return 'image';
87
+ if (mime.startsWith('video/')) return 'video';
88
+ if (mime.startsWith('audio/')) return 'audio';
89
+ return 'document';
90
+ }
91
+
92
+ function mediaNameOf(nameOrUrl) {
93
+ const withoutQuery = String(nameOrUrl || '').split(/[?#]/)[0];
94
+ const base = withoutQuery.split('/').pop() || '';
95
+ try {
96
+ return decodeURIComponent(base);
97
+ } catch {
98
+ return base;
99
+ }
100
+ }
101
+
102
+ function isHttpUrl(url) {
103
+ return /^https?:\/\//i.test(url);
104
+ }
105
+
106
+ /** Inline attachment reference — the image bytes ride inside the email. */
107
+ function isCidUrl(url) {
108
+ return /^cid:/i.test(url);
109
+ }
110
+
111
+ /** Self-contained image source: fetchable over https or embedded as cid. */
112
+ function isEmbeddableImageSrc(url) {
113
+ return isHttpUrl(url) || isCidUrl(url);
114
+ }
115
+
116
+ // --- EmbedChrome (email form) ------------------------------------------------
117
+
118
+ function chromePill(content) {
119
+ return `<span style="display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:999px;padding:4px 12px;font-size:12px;line-height:16px;color:${COLOR.text};white-space:nowrap">${content}</span>`;
120
+ }
121
+
122
+ /**
123
+ * The expanded-embed card: rounded-16 stone-900 border, black fill, a header
124
+ * row carrying the name pill (left) and an action pill (right), media below.
125
+ * actionLabel+actionHref make the right pill a link; actionText makes it a
126
+ * plain label (e.g. "Attachment").
127
+ */
128
+ function embedShell({ name, actionLabel, actionHref, actionText, body }) {
129
+ const namePill = name ? chromePill(escapeText(name)) : '';
130
+ const actionPill = actionLabel && actionHref
131
+ ? `<a href="${escapeAttr(actionHref)}" target="_blank" style="text-decoration:none">${chromePill(
132
+ `<span style="color:${COLOR.link}">${escapeText(actionLabel)}</span>`,
133
+ )}</a>`
134
+ : actionText
135
+ ? chromePill(`<span style="color:${COLOR.textMuted}">${escapeText(actionText)}</span>`)
136
+ : '';
137
+ return (
138
+ `<div style="border:1px solid ${COLOR.border};border-radius:16px;background:${COLOR.bg};margin:16px 0;overflow:hidden">` +
139
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
140
+ `<td align="left" style="padding:10px 10px 8px">${namePill}</td>` +
141
+ `<td align="right" style="padding:10px 10px 8px">${actionPill}</td>` +
142
+ `</tr></table>` +
143
+ body +
144
+ `</div>`
145
+ );
146
+ }
147
+
148
+ // --- Media embeds -------------------------------------------------------------
149
+
150
+ /** Bare rounded image per the ruleset — images never wear chrome. */
151
+ function renderImageEmbed(alt, url) {
152
+ return `<img src="${escapeAttr(url)}" alt="${escapeAttr(alt || '')}" style="display:block;max-width:100%;height:auto;border-radius:16px;border:1px solid ${COLOR.border};margin:16px 0">`;
153
+ }
154
+
155
+ const PLAY_PILL =
156
+ `<span style="display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:999px;padding:10px 20px;font-size:14px;color:${COLOR.text}">&#9654;&nbsp;&nbsp;Play video</span>`;
157
+
158
+ /** Poster frame + centered play pill, optionally wrapped in a link. */
159
+ function posterBlock(posterSrc, label, href) {
160
+ const img = `<img src="${escapeAttr(posterSrc)}" alt="${escapeAttr(label || 'video')}" style="display:block;width:100%;height:auto;background:${COLOR.bg}">`;
161
+ const pillRow =
162
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
163
+ `<td align="center" style="padding:12px 16px 16px;background:${COLOR.bg}">${PLAY_PILL}</td></tr></table>`;
164
+ const inner = img + pillRow;
165
+ return href
166
+ ? `<a href="${escapeAttr(href)}" target="_blank" style="text-decoration:none;display:block">${inner}</a>`
167
+ : inner;
168
+ }
169
+
170
+ /**
171
+ * Video embed: a real <video> for clients that support it (Apple Mail, iOS
172
+ * Mail), with a fallback that Gmail/Outlook render instead — the poster
173
+ * frame when one is provided, a play card otherwise.
174
+ */
175
+ function renderVideoEmbed(label, url, poster) {
176
+ const name = label || mediaNameOf(url) || 'Video';
177
+ const safeUrl = escapeAttr(url);
178
+ const posterSrc = poster && isEmbeddableImageSrc(poster) ? poster : null;
179
+ const fallback = posterSrc
180
+ ? posterBlock(posterSrc, name, url)
181
+ : `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
182
+ `<td align="center" style="padding:48px 16px;background:${COLOR.bg}">` +
183
+ `<a href="${safeUrl}" target="_blank" style="text-decoration:none">${PLAY_PILL}</a></td></tr></table>`;
184
+ const body =
185
+ `<video controls preload="metadata" src="${safeUrl}"${posterSrc ? ` poster="${escapeAttr(posterSrc)}"` : ''} style="display:block;width:100%;max-height:480px;background:${COLOR.bg}">` +
186
+ fallback +
187
+ `</video>`;
188
+ return embedShell({ name, actionLabel: 'Open ↗', actionHref: url, body });
189
+ }
190
+
191
+ /**
192
+ * A video that travels WITH the email as a regular attachment (the
193
+ * self-contained path for local recordings): poster frame in the body when
194
+ * available, and the file itself in the mail client's attachment strip —
195
+ * Gmail gives attached videos a playable preview natively.
196
+ */
197
+ function renderAttachedVideoCard(label, poster) {
198
+ const name = label || 'Video';
199
+ const posterSrc = poster && isEmbeddableImageSrc(poster) ? poster : null;
200
+ const note =
201
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
202
+ `<td align="center" style="padding:${posterSrc ? '0 16px 16px' : '40px 16px 16px'};background:${COLOR.bg}">` +
203
+ (posterSrc ? '' : `${PLAY_PILL}<br>`) +
204
+ `<span style="display:inline-block;padding-top:8px;font-size:12px;color:${COLOR.textMuted}">Attached to this email &mdash; open the attachment to play</span>` +
205
+ `</td></tr></table>`;
206
+ const body = (posterSrc
207
+ ? `<img src="${escapeAttr(posterSrc)}" alt="${escapeAttr(name)}" style="display:block;width:100%;height:auto;background:${COLOR.bg}">`
208
+ : '') + note;
209
+ return embedShell({ name, actionText: 'Attachment', body });
210
+ }
211
+
212
+ /**
213
+ * Non-playable file in an embed position (pdf/docx/audio/anything): the
214
+ * compact file row from FileEmbed, as a card linking out.
215
+ */
216
+ function renderFileCardEmbed(label, url) {
217
+ const name = label || mediaNameOf(url) || 'File';
218
+ const ext = mediaExtensionOf(name).toUpperCase();
219
+ const inner =
220
+ `<span style="font-size:13px;color:${COLOR.text}">${escapeText(name)}</span>` +
221
+ (ext ? `<span style="font-size:10px;color:${COLOR.textMuted};padding-left:8px">${escapeText(ext)}</span>` : '');
222
+ const body = isHttpUrl(url)
223
+ ? `<a href="${escapeAttr(url)}" target="_blank" style="text-decoration:none;display:block;padding:4px 14px 14px">${inner}<span style="font-size:12px;color:${COLOR.link};padding-left:10px">Open &#8599;</span></a>`
224
+ : `<div style="padding:4px 14px 14px">${inner}<span style="font-size:12px;color:${COLOR.textMuted};padding-left:10px">On your computer &mdash; open in amalgm</span></div>`;
225
+ return embedShell({
226
+ name,
227
+ actionLabel: isHttpUrl(url) ? 'Open ↗' : 'amalgm ↗',
228
+ actionHref: isHttpUrl(url) ? url : 'https://amalgm.ai',
229
+ body,
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Media dispatch for `![label](url "poster")` in an email.
235
+ *
236
+ * Self-contained sources produced by the notify media pipeline:
237
+ * cid:<id> — image bytes ride inside the email (inline attachment)
238
+ * attached:<name> — video travels as a regular attachment; the body
239
+ * shows its poster card
240
+ * The optional markdown title carries the video poster (cid: or https).
241
+ * Local sources that could not be attached (amalgm:// refs or filesystem
242
+ * paths) fall back to the file card.
243
+ */
244
+ function renderMediaEmbed(alt, url, poster) {
245
+ if (isCidUrl(url)) {
246
+ return renderImageEmbed(alt, url);
247
+ }
248
+ const attachedMatch = url.match(/^attached:(.*)$/i);
249
+ if (attachedMatch) {
250
+ let name = attachedMatch[1];
251
+ try {
252
+ name = decodeURIComponent(name);
253
+ } catch {}
254
+ return renderAttachedVideoCard(alt || name, poster);
255
+ }
256
+ const amalgmMatch = url.match(/^amalgm:\/\/file\/([^\s)?"]+)/);
257
+ if (amalgmMatch) {
258
+ let decoded = amalgmMatch[1];
259
+ try {
260
+ decoded = decodeURIComponent(decoded);
261
+ } catch {}
262
+ return renderFileCardEmbed(alt || mediaNameOf(decoded), decoded);
263
+ }
264
+ if (!isHttpUrl(url)) {
265
+ return renderFileCardEmbed(alt || mediaNameOf(url), url);
266
+ }
267
+ const kind = mediaKindOf(url);
268
+ if (kind === 'video') return renderVideoEmbed(alt, url, poster);
269
+ if (kind === 'audio' || kind === 'document') return renderFileCardEmbed(alt, url);
270
+ // Images and extension-less URLs keep vanilla image semantics.
271
+ return renderImageEmbed(alt, url);
272
+ }
273
+
274
+ // --- Entity embeds (tile: / full:) ---------------------------------------------
275
+
276
+ const ENTITY_TYPE_LABEL = {
277
+ chat: 'Chat',
278
+ conversation: 'Chat',
279
+ automation: 'Automation',
280
+ app: 'App',
281
+ artifact: 'App',
282
+ service: 'App',
283
+ preview: 'Preview',
284
+ task: 'Task',
285
+ event: 'Event',
286
+ trigger: 'Trigger',
287
+ };
288
+
289
+ function entityTypeLabel(type) {
290
+ return ENTITY_TYPE_LABEL[type] || (type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Item');
291
+ }
292
+
293
+ /**
294
+ * Inline tile — the email form of the chat's render tile: a compact linked
295
+ * card with a type eyebrow and the entity name, pointing at the amalgm app.
296
+ */
297
+ function renderEntityTile(label, type) {
298
+ const safeLabel = escapeText(label || entityTypeLabel(type));
299
+ return (
300
+ `<a href="https://amalgm.ai" target="_blank" style="text-decoration:none;display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:12px;padding:8px 14px;margin:2px 0;vertical-align:middle">` +
301
+ `<span style="display:block;font-size:11px;line-height:14px;color:${COLOR.textMuted};text-transform:uppercase;letter-spacing:0.04em">${escapeText(entityTypeLabel(type))}</span>` +
302
+ `<span style="display:block;font-size:14px;line-height:20px;color:${COLOR.text}">${safeLabel}</span>` +
303
+ `</a>`
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Expanded entity — the email form of the full: render. Email can't iframe
309
+ * the live app, so it's the EmbedChrome card with the entity identity and a
310
+ * prominent open action.
311
+ */
312
+ function renderEntityExpanded(label, type) {
313
+ const display = label || entityTypeLabel(type);
314
+ const body =
315
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
316
+ `<td align="center" style="padding:36px 16px 40px">` +
317
+ `<span style="display:block;font-size:11px;line-height:14px;color:${COLOR.textMuted};text-transform:uppercase;letter-spacing:0.04em;padding-bottom:6px">${escapeText(entityTypeLabel(type))}</span>` +
318
+ `<span style="display:block;font-size:16px;font-weight:650;color:${COLOR.text};padding-bottom:16px">${escapeText(display)}</span>` +
319
+ `<a href="https://amalgm.ai" target="_blank" style="text-decoration:none">` +
320
+ `<span style="display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:999px;padding:8px 18px;font-size:13px;color:${COLOR.link}">Open in amalgm &#8599;</span>` +
321
+ `</a></td></tr></table>`;
322
+ return embedShell({ name: display, actionLabel: 'Open ↗', actionHref: 'https://amalgm.ai', body });
323
+ }
324
+
325
+ function renderEntityEmbed(mode, type, label) {
326
+ return mode === 'full' ? renderEntityExpanded(label, type) : renderEntityTile(label, type);
327
+ }
328
+
329
+ module.exports = {
330
+ mediaExtensionOf,
331
+ mediaMimeOf,
332
+ mediaKindOf,
333
+ mediaNameOf,
334
+ isHttpUrl,
335
+ isCidUrl,
336
+ isEmbeddableImageSrc,
337
+ renderImageEmbed,
338
+ renderVideoEmbed,
339
+ renderAttachedVideoCard,
340
+ renderFileCardEmbed,
341
+ renderMediaEmbed,
342
+ renderEntityEmbed,
343
+ };
@@ -11,8 +11,14 @@
11
11
  * rendering ruleset in amalgm-ui/lib/rendering/tokens.ts — keep both in sync:
12
12
  * body 14px, h1 caps at 18px (the amalgm section-header size), Notion-style
13
13
  * table grid, black chat-style code blocks, blue hyperlinks/citations.
14
+ *
15
+ * Media (`![label](url)`) and entity renders (`[label](tile:type:id)`,
16
+ * `[label](full:type:id)`) on their own line become block embeds via
17
+ * lib/email-embeds.js — the email mirror of FileEmbed/EmbedChrome/tiles.
14
18
  */
15
19
 
20
+ const embeds = require('./email-embeds');
21
+
16
22
  const EMAIL_TOKENS = {
17
23
  font: {
18
24
  text: "'SF Pro Text',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif",
@@ -69,10 +75,28 @@ function renderAmalgmCitation(label, _kindAndPath, description) {
69
75
  function inlineFormat(text) {
70
76
  return (
71
77
  text
72
- // Images: ![alt](url)
78
+ // Media mid-sentence: images stay inline; video becomes a play link;
79
+ // local files become a citation-blue span. (Standalone media lines are
80
+ // handled as block embeds in markdownToEmailHtml before reaching here.)
73
81
  .replace(
74
- /!\[([^\]]*)\]\(([^)]+)\)/g,
75
- '<img src="$2" alt="$1" style="max-width:100%;height:auto;border-radius:16px;margin:8px 0">',
82
+ /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
83
+ (_, alt, url) => {
84
+ if (/^attached:/i.test(url)) {
85
+ return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(alt || 'video')}</span> <span style="color:${EMAIL_TOKENS.color.textSecondary}">(attached)</span>`;
86
+ }
87
+ if (!embeds.isEmbeddableImageSrc(url)) {
88
+ return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(alt || embeds.mediaNameOf(url) || 'file')}</span>`;
89
+ }
90
+ if (embeds.mediaKindOf(url) === 'video') {
91
+ return `<a href="${escapeHtmlAttr(url)}" target="_blank" style="color:${EMAIL_TOKENS.color.link};text-decoration:none">&#9654;&nbsp;${escapeHtmlText(alt || 'video')}</a>`;
92
+ }
93
+ return `<img src="${escapeHtmlAttr(url)}" alt="${escapeHtmlAttr(alt)}" style="max-width:100%;height:auto;border-radius:16px;margin:8px 0">`;
94
+ },
95
+ )
96
+ // Entity renders mid-sentence collapse to the compact tile.
97
+ .replace(
98
+ /\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)/g,
99
+ (_, label, mode, type) => embeds.renderEntityEmbed('tile', type, label),
76
100
  )
77
101
  // amalgm:// citations (run before generic link regex so the optional
78
102
  // title attribute doesn't get swallowed into the href). Format:
@@ -230,6 +254,22 @@ function markdownToEmailHtml(md) {
230
254
  out.push(`<hr style="border:none;border-top:1px solid ${EMAIL_TOKENS.color.border};margin:24px 0">`);
231
255
  continue;
232
256
  }
257
+ // Standalone media line → block embed (image bare, video in the
258
+ // EmbedChrome card, attached/local/doc files as cards). The optional
259
+ // title carries a video poster: ![label](url "cid:poster-1").
260
+ const mediaMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/);
261
+ if (mediaMatch) {
262
+ flushAll();
263
+ out.push(embeds.renderMediaEmbed(mediaMatch[1], mediaMatch[2], mediaMatch[3] || null));
264
+ continue;
265
+ }
266
+ // Standalone entity render → tile card or expanded card.
267
+ const entityMatch = trimmed.match(/^\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)$/);
268
+ if (entityMatch) {
269
+ flushAll();
270
+ out.push(embeds.renderEntityEmbed(entityMatch[2], entityMatch[3], entityMatch[1]));
271
+ continue;
272
+ }
233
273
  const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
234
274
  if (headingMatch) {
235
275
  flushAll();
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Self-contained email media pipeline.
3
+ *
4
+ * notify_user messages may embed media that lives on this computer
5
+ * (`![label](amalgm://file/...)` or absolute paths). A mail client can never
6
+ * fetch those, so before rendering we convert them into attachments that
7
+ * travel WITH the email:
8
+ *
9
+ * - images → inline CID attachments; the src is rewritten to `cid:<id>` so
10
+ * the picture displays in the body, fully offline.
11
+ * - videos → regular attachments (Gmail gives those a playable preview);
12
+ * the src is rewritten to `attached:<filename>` which email-embeds
13
+ * renders as the poster card. The poster comes from an explicit markdown
14
+ * title (`![clip](path "poster-path")`), then a sibling animated
15
+ * `<name>.preview.gif` / `<name>.gif`, then a static
16
+ * `<name>.poster.png` / `.jpg` / `.jpeg` / `.webp`, and itself becomes an
17
+ * inline CID.
18
+ *
19
+ * Anything that can't be attached (missing file, too large, over budget) is
20
+ * left untouched so the renderer falls back to its file card. Web https
21
+ * sources are never rewritten.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+ const { mediaKindOf, mediaMimeOf } = require('../lib/email-embeds');
28
+
29
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
30
+ const MAX_VIDEO_BYTES = 20 * 1024 * 1024;
31
+ const MAX_TOTAL_BYTES = 22 * 1024 * 1024; // proxy caps at 25MB; leave margin
32
+ const MAX_ATTACHMENTS = 8;
33
+
34
+ const MEDIA_LINE_RE = /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/;
35
+
36
+ /** Resolve a media src to an absolute path on this computer, or null. */
37
+ function resolveLocalPath(src) {
38
+ if (!src) return null;
39
+ const amalgmMatch = src.match(/^amalgm:\/\/file\/([^?#]+)/);
40
+ if (amalgmMatch) {
41
+ try {
42
+ return decodeURIComponent(amalgmMatch[1]);
43
+ } catch {
44
+ return amalgmMatch[1];
45
+ }
46
+ }
47
+ if (src.startsWith('file://')) return decodeURIComponentSafe(src.slice('file://'.length));
48
+ let candidate = src;
49
+ try {
50
+ candidate = decodeURIComponent(src);
51
+ } catch {}
52
+ if (candidate.startsWith('~/')) return path.join(os.homedir(), candidate.slice(2));
53
+ if (candidate.startsWith('/')) return candidate;
54
+ return null;
55
+ }
56
+
57
+ function decodeURIComponentSafe(value) {
58
+ try {
59
+ return decodeURIComponent(value);
60
+ } catch {
61
+ return value;
62
+ }
63
+ }
64
+
65
+ function statSafe(filePath) {
66
+ try {
67
+ const stat = fs.statSync(filePath);
68
+ return stat.isFile() ? stat : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Sibling preview for a video, best first:
76
+ * - <name>.preview.gif / <name>.gif — animated GIF; Gmail animates inline
77
+ * CID GIFs, so the email "plays" the clip right in the body
78
+ * - <name>.poster.png/jpg/jpeg/webp — static first frame
79
+ * (<name> is the full filename or its stem.)
80
+ */
81
+ function findSiblingPoster(videoPath) {
82
+ const dir = path.dirname(videoPath);
83
+ const base = path.basename(videoPath);
84
+ const stem = base.replace(/\.[^.]+$/, '');
85
+ const candidates = [];
86
+ for (const name of [base, stem]) {
87
+ candidates.push(path.join(dir, `${name}.preview.gif`), path.join(dir, `${name}.gif`));
88
+ }
89
+ for (const name of [base, stem]) {
90
+ for (const ext of ['png', 'jpg', 'jpeg', 'webp']) {
91
+ candidates.push(path.join(dir, `${name}.poster.${ext}`));
92
+ }
93
+ }
94
+ return candidates.find((candidate) => statSafe(candidate)) || null;
95
+ }
96
+
97
+ /**
98
+ * Convert local media embeds in a markdown message into email attachments.
99
+ * Returns { message, attachments } — message has cid:/attached: sources,
100
+ * attachments are in the proxy's shape (filename, content, contentType,
101
+ * contentId?).
102
+ */
103
+ function prepareEmailMedia(message) {
104
+ const attachments = [];
105
+ let totalBytes = 0;
106
+ let counter = 0;
107
+
108
+ const budgetAllows = (bytes) =>
109
+ attachments.length < MAX_ATTACHMENTS && totalBytes + bytes <= MAX_TOTAL_BYTES;
110
+
111
+ const attachFile = (filePath, stat, contentId) => {
112
+ const content = fs.readFileSync(filePath).toString('base64');
113
+ totalBytes += stat.size;
114
+ attachments.push({
115
+ filename: path.basename(filePath),
116
+ content,
117
+ contentType: mediaMimeOf(filePath) || 'application/octet-stream',
118
+ ...(contentId ? { contentId } : {}),
119
+ });
120
+ };
121
+
122
+ const attachPoster = (posterSource) => {
123
+ // Poster may be an explicit local source or an https URL (kept as-is).
124
+ if (!posterSource) return null;
125
+ if (/^https?:\/\//i.test(posterSource) || /^cid:/i.test(posterSource)) return posterSource;
126
+ const posterPath = resolveLocalPath(posterSource);
127
+ const stat = posterPath ? statSafe(posterPath) : null;
128
+ if (!stat || stat.size > MAX_IMAGE_BYTES || !budgetAllows(stat.size)) return null;
129
+ if (mediaKindOf(posterPath) !== 'image') return null;
130
+ const contentId = `poster-${++counter}`;
131
+ attachFile(posterPath, stat, contentId);
132
+ return `cid:${contentId}`;
133
+ };
134
+
135
+ const lines = message.split('\n').map((line) => {
136
+ const match = line.trim().match(MEDIA_LINE_RE);
137
+ if (!match) return line;
138
+ const [, alt, src, title] = match;
139
+
140
+ if (/^(https?:|cid:|attached:|data:)/i.test(src)) return line;
141
+ const filePath = resolveLocalPath(src);
142
+ const stat = filePath ? statSafe(filePath) : null;
143
+ if (!stat) return line;
144
+
145
+ const kind = mediaKindOf(filePath);
146
+
147
+ if (kind === 'image') {
148
+ if (stat.size > MAX_IMAGE_BYTES || !budgetAllows(stat.size)) return line;
149
+ const contentId = `media-${++counter}`;
150
+ attachFile(filePath, stat, contentId);
151
+ return `![${alt || path.basename(filePath)}](cid:${contentId})`;
152
+ }
153
+
154
+ if (kind === 'video') {
155
+ if (stat.size > MAX_VIDEO_BYTES || !budgetAllows(stat.size)) return line;
156
+ attachFile(filePath, stat, null);
157
+ const poster = attachPoster(title) || attachPoster(findSiblingPoster(filePath));
158
+ const label = alt || path.basename(filePath);
159
+ const target = `attached:${encodeURIComponent(path.basename(filePath))}`;
160
+ return poster ? `![${label}](${target} "${poster}")` : `![${label}](${target})`;
161
+ }
162
+
163
+ return line;
164
+ });
165
+
166
+ return { message: lines.join('\n'), attachments };
167
+ }
168
+
169
+ module.exports = { prepareEmailMedia };
@@ -9,6 +9,7 @@
9
9
  const { PROXY_BASE_URL, PROXY_TOKEN } = require('../config');
10
10
  const { ensureFreshProxyToken, readProxyToken } = require('../../proxy-token-store');
11
11
  const { formatNotificationEmail } = require('../lib/email-md');
12
+ const { prepareEmailMedia } = require('./email-media');
12
13
  const { textResult, errorResult } = require('../lib/tool-result');
13
14
 
14
15
  async function notifyUserViaProxy({ subject: customSubject, message, link, sessionId }) {
@@ -27,7 +28,10 @@ async function notifyUserViaProxy({ subject: customSubject, message, link, sessi
27
28
 
28
29
  const subject =
29
30
  (customSubject && customSubject.trim()) || message.slice(0, 80).replace(/\n/g, ' ');
30
- const html = formatNotificationEmail(message, link || null);
31
+ // Local media embeds become attachments riding inside the email: images
32
+ // inline via cid:, videos as regular attachments with a poster card.
33
+ const media = prepareEmailMedia(message);
34
+ const html = formatNotificationEmail(media.message, link || null);
31
35
 
32
36
  try {
33
37
  const res = await fetch(notifyUrl, {
@@ -40,6 +44,7 @@ async function notifyUserViaProxy({ subject: customSubject, message, link, sessi
40
44
  subject,
41
45
  html,
42
46
  text: message,
47
+ ...(media.attachments.length ? { attachments: media.attachments } : {}),
43
48
  ...(sessionId ? { sessionId } : {}),
44
49
  }),
45
50
  });
@@ -64,7 +69,7 @@ module.exports = [
64
69
  {
65
70
  name: 'notify_user',
66
71
  description:
67
- 'Send a notification to the user via email (from emails@amalgm.ai). Use this to keep the user informed about important results, completions, or errors — especially during scheduled tasks, event triggers, and long-running operations where the user is likely not actively watching.\n\nBest practices:\n- Always call notify_user at the end of a scheduled task or event-triggered run.\n- For errors, include enough context that the user can act without opening the session.\n- Keep it scannable: lead with what happened, then details.\n\nFormatting: The message is rendered as full markdown. You can use headings (##), **bold**, *italic*, `inline code`, ```code blocks```, [links](url), lists (- or 1.), blockquotes (>), and --- horizontal rules. Write it like a concise status update — no greeting, no sign-off, just the information.',
72
+ 'Send a notification to the user via email (from emails@amalgm.ai). Use this to keep the user informed about important results, completions, or errors — especially during scheduled tasks, event triggers, and long-running operations where the user is likely not actively watching.\n\nBest practices:\n- Always call notify_user at the end of a scheduled task or event-triggered run.\n- For errors, include enough context that the user can act without opening the session.\n- Keep it scannable: lead with what happened, then details.\n\nFormatting: The message is rendered as full markdown. You can use headings (##), **bold**, *italic*, `inline code`, ```code blocks```, [links](url), tables, lists (- or 1.), blockquotes (>), and --- horizontal rules. Rich embeds on their own line: `![label](amalgm://file/ENCODED_PATH)` with a local image attaches it INSIDE the email and displays it inline; with a local video (webm/mp4/mov, ≤20MB) the file travels as a real attachment and the body shows a poster card. Poster preference: explicit markdown title `![clip](path "poster-path")`, then sibling `<name>.preview.gif` / `<name>.gif` for Gmail-friendly motion, then static `<name>.poster.png` / `.jpg` / `.jpeg` / `.webp`. `![label](https://...)` embeds web media (video player with play-card fallback). `[label](tile:TYPE:ID)` / `[label](full:TYPE:ID)` render entity cards linking back to amalgm. Write it like a concise status update — no greeting, no sign-off, just the information.',
68
73
  inputSchema: {
69
74
  type: 'object',
70
75
  properties: {
@@ -44,6 +44,16 @@ test('core tool actions carry stable toolbox ids', () => {
44
44
  assert.equal(notifyUser.toolboxActionId, 'notifications.notify_user');
45
45
  });
46
46
 
47
+ test('notify_user description teaches self-contained video embeds with GIF previews', () => {
48
+ const notifyUser = CORE_TOOLS.find((tool) => tool.name === 'notify_user');
49
+
50
+ assert.equal(notifyUser.description.includes('![label](amalgm://file/ENCODED_PATH)'), true);
51
+ assert.equal(notifyUser.description.includes('<name>.preview.gif'), true);
52
+ assert.equal(notifyUser.description.includes('<name>.gif'), true);
53
+ assert.equal(notifyUser.description.includes('<name>.poster.png'), true);
54
+ assert.equal(notifyUser.description.includes('≤20MB'), true);
55
+ });
56
+
47
57
  test('browser is a single toolbox entry holding every browser action', () => {
48
58
  const browser = CORE_TOOL_GROUPS.find((group) => group.id === 'browser');
49
59
  assert.deepEqual(browser.tools.map((tool) => tool.name), [
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const test = require('node:test');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const { prepareEmailMedia } = require('../notify/email-media');
10
+
11
+ test('prepareEmailMedia attaches local video and prefers sibling animated GIF preview', () => {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-email-media-'));
13
+ try {
14
+ const videoPath = path.join(dir, 'qa-pass.webm');
15
+ const gifPath = path.join(dir, 'qa-pass.preview.gif');
16
+ const staticPosterPath = path.join(dir, 'qa-pass.poster.png');
17
+ fs.writeFileSync(videoPath, Buffer.from('not really a webm but enough for extension detection'));
18
+ fs.writeFileSync(gifPath, Buffer.from('GIF89a'));
19
+ fs.writeFileSync(staticPosterPath, Buffer.from('png fallback should not be chosen'));
20
+
21
+ const src = `amalgm://file/${encodeURIComponent(videoPath)}`;
22
+ const result = prepareEmailMedia(`Here is the recording:\n![QA recording](${src})`);
23
+
24
+ assert.match(result.message, /!\[QA recording\]\(attached:qa-pass\.webm "cid:poster-1"\)/);
25
+ assert.equal(result.attachments.length, 2);
26
+ assert.equal(result.attachments[0].filename, 'qa-pass.webm');
27
+ assert.equal(result.attachments[0].contentType, 'video/webm');
28
+ assert.equal(result.attachments[1].filename, 'qa-pass.preview.gif');
29
+ assert.equal(result.attachments[1].contentType, 'image/gif');
30
+ assert.equal(result.attachments[1].contentId, 'poster-1');
31
+ } finally {
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ });
@@ -44,3 +44,15 @@ test('platform context browser names match the registered actions', () => {
44
44
  assert.equal(text.includes(`\`${name}\``), true, `prompt should mention ${name}`);
45
45
  }
46
46
  });
47
+
48
+ test('platform context teaches rich media embeds with GIF video previews', () => {
49
+ const text = fs.readFileSync(CONTEXT_PATH, 'utf8');
50
+
51
+ assert.equal(text.includes('## Embedding Images and Videos'), true);
52
+ assert.equal(text.includes('![label](src)'), true);
53
+ assert.equal(text.includes('amalgm://file/ENCODED_ABSOLUTE_PATH'), true);
54
+ assert.equal(text.includes('<name>.preview.gif'), true);
55
+ assert.equal(text.includes('<name>.gif'), true);
56
+ assert.equal(text.includes('<name>.poster.png'), true);
57
+ assert.equal(text.includes('≤20MB'), true);
58
+ });
@@ -203,6 +203,19 @@ Types:
203
203
  - App: [label](full:app:APP_ID)
204
204
  - Preview: [label](full:preview:PORT)
205
205
 
206
+ ## Embedding Images and Videos
207
+
208
+ Markdown image syntax is the embed syntax: `![label](src)` on its own line renders the media inline — in chat messages AND in notify_user emails. Use it to show screenshots, demo clips, and QA recordings instead of describing them.
209
+
210
+ Sources:
211
+ - Local file: `![run-recording.webm](amalgm://file/ENCODED_ABSOLUTE_PATH)` — percent-encode the path exactly like a file citation. Videos (webm/mp4/mov) get an inline player with name + options chrome; images render bare and rounded. PDFs, spreadsheets, and docx embed as document previews the same way.
212
+ - Web URL: `![label](https://...)` — the extension decides the embed (video player for .webm/.mp4/.mov, image otherwise).
213
+
214
+ Rules:
215
+ - Put embeds on their own line. Mid-sentence, prefer a normal `amalgm://` file citation instead.
216
+ - Emails are self-contained: a local image embed is attached inside the email and displays inline; a local video (≤20MB) travels as a real attachment with a poster card in the body. Poster preference: explicit markdown title `![clip](path "poster-path")`, then sibling `<name>.preview.gif` / `<name>.gif` for Gmail-friendly motion, then static `<name>.poster.png` / `.jpg` / `.jpeg` / `.webp`. Oversized or missing files degrade to a file card. Entity references (`tile:`/`full:`) work in email too, as cards.
217
+ - Don't wrap embeds in backticks. A browser recording you just captured is a great thing to embed in your reply or in a QA report email.
218
+
206
219
  ## Citing Files, Folders, Skills, and Agents
207
220
 
208
221
  When you reference a file, folder, skill, or agent that exists on the user's local volume, emit it as an `amalgm://` markdown link. The frontend renders these as inline citation chips by default, with a hover card showing the path and (for skills) description. Skills are files too: if you only have the `SKILL.md` path, a normal file citation is valid. This is the same format the user's `@`-menu produces, and it's how agents talk to each other about local resources.