@agent-native/core 0.136.0 → 0.136.3

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 (120) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +31 -5
  3. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +7 -0
  4. package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +52 -19
  5. package/corpus/templates/analytics/.agents/skills/provider-api/SKILL.md +25 -3
  6. package/corpus/templates/analytics/AGENTS.md +6 -7
  7. package/corpus/templates/analytics/actions/gong-calls.ts +3 -3
  8. package/corpus/templates/analytics/actions/provider-api-catalog.ts +1 -1
  9. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  10. package/corpus/templates/analytics/app/i18n-data.ts +46 -0
  11. package/corpus/templates/analytics/app/pages/Settings.tsx +67 -1
  12. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +6 -0
  13. package/corpus/templates/analytics/changelog/2026-08-03-dashboard-builds-now-finish-without-an-extra-confirmation.md +6 -0
  14. package/corpus/templates/analytics/changelog/2026-08-03-error-alert-emails-are-opt-in.md +6 -0
  15. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +10 -9
  16. package/corpus/templates/analytics/server/lib/error-capture.ts +38 -10
  17. package/corpus/templates/analytics/server/lib/gong.ts +27 -4
  18. package/corpus/templates/analytics/server/plugins/agent-chat.ts +64 -4
  19. package/corpus/templates/analytics/shared/analytics-user-prefs.ts +7 -0
  20. package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +15 -6
  21. package/corpus/templates/clips/actions/apply-rewind-extension.ts +9 -0
  22. package/corpus/templates/clips/actions/cleanup-dictation.ts +2 -2
  23. package/corpus/templates/clips/actions/cleanup-transcript.ts +8 -11
  24. package/corpus/templates/clips/actions/clear-edits.ts +15 -2
  25. package/corpus/templates/clips/actions/create-recording-agent-link.ts +10 -0
  26. package/corpus/templates/clips/actions/finalize-meeting.ts +6 -6
  27. package/corpus/templates/clips/actions/finalize-recording.ts +33 -8
  28. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +1 -3
  29. package/corpus/templates/clips/actions/list-recordings.ts +39 -24
  30. package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +4 -1
  31. package/corpus/templates/clips/actions/regenerate-title.ts +2 -2
  32. package/corpus/templates/clips/actions/stitch-recordings.ts +36 -7
  33. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +1 -4
  34. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +3 -1
  35. package/corpus/templates/clips/app/components/library/recording-card.tsx +9 -0
  36. package/corpus/templates/clips/app/components/player/recording-views-badge.tsx +52 -7
  37. package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
  38. package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +17 -10
  39. package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
  40. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +5 -0
  41. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -0
  42. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  43. package/corpus/templates/clips/changelog/2026-07-31-agent-views-now-show-the-agent-s-name-from-the-link-it-was-g.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-31-clip-cards-and-the-watch-share-page-header-now-show-agent-vi.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-08-01-recordings-stored-in-private-s3-compatible-buckets-finalize-.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-08-03-clip-cleanup-titles-and-meeting-summaries-now-use-luna-by-de.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-08-03-get-a-monthly-recap-email-showing-how-many-people-and-ai-age.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-08-03-get-an-email-the-first-time-an-ai-agent-reads-one-of-your-cl.md +6 -0
  49. package/corpus/templates/clips/learnings.defaults.md +2 -2
  50. package/corpus/templates/clips/package.json +1 -1
  51. package/corpus/templates/clips/scripts/send-real-recap.ts +165 -0
  52. package/corpus/templates/clips/scripts/send-test-emails.ts +60 -0
  53. package/corpus/templates/clips/server/db/schema.ts +4 -0
  54. package/corpus/templates/clips/server/jobs/transactional-emails.ts +256 -0
  55. package/corpus/templates/clips/server/lib/agent-views.ts +38 -11
  56. package/corpus/templates/clips/server/lib/media-storage-provenance.ts +16 -0
  57. package/corpus/templates/clips/server/lib/public-agent-context.ts +3 -1
  58. package/corpus/templates/clips/server/lib/recap-metrics.ts +373 -0
  59. package/corpus/templates/clips/server/lib/s3-upload-provider.ts +84 -4
  60. package/corpus/templates/clips/server/lib/transactional-email-store.ts +41 -2
  61. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +245 -3
  62. package/corpus/templates/clips/server/plugins/db.ts +16 -0
  63. package/corpus/templates/clips/server/register-secrets.ts +2 -2
  64. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +7 -2
  65. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +42 -1
  66. package/corpus/templates/design/actions/take-design-screenshot.ts +5 -3
  67. package/corpus/templates/design/package.json +1 -1
  68. package/corpus/templates/design/server/lib/playwright-runtime.ts +13 -5
  69. package/corpus/templates/mail/actions/get-automation-settings.ts +4 -12
  70. package/corpus/templates/mail/app/pages/SettingsPage.tsx +1 -1
  71. package/corpus/templates/mail/changelog/2026-08-03-inbox-automations-now-prefer-the-lowest-cost-luna-model-when.md +6 -0
  72. package/corpus/templates/mail/server/lib/automation-engine.ts +19 -14
  73. package/corpus/templates/mail/server/lib/automation-model.ts +110 -0
  74. package/corpus/templates/mail/server/plugins/agent-chat.ts +1 -1
  75. package/corpus/templates/slides/actions/generate-slides-ai.ts +92 -24
  76. package/corpus/templates/slides/changelog/2026-08-03-legacy-slide-outlines-now-prefer-the-lowest-cost-luna-model-.md +6 -0
  77. package/corpus/templates/tasks/package.json +2 -2
  78. package/dist/agent/production-agent.d.ts +2 -2
  79. package/dist/agent/production-agent.js +1 -1
  80. package/dist/catalog.json +2 -0
  81. package/dist/client/AgentTaskCard.js +4 -2
  82. package/dist/client/i18n.js +3 -0
  83. package/dist/client/settings/VoiceTranscriptionSection.js +2 -2
  84. package/dist/collab/awareness.d.ts +2 -2
  85. package/dist/collab/routes.d.ts +1 -1
  86. package/dist/collab/struct-routes.d.ts +1 -1
  87. package/dist/localization/default-messages.d.ts +5 -0
  88. package/dist/localization/default-messages.js +5 -0
  89. package/dist/mcp/oauth-client-metadata.d.ts +7 -0
  90. package/dist/mcp/oauth-client-metadata.js +47 -0
  91. package/dist/mcp/oauth-route.js +7 -3
  92. package/dist/mcp/oauth-store.d.ts +2 -0
  93. package/dist/mcp/oauth-store.js +25 -3
  94. package/dist/notifications/routes.d.ts +1 -1
  95. package/dist/observability/routes.d.ts +3 -3
  96. package/dist/observability/traces.d.ts +1 -1
  97. package/dist/observability/traces.js +2 -1
  98. package/dist/progress/routes.d.ts +1 -1
  99. package/dist/provider-api/index.js +29 -1
  100. package/dist/secrets/routes.d.ts +6 -6
  101. package/dist/server/agent-access.d.ts +3 -1
  102. package/dist/server/agent-access.js +2 -1
  103. package/dist/server/agent-chat/browser-team-tools.d.ts +1 -0
  104. package/dist/server/agent-chat/browser-team-tools.js +5 -4
  105. package/dist/server/agent-chat/context-tools.js +2 -2
  106. package/dist/server/agent-chat-plugin.js +13 -6
  107. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  108. package/dist/server/agent-teams-run-queue.d.ts +2 -0
  109. package/dist/server/agent-teams.d.ts +2 -0
  110. package/dist/server/agent-teams.js +45 -2
  111. package/dist/server/request-context.d.ts +2 -0
  112. package/dist/server/short-lived-token.d.ts +7 -0
  113. package/dist/server/short-lived-token.js +7 -1
  114. package/dist/server/ssr-handler.js +19 -1
  115. package/dist/server/transcribe-voice.d.ts +1 -1
  116. package/dist/server/transcribe-voice.js +24 -16
  117. package/dist/vite/client.d.ts +37 -1
  118. package/dist/vite/client.js +110 -1
  119. package/docs/content/template-assets.mdx +14 -4
  120. package/package.json +3 -4
