amalgm 0.1.135 → 0.1.136

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 (37) hide show
  1. package/package.json +1 -1
  2. package/runtime/lib/harnesses.js +61 -162
  3. package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +124 -0
  4. package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +160 -0
  5. package/runtime/scripts/amalgm-mcp/agent-bundles/automation-entries.js +186 -0
  6. package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +272 -32
  7. package/runtime/scripts/amalgm-mcp/agent-bundles/rest.js +25 -8
  8. package/runtime/scripts/amalgm-mcp/automations/rest.js +21 -11
  9. package/runtime/scripts/amalgm-mcp/automations/store.js +68 -1
  10. package/runtime/scripts/amalgm-mcp/automations/tools.js +13 -4
  11. package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-deeplinks.js +117 -0
  13. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +42 -19
  14. package/runtime/scripts/amalgm-mcp/lib/email-md.js +24 -17
  15. package/runtime/scripts/amalgm-mcp/notify/index.js +6 -2
  16. package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
  17. package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +8 -8
  18. package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +288 -0
  19. package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +37 -1
  20. package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +22 -0
  21. package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +15 -5
  22. package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +35 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
  24. package/runtime/scripts/chat-core/adapters/acp-client.js +156 -0
  25. package/runtime/scripts/chat-core/adapters/cursor.js +254 -0
  26. package/runtime/scripts/chat-core/adapters/pi.js +302 -0
  27. package/runtime/scripts/chat-core/auth.js +20 -3
  28. package/runtime/scripts/chat-core/chat-payload.js +2 -0
  29. package/runtime/scripts/chat-core/contract.js +56 -1
  30. package/runtime/scripts/chat-core/normalizers/cursor.js +174 -0
  31. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +38 -0
  32. package/runtime/scripts/chat-core/normalizers/pi.js +143 -0
  33. package/runtime/scripts/chat-core/server.js +4 -0
  34. package/runtime/scripts/chat-core/tests/cursor.test.js +178 -0
  35. package/runtime/scripts/chat-core/tests/pi.test.js +182 -0
  36. package/runtime/scripts/chat-core/tooling/native-config.js +38 -0
  37. package/runtime/scripts/chat-core/usage.js +20 -0
@@ -8,14 +8,14 @@ const {
8
8
  createAutomation,
9
9
  deleteAutomation,
10
10
  deleteAutomationByTriggerId,
11
- getAutomation,
12
- getAutomationByTriggerId,
13
11
  getAutomationRun,
12
+ getPublicAutomation,
13
+ getPublicAutomationByTriggerId,
14
14
  listAutomationRuns,
15
- listAutomations,
16
- listTriggers,
15
+ listPublicAutomations,
16
+ listPublicTriggers,
17
+ listPublicWorkflows,
17
18
  listWorkflowCellRuns,
18
- listWorkflows,
19
19
  updateAutomation,
20
20
  updateAutomationByTriggerId,
21
21
  } = require('./store');
