amalgm 0.1.103 → 0.1.104

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 (56) hide show
  1. package/package.json +1 -1
  2. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +169 -81
  3. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-c-00599c-ffffff.png +0 -0
  4. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-c-5c6bc0-ffffff.png +0 -0
  5. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-c-68217a-ffffff.png +0 -0
  6. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-go-00add8-ffffff.png +0 -0
  7. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-j-e76f00-ffffff.png +0 -0
  8. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-js-f7df1e-1c1917.png +0 -0
  9. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-kt-7f52ff-ffffff.png +0 -0
  10. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-php-777bb4-ffffff.png +0 -0
  11. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-rb-cc342d-ffffff.png +0 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-rs-ce4a1f-ffffff.png +0 -0
  13. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-s-cd6799-ffffff.png +0 -0
  14. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-sw-f05138-ffffff.png +0 -0
  15. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-ts-3178c6-ffffff.png +0 -0
  16. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-letter-v-42b883-ffffff.png +0 -0
  17. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-box-ffffff.png +0 -0
  18. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-braces-facc15.png +0 -0
  19. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-braces-fb923c.png +0 -0
  20. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-calendarclock-d6d3d1.png +0 -0
  21. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-database-60a5fa.png +0 -0
  22. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-externallink-ffffff.png +0 -0
  23. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-file-a8a29e.png +0 -0
  24. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-filearchive-facc15.png +0 -0
  25. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-fileimage-2dd4bf.png +0 -0
  26. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-fileimage-c084fc.png +0 -0
  27. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-filespreadsheet-4ade80.png +0 -0
  28. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-filetext-60a5fa.png +0 -0
  29. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-filetext-a8a29e.png +0 -0
  30. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-filetext-f87171.png +0 -0
  31. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-folder-d6d3d1.png +0 -0
  32. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-globe-d6d3d1.png +0 -0
  33. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-lock-facc15.png +0 -0
  34. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-messagesquare-ffffff.png +0 -0
  35. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-music-f472b6.png +0 -0
  36. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-package-38bdf8.png +0 -0
  37. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-package-facc15.png +0 -0
  38. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-settings-a8a29e.png +0 -0
  39. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-settings-d6d3d1.png +0 -0
  40. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-settings-f87171.png +0 -0
  41. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-terminal-4ade80.png +0 -0
  42. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-video-f472b6.png +0 -0
  43. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-lucide-zap-fbbf24.png +0 -0
  44. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-path-css3-1572b6.png +0 -0
  45. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-path-html5-e34f26.png +0 -0
  46. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-path-markdown-a8a29e.png +0 -0
  47. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-path-python-4b8bbe.png +0 -0
  48. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-react.png +0 -0
  49. package/runtime/scripts/amalgm-mcp/lib/email-icon-assets/amalgm-icon-skill.png +0 -0
  50. package/runtime/scripts/amalgm-mcp/lib/email-icons.js +53 -0
  51. package/runtime/scripts/amalgm-mcp/lib/email-md.js +18 -3
  52. package/runtime/scripts/amalgm-mcp/lib/file-icon-registry.json +995 -0
  53. package/runtime/scripts/amalgm-mcp/notify/email-media.js +16 -4
  54. package/runtime/scripts/amalgm-mcp/notify/index.js +6 -1
  55. package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +97 -0
  56. package/runtime/scripts/amalgm-mcp/tests/email-media.test.js +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.103",
3
+ "version": "0.1.104",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -3,11 +3,12 @@
3
3
  * primitives: FileEmbed (media), EmbedChrome (the floating name/options
4
4
  * pills on expanded embeds), and the tile:/full: entity renders.
5
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.
6
+ * The UI standard is EmbedChrome: top-left `[icon] filename` and top-right
7
+ * `[external-link] Open` pills inside the embed body. Email keeps that visual
8
+ * contract with table-built pills instead of CSS positioning: Gmail reflows
9
+ * absolute/fixed chrome, but it preserves tables. Body mechanics are still
10
+ * surface-specific: Gmail/Outlook drop <video> but render its fallback content;
11
+ * Apple Mail and iOS Mail play video inline.
11
12
  *
12
13
  * Media table mirrors amalgm-ui/lib/rendering/media.ts; visual values
13
14
  * mirror EMAIL_TOKENS in email-md.js / lib/rendering/tokens.ts. Keep all