@@ -32,6 +32,17 @@ export const transactionalEmailStateSchema = z.enum([
32
32
  "failed",
33
33
  ]);
34
34
 
35
+ export const recapMonthSchema = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/);
36
+
37
+ /** The three recap modules the agent writes; everything else is templated. */
38
+ export const recapCopySchema = z
39
+ .object({
40
+ heroLine: nonEmptyStringSchema.max(400),
41
+ agentBreakdown: nonEmptyStringSchema.max(400),
42
+ completionNote: nonEmptyStringSchema.max(400),
43
+ })
44
+ .strict();
45
+
35
46
  const commonPayloadFields = {
36
47
  recipient: recipientSchema,
37
48
  shareId: nonEmptyStringSchema.optional(),
@@ -60,6 +71,22 @@ export const transactionalEmailPayloadSchema = z.discriminatedUnion("type", [
60
71
  recordingIds: z.array(recordingIdSchema).length(1),
61
72
  })
62
73
  .strict(),
74
+ z
75
+ .object({
76
+ type: z.literal("first-agent-view"),
77
+ ...commonPayloadFields,
78
+ recordingIds: z.array(recordingIdSchema).length(1),
79
+ })
80
+ .strict(),
81
+ z
82
+ .object({
83
+ type: z.literal("monthly-recap"),
84
+ ...commonPayloadFields,
85
+ // The month's top clip, which anchors the card and the AI copy.
86
+ recordingIds: z.array(recordingIdSchema).length(1),
87
+ month: recapMonthSchema,
88
+ })
89
+ .strict(),
63
90
  z
64
91
  .object({
65
92
  type: z.literal("two-clips"),
@@ -85,6 +112,7 @@ export const transactionalEmailConfigSchema = z
85
112
  shareDiscoveryCursor: reconciliationCursorSchema,
86
113
  reminderCursor: reconciliationCursorSchema,
87
114
  firstViewCursor: reconciliationCursorSchema,
115
+ firstAgentViewCursor: reconciliationCursorSchema,
88
116
  firstImportCursor: reconciliationCursorSchema,
89
117
  })
90
118
  .strict();
@@ -93,6 +121,7 @@ export const transactionalEmailCursorNameSchema = z.enum([
93
121
  "shareDiscoveryCursor",
94
122
  "reminderCursor",
95
123
  "firstViewCursor",
124
+ "firstAgentViewCursor",
96
125
  "firstImportCursor",
97
126
  ]);
98
127
 
@@ -102,7 +131,9 @@ export const transactionalEmailJobSchema = z
102
131
  type: z.enum([
103
132
  "first-view",
104
133
  "unviewed-reminder",
134
+ "first-agent-view",
105
135
  "first-import",
136
+ "monthly-recap",
106
137
  "two-clips",
107
138
  ]),
108
139
  state: transactionalEmailStateSchema,
@@ -110,6 +141,7 @@ export const transactionalEmailJobSchema = z
110
141
  recordingIds: z.array(recordingIdSchema).min(1),
111
142
  shareId: nonEmptyStringSchema.optional(),
112
143
  requestedBy: nonEmptyStringSchema.optional(),
144
+ month: recapMonthSchema.optional(),
113
145
  generatedSummary: z.string().max(20_000).optional(),
114
146
  attempts: z.number().int().nonnegative(),
115
147
  createdAt: timestampSchema,
@@ -133,6 +165,7 @@ export const transactionalEmailJobSchema = z
133
165
  recordingIds: job.recordingIds,
134
166
  shareId: job.shareId,
135
167
  requestedBy: job.requestedBy,
168
+ ...(job.month === undefined ? {} : { month: job.month }),
136
169
  });