@@ -28,9 +28,9 @@ function sendError(sendJson, status, error) {
28
28
 
29
29
  async function handleList(_req, sendJson) {
30
30
  sendJson(200, {
31
- automations: listAutomations(),
32
- triggers: listTriggers(),
33
- workflows: listWorkflows(),
31
+ automations: listPublicAutomations(),
32
+ triggers: listPublicTriggers(),
33
+ workflows: listPublicWorkflows(),
34
34
  });
35
35
  }
36
36
 
@@ -38,9 +38,9 @@ async function handleGet(body, sendJson) {
38
38
  const automationId = body?.automation_id || body?.automationId;
39
39
  const triggerId = body?.trigger_id || body?.triggerId;
40
40
  const automation = automationId
41
- ? getAutomation(automationId)
41
+ ? getPublicAutomation(automationId)
42
42
  : triggerId
43
- ? getAutomationByTriggerId(triggerId)
43
+ ? getPublicAutomationByTriggerId(triggerId)
44
44
  : null;
45
45
  if (!automation) return sendJson(404, { error: 'Automation not found' });
46
46
  sendJson(200, {
@@ -64,13 +64,18 @@ async function handleRuns(body, sendJson) {
64
64
  if (runId) {
65
65
  const run = getAutomationRun(runId);
66
66
  if (!run) return sendJson(404, { error: 'Run not found' });
67
+ if (!getPublicAutomation(run.automationId)) {
68
+ return sendJson(404, { error: 'Run not found' });
69
+ }
67
70
  return sendJson(200, { ok: true, run, cellRuns: listWorkflowCellRuns({ runId: run.id }) });
68
71
  }
69
72
  const automationId = body?.automation_id || body?.automationId;
70
73
  if (!automationId) return sendJson(400, { error: 'automation_id or run_id is required' });
74
+ const automation = getPublicAutomation(automationId);
75
+ if (!automation) return sendJson(404, { error: 'Automation not found' });
71
76
  const limit = Math.max(1, Math.min(Number(body?.limit) || 50, 500));
72
77
  const offset = Math.max(0, Number(body?.offset) || 0);
73
- const page = listAutomationRuns({ automationId, limit: limit + 1, offset });
78
+ const page = listAutomationRuns({ automationId: automation.id, limit: limit + 1, offset });
74
79
  sendJson(200, {
75
80
  ok: true,
76
81
  runs: page.slice(0, limit),
@@ -110,6 +115,7 @@ async function handleUpdate(body, sendJson) {
110
115
  const { automation_id, automationId, workflow, ...updates } = body || {};
111
116
  const id = automation_id || automationId;
112
117
  if (!id) return sendJson(400, { error: 'automation_id is required' });
118
+ if (!getPublicAutomation(id)) return sendJson(404, { error: 'Automation not found' });
113
119
  const automation = updateAutomation(id, {
114
120
  ...updates,
115
121
  ...(workflow !== undefined && updates.workflowText === undefined ? { workflowText: workflow } : {}),
@@ -124,6 +130,7 @@ async function handleDelete(body, sendJson) {
124
130
  try {
125
131
  const id = body?.automation_id || body?.automationId;
126
132
  if (!id) return sendJson(400, { error: 'automation_id is required' });
133
+ if (!getPublicAutomation(id)) return sendJson(404, { error: 'Automation not found' });
127
134
  const deleted = deleteAutomation(id, { source: 'automations:rest:delete' });
128
135
  sendJson(200, { ok: true, deleted });
129
136
  } catch (error) {
@@ -135,6 +142,7 @@ async function handleRunNow(body, sendJson) {
135
142
  try {
136
143
  const id = body?.automation_id || body?.automationId;
137
144
  if (!id) return sendJson(400, { error: 'automation_id is required' });
145
+ if (!getPublicAutomation(id)) return sendJson(404, { error: 'Automation not found' });
138
146
  const runs = await executeAutomationById(id, {
139
147
  id: `manual:${Date.now()}`,
140
148
  source: 'amalgm.manual',
@@ -154,6 +162,7 @@ async function handleTriggerUpdate(body, sendJson) {
154
162
  try {
155
163
  const id = body?.trigger_id || body?.triggerId;
156
164
  if (!id) return sendJson(400, { error: 'trigger_id is required' });
165
+ if (!getPublicAutomationByTriggerId(id)) return sendJson(404, { error: 'Trigger not found' });
157
166
  const automation = updateAutomationByTriggerId(id, body || {}, { source: 'automations:rest:trigger-update' });
158
167
  sendJson(200, { ok: true, automation, trigger: automation.triggers.find((trigger) => trigger.id === id) || null, workflow: automation.workflow });
159
168
  } catch (error) {
@@ -165,6 +174,7 @@ async function handleTriggerDelete(body, sendJson) {
165
174
  try {
166
175
  const id = body?.trigger_id || body?.triggerId;
167
176
  if (!id) return sendJson(400, { error: 'trigger_id is required' });
177
+ if (!getPublicAutomationByTriggerId(id)) return sendJson(404, { error: 'Trigger not found' });
168
178
  const deleted = deleteAutomationByTriggerId(id, { source: 'automations:rest:trigger-delete' });
169
179
  sendJson(200, { ok: true, deleted });
170
180
  } catch (error) {
@@ -698,6 +698,22 @@ function composeAutomation(base, triggers, workflowOrWorkflows) {
698
698
  };
699
699
  }
700
700
 
701
+ function isInternalWorkflow(workflow) {
702
+ return workflow?.internal === true;
703
+ }
704
+
705
+ function isInternalTrigger(trigger) {
706
+ return trigger?.internal === true;
707
+ }
708
+
709
+ function isInternalAutomation(automation) {
710
+ if (!automation) return false;
711
+ if (automation.internal === true) return true;
712
+ if (Array.isArray(automation.workflows) && automation.workflows.some(isInternalWorkflow)) return true;
713
+ if (Array.isArray(automation.triggers) && automation.triggers.some(isInternalTrigger)) return true;
714
+ return false;
715
+ }
716
+
701
717
  function readAutomationGraph(db = openLocalDb()) {
702
718
  ensureAutomationsStore();
703
719
  const automationRows = db.prepare('SELECT * FROM automation_definitions ORDER BY datetime(created_at) DESC, lower(name)').all();
@@ -748,8 +764,28 @@ function readAutomationGraph(db = openLocalDb()) {
748
764
  return { automations, triggers, workflows };
749
765
  }
750
766
 
767
+ function publicAutomationGraph(graph = readAutomationGraph()) {
768
+ const automations = (graph.automations || []).filter((automation) => !isInternalAutomation(automation));
769
+ const automationIds = new Set(automations.map((automation) => automation.id));
770
+ const triggerIds = new Set(automations.flatMap((automation) => automation.triggerIds || []));
771
+ const workflowIds = new Set(automations.flatMap((automation) => automation.workflowIds || []));
772
+
773
+ return {
774
+ automations,
775
+ triggers: (graph.triggers || []).filter((trigger) => (
776
+ automationIds.has(trigger.automationId)
777
+ && triggerIds.has(trigger.id)
778
+ && !isInternalTrigger(trigger)
779
+ )),
780
+ workflows: (graph.workflows || []).filter((workflow) => (
781
+ workflowIds.has(workflow.id)
782
+ && !isInternalWorkflow(workflow)
783
+ )),
784
+ };
785
+ }
786
+
751
787
  function publishResources(source = 'automations:save') {
752
- const resources = readAutomationGraph();
788
+ const resources = publicAutomationGraph(readAutomationGraph());
753
789
  appendStateEvent({ resource: 'automations', op: 'replace', value: resources.automations, source });
754
790
  appendStateEvent({ resource: 'triggers', op: 'replace', value: resources.triggers, source });
755
791
  appendStateEvent({ resource: 'workflows', op: 'replace', value: resources.workflows, source });
@@ -1012,18 +1048,44 @@ function listAutomations() {
1012
1048
  return readAutomationGraph().automations;
1013
1049
  }
1014
1050
 
1051
+ function listPublicAutomations() {
1052
+ return publicAutomationGraph().automations;
1053
+ }
1054
+
1015
1055
  function listTriggers() {
1016
1056
  return readAutomationGraph().triggers;
1017
1057
  }
1018
1058
 
1059
+ function listPublicTriggers() {
1060
+ return publicAutomationGraph().triggers;
1061
+ }
1062
+
1019
1063
  function listWorkflows() {
1020
1064
  return readAutomationGraph().workflows;
1021
1065
  }
1022
1066
 
1067
+ function listPublicWorkflows() {
1068
+ return publicAutomationGraph().workflows;
1069
+ }
1070
+
1023
1071
  function listEventTriggersForIngress() {
1024
1072
  return listTriggers().filter((trigger) => trigger.kind === 'event' && trigger.enabled !== false);
1025
1073
  }
1026
1074
 
1075
+ function getPublicAutomation(automationId) {
1076
+ const id = cleanString(automationId);
1077
+ if (!id) return null;
1078
+ return listPublicAutomations().find((automation) => automation.id === id) || null;
1079
+ }
1080
+
1081
+ function getPublicAutomationByTriggerId(triggerId) {
1082
+ const id = cleanString(triggerId);
1083
+ if (!id) return null;
1084
+ return listPublicAutomations().find((automation) => (
1085
+ automation.triggers.some((trigger) => trigger.id === id)
1086
+ )) || null;
1087
+ }
1088
+
1027
1089
  function markTriggerFired(triggerId, options = {}) {
1028
1090
  ensureAutomationsStore();
1029
1091
  const automation = getAutomationByTriggerId(triggerId);
@@ -1598,10 +1660,15 @@ module.exports = {
1598
1660
  getAutomation,
1599
1661
  getAutomationByTriggerId,
1600
1662
  getAutomationRun,
1663
+ getPublicAutomation,
1664
+ getPublicAutomationByTriggerId,
1601
1665
  getSchedulerHeartbeat,
1602
1666
  listAutomationRuns,
1603
1667
  listAutomations,
1604
1668
  listEventTriggersForIngress,
1669
+ listPublicAutomations,
1670
+ listPublicTriggers,
1671
+ listPublicWorkflows,
1605
1672
  listTriggers,
1606
1673
  listWorkflowCellRuns,
1607
1674
  listWorkflows,
@@ -4,9 +4,9 @@ const { textResult, errorResult } = require('../lib/tool-result');
4
4
  const {
5
5
  createAutomation,
6
6
  deleteAutomation,
7
- getAutomation,
7
+ getPublicAutomation,
8
8
  listAutomationRuns,
9
- listAutomations,
9
+ listPublicAutomations,
10
10
  updateAutomation,
11
11
  } = require('./store');
12
12
  const { executeAutomationById } = require('./runner');
@@ -312,7 +312,7 @@ module.exports = [
312
312
  },
313
313
  async handler({ enabled_only, include_workflow_text, verbose } = {}) {
314
314
  try {
315
- let automations = listAutomations();
315
+ let automations = listPublicAutomations();
316
316
  if (enabled_only) automations = automations.filter((automation) => automation.enabled !== false);
317
317
  const includeWorkflowText = parseBoolean(include_workflow_text, false);
318
318
  if (automations.length === 0) return textResult('No automations found.');
@@ -341,7 +341,7 @@ module.exports = [
341
341
  },
342
342
  async handler({ automation_id, include_runs, verbose } = {}) {
343
343
  try {
344
- const automation = getAutomation(automation_id);
344
+ const automation = getPublicAutomation(automation_id);
345
345
  if (!automation) return errorResult(`Automation not found: ${automation_id}`);
346
346
  const runs = include_runs === false ? undefined : listAutomationRuns({ automationId: automation.id, limit: 20 });
347
347
  return textResult(JSON.stringify({
@@ -397,6 +397,9 @@ module.exports = [
397
397
  },
398
398
  async handler({ automation_id, workflow, verbose, ...updates } = {}) {
399
399
  try {
400
+ if (!getPublicAutomation(automation_id)) {
401
+ return errorResult(`Automation not found: ${automation_id}`);
402
+ }
400
403
  const automation = updateAutomation(automation_id, {
401
404
  ...updates,
402
405
  ...(workflow !== undefined && updates.workflowText === undefined ? { workflowText: workflow } : {}),
@@ -419,6 +422,9 @@ module.exports = [
419
422
  },
420
423
  async handler({ automation_id } = {}) {
421
424
  try {
425
+ if (!getPublicAutomation(automation_id)) {
426
+ return errorResult(`Automation not found: ${automation_id}`);
427
+ }
422
428
  const deleted = deleteAutomation(automation_id, { source: 'automations_delete' });
423
429
  return textResult(`Automation "${deleted.name}" deleted.`);
424
430
  } catch (error) {
@@ -441,6 +447,9 @@ module.exports = [
441
447
  },
442
448
  async handler({ automation_id, trigger_id, payload, verbose } = {}) {
443
449
  try {
450
+ if (!getPublicAutomation(automation_id)) {
451
+ return errorResult(`Automation not found: ${automation_id}`);
452
+ }
444
453
  const runs = await executeAutomationById(automation_id, {
445
454
  id: `manual:${Date.now()}`,
446
455
  source: 'amalgm.manual',
@@ -582,6 +582,7 @@ function envForBuild(
582
582
  ...baseEnv,
583
583
  DESKTOP_RELEASE_LANE: request.lane,
584
584
  DESKTOP_RELEASE_CHANNEL: request.lane === 'preview' ? 'preview' : 'latest',
585
+ AMALGM_DESKTOP_THIN: cleanString(baseEnv.AMALGM_DESKTOP_THIN) || '1',
585
586
  AMALGM_RUNTIME_LABEL: request.lane === 'preview' ? 'preview' : 'main',
586
587
  AMALGM_BRANCH: request.lane,
587
588
  ELECTRON_VERSION: version,
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_APP_ORIGIN = 'https://amalgm.ai';
4
+
5
+ function normalizeAppOrigin(value) {
6
+ const raw = String(value || '').trim();
7
+ if (!raw) return DEFAULT_APP_ORIGIN;
8
+
9
+ try {
10
+ const candidate = raw.includes('://') ? raw : `https://${raw}`;
11
+ const url = new URL(candidate);
12
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return DEFAULT_APP_ORIGIN;
13
+ if (url.hostname === 'app.amalgm.ai' || url.hostname === 'www.amalgm.ai') {
14
+ url.hostname = 'amalgm.ai';
15
+ }
16
+ url.pathname = '';
17
+ url.search = '';
18
+ url.hash = '';
19
+ return url.toString().replace(/\/$/, '');
20
+ } catch {
21
+ return DEFAULT_APP_ORIGIN;
22
+ }
23
+ }
24
+
25
+ function resolveAppOrigin(explicitOrigin) {
26
+ return normalizeAppOrigin(
27
+ explicitOrigin
28
+ || process.env.AMALGM_APP_URL
29
+ || process.env.AMALGM_WEB_URL
30
+ || process.env.NEXT_PUBLIC_APP_URL
31
+ || process.env.NEXT_PUBLIC_SITE_URL
32
+ || DEFAULT_APP_ORIGIN,
33
+ );
34
+ }
35
+
36
+ function normalizeEmailLink(value, appOrigin) {
37
+ const raw = String(value || '').trim();
38
+ if (!raw) return null;
39
+
40
+ const origin = resolveAppOrigin(appOrigin);
41
+ if (raw.startsWith('/')) return new URL(raw, `${origin}/`).toString();
42
+
43
+ try {
44
+ const url = new URL(raw);
45
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
46
+ return url.toString();
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function buildAbsoluteAppUrl(pathname, appOrigin) {
53
+ return new URL(pathname, `${resolveAppOrigin(appOrigin)}/`).toString();
54
+ }
55
+
56
+ function buildSessionDeepLink(sessionId, appOrigin) {
57
+ const value = String(sessionId || '').trim();
58
+ if (!value) return null;
59
+ return buildAbsoluteAppUrl(`/session/${encodeURIComponent(value)}`, appOrigin);
60
+ }
61
+
62
+ function buildEntityDeepLink(type, id, options = {}) {
63
+ const normalizedType = String(type || '').trim().toLowerCase();
64
+ const normalizedId = String(id || '').trim();
65
+ const fallback = normalizeEmailLink(options.defaultOpenUrl, options.appOrigin) || resolveAppOrigin(options.appOrigin);
66
+
67
+ switch (normalizedType) {
68
+ case 'chat':
69
+ case 'conversation':
70
+ return normalizedId
71
+ ? buildAbsoluteAppUrl(`/session/${encodeURIComponent(normalizedId)}`, options.appOrigin)
72
+ : fallback;
73
+ case 'app':
74
+ case 'artifact':
75
+ case 'service':
76
+ return normalizedId
77
+ ? buildAbsoluteAppUrl(`/apps/${encodeURIComponent(normalizedId)}`, options.appOrigin)
78
+ : fallback;
79
+ case 'automation':
80
+ case 'task':
81
+ return normalizedId
82
+ ? buildAbsoluteAppUrl(`/tab?kind=tasks&view=task&id=${encodeURIComponent(normalizedId)}`, options.appOrigin)
83
+ : fallback;
84
+ case 'event':
85
+ case 'trigger':
86
+ return normalizedId
87
+ ? buildAbsoluteAppUrl(`/tab?kind=tasks&view=event&id=${encodeURIComponent(normalizedId)}`, options.appOrigin)
88
+ : fallback;
89
+ case 'preview':
90
+ return normalizedId
91
+ ? buildAbsoluteAppUrl(`/tab?kind=preview&port=${encodeURIComponent(normalizedId)}`, options.appOrigin)
92
+ : fallback;
93
+ default:
94
+ return fallback;
95
+ }
96
+ }
97
+
98
+ function buildEmailRenderContext(options = {}) {
99
+ const appOrigin = resolveAppOrigin(options.appOrigin);
100
+ const detailsUrl = normalizeEmailLink(options.detailsUrl || options.link, appOrigin);
101
+ const sessionUrl = normalizeEmailLink(options.sessionUrl, appOrigin) || buildSessionDeepLink(options.sessionId, appOrigin);
102
+ const explicitDefaultOpenUrl = normalizeEmailLink(options.defaultOpenUrl, appOrigin);
103
+ return {
104
+ appOrigin,
105
+ detailsUrl,
106
+ sessionUrl,
107
+ defaultOpenUrl: explicitDefaultOpenUrl || detailsUrl || sessionUrl || appOrigin,
108
+ };
109
+ }
110
+
111
+ module.exports = {
112
+ buildEmailRenderContext,
113
+ buildEntityDeepLink,
114
+ buildSessionDeepLink,
115
+ normalizeEmailLink,
116
+ resolveAppOrigin,
117
+ };
@@ -27,6 +27,7 @@ const COLOR = {
27
27
 
28
28
  const FILE_ICON_REGISTRY = require('./file-icon-registry.json');
29
29
  const { iconContentId } = require('./email-icons');
30
+ const { buildEntityDeepLink } = require('./email-deeplinks');
30
31
 
31
32
  const EXTENSION_MIME = {
32
33
  // Images
@@ -297,6 +298,10 @@ function renderImageEmbed(alt, url, href) {
297
298
  : img;
298
299
  }
299
300
 
301
+ function defaultOpenHref(renderContext) {
302
+ return (renderContext && renderContext.defaultOpenUrl) || 'https://amalgm.ai';
303
+ }
304
+
300
305
  const PLAY_PILL =
301
306
  `<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>`;
302
307
 
@@ -340,19 +345,20 @@ function renderVideoEmbed(label, url, poster) {
340
345
  * available, and the file itself in the mail client's attachment strip —
341
346
  * Gmail gives attached videos a playable preview natively.
342
347
  */
343
- function renderAttachedVideoCard(label, poster) {
348
+ function renderAttachedVideoCard(label, poster, renderContext = {}) {
344
349
  const identity = resolveFileEmbedIdentity({ url: `attached:${label || ''}`, fallback: label || 'Video' });
345
350
  const name = identity.label;
346
351
  const posterSrc = poster && isEmbeddableImageSrc(poster) ? poster : null;
352
+ const openHref = defaultOpenHref(renderContext);
347
353
  // Poster is the animating GIF in Gmail; the real video rides in the
348
354
  // attachment strip. No in-body note — the header + strip carry it.
349
355
  // Poster <img> wrapped in a link so Gmail doesn't slap its save/download
350
356
  // hover overlay on it (it treats linked images as clickable, not savable).
351
357
  const body = posterSrc
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>`
358
+ ? `<a href="${escapeAttr(openHref)}" 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>`
353
359
  : `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
354
360
  `<td align="center" style="padding:44px 16px;background:${COLOR.bg}">${PLAY_PILL}</td></tr></table>`;
355
- return embedShell({ iconDescriptor: resolveFileIconDescriptor(name), name, action: { href: 'https://amalgm.ai' }, body });
361
+ return embedShell({ iconDescriptor: resolveFileIconDescriptor(name), name, action: { href: openHref }, body });
356
362
  }
357
363
 
358
364
  /**
@@ -360,11 +366,15 @@ function renderAttachedVideoCard(label, poster) {
360
366
  * cannot safely preview arbitrary local documents, so the chrome carries the
361
367
  * identity and open affordance without inventing a second email-only file row.
362
368
  */
363
- function renderFileCardEmbed(label, url) {
369
+ function renderFileCardEmbed(label, url, renderContext = {}) {
364
370
  const identity = resolveFileEmbedIdentity({ url, fallback: label || 'File' });
365
371
  const name = identity.label;
366
372
  const http = isHttpUrl(url);
367
- return embedShell({ iconDescriptor: resolveFileIconDescriptor(name), name, action: { href: http ? url : 'https://amalgm.ai' } });
373
+ return embedShell({
374
+ iconDescriptor: resolveFileIconDescriptor(name),
375
+ name,
376
+ action: { href: http ? url : defaultOpenHref(renderContext) },
377
+ });
368
378
  }
369
379
 
370
380
  /**
@@ -378,9 +388,9 @@ function renderFileCardEmbed(label, url) {
378
388
  * Local sources that could not be attached (amalgm:// refs or filesystem
379
389
  * paths) fall back to the file card.
380
390
  */
381
- function renderMediaEmbed(alt, url, poster) {
391
+ function renderMediaEmbed(alt, url, poster, renderContext = {}) {
382
392
  if (isCidUrl(url)) {
383
- return renderImageEmbed(alt, url, 'https://amalgm.ai');
393
+ return renderImageEmbed(alt, url, defaultOpenHref(renderContext));
384
394
  }
385
395
  const attachedMatch = url.match(/^attached:(.*)$/i);
386
396
  if (attachedMatch) {
@@ -388,7 +398,7 @@ function renderMediaEmbed(alt, url, poster) {
388
398
  try {
389
399
  name = decodeURIComponent(name);
390
400
  } catch {}
391
- return renderAttachedVideoCard(name, poster);
401
+ return renderAttachedVideoCard(name, poster, renderContext);
392
402
  }
393
403
  const amalgmMatch = url.match(/^amalgm:\/\/file\/([^\s)?"]+)/);
394
404
  if (amalgmMatch) {
@@ -396,14 +406,14 @@ function renderMediaEmbed(alt, url, poster) {
396
406
  try {
397
407
  decoded = decodeURIComponent(decoded);
398
408
  } catch {}
399
- return renderFileCardEmbed(alt || mediaNameOf(decoded), decoded);
409
+ return renderFileCardEmbed(alt || mediaNameOf(decoded), decoded, renderContext);
400
410
  }
401
411
  if (!isHttpUrl(url)) {
402
- return renderFileCardEmbed(alt || mediaNameOf(url), url);
412
+ return renderFileCardEmbed(alt || mediaNameOf(url), url, renderContext);
403
413
  }
404
414
  const kind = mediaKindOf(url);
405
415
  if (kind === 'video') return renderVideoEmbed(alt, url, poster);
406
- if (kind === 'audio' || kind === 'document') return renderFileCardEmbed(alt, url);
416
+ if (kind === 'audio' || kind === 'document') return renderFileCardEmbed(alt, url, renderContext);
407
417
  // Images and extension-less URLs keep vanilla image semantics.
408
418
  return renderImageEmbed(alt, url);
409
419
  }
@@ -431,11 +441,12 @@ function entityTypeLabel(type) {
431
441
  * Inline tile — the email-safe form of ChatRow: one black rounded row with an
432
442
  * icon and title. No email-only type eyebrow.
433
443
  */
434
- function renderEntityTile(label, type) {
444
+ function renderEntityTile(label, type, id, renderContext = {}) {
435
445
  const safeLabel = escapeText(label || entityTypeLabel(type));
436
446
  const iconDescriptor = resolveEntityIconDescriptor(type);
447
+ const href = buildEntityDeepLink(type, id, renderContext);
437
448
  return (
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">` +
449
+ `<a href="${escapeAttr(href)}" 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
450
  `<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr>` +
440
451
  `<td style="padding:0 10px 0 0;vertical-align:middle;line-height:0">${iconHtml(iconDescriptor)}</td>` +
441
452
  `<td style="padding:0;vertical-align:middle;font-size:14px;line-height:18px;color:${COLOR.text};font-weight:650">${safeLabel}</td>` +
@@ -449,13 +460,25 @@ function renderEntityTile(label, type) {
449
460
  * the live app, so it's the EmbedChrome card with the entity identity and a
450
461
  * prominent open action.
451
462
  */
452
- function renderEntityExpanded(label, type) {
463
+ function renderEntityExpanded(label, type, id, renderContext = {}) {
453
464
  const display = label || entityTypeLabel(type);
454
- return embedShell({ iconDescriptor: resolveEntityIconDescriptor(type), name: display, action: { href: 'https://amalgm.ai' } });
455
- }
456
-
457
- function renderEntityEmbed(mode, type, label) {
458
- return mode === 'full' ? renderEntityExpanded(label, type) : renderEntityTile(label, type);
465
+ return embedShell({
466
+ iconDescriptor: resolveEntityIconDescriptor(type),
467
+ name: display,
468
+ action: { href: buildEntityDeepLink(type, id, renderContext) },
469
+ });
470
+ }
471
+
472
+ function renderEntityEmbed(mode, type, label, idOrRenderContext, maybeRenderContext) {
473
+ const id = typeof idOrRenderContext === 'string' ? idOrRenderContext : '';
474
+ const renderContext = (
475
+ idOrRenderContext && typeof idOrRenderContext === 'object' && !Array.isArray(idOrRenderContext)
476
+ ? idOrRenderContext
477
+ : maybeRenderContext
478
+ ) || {};
479
+ return mode === 'full'
480
+ ? renderEntityExpanded(label, type, id, renderContext)
481
+ : renderEntityTile(label, type, id, renderContext);
459
482
  }
460
483
 
461
484
  module.exports = {