@@ -24,6 +25,9 @@ const COLOR = {
24
25
  link: '#60a5fa', // blue-400
25
26
  };
26
27
 
28
+ const FILE_ICON_REGISTRY = require('./file-icon-registry.json');
29
+ const { iconContentId } = require('./email-icons');
30
+
27
31
  const EXTENSION_MIME = {
28
32
  // Images
29
33
  png: 'image/png',
@@ -80,8 +84,8 @@ function mediaMimeOf(nameOrUrl) {
80
84
  return EXTENSION_MIME[mediaExtensionOf(nameOrUrl)] || null;
81
85
  }
82
86
 
83
- function mediaKindOf(nameOrUrl) {
84
- const mime = mediaMimeOf(nameOrUrl);
87
+ function mediaKindOf(nameOrUrl, explicitMime) {
88
+ const mime = explicitMime || mediaMimeOf(nameOrUrl);
85
89
  if (!mime) return 'unknown';
86
90
  if (mime.startsWith('image/')) return 'image';
87
91
  if (mime.startsWith('video/')) return 'video';
@@ -99,6 +103,51 @@ function mediaNameOf(nameOrUrl) {
99
103
  }
100
104
  }
101
105
 
106
+ function decodeURIComponentSafe(value) {
107
+ try {
108
+ return decodeURIComponent(value);
109
+ } catch {
110
+ return value;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Decode a URL/path/pseudo-url into the basename that should appear in file
116
+ * chrome. This mirrors amalgm-ui/lib/rendering/embedIdentity.ts: file-backed
117
+ * embed tabs show the real filename, never markdown alt text.
118
+ */
119
+ function fileBasenameOf(value) {
120
+ const raw = String(value || '').trim();
121
+ if (!raw) return '';
122
+
123
+ const attached = raw.match(/^attached:(.*)$/i);
124
+ if (attached) return decodeURIComponentSafe(attached[1]).replace(/\\/g, '/').split('/').pop() || '';
125
+
126
+ const amalgmFile = raw.match(/^amalgm:\/\/file\/([^?#]+)/i);
127
+ if (amalgmFile) return decodeURIComponentSafe(amalgmFile[1]).replace(/\\/g, '/').split('/').pop() || '';
128
+
129
+ const withoutQuery = raw.split(/[?#]/)[0];
130
+ const base = withoutQuery.replace(/\\/g, '/').split('/').pop() || '';
131
+ return decodeURIComponentSafe(base);
132
+ }
133
+
134
+ function resolveFileEmbedIdentity({ name, path: filePath, url, mimeType, fallback }) {
135
+ const label =
136
+ fileBasenameOf(name) ||
137
+ fileBasenameOf(filePath) ||
138
+ fileBasenameOf(url) ||
139
+ String(fallback || '').trim() ||
140
+ 'File';
141
+ const mime = String(mimeType || '').trim() || mediaMimeOf(label) || mediaMimeOf(filePath || '') || mediaMimeOf(url || '');
142
+ const kind = mediaKindOf(label || filePath || url || '', mime || undefined);
143
+ return {
144
+ label,
145
+ extension: mediaExtensionOf(label),
146
+ mimeType: mime || null,
147
+ kind: kind === 'unknown' ? 'file' : kind,
148
+ };
149
+ }
150
+
102
151
  function isHttpUrl(url) {
103
152
  return /^https?:\/\//i.test(url);
104
153
  }
@@ -116,69 +165,117 @@ function isEmbeddableImageSrc(url) {
116
165
  // --- EmbedChrome (email form) ------------------------------------------------
117
166
 
118
167
  /**
119
- * Embed-type glyph shown before the name Gmail-safe geometric unicode
120
- * (inline SVG is stripped by Gmail), the email stand-in for the UI's
121
- * FileTypeIcon / app icon.
168
+ * Shared icon registry bridge. The React FileTypeIcon and email renderer both
169
+ * resolve from file-icon-registry.json. Email renders the same descriptor as a
170
+ * CID PNG because Gmail strips inline SVG inside message bodies.
122
171
  */
123
- // Codepoints with NO emoji variant — Gmail swaps U+25B6 () for a colored
124
- // emoji even with the U+FE0E text selector, so we use the small triangle
125
- // U+25B8 (▸) instead. ◆ / ▣ / ♪ have no emoji form and render monochrome.
126
- const EMBED_GLYPH = {
127
- video: '▸', //
128
- audio: '♪', // ♪
129
- app: '◆', //
130
- entity: '◆', //
131
- document: '▣', //
132
- file: '▣', // ▣
133
- };
172
+ function normalizedBasename(value) {
173
+ return String(value || '').replace(/\\/g, '/').split('/').pop().trim().toLowerCase();
174
+ }
175
+
176
+ function matchesSpecialFilename(rule, normalizedName) {
177
+ return Boolean(
178
+ (rule.equals || []).includes(normalizedName) ||
179
+ (rule.startsWith || []).some((prefix) => normalizedName.startsWith(prefix)) ||
180
+ (rule.endsWith || []).some((suffix) => normalizedName.endsWith(suffix)),
181
+ );
182
+ }
183
+
184
+ function resolveFileIconDescriptor(name) {
185
+ const normalizedName = normalizedBasename(name);
186
+ const special = (FILE_ICON_REGISTRY.specialFilenameIcons || []).find((rule) => matchesSpecialFilename(rule, normalizedName));
187
+ if (special) return special.icon;
188
+ const ext = mediaExtensionOf(normalizedName);
189
+ return FILE_ICON_REGISTRY.extensionIcons[ext] || FILE_ICON_REGISTRY.defaults.file;
190
+ }
191
+
192
+ function resolveEntityIconDescriptor(type) {
193
+ const normalized = type === 'conversation' ? 'chat' : type === 'trigger' ? 'event' : type;
194
+ return FILE_ICON_REGISTRY.entityIcons[normalized] || FILE_ICON_REGISTRY.entityIcons.entity || FILE_ICON_REGISTRY.defaults.file;
195
+ }
196
+
197
+ function iconHtml(descriptor) {
198
+ if (!descriptor) descriptor = FILE_ICON_REGISTRY.defaults.file;
199
+ const contentId = iconContentId(descriptor);
200
+ return `<img src="cid:${contentId}" alt="" width="14" height="14" style="display:block;width:14px;height:14px;border:0;outline:none;text-decoration:none">`;
201
+ }
202
+
203
+ function openIconHtml() {
204
+ return iconHtml({ kind: 'lucide', name: 'ExternalLink', colorClass: 'text-white', color: COLOR.text });
205
+ }
134
206
 
135
- function glyphSpan(glyph) {
136
- return glyph
137
- ? `<span style="font-size:12px;line-height:16px;color:${COLOR.text};padding-right:7px">${glyph}</span>`
138
- : '';
207
+ function chromeLabelParts(name) {
208
+ const value = String(name || 'File');
209
+ const solidLength = 12;
210
+ const fadeLength = 5;
211
+ if (value.length <= solidLength + 1) return { solid: value, fade: '' };
212
+ return {
213
+ solid: value.slice(0, solidLength),
214
+ fade: value.slice(solidLength, solidLength + fadeLength),
215
+ };
139
216
  }
140
217
 
141
218
  /**
142
- * One header pill, the email form of EmbedChrome's floating pill: a pure-black
143
- * fill (Gmail dark mode recolors near-blacks like stone-950 which is why the
144
- * name used to flip to a white pill but leaves #000 alone) with a stone-800
145
- * border carrying the rounded shape and white text.
219
+ * One floating pill, matching EmbedChrome: 32px tall, black fill, stone-800
220
+ * border, rounded-xl, white 13px text. This is intentionally a table, not
221
+ * inline flex/span layout, so Gmail keeps the icon and filename together.
146
222
  */
147
- function headerPill(inner) {
148
- return `<span style="display:inline-block;background:${COLOR.bg};border:1px solid ${COLOR.pillBorder};border-radius:12px;padding:5px 11px;font-size:13px;line-height:16px;color:${COLOR.text};white-space:nowrap">${inner}</span>`;
223
+ function chromePill(cells, extra = '') {
224
+ return (
225
+ `<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-table;height:32px;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:12px;border-collapse:separate;box-shadow:0 14px 35px rgba(0,0,0,0.45);${extra}">` +
226
+ `<tr>${cells}</tr></table>`
227
+ );
149
228
  }
150
229
 
151
- /** `[glyph] name` — name clipped with an ellipsis (email's stand-in for the UI's edge fade). */
152
- function namePill(glyph, name) {
230
+ /** `[glyph] name` — title text fades after the tab-width budget, matching the UI treatment. */
231
+ function namePill(iconDescriptor, name) {
232
+ const { solid, fade } = chromeLabelParts(name);
233
+ const fadeOpacities = [0.75, 0.5, 0.3, 0.16, 0.06];
153
234
  const label =
154
- `<span style="display:inline-block;max-width:220px;overflow:hidden;text-overflow:ellipsis;` +
155
- `white-space:nowrap;vertical-align:bottom;color:${COLOR.text}">${escapeText(name)}</span>`;
156
- return headerPill(`${glyphSpan(glyph)}${label}`);
235
+ escapeText(solid) +
236
+ (fade
237
+ .split('')
238
+ .map((char, index) => `<span style="color:rgba(255,255,255,${fadeOpacities[index] || 0.06})">${escapeText(char)}</span>`)
239
+ .join(''));
240
+ const labelStyle = fade ? 'width:122px;max-width:122px;' : 'max-width:224px;';
241
+ return chromePill(
242
+ `<td style="padding:0 0 0 12px;vertical-align:middle;line-height:0;width:18px">${iconHtml(iconDescriptor)}</td>` +
243
+ `<td style="padding:0 12px 0 8px;vertical-align:middle">` +
244
+ `<div title="${escapeAttr(name)}" style="display:block;${labelStyle}overflow:hidden;white-space:nowrap;word-break:normal;overflow-wrap:normal;font-size:13px;line-height:30px;color:${COLOR.text};font-weight:400">${label}</div>` +
245
+ `</td>`
246
+ );
157
247
  }
158
248
 
159
249
  /** The single "Open ↗" affordance — a link when href is given. */
160
250
  function openPill(href) {
161
- const inner = `Open<span style="color:${COLOR.textMuted};padding-left:5px">&#8599;</span>`;
251
+ const pill = chromePill(
252
+ `<td style="padding:0 0 0 12px;vertical-align:middle;line-height:0;width:14px">${openIconHtml()}</td>` +
253
+ `<td style="padding:0 12px 0 7px;vertical-align:middle;font-size:13px;line-height:30px;color:${COLOR.text};font-weight:400;white-space:nowrap">Open</td>`
254
+ );
162
255
  return href
163
- ? `<a href="${escapeAttr(href)}" target="_blank" style="text-decoration:none">${headerPill(inner)}</a>`
164
- : headerPill(inner);
256
+ ? `<a href="${escapeAttr(href)}" target="_blank" style="text-decoration:none;display:inline-block">${pill}</a>`
257
+ : pill;
165
258
  }
166
259
 
167
260
  /**
168
- * The expanded-embed card: rounded-16 stone-900 border, black fill, a header
169
- * row carrying the `[glyph] name` pill (left) and a single Open pill (right),
170
- * media below. `action.href` points the Open pill; pass action:null for none.
261
+ * The expanded-embed card: rounded-16 stone-900 border, black fill, and
262
+ * floating-style pills inside the body. The chrome row is a table with black
263
+ * background, not an email-only header label: it is the email-safe form of
264
+ * EmbedChrome's top-left/top-right tabs.
171
265
  */
172
- function embedShell({ glyph, name, action, body }) {
173
- const left = name ? namePill(glyph, name) : '';
266
+ function embedShell({ iconDescriptor, name, action, body = '', minHeight = 96 }) {
267
+ const left = name ? namePill(iconDescriptor, name) : '';
174
268
  const right = action ? openPill(action.href) : '';
269
+ const content = body || `<div style="height:${minHeight}px;line-height:${minHeight}px;font-size:0;background:${COLOR.bg}">&nbsp;</div>`;
175
270
  return (
176
271
  `<div style="border:1px solid ${COLOR.border};border-radius:16px;background:${COLOR.bg};margin:16px 0;overflow:hidden">` +
177
- `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
178
- `<td align="left" style="padding:10px 10px 8px">${left}</td>` +
179
- `<td align="right" style="padding:10px 10px 8px;white-space:nowrap">${right}</td>` +
272
+ `<div style="padding:12px 12px 10px;background:${COLOR.bg}">` +
273
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr>` +
274
+ `<td align="left" style="padding:0;vertical-align:top;line-height:0">${left}</td>` +
275
+ `<td align="right" style="padding:0;vertical-align:top;line-height:0">${right}</td>` +
180
276
  `</tr></table>` +
181
- body +
277
+ `</div>` +
278
+ content +
182
279
  `</div>`
183
280
  );
184
281
  }
@@ -221,7 +318,8 @@ function posterBlock(posterSrc, label, href) {
221
318
  * frame when one is provided, a play card otherwise.
222
319
  */
223
320
  function renderVideoEmbed(label, url, poster) {
224
- const name = label || mediaNameOf(url) || 'Video';
321
+ const identity = resolveFileEmbedIdentity({ url, fallback: label || 'Video' });
322
+ const name = identity.label;
225
323
  const safeUrl = escapeAttr(url);
226
324
  const posterSrc = poster && isEmbeddableImageSrc(poster) ? poster : null;
227
325
  const fallback = posterSrc
@@ -233,7 +331,7 @@ function renderVideoEmbed(label, url, poster) {
233
331
  `<video controls preload="metadata" src="${safeUrl}"${posterSrc ? ` poster="${escapeAttr(posterSrc)}"` : ''} style="display:block;width:100%;max-height:480px;background:${COLOR.bg}">` +
234
332
  fallback +
235
333
  `</video>`;
236
- return embedShell({ glyph: EMBED_GLYPH.video, name, action: { href: url }, body });
334
+ return embedShell({ iconDescriptor: resolveFileIconDescriptor(name), name, action: { href: url }, body });
237
335
  }
238
336
 
239
337
  /**
@@ -243,7 +341,8 @@ function renderVideoEmbed(label, url, poster) {
243
341
  * Gmail gives attached videos a playable preview natively.
244
342
  */
245
343
  function renderAttachedVideoCard(label, poster) {
246
- const name = label || 'Video';
344
+ const identity = resolveFileEmbedIdentity({ url: `attached:${label || ''}`, fallback: label || 'Video' });
345
+ const name = identity.label;
247
346
  const posterSrc = poster && isEmbeddableImageSrc(poster) ? poster : null;
248
347
  // Poster is the animating GIF in Gmail; the real video rides in the
249
348
  // attachment strip. No in-body note — the header + strip carry it.
@@ -253,29 +352,19 @@ function renderAttachedVideoCard(label, poster) {
253
352
  ? `<a href="https://amalgm.ai" target="_blank" style="text-decoration:none;display:block"><img src="${escapeAttr(posterSrc)}" alt="${escapeAttr(name)}" style="display:block;width:100%;height:auto;background:${COLOR.bg}"></a>`
254
353
  : `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
255
354
  `<td align="center" style="padding:44px 16px;background:${COLOR.bg}">${PLAY_PILL}</td></tr></table>`;
256
- return embedShell({ glyph: EMBED_GLYPH.video, name, action: { href: 'https://amalgm.ai' }, body });
355
+ return embedShell({ iconDescriptor: resolveFileIconDescriptor(name), name, action: { href: 'https://amalgm.ai' }, body });
257
356
  }
258
357
 
259
358
  /**
260
- * Non-playable file in an embed position (pdf/docx/audio/anything): the
261
- * compact file row from FileEmbed, as a card linking out.
359
+ * Non-playable file in an embed position (pdf/docx/audio/anything): email
360
+ * cannot safely preview arbitrary local documents, so the chrome carries the
361
+ * identity and open affordance without inventing a second email-only file row.
262
362
  */
263
363
  function renderFileCardEmbed(label, url) {
264
- const name = label || mediaNameOf(url) || 'File';
265
- const ext = mediaExtensionOf(name).toUpperCase();
364
+ const identity = resolveFileEmbedIdentity({ url, fallback: label || 'File' });
365
+ const name = identity.label;
266
366
  const http = isHttpUrl(url);
267
- const glyph = mediaKindOf(url) === 'audio' ? EMBED_GLYPH.audio : EMBED_GLYPH.document;
268
- const extChip = ext
269
- ? `<span style="font-size:10px;color:${COLOR.textMuted};padding-left:8px">${escapeText(ext)}</span>`
270
- : '';
271
- const meta = http
272
- ? ''
273
- : `<span style="font-size:12px;color:${COLOR.textMuted};padding-left:10px">On your computer</span>`;
274
- const body =
275
- `<div style="padding:6px 16px 16px">` +
276
- `<span style="font-size:13px;color:${COLOR.text}">${escapeText(name)}</span>${extChip}${meta}` +
277
- `</div>`;
278
- return embedShell({ glyph, name, action: { href: http ? url : 'https://amalgm.ai' }, body });
367
+ return embedShell({ iconDescriptor: resolveFileIconDescriptor(name), name, action: { href: http ? url : 'https://amalgm.ai' } });
279
368
  }
280
369
 
281
370
  /**
@@ -299,7 +388,7 @@ function renderMediaEmbed(alt, url, poster) {
299
388
  try {
300
389
  name = decodeURIComponent(name);
301
390
  } catch {}
302
- return renderAttachedVideoCard(alt || name, poster);
391
+ return renderAttachedVideoCard(name, poster);
303
392
  }
304
393
  const amalgmMatch = url.match(/^amalgm:\/\/file\/([^\s)?"]+)/);
305
394
  if (amalgmMatch) {
@@ -339,15 +428,18 @@ function entityTypeLabel(type) {
339
428
  }
340
429
 
341
430
  /**
342
- * Inline tile — the email form of the chat's render tile: a compact linked
343
- * card with a type eyebrow and the entity name, pointing at the amalgm app.
431
+ * Inline tile — the email-safe form of ChatRow: one black rounded row with an
432
+ * icon and title. No email-only type eyebrow.
344
433
  */
345
434
  function renderEntityTile(label, type) {
346
435
  const safeLabel = escapeText(label || entityTypeLabel(type));
436
+ const iconDescriptor = resolveEntityIconDescriptor(type);
347
437
  return (
348
- `<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">` +
349
- `<span style="display:block;font-size:11px;line-height:14px;color:${COLOR.textMuted};text-transform:uppercase;letter-spacing:0.04em">${escapeText(entityTypeLabel(type))}</span>` +
350
- `<span style="display:block;font-size:14px;line-height:20px;color:${COLOR.text}">${safeLabel}</span>` +
438
+ `<a href="https://amalgm.ai" target="_blank" style="text-decoration:none;display:inline-block;background:${COLOR.bg};border:1px solid ${COLOR.border};border-radius:12px;padding:8px 12px;margin:2px 0;vertical-align:middle">` +
439
+ `<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr>` +
440
+ `<td style="padding:0 10px 0 0;vertical-align:middle;line-height:0">${iconHtml(iconDescriptor)}</td>` +
441
+ `<td style="padding:0;vertical-align:middle;font-size:14px;line-height:18px;color:${COLOR.text};font-weight:650">${safeLabel}</td>` +
442
+ `</tr></table>` +
351
443
  `</a>`
352
444
  );
353
445
  }
@@ -359,13 +451,7 @@ function renderEntityTile(label, type) {
359
451
  */
360
452
  function renderEntityExpanded(label, type) {
361
453
  const display = label || entityTypeLabel(type);
362
- const body =
363
- `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
364
- `<td align="center" style="padding:40px 16px 44px">` +
365
- `<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>` +
366
- `<span style="display:block;font-size:16px;font-weight:650;color:${COLOR.text}">${escapeText(display)}</span>` +
367
- `</td></tr></table>`;
368
- return embedShell({ glyph: EMBED_GLYPH.entity, name: display, action: { href: 'https://amalgm.ai' }, body });
454
+ return embedShell({ iconDescriptor: resolveEntityIconDescriptor(type), name: display, action: { href: 'https://amalgm.ai' } });
369
455
  }
370
456
 
371
457
  function renderEntityEmbed(mode, type, label) {
@@ -377,6 +463,8 @@ module.exports = {
377
463
  mediaMimeOf,
378
464
  mediaKindOf,
379
465
  mediaNameOf,
466
+ fileBasenameOf,
467
+ resolveFileEmbedIdentity,
380
468
  isHttpUrl,
381
469
  isCidUrl,
382
470
  isEmbeddableImageSrc,
@@ -0,0 +1,53 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const ICON_ASSET_DIR = path.join(__dirname, 'email-icon-assets');
5
+ const MAX_TOTAL_ATTACHMENTS = 8;
6
+
7
+ function sanitizeId(value) {
8
+ return String(value || 'icon')
9
+ .toLowerCase()
10
+ .replace(/#[0-9a-f]+/gi, (match) => match.slice(1))
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '')
13
+ .slice(0, 80) || 'icon';
14
+ }
15
+
16
+ function iconContentId(descriptor) {
17
+ if (!descriptor) return 'amalgm-icon-file';
18
+ if (descriptor.kind === 'lucide') return `amalgm-icon-lucide-${sanitizeId(descriptor.name)}-${sanitizeId(descriptor.color)}`;
19
+ if (descriptor.kind === 'letter') return `amalgm-icon-letter-${sanitizeId(descriptor.letters)}-${sanitizeId(descriptor.bg)}-${sanitizeId(descriptor.fg)}`;
20
+ if (descriptor.kind === 'path') return `amalgm-icon-path-${sanitizeId(descriptor.name)}-${sanitizeId(descriptor.fill)}`;
21
+ if (descriptor.kind === 'react') return 'amalgm-icon-react';
22
+ if (descriptor.kind === 'skill') return 'amalgm-icon-skill';
23
+ return 'amalgm-icon-file';
24
+ }
25
+
26
+ function collectIconContentIds(html) {
27
+ const ids = new Set();
28
+ const pattern = /cid:(amalgm-icon-[a-z0-9-]+)/gi;
29
+ let match;
30
+ while ((match = pattern.exec(html))) ids.add(match[1]);
31
+ return Array.from(ids);
32
+ }
33
+
34
+ function prepareEmailIconAttachments(html, existingAttachments = []) {
35
+ const remaining = Math.max(0, MAX_TOTAL_ATTACHMENTS - existingAttachments.length);
36
+ if (remaining <= 0) return [];
37
+
38
+ const attachments = [];
39
+ for (const contentId of collectIconContentIds(html)) {
40
+ if (attachments.length >= remaining) break;
41
+ const filePath = path.join(ICON_ASSET_DIR, `${contentId}.png`);
42
+ if (!fs.existsSync(filePath)) continue;
43
+ attachments.push({
44
+ filename: `${contentId}.png`,
45
+ content: fs.readFileSync(filePath).toString('base64'),
46
+ contentType: 'image/png',
47
+ contentId,
48
+ });
49
+ }
50
+ return attachments;
51
+ }
52
+
53
+ module.exports = { iconContentId, prepareEmailIconAttachments };
@@ -59,6 +59,20 @@ function escapeHtmlText(value) {
59
59
  .replace(/>/g, '&gt;');
60
60
  }
61
61
 
62
+ function parseStandaloneMediaLine(line) {
63
+ const match = line.trim().match(/^!\[([^\]]*)\]\((.*)\)$/);
64
+ if (!match) return null;
65
+ let src = match[2].trim();
66
+ let title = null;
67
+ const titleMatch = src.match(/^(.*)\s+"([^"]*)"$/);
68
+ if (titleMatch) {
69
+ src = titleMatch[1].trim();
70
+ title = titleMatch[2];
71
+ }
72
+ if (!src) return null;
73
+ return { alt: match[1], src, title };
74
+ }
75
+
62
76
  // Inline citation for amalgm:// references (files, folders, skills, agents).
63
77
  // Codex-style: plain blue text at the surrounding font size — no chip.
64
78
  // Non-clickable in email (local resources can't open from a mail client);
@@ -82,7 +96,8 @@ function inlineFormat(text) {
82
96
  /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
83
97
  (_, alt, url) => {
84
98
  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>`;
99
+ const filename = embeds.fileBasenameOf(url) || alt || 'video';
100
+ return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(filename)}</span> <span style="color:${EMAIL_TOKENS.color.textSecondary}">(attached)</span>`;
86
101
  }
87
102
  if (!embeds.isEmbeddableImageSrc(url)) {
88
103
  return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(alt || embeds.mediaNameOf(url) || 'file')}</span>`;
@@ -257,10 +272,10 @@ function markdownToEmailHtml(md) {
257
272
  // Standalone media line → block embed (image bare, video in the
258
273
  // EmbedChrome card, attached/local/doc files as cards). The optional
259
274
  // title carries a video poster: ![label](url "cid:poster-1").
260
- const mediaMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/);
275
+ const mediaMatch = parseStandaloneMediaLine(trimmed);
261
276
  if (mediaMatch) {
262
277
  flushAll();
263
- out.push(embeds.renderMediaEmbed(mediaMatch[1], mediaMatch[2], mediaMatch[3] || null));
278
+ out.push(embeds.renderMediaEmbed(mediaMatch.alt, mediaMatch.src, mediaMatch.title || null));
264
279
  continue;
265
280
  }
266
281
  // Standalone entity render → tile card or expanded card.