137
170
  if (!parsedPayload.success) {
138
171
  context.addIssue({
@@ -172,6 +205,12 @@ export type TransactionalEmailConfig = z.infer<
172
205
  typeof transactionalEmailConfigSchema
173
206
  >;
174
207
  export type TransactionalEmailJob = z.infer<typeof transactionalEmailJobSchema>;
208
+ export type RecapCopy = z.infer<typeof recapCopySchema>;
209
+
210
+ /** Job types whose copy is written by the agent before they can be sent. */
211
+ export function isAiBackedType(type: TransactionalEmailJob["type"]): boolean {
212
+ return type === "two-clips";
213
+ }
175
214
 
176
215
  export type TransactionalEmailStoreOptions = {
177
216
  root?: string;
@@ -625,7 +664,7 @@ export function createTransactionalEmailStore(
625
664
  const claimant = recipientSchema.parse(claimantEmail.trim().toLowerCase());
626
665
  return withJobLock(logicalKey, async () => {
627
666
  const job = await readJob(logicalKey);
628
- if (!job || job.type !== "two-clips" || job.state !== "awaiting_ai") {
667
+ if (!job || !isAiBackedType(job.type) || job.state !== "awaiting_ai") {
629
668
  return null;
630
669
  }
631
670
  const timestamp = now().toISOString();
@@ -652,7 +691,7 @@ export function createTransactionalEmailStore(
652
691
  const dispatchedAt = job?.aiDispatchedAt ?? job?.updatedAt;
653
692
  if (
654
693
  !job ||
655
- job.type !== "two-clips" ||
694
+ !isAiBackedType(job.type) ||
656
695
  job.state !== "ai_dispatched" ||
657
696
  !dispatchedAt ||
658
697
  Date.parse(dispatchedAt) > staleBefore.getTime()
@@ -5,7 +5,18 @@ import {
5
5
  sendEmail,
6
6
  } from "@agent-native/core/server";
7
7
 
8
+ import { recapMonthLabel } from "./recap-metrics.js";
9
+ import type { RecapCopy } from "./transactional-email-store.js";
10
+
8
11
  const CLIPS_BRAND_NAME = "Clips";
12
+ const CLIPS_SENDER_NAME = "Agent-Native Clips";
13
+ /**
14
+ * Yields `clips@agent-native.com` on first-party deployments and is ignored
15
+ * where the configured sender is someone else's verified address, so a
16
+ * self-hosted install keeps sending from an address its provider accepts.
17
+ */
18
+ const CLIPS_SENDER_SLUG = "clips";
19
+ const UNIDENTIFIED_AGENT_NAME = "An AI agent";
9
20
  const EMAIL_SEND_TIMEOUT_MS = 60_000;
10
21
  const FRIENDLY_REPLY_TO = "hello@agent-native.com";
11
22
  const UNTITLED_CLIP = "Untitled Clip";
@@ -31,11 +42,26 @@ export type ClipsTransactionalEmailInput =
31
42
  senderName?: string | null;
32
43
  brandLogoUrl?: string | null;
33
44
  })
45
+ | (TransactionalEmailBase & {
46
+ kind: "first-agent-view";
47
+ recordingId: string;
48
+ title?: string | null;
49
+ /** Absent when the reading agent could not be identified by product. */
50
+ agentName?: string | null;
51
+ })
34
52
  | (TransactionalEmailBase & {
35
53
  kind: "first-import";
36
54
  recordingId: string;
37
55
  title?: string | null;
38
56
  })
57
+ | (TransactionalEmailBase & {
58
+ kind: "monthly-recap";
59
+ month: string;
60
+ humanViews: number;
61
+ agentSessions: number;
62
+ topClip: RecapTopClipInput;
63
+ copy: RecapCopy;
64
+ })
39
65
  | (TransactionalEmailBase & {
40
66
  kind: "two-clips";
41
67
  generatedSummary?: string | null;
@@ -60,6 +86,16 @@ export type ClipsTransactionalEmailInput =
60
86
  videoTimestampMs?: number | null;
61
87
  });
62
88
 
89
+ export interface RecapTopClipInput {
90
+ recordingId: string;
91
+ title?: string | null;
92
+ thumbnailUrl?: string | null;
93
+ durationMs: number;
94
+ recordedAt: string;
95
+ humanViews: number;
96
+ agentSessions: number;
97
+ }
98
+
63
99
  export interface ClipsTransactionalEmailRenderOptions {
64
100
  appUrl: string;
65
101
  appBasePath?: string;
@@ -169,6 +205,158 @@ function resolveBrandLogoUrl(
169
205
  }
170
206
  }
171
207
 
208
+ function escapeHtml(value: string): string {
209
+ return value
210
+ .replace(/&/g, "&amp;")
211
+ .replace(/</g, "&lt;")
212
+ .replace(/>/g, "&gt;")
213
+ .replace(/"/g, "&quot;");
214
+ }
215
+
216
+ function countLabel(count: number, singular: string, plural: string): string {
217
+ return `${count} ${count === 1 ? singular : plural}`;
218
+ }
219
+
220
+ export function renderRecapSubject(
221
+ humanViews: number,
222
+ agentSessions: number,
223
+ month: string,
224
+ ): string {
225
+ const monthLabel = recapMonthLabel(month);
226
+ const views = countLabel(humanViews, "human view", "human views");
227
+ const reads = countLabel(agentSessions, "agent read", "agent reads");
228
+ if (humanViews > 0 && agentSessions > 0) {
229
+ return `${views} and ${reads} on your clips in ${monthLabel}`;
230
+ }
231
+ if (humanViews > 0) return `${views} on your clips in ${monthLabel}`;
232
+ return `${reads} on your clips in ${monthLabel}`;
233
+ }
234
+
235
+ export interface RecapCopySource {
236
+ humanViews: number;
237
+ agentSessions: number;
238
+ topClip: {
239
+ humanViews: number;
240
+ completedPct: number;
241
+ dropOffMs: number | null;
242
+ /** A null `agentLabel` is an agent we could not identify by product. */
243
+ agentBreakdown: { agentLabel: string | null; sessions: number }[];
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Builds every recap module from the metrics themselves.
249
+ *
250
+ * Deliberately not agent-written: a recap that waited on the owner opening
251
+ * Clips would silently never arrive for the owners least likely to open it.
252
+ * Each module is mechanical, so nothing is lost by composing it here.
253
+ */
254
+ export function composeRecapCopy(recap: RecapCopySource): RecapCopy {
255
+ const views = countLabel(recap.humanViews, "time", "times");
256
+ const reads = countLabel(recap.agentSessions, "agent", "agents");
257
+ const heroLine =
258
+ recap.humanViews > 0 && recap.agentSessions > 0
259
+ ? `Your clips were watched ${views}. ${reads} read them.`
260
+ : recap.humanViews > 0
261
+ ? `Your clips were watched ${views}.`
262
+ : `${reads} read your clips.`;
263
+
264
+ const completionNote =
265
+ recap.topClip.humanViews === 0
266
+ ? "No human views on this one yet"
267
+ : `${recap.topClip.completedPct}% average completion${
268
+ recap.topClip.dropOffMs === null
269
+ ? ""
270
+ : ` · most stopped at ${formatClipDuration(recap.topClip.dropOffMs)}`
271
+ }`;
272
+
273
+ const agentBreakdown =
274
+ recap.topClip.agentBreakdown.length === 0
275
+ ? "No agent reads yet"
276
+ : recap.topClip.agentBreakdown
277
+ .map((entry) =>
278
+ entry.agentLabel
279
+ ? `${entry.sessions} from ${entry.agentLabel}`
280
+ : `${entry.sessions} unidentified`,
281
+ )
282
+ .join(" · ");
283
+
284
+ return { heroLine, completionNote, agentBreakdown };
285
+ }
286
+
287
+ export function formatClipDuration(durationMs: number): string {
288
+ const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
289
+ const minutes = Math.floor(totalSeconds / 60);
290
+ const seconds = totalSeconds % 60;
291
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
292
+ }
293
+
294
+ function formatRecordedDate(recordedAt: string): string {
295
+ const parsed = new Date(recordedAt);
296
+ return Number.isNaN(parsed.getTime())
297
+ ? ""
298
+ : parsed.toLocaleDateString("en-US", {
299
+ month: "short",
300
+ day: "numeric",
301
+ timeZone: "UTC",
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Email clients resolve neither CSS custom properties nor external
307
+ * stylesheets, so the recap card inlines the same literal palette that
308
+ * `renderEmail` already draws the surrounding card with.
309
+ */
310
+ const CARD_BG = "#0a0a0c"; // guard:allow-raw-color — inlined for email clients
311
+ const CARD_BORDER = "#3f3f46"; // guard:allow-raw-color — inlined for email clients
312
+ const CARD_DIVIDER = "#27272a"; // guard:allow-raw-color — inlined for email clients
313
+ const CARD_STRONG = "#fafafa"; // guard:allow-raw-color — inlined for email clients
314
+ const CARD_MUTED = "#a1a1aa"; // guard:allow-raw-color — inlined for email clients
315
+
316
+ /** The clip card plus the Watched/Read pair, as one trusted HTML block. */
317
+ function recapHeroHtml(
318
+ clip: RecapTopClipInput,
319
+ copy: RecapCopy,
320
+ url: string,
321
+ ): string {
322
+ const title = escapeHtml(clipTitle(clip.title));
323
+ const recordedDate = escapeHtml(formatRecordedDate(clip.recordedAt));
324
+ const duration = escapeHtml(formatClipDuration(clip.durationMs));
325
+ const meta = [recordedDate, duration].filter(Boolean).join(" · ");
326
+ const safeUrl = escapeHtml(url);
327
+ const thumbnail = clip.thumbnailUrl?.trim()
328
+ ? `<tr><td style="padding:0 0 14px;"><a href="${safeUrl}" style="display:block;"><img src="${escapeHtml(clip.thumbnailUrl.trim())}" alt="${title}" width="516" style="display:block; width:100%; max-width:516px; border:0; border-radius:8px;" /></a></td></tr>`
329
+ : "";
330
+
331
+ const numberCell = (
332
+ heading: string,
333
+ value: number,
334
+ subLine: string,
335
+ ) => `<td width="50%" valign="top" style="padding:0 8px;">
336
+ <div style="font-size:12px; letter-spacing:0.08em; text-transform:uppercase; color:${CARD_MUTED};">${escapeHtml(heading)}</div>
337
+ <div style="font-size:28px; font-weight:600; color:${CARD_STRONG}; padding:2px 0 4px;">${value}</div>
338
+ <div style="font-size:13px; line-height:1.5; color:${CARD_MUTED};">${escapeHtml(subLine)}</div>
339
+ </td>`;
340
+
341
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px; border:1px solid ${CARD_BORDER}; border-radius:10px; background:${CARD_BG};">
342
+ <tr><td style="padding:16px;">
343
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
344
+ ${thumbnail}
345
+ <tr><td style="padding:0 0 4px;">
346
+ <a href="${safeUrl}" style="font-size:17px; font-weight:600; color:${CARD_STRONG}; text-decoration:none;">${title}</a>
347
+ </td></tr>
348
+ <tr><td style="padding:0 0 16px; font-size:13px; color:${CARD_MUTED};">${escapeHtml(meta)}</td></tr>
349
+ <tr><td style="border-top:1px solid ${CARD_DIVIDER}; padding-top:16px;">
350
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr>
351
+ ${numberCell("Watched", clip.humanViews, copy.completionNote)}
352
+ ${numberCell("Read", clip.agentSessions, copy.agentBreakdown)}
353
+ </tr></table>
354
+ </td></tr>
355
+ </table>
356
+ </td></tr>
357
+ </table>`;
358
+ }
359
+
172
360
  function validReplyTo(value: string | null | undefined): string | undefined {
173
361
  const candidate = singleLine(value).toLowerCase();
174
362
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(candidate) ? candidate : undefined;
@@ -178,7 +366,10 @@ export function renderClipsTransactionalEmail(
178
366
  input: ClipsTransactionalEmailInput,
179
367
  options: ClipsTransactionalEmailRenderOptions,
180
368
  ): RenderedClipsTransactionalEmail {
181
- const title = input.kind === "two-clips" ? undefined : clipTitle(input.title);
369
+ const title =
370
+ input.kind === "two-clips" || input.kind === "monthly-recap"
371
+ ? undefined
372
+ : clipTitle(input.title);
182
373
 
183
374
  switch (input.kind) {
184
375
  case "first-view": {
@@ -230,6 +421,32 @@ export function renderClipsTransactionalEmail(
230
421
  return { subject, ...rendered };
231
422
  }
232
423
 
424
+ case "first-agent-view": {
425
+ const agentName = singleLine(input.agentName) || UNIDENTIFIED_AGENT_NAME;
426
+ const subject = "An AI agent “watched” your Clip";
427
+ const rendered = renderEmail({
428
+ brandName: CLIPS_BRAND_NAME,
429
+ preheader: subject,
430
+ heading: "An AI agent “watched” your Clip",
431
+ paragraphs: [
432
+ `${emailStrong(agentName)} accessed ${emailStrong(title!)} today — the full transcript and the frames, not just the title.`,
433
+ "Every clip you record ships in two formats: video for people, structured JSON and readable frames for agents. When someone points an agent at your clip, it reads the whole thing in one pass.",
434
+ "Which means this one stopped being a video and became documentation that answers questions without you. You can even import videos from other screen recording apps to give them agentic readability.",
435
+ ],
436
+ cta: {
437
+ label: "See Clip Analytics",
438
+ url: clipUrl(input.recordingId, options),
439
+ },
440
+ secondaryCta: {
441
+ label: "Import a video",
442
+ url: recordUrl(options),
443
+ },
444
+ footer:
445
+ "You received this one-time note because an AI agent read one of your Clips for the first time.",
446
+ });
447
+ return { subject, ...rendered };
448
+ }
449
+
233
450
  case "first-import": {
234
451
  const subject = "Your first imported video is now Agent-Native";
235
452
  const url = clipUrl(input.recordingId, options);
@@ -256,6 +473,26 @@ export function renderClipsTransactionalEmail(
256
473
  return { subject, ...rendered };
257
474
  }
258
475
 
476
+ case "monthly-recap": {
477
+ const subject = renderRecapSubject(
478
+ input.humanViews,
479
+ input.agentSessions,
480
+ input.month,
481
+ );
482
+ const url = clipUrl(input.topClip.recordingId, options);
483
+ const rendered = renderEmail({
484
+ brandName: CLIPS_BRAND_NAME,
485
+ preheader: subject,
486
+ heading: `${input.copy.heroLine} Here’s your top Clip of the month:`,
487
+ paragraphs: [],
488
+ heroHtml: recapHeroHtml(input.topClip, input.copy, url),
489
+ cta: { label: "View more Clips Analytics", url },
490
+ secondaryCta: { label: "Record a new Clip", url: recordUrl(options) },
491
+ footer: `You received this because your Clips were watched in ${recapMonthLabel(input.month)}. Recaps arrive once a month; ask your agent for a Clips recap any time.`,
492
+ });
493
+ return { subject, ...rendered };
494
+ }
495
+
259
496
  case "two-clips": {
260
497
  const summary =
261
498
  singleLine(input.generatedSummary) ||
@@ -364,8 +601,13 @@ export async function sendClipsTransactionalEmail(
364
601
  html: rendered.html,
365
602
  text: rendered.text,
366
603
  fromName: reminderSender
367
- ? `${reminderSender} (via Agent-Native Clips)`
368
- : undefined,
604
+ ? `${reminderSender} (via ${CLIPS_SENDER_NAME})`
605
+ : CLIPS_SENDER_NAME,
606
+ appSender: {
607
+ name: CLIPS_SENDER_NAME,
608
+ slug: CLIPS_SENDER_SLUG,
609
+ replyTo: FRIENDLY_REPLY_TO,
610
+ },
369
611
  replyTo:
370
612
  input.kind === "unviewed-reminder"
371
613
  ? (validReplyTo(input.senderEmail) ?? FRIENDLY_REPLY_TO)
@@ -915,6 +915,22 @@ const migrations = runMigrations(
915
915
  name: "recording-upload-generation-fence",
916
916
  sql: `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS upload_generation_id TEXT`,
917
917
  },
918
+ {
919
+ version: 56,
920
+ name: "recording-agent-views-user-agent",
921
+ sql: `ALTER TABLE recording_agent_views ADD COLUMN IF NOT EXISTS user_agent TEXT`,
922
+ },
923
+ {
924
+ version: 57,
925
+ name: "recording-agent-views-clear-placeholder-label",
926
+ // Rows written before the user-agent column stored the literal placeholder
927
+ // 'Agent' for any agent the user-agent patterns could not name, which is
928
+ // indistinguishable from an agent that really is called "Agent". NULL is
929
+ // the one value that means "we don't know", so unnamed history renders as
930
+ // unknown too.
931
+ // guard:allow-unscoped — startup migration normalizes a legacy placeholder across all rows.
932
+ sql: `UPDATE recording_agent_views SET agent_label = NULL WHERE agent_label = 'Agent'`,
933
+ },
918
934
  ],
919
935
  { table: "clips_migrations" },
920
936
  );
@@ -12,7 +12,7 @@ import { registerRequiredSecret } from "@agent-native/core/secrets";
12
12
  // available for desktop voice dictation and other provider-specific tools.
13
13
  //
14
14
  // We support two BYOK providers:
15
- // 1. Gemini — recommended for fast LLM cleanup in the desktop tray.
15
+ // 1. Gemini — BYOK fallback for fast text cleanup in the desktop tray.
16
16
  // 2. Groq — optional voice-dictation provider, not a recording transcript
17
17
  // fallback.
18
18
  //
@@ -30,7 +30,7 @@ registerRequiredSecret({
30
30
  key: "GEMINI_API_KEY",
31
31
  label: "Gemini API Key (recommended)",
32
32
  description:
33
- "Fast LLM-backed transcription cleanup via Gemini Flash Lite. Recommended for Clips voice dictation when you want to bring your own key.",
33
+ "Fast text-model-backed transcription cleanup via Builder/Luna or Gemini Flash Lite. Recommended for Clips voice dictation when you want to bring your own key.",
34
34
  docsUrl: "https://aistudio.google.com/apikey",
35
35
  scope: "user",
36
36
  kind: "api-key",
@@ -42,6 +42,7 @@ import {
42
42
  } from "../../../shared/transcript-segments.js";
43
43
  import { resolveTranscriptPresentation } from "../../../shared/transcript-status.js";
44
44
  import { getDb, schema } from "../../db/index.js";
45
+ import { countRecordingAgentViews } from "../../lib/agent-views.js";
45
46
  import { isMediaVerificationPending } from "../../lib/media-verification-state.js";
46
47
  import { resolvePlayerThumbnailUrl } from "../../lib/player-thumbnail-url.js";
47
48
  import { resolvePlayerVideoUrl } from "../../lib/player-video-url.js";
@@ -411,7 +412,10 @@ export default defineEventHandler(async (event) => {
411
412
  recordingStatus: rec.status,
412
413
  });
413
414
 
414
- const viewCount = await countRecordingViews(recordingId);
415
+ const [viewCount, agentViewCount] = await Promise.all([
416
+ countRecordingViews(recordingId),
417
+ countRecordingAgentViews(recordingId),
418
+ ]);
415
419
 
416
420
  const viewerRole =
417
421
  viewerAccess?.role ?? (viewerIsOrgMember ? "viewer" : null);
@@ -471,8 +475,9 @@ export default defineEventHandler(async (event) => {
471
475
  updatedAt: rec.updatedAt,
472
476
  },
473
477
  agentContextUrl,
474
- // Aggregate count only — never viewer identities on this public payload.
478
+ // Aggregate counts only — never viewer identities on this public payload.
475
479
  viewCount,
480
+ agentViewCount,
476
481
  transcript: transcript
477
482
  ? {
478
483
  status: transcriptPresentation.status,
@@ -64,12 +64,15 @@ import {
64
64
  loomEmbedUrlForRecording,
65
65
  } from "../../../../shared/loom.js";
66
66
  import { getDb, schema } from "../../../db/index.js";
67
+ import { allowsLegacyS3ObjectForPersistedMedia } from "../../../lib/media-storage-provenance.js";
67
68
  import { getOrganizationRoleForEmail } from "../../../lib/recordings.js";
69
+ import { fetchS3ObjectByUrl } from "../../../lib/s3-upload-provider.js";
68
70
  import { verifySharePassword } from "../../../lib/share-password.js";
69
71
 
70
72
  interface RecordingRow {
71
73
  expiresAt?: string | null;
72
74
  organizationId?: string | null;
75
+ ownerEmail?: string | null;
73
76
  password?: string | null;
74
77
  sourceAppName?: string | null;
75
78
  sourceWindowTitle?: string | null;
@@ -453,7 +456,45 @@ export default defineEventHandler(async (event: H3Event) => {
453
456
 
454
457
  let upstream: Response | { error: string; status: number };
455
458
  try {
456
- upstream = await fetchProviderMedia(sourceUrl, rangeHeader);
459
+ const db = getDb();
460
+ const [persistedMedia] = await db
461
+ .select({
462
+ videoUrl: schema.recordings.videoUrl,
463
+ editsJson: schema.recordings.editsJson,
464
+ })
465
+ .from(schema.recordings)
466
+ .where(eq(schema.recordings.id, recordingId))
467
+ .limit(1);
468
+ const allowLegacyObjectKey = allowsLegacyS3ObjectForPersistedMedia({
469
+ requestedUrl: sourceUrl,
470
+ persistedUrl: persistedMedia?.videoUrl,
471
+ editsJson: persistedMedia?.editsJson,
472
+ });
473
+ const signedS3Response = await runWithRequestContext(
474
+ {
475
+ userEmail: rec.ownerEmail ?? undefined,
476
+ orgId: rec.organizationId ?? undefined,
477
+ },
478
+ () =>
479
+ fetchS3ObjectByUrl(sourceUrl, {
480
+ range: rangeHeader?.startsWith("bytes=")
481
+ ? rangeHeader
482
+ : undefined,
483
+ timeoutMs: PROVIDER_MEDIA_FETCH_TIMEOUT_MS,
484
+ recordingId,
485
+ ...(allowLegacyObjectKey ? { allowLegacyObjectKey } : {}),
486
+ }),
487
+ );
488
+ if (
489
+ signedS3Response?.status === 200 ||
490
+ signedS3Response?.status === 206 ||
491
+ signedS3Response?.status === 416
492
+ ) {
493
+ upstream = signedS3Response;
494
+ } else {
495
+ await signedS3Response?.body?.cancel().catch(() => undefined);
496
+ upstream = await fetchProviderMedia(sourceUrl, rangeHeader);
497
+ }
457
498
  } catch (err) {
458
499
  setResponseStatus(event, statusCodeForProviderFetchError(err));
459
500
  return { error: messageForProviderFetchError(err) };
@@ -29,9 +29,11 @@
29
29
  * the actionable-today half of the loop — the agent can read and fix
30
30
  * these findings without needing to "see" anything.
31
31
  *
32
- * Requires a real headless Chromium. Local dev has Playwright's browsers
33
- * installed (`playwright install chromium`, same as the design e2e suite);
34
- * hosted/serverless deploys (Netlify Functions) do not bundle a Chromium
32
+ * Requires a real headless Chromium. Local dev needs Playwright's headless
33
+ * shell (`playwright install --only-shell chromium`); plain
34
+ * `install chromium` also downloads the full headed browser — several hundred
35
+ * MB more disk, needed only by `pnpm e2e:headed`/`e2e:ui`, never by this
36
+ * action. Hosted/serverless deploys (Netlify Functions) do not bundle a Chromium
35
37
  * binary, so this action detects that failure and returns a structured,
36
38
  * model-actionable `{ ok: false, reason }` telling the agent to fall back to
37
39
  * `run-design-audit` instead of surfacing a raw stack trace.
@@ -47,7 +47,7 @@
47
47
  "zod": "^4.3.6"
48
48
  },
49
49
  "devDependencies": {
50
- "@playwright/test": "^1.61.1",
50
+ "@playwright/test": "catalog:",
51
51
  "@radix-ui/react-accordion": "^1.2.12",
52
52
  "@radix-ui/react-alert-dialog": "^1.1.15",
53
53
  "@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -15,13 +15,17 @@ export type PlaywrightModule = {
15
15
  };
16
16
 
17
17
  /**
18
- * Dynamic import of a real Chromium-capable Playwright package. Tries the
19
- * bare `"playwright"` package first (present when `@agent-native/core`'s
20
- * optional dependency resolved), then falls back to `@playwright/test` (a
18
+ * Dynamic import of a real Chromium-capable Playwright package. Tries the bare
19
+ * `"playwright"` package first (supplied as an optional dependency of
20
+ * `@agent-native/creative-context`), then falls back to `@playwright/test` (a
21
21
  * direct devDependency of this template, used by its own e2e suite, which
22
22
  * re-exports the same chromium/Browser API). Loaded via a non-literal
23
23
  * specifier so bundlers don't try to statically resolve/include it — it's
24
24
  * optional and can be entirely absent (e.g. in a hosted deploy).
25
+ *
26
+ * When both are absent the FIRST error is what callers need: reporting the
27
+ * fallback's "Cannot find package '@playwright/test'" names a package the user
28
+ * never asked for and sends them installing the wrong thing.
25
29
  */
26
30
  export async function importPlaywright(): Promise<PlaywrightModule> {
27
31
  try {
@@ -29,8 +33,12 @@ export async function importPlaywright(): Promise<PlaywrightModule> {
29
33
  return (await import(
30
34
  /* @vite-ignore */ specifier
31
35
  )) as unknown as PlaywrightModule;
32
- } catch {
33
- return (await import("@playwright/test")) as unknown as PlaywrightModule;
36
+ } catch (playwrightErr) {
37
+ try {
38
+ return (await import("@playwright/test")) as unknown as PlaywrightModule;
39
+ } catch {
40
+ throw playwrightErr;
41
+ }
34
42
  }
35
43
  }
36
44
 
@@ -1,8 +1,10 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { getRequestUserEmail } from "@agent-native/core/server";
3
- import { getSetting, getUserSetting } from "@agent-native/core/settings";
3
+ import { getUserSetting } from "@agent-native/core/settings";
4
4
  import { z } from "zod";
5
5
 
6
+ import { resolveAutomationModelSettings } from "../server/lib/automation-model.js";
7
+
6
8
  export default defineAction({
7
9
  description:
8
10
  "Read the engine and model used to evaluate inbox automation rules, falling back to the app's configured agent engine.",
@@ -17,16 +19,6 @@ export default defineAction({
17
19
  engine?: string;
18
20
  model?: string;
19
21
  } | null;
20
- const agentEngine = (await getSetting("agent-engine").catch(
21
- () => null,
22
- )) as {
23
- engine?: string;
24
- model?: string;
25
- } | null;
26
-
27
- return {
28
- engine: data?.engine || agentEngine?.engine || "anthropic",
29
- model: data?.model || agentEngine?.model || "claude-haiku-4-5-20251001",
30
- };
22
+ return resolveAutomationModelSettings(ownerEmail, data);
31
23
  },
32
24
  });
@@ -906,7 +906,7 @@ function AutomationsSection() {
906
906
  { method: "GET" },
907
907
  );
908
908
  } catch {
909
- return { engine: "anthropic", model: defaultModel };
909
+ return { model: defaultModel };
910
910
  }
911
911
  },
912
912
  staleTime: 30_000,
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-03
4
+ ---
5
+
6
+ Inbox automations now prefer the lowest-cost Luna model when a Luna-capable provider is available.