@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
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Send the monthly recap for a real account using live Clips data.
3
+ *
4
+ * Talks to the database directly with a read-only driver instead of booting
5
+ * the app, so it cannot run a migration against a shared database. Copy is
6
+ * composed exactly as the worker composes it, so this sends the real thing.
7
+ *
8
+ * npx tsx scripts/send-real-recap.ts --owner a@b.com --to a@b.com --month 2026-07
9
+ */
10
+
11
+ import postgres from "postgres";
12
+
13
+ import {
14
+ composeRecapCopy,
15
+ sendClipsTransactionalEmail,
16
+ } from "../server/lib/transactional-email-templates.js";
17
+
18
+ function arg(args: string[], name: string): string | undefined {
19
+ const index = args.indexOf(`--${name}`);
20
+ return index >= 0 ? args[index + 1] : undefined;
21
+ }
22
+
23
+ function required(args: string[], name: string): string {
24
+ const value = arg(args, name);
25
+ if (!value) throw new Error(`--${name} is required`);
26
+ return value;
27
+ }
28
+
29
+ async function main(args: string[]): Promise<void> {
30
+ const owner = required(args, "owner");
31
+ const to = required(args, "to");
32
+ const month = required(args, "month");
33
+ const databaseUrl = process.env.CLIPS_DATABASE_URL;
34
+ if (!databaseUrl) throw new Error("CLIPS_DATABASE_URL is not set");
35
+
36
+ const [year, monthNumber] = month.split("-").map(Number);
37
+ const startAt = new Date(Date.UTC(year, monthNumber - 1, 1)).toISOString();
38
+ const endAt = new Date(Date.UTC(year, monthNumber, 1)).toISOString();
39
+
40
+ const sql = postgres(databaseUrl, { ssl: "require", max: 1 });
41
+ try {
42
+ const owned = await sql<
43
+ {
44
+ id: string;
45
+ title: string;
46
+ thumbnail_url: string | null;
47
+ duration_ms: number;
48
+ created_at: string;
49
+ }[]
50
+ >`select id, title, thumbnail_url, duration_ms, created_at
51
+ from recordings where lower(owner_email) = lower(${owner})`;
52
+ if (owned.length === 0) throw new Error(`${owner} owns no recordings`);
53
+ const ids = owned.map((row) => row.id);
54
+
55
+ const humans = await sql<{ recording_id: string; n: number }[]>`
56
+ select recording_id, count(*)::int n
57
+ from recording_views
58
+ where recording_id in ${sql(ids)}
59
+ and viewed_at >= ${startAt} and viewed_at < ${endAt}
60
+ group by recording_id`;
61
+ const agents = await sql<{ recording_id: string; n: number }[]>`
62
+ select recording_id, count(*)::int n
63
+ from recording_agent_views
64
+ where recording_id in ${sql(ids)}
65
+ and first_seen_at >= ${startAt} and first_seen_at < ${endAt}
66
+ group by recording_id`;
67
+
68
+ const humanByClip = new Map(humans.map((row) => [row.recording_id, row.n]));
69
+ const agentByClip = new Map(agents.map((row) => [row.recording_id, row.n]));
70
+ const humanViews = [...humanByClip.values()].reduce((a, b) => a + b, 0);
71
+ const agentSessions = [...agentByClip.values()].reduce((a, b) => a + b, 0);
72
+ if (humanViews === 0 && agentSessions === 0) {
73
+ throw new Error(`${owner} had no audience in ${month}; no recap is due`);
74
+ }
75
+
76
+ const top = owned
77
+ .filter((row) => humanByClip.has(row.id) || agentByClip.has(row.id))
78
+ .map((row) => ({
79
+ row,
80
+ audience:
81
+ (humanByClip.get(row.id) ?? 0) + (agentByClip.get(row.id) ?? 0),
82
+ }))
83
+ .sort(
84
+ (left, right) =>
85
+ right.audience - left.audience ||
86
+ right.row.created_at.localeCompare(left.row.created_at) ||
87
+ left.row.id.localeCompare(right.row.id),
88
+ )[0].row;
89
+
90
+ const monthViewerIds = (
91
+ await sql<{ viewer_id: string }[]>`
92
+ select distinct viewer_id from recording_views
93
+ where recording_id = ${top.id}
94
+ and viewed_at >= ${startAt} and viewed_at < ${endAt}`
95
+ ).map((row) => row.viewer_id);
96
+ let completedPct = 0;
97
+ let dropOffMs: number | null = null;
98
+ if (monthViewerIds.length > 0) {
99
+ const [completion] = await sql<{ mean: string | null }[]>`
100
+ select avg(completed_pct) mean from recording_viewers
101
+ where recording_id = ${top.id} and id in ${sql(monthViewerIds)}`;
102
+ completedPct = Math.round(Number(completion?.mean ?? 0));
103
+ const [progress] = await sql<{ timestamp_ms: number }[]>`
104
+ select timestamp_ms from recording_events
105
+ where recording_id = ${top.id} and viewer_id in ${sql(monthViewerIds)}
106
+ and kind in ('watch-progress', 'pause')
107
+ and created_at >= ${startAt} and created_at < ${endAt}
108
+ order by timestamp_ms desc limit 1`;
109
+ dropOffMs = progress ? Number(progress.timestamp_ms) : null;
110
+ }
111
+ const agentBreakdown = (
112
+ await sql<{ agent_label: string | null; n: number }[]>`
113
+ select agent_label, count(*)::int n from recording_agent_views
114
+ where recording_id = ${top.id}
115
+ and first_seen_at >= ${startAt} and first_seen_at < ${endAt}
116
+ group by agent_label order by n desc, agent_label asc`
117
+ ).map((row) => ({
118
+ // "Agent" is the agent-views fallback label, not a product name.
119
+ agentLabel: row.agent_label === "Agent" ? null : row.agent_label,
120
+ sessions: row.n,
121
+ }));
122
+
123
+ console.log(
124
+ `Account totals for ${month}: ${humanViews} human views, ${agentSessions} agent sessions`,
125
+ );
126
+ console.log(
127
+ `Top clip: ${top.title} (${humanByClip.get(top.id) ?? 0} human / ${agentByClip.get(top.id) ?? 0} agent)`,
128
+ );
129
+
130
+ await sendClipsTransactionalEmail({
131
+ kind: "monthly-recap",
132
+ to,
133
+ month,
134
+ humanViews,
135
+ agentSessions,
136
+ topClip: {
137
+ recordingId: top.id,
138
+ title: top.title,
139
+ thumbnailUrl: top.thumbnail_url,
140
+ durationMs: Number(top.duration_ms),
141
+ recordedAt: top.created_at,
142
+ humanViews: humanByClip.get(top.id) ?? 0,
143
+ agentSessions: agentByClip.get(top.id) ?? 0,
144
+ },
145
+ copy: composeRecapCopy({
146
+ humanViews,
147
+ agentSessions,
148
+ topClip: {
149
+ humanViews: humanByClip.get(top.id) ?? 0,
150
+ completedPct,
151
+ dropOffMs,
152
+ agentBreakdown,
153
+ },
154
+ }),
155
+ });
156
+ console.log(`Sent monthly-recap for ${owner} to ${to}`);
157
+ } finally {
158
+ await sql.end();
159
+ }
160
+ }
161
+
162
+ main(process.argv.slice(2)).catch((error) => {
163
+ console.error(error instanceof Error ? error.message : error);
164
+ process.exitCode = 1;
165
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * One-off: send the two new Clips transactional emails to a real inbox so the
3
+ * rendering can be eyeballed in a mail client. Sample data only — this never
4
+ * touches the job queue or real recordings.
5
+ *
6
+ * pnpm script send-test-emails --to someone@example.com
7
+ */
8
+
9
+ import { getEmailProvider, isEmailConfigured } from "@agent-native/core/server";
10
+
11
+ import { sendClipsTransactionalEmail } from "../server/lib/transactional-email-templates.js";
12
+
13
+ function argValue(args: string[], name: string): string | undefined {
14
+ const index = args.indexOf(`--${name}`);
15
+ return index >= 0 ? args[index + 1] : undefined;
16
+ }
17
+
18
+ export default async function main(args: string[]): Promise<void> {
19
+ const to = argValue(args, "to");
20
+ if (!to) throw new Error("--to <email> is required");
21
+
22
+ if (!(await isEmailConfigured())) {
23
+ throw new Error(
24
+ "No email provider configured; set SENDGRID_API_KEY or RESEND_API_KEY.",
25
+ );
26
+ }
27
+ console.log(`Provider: ${await getEmailProvider()}`);
28
+
29
+ await sendClipsTransactionalEmail({
30
+ kind: "first-agent-view",
31
+ to,
32
+ recordingId: "demo-agent-view",
33
+ title: "Deploy walkthrough",
34
+ agentName: "Claude",
35
+ });
36
+ console.log(`Sent first-agent-view to ${to}`);
37
+
38
+ await sendClipsTransactionalEmail({
39
+ kind: "monthly-recap",
40
+ to,
41
+ month: "2026-07",
42
+ humanViews: 9,
43
+ agentSessions: 4,
44
+ topClip: {
45
+ recordingId: "demo-recap-top",
46
+ title: "Deploy walkthrough",
47
+ thumbnailUrl: null,
48
+ durationMs: 252_000,
49
+ recordedAt: "2026-07-12T00:00:00.000Z",
50
+ humanViews: 9,
51
+ agentSessions: 4,
52
+ },
53
+ copy: {
54
+ heroLine: "9 people watched your clip. 4 agents read it.",
55
+ agentBreakdown: "3 from Claude · 1 from ChatGPT",
56
+ completionNote: "71% average completion · most stopped at 4:12",
57
+ },
58
+ });
59
+ console.log(`Sent monthly-recap to ${to}`);
60
+ }
@@ -414,7 +414,11 @@ export const recordingAgentViews = table(
414
414
  recordingId: text("recording_id").notNull(),
415
415
  // sha256 of user-agent + request IP. Never stores the raw IP.
416
416
  agentKey: text("agent_key").notNull(),
417
+ // Null when nothing named the agent — distinct from a self-declared name.
417
418
  agentLabel: text("agent_label"),
419
+ // Raw (truncated) user-agent, kept so an unnamed agent stays identifiable
420
+ // and new AGENT_LABELS patterns come from real traffic, not guesses.
421
+ userAgent: text("user_agent"),
418
422
  // Time bucket that collapses one agent's burst of context/transcript/frame
419
423
  // polls into a single view.
420
424
  viewSessionId: text("view_session_id").notNull(),
@@ -18,6 +18,13 @@ import {
18
18
  } from "drizzle-orm";
19
19
 
20
20
  import { getDb, schema } from "../db/index.js";
21
+ import {
22
+ computeMonthlyRecap,
23
+ listOwnersWithMonthlyAudience,
24
+ previousRecapMonth,
25
+ recapMonthRange,
26
+ type MonthlyRecap,
27
+ } from "../lib/recap-metrics.js";
21
28
  import { ownerEmailMatches } from "../lib/recordings.js";
22
29
  import {
23
30
  AI_DISPATCH_STALE_MS,
@@ -26,6 +33,7 @@ import {
26
33
  type TransactionalEmailJob,
27
34
  } from "../lib/transactional-email-store.js";
28
35
  import {
36
+ composeRecapCopy,
29
37
  sendClipsTransactionalEmail,
30
38
  type ClipsTransactionalEmailInput,
31
39
  } from "../lib/transactional-email-templates.js";
@@ -36,6 +44,7 @@ const RECONCILIATION_WRITE_CONCURRENCY = 8;
36
44
  const RECIPIENT_SHARE_BATCH_SIZE = 2;
37
45
  const DELIVERY_BATCH_SIZE = 25;
38
46
  const REMINDER_DELAY_MS = 48 * 60 * 60 * 1000;
47
+ const RECAP_SEND_HOUR_UTC = 14;
39
48
  const SENDING_LEASE_MS = 2 * 60 * 1000;
40
49
  const MAX_ATTEMPTS = 3;
41
50
  const RETRY_BASE_DELAY_MS = 60_000;
@@ -73,6 +82,17 @@ type FirstViewCandidate = CountedView & {
73
82
  ownerEmail: string;
74
83
  };
75
84
 
85
+ type AgentView = {
86
+ id: string;
87
+ recordingId: string;
88
+ agentLabel: string | null;
89
+ firstSeenAt: string;
90
+ };
91
+
92
+ type AgentViewCandidate = AgentView & {
93
+ ownerEmail: string;
94
+ };
95
+
76
96
  type ReconciliationCursor = {
77
97
  createdAt: string;
78
98
  id: string;
@@ -109,6 +129,7 @@ export type TransactionalEmailStore = Pick<
109
129
  | "transitionSending"
110
130
  | "updateReconciliationCursor"
111
131
  | "enqueueOrConvergeFirstImport"
132
+ | "readJob"
112
133
  >;
113
134
 
114
135
  export interface TransactionalEmailRepository {
@@ -133,6 +154,15 @@ export interface TransactionalEmailRepository {
133
154
  cursor: ReconciliationCursor | null,
134
155
  limit: number,
135
156
  ): Promise<FirstViewCandidate[]>;
157
+ listAgentViews(
158
+ enabledAt: string,
159
+ cursor: ReconciliationCursor | null,
160
+ limit: number,
161
+ ): Promise<AgentViewCandidate[]>;
162
+ getFirstOwnerAgentView(
163
+ ownerEmail: string,
164
+ enabledAt: string,
165
+ ): Promise<AgentView | null>;
136
166
  listReadyImports(
137
167
  enabledAt: string,
138
168
  cursor: ReconciliationCursor | null,
@@ -164,6 +194,11 @@ export interface TransactionalEmailRepository {
164
194
  recipient: string,
165
195
  enabledAt: string,
166
196
  ): Promise<boolean>;
197
+ listOwnersWithMonthlyAudience(month: string): Promise<string[]>;
198
+ computeMonthlyRecap(
199
+ ownerEmail: string,
200
+ month: string,
201
+ ): Promise<MonthlyRecap | null>;
167
202
  }
168
203
 
169
204
  export interface TransactionalEmailWorkerDependencies {
@@ -372,6 +407,69 @@ function defaultRepository(): TransactionalEmailRepository {
372
407
  )
373
408
  .limit(limit);
374
409
  },
410
+ async listAgentViews(enabledAt, cursor, limit) {
411
+ return db
412
+ .select({
413
+ id: schema.recordingAgentViews.id,
414
+ recordingId: schema.recordingAgentViews.recordingId,
415
+ agentLabel: schema.recordingAgentViews.agentLabel,
416
+ firstSeenAt: schema.recordingAgentViews.firstSeenAt,
417
+ ownerEmail: schema.recordings.ownerEmail,
418
+ })
419
+ .from(schema.recordingAgentViews)
420
+ .innerJoin(
421
+ schema.recordings,
422
+ eq(schema.recordings.id, schema.recordingAgentViews.recordingId),
423
+ )
424
+ .where(
425
+ and(
426
+ gte(schema.recordingAgentViews.firstSeenAt, enabledAt),
427
+ cursor
428
+ ? or(
429
+ gt(schema.recordingAgentViews.firstSeenAt, cursor.createdAt),
430
+ and(
431
+ eq(
432
+ schema.recordingAgentViews.firstSeenAt,
433
+ cursor.createdAt,
434
+ ),
435
+ gt(schema.recordingAgentViews.id, cursor.id),
436
+ ),
437
+ )
438
+ : undefined,
439
+ ),
440
+ )
441
+ .orderBy(
442
+ asc(schema.recordingAgentViews.firstSeenAt),
443
+ asc(schema.recordingAgentViews.id),
444
+ )
445
+ .limit(limit);
446
+ },
447
+ async getFirstOwnerAgentView(ownerEmail, enabledAt) {
448
+ const [view] = await db
449
+ .select({
450
+ id: schema.recordingAgentViews.id,
451
+ recordingId: schema.recordingAgentViews.recordingId,
452
+ agentLabel: schema.recordingAgentViews.agentLabel,
453
+ firstSeenAt: schema.recordingAgentViews.firstSeenAt,
454
+ })
455
+ .from(schema.recordingAgentViews)
456
+ .innerJoin(
457
+ schema.recordings,
458
+ eq(schema.recordings.id, schema.recordingAgentViews.recordingId),
459
+ )
460
+ .where(
461
+ and(
462
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
463
+ gte(schema.recordingAgentViews.firstSeenAt, enabledAt),
464
+ ),
465
+ )
466
+ .orderBy(
467
+ asc(schema.recordingAgentViews.firstSeenAt),
468
+ asc(schema.recordingAgentViews.id),
469
+ )
470
+ .limit(1);
471
+ return view ?? null;
472
+ },
375
473
  async listReadyImports(enabledAt, cursor, limit) {
376
474
  return db
377
475
  .select({
@@ -524,6 +622,8 @@ function defaultRepository(): TransactionalEmailRepository {
524
622
  .limit(1);
525
623
  return view ?? null;
526
624
  },
625
+ listOwnersWithMonthlyAudience,
626
+ computeMonthlyRecap,
527
627
  async isFirstImport(recording, recipient, enabledAt) {
528
628
  if (!isImportedRecording(recording)) return false;
529
629
  const imports = await db
@@ -700,6 +800,41 @@ async function reconcileFirstViews(
700
800
  };
701
801
  }
702
802
 
803
+ async function reconcileFirstAgentViews(
804
+ repository: TransactionalEmailRepository,
805
+ store: TransactionalEmailStore,
806
+ enabledAt: string,
807
+ cursor: ReconciliationCursor | null,
808
+ limit: number,
809
+ ): Promise<{ enqueued: number; nextCursor: ReconciliationCursor | null }> {
810
+ const page = await repository.listAgentViews(enabledAt, cursor, limit);
811
+ let enqueued = 0;
812
+ for (const candidate of page) {
813
+ const ownerEmail = normalizedEmail(candidate.ownerEmail);
814
+ if (!ownerEmail || isSuppressedTransactionalRecipient(ownerEmail)) continue;
815
+ const firstAgentView = await repository.getFirstOwnerAgentView(
816
+ ownerEmail,
817
+ enabledAt,
818
+ );
819
+ if (!firstAgentView || firstAgentView.id !== candidate.id) continue;
820
+ const result = await store.enqueue(`first-agent-view:${ownerEmail}`, {
821
+ type: "first-agent-view",
822
+ recipient: ownerEmail,
823
+ recordingIds: [candidate.recordingId],
824
+ requestedBy: ownerEmail,
825
+ });
826
+ if (result.created) enqueued += 1;
827
+ }
828
+ const lastView = page[page.length - 1];
829
+ return {
830
+ enqueued,
831
+ nextCursor:
832
+ page.length >= limit && lastView
833
+ ? { createdAt: lastView.firstSeenAt, id: lastView.id }
834
+ : null,
835
+ };
836
+ }
837
+
703
838
  async function reconcileFirstImports(
704
839
  repository: TransactionalEmailRepository,
705
840
  store: TransactionalEmailStore,
@@ -732,6 +867,49 @@ async function reconcileFirstImports(
732
867
  };
733
868
  }
734
869
 
870
+ /**
871
+ * Recaps close on the UTC month boundary but wait until `RECAP_SEND_HOUR_UTC`
872
+ * on the 1st so they land mid-morning in the Americas rather than at midnight.
873
+ * A month is only ever enqueued once per owner, so a late first run of the day
874
+ * still sends rather than skipping the month.
875
+ */
876
+ async function reconcileMonthlyRecaps(
877
+ repository: TransactionalEmailRepository,
878
+ store: TransactionalEmailStore,
879
+ enabledAt: string,
880
+ now: Date,
881
+ ): Promise<number> {
882
+ if (now.getUTCHours() < RECAP_SEND_HOUR_UTC) return 0;
883
+ const month = previousRecapMonth(now);
884
+ // Never recap a month that closed before transactional email was switched
885
+ // on. A month still open at that point does get a recap: the audience it
886
+ // reports is the owner's own, and skipping it would cost them a full month.
887
+ if (recapMonthRange(month).endAt <= enabledAt) return 0;
888
+
889
+ let enqueued = 0;
890
+ for (const ownerEmail of await repository.listOwnersWithMonthlyAudience(
891
+ month,
892
+ )) {
893
+ const recipient = normalizedEmail(ownerEmail);
894
+ if (!recipient || isSuppressedTransactionalRecipient(recipient)) continue;
895
+ const logicalKey = `monthly-recap:${recipient}:${month}`;
896
+ // Checked before the analytics work: this pass reruns every minute for the
897
+ // rest of the month, and recomputing a recap already queued is pure waste.
898
+ if (await store.readJob(logicalKey)) continue;
899
+ const recap = await repository.computeMonthlyRecap(recipient, month);
900
+ if (!recap) continue;
901
+ const result = await store.enqueue(logicalKey, {
902
+ type: "monthly-recap",
903
+ recipient,
904
+ recordingIds: [recap.topClip.recordingId],
905
+ requestedBy: recipient,
906
+ month,
907
+ });
908
+ if (result.created) enqueued += 1;
909
+ }
910
+ return enqueued;
911
+ }
912
+
735
913
  async function makeSendInput(
736
914
  job: TransactionalEmailJob,
737
915
  repository: TransactionalEmailRepository,
@@ -739,6 +917,45 @@ async function makeSendInput(
739
917
  ): Promise<ClipsTransactionalEmailInput | null> {
740
918
  const recipient = normalizedEmail(job.recipient);
741
919
  if (!recipient || isSuppressedTransactionalRecipient(recipient)) return null;
920
+
921
+ if (job.type === "monthly-recap") {
922
+ // Ranked again at send time instead of trusting the queued clip: a month
923
+ // whose top clip was trashed or overtaken still deserves its recap, and
924
+ // the analytics already exclude clips the owner can no longer open.
925
+ if (!job.month) return null;
926
+ const recap = await repository.computeMonthlyRecap(recipient, job.month);
927
+ if (!recap) return null;
928
+ return {
929
+ kind: "monthly-recap",
930
+ to: recipient,
931
+ month: job.month,
932
+ humanViews: recap.humanViews,
933
+ agentSessions: recap.agentSessions,
934
+ topClip: {
935
+ recordingId: recap.topClip.recordingId,
936
+ title: recap.topClip.title,
937
+ thumbnailUrl: recap.topClip.thumbnailUrl,
938
+ durationMs: recap.topClip.durationMs,
939
+ recordedAt: recap.topClip.recordedAt,
940
+ humanViews: recap.topClip.humanViews,
941
+ agentSessions: recap.topClip.agentSessions,
942
+ },
943
+ copy: composeRecapCopy({
944
+ humanViews: recap.humanViews,
945
+ agentSessions: recap.agentSessions,
946
+ topClip: {
947
+ humanViews: recap.topClip.humanViews,
948
+ completedPct: recap.topClip.completedPct,
949
+ dropOffMs: recap.topClip.dropOffMs,
950
+ agentBreakdown: recap.topClip.agentBreakdown.map((entry) => ({
951
+ agentLabel: entry.agentLabel,
952
+ sessions: entry.sessions,
953
+ })),
954
+ },
955
+ }),
956
+ };
957
+ }
958
+
742
959
  const recordings = await Promise.all(
743
960
  job.recordingIds.map((recordingId) => repository.getRecording(recordingId)),
744
961
  );
@@ -788,6 +1005,24 @@ async function makeSendInput(
788
1005
  };
789
1006
  }
790
1007
 
1008
+ if (job.type === "first-agent-view") {
1009
+ if (normalizedEmail(recordings[0].ownerEmail) !== recipient) return null;
1010
+ const firstAgentView = await repository.getFirstOwnerAgentView(
1011
+ recipient,
1012
+ enabledAt,
1013
+ );
1014
+ if (!firstAgentView || firstAgentView.recordingId !== recordings[0].id) {
1015
+ return null;
1016
+ }
1017
+ return {
1018
+ kind: "first-agent-view",
1019
+ to: recipient,
1020
+ recordingId: recordings[0].id,
1021
+ title: recordings[0].title,
1022
+ agentName: firstAgentView.agentLabel,
1023
+ };
1024
+ }
1025
+
791
1026
  if (job.type === "first-import") {
792
1027
  if (
793
1028
  normalizedEmail(recordings[0].ownerEmail) !== recipient ||
@@ -889,6 +1124,18 @@ export async function runTransactionalEmailsOnce(
889
1124
  "firstViewCursor",
890
1125
  firstViews.nextCursor,
891
1126
  );
1127
+ const firstAgentViews = await reconcileFirstAgentViews(
1128
+ repository,
1129
+ store,
1130
+ config.enabledAt,
1131
+ config.firstAgentViewCursor ?? null,
1132
+ reconciliationLimit,
1133
+ );
1134
+ result.enqueued += firstAgentViews.enqueued;
1135
+ await store.updateReconciliationCursor(
1136
+ "firstAgentViewCursor",
1137
+ firstAgentViews.nextCursor,
1138
+ );
892
1139
  const firstImports = await reconcileFirstImports(
893
1140
  repository,
894
1141
  store,
@@ -902,6 +1149,13 @@ export async function runTransactionalEmailsOnce(
902
1149
  firstImports.nextCursor,
903
1150
  );
904
1151
 
1152
+ result.enqueued += await reconcileMonthlyRecaps(
1153
+ repository,
1154
+ store,
1155
+ config.enabledAt,
1156
+ currentTime,
1157
+ );
1158
+
905
1159
  const jobs = await store.listJobs();
906
1160
  const warn = dependencies.warn ?? console.warn;
907
1161
  for (const job of jobs) {
@@ -921,7 +1175,9 @@ export async function runTransactionalEmailsOnce(
921
1175
  (job) =>
922
1176
  job.state === "pending" &&
923
1177
  (job.type === "first-view" ||
1178
+ job.type === "first-agent-view" ||
924
1179
  job.type === "first-import" ||
1180
+ job.type === "monthly-recap" ||
925
1181
  job.type === "unviewed-reminder"),
926
1182
  ),
927
1183
  (job) => store.transition(job.logicalKey, ["pending"], "ready"),