@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
@@ -36,13 +36,16 @@ const AGENT_LABELS: [RegExp, string][] = [
36
36
  [/bytespider|amazonbot|ccbot|diffbot|youbot/i, "Crawler"],
37
37
  ];
38
38
 
39
- export const UNKNOWN_AGENT_LABEL = "Agent";
40
-
41
- export function agentLabelFromUserAgent(userAgent: string): string {
39
+ /**
40
+ * Null, not a placeholder string: an agent we could not name is a different
41
+ * fact from one that names itself "Agent", and only the null case should be
42
+ * rendered as unknown or fed back into {@link AGENT_LABELS}.
43
+ */
44
+ export function agentLabelFromUserAgent(userAgent: string): string | null {
42
45
  for (const [pattern, label] of AGENT_LABELS) {
43
46
  if (pattern.test(userAgent)) return label;
44
47
  }
45
- return UNKNOWN_AGENT_LABEL;
48
+ return null;
46
49
  }
47
50
 
48
51
  export function agentViewSessionId(
@@ -66,15 +69,20 @@ export function agentKeyFor(userAgent: string, ip: string): string {
66
69
  export async function recordAgentView(
67
70
  event: H3Event,
68
71
  recordingId: string,
69
- nowMs: number = Date.now(),
72
+ options: { agentLabel?: string | null; nowMs?: number } = {},
70
73
  ): Promise<void> {
71
74
  try {
75
+ const nowMs = options.nowMs ?? Date.now();
72
76
  const userAgent = (getRequestHeader(event, "user-agent") ?? "").slice(
73
77
  0,
74
78
  512,
75
79
  );
76
80
  const ip = getRequestIP(event) || "unknown";
77
81
  const now = new Date(nowMs).toISOString();
82
+ // A label the link was minted with beats user-agent sniffing: it was set by
83
+ // whoever created the agent link and is carried in the signed token.
84
+ const agentLabel =
85
+ options.agentLabel?.trim() || agentLabelFromUserAgent(userAgent);
78
86
 
79
87
  await getDb()
80
88
  .insert(schema.recordingAgentViews)
@@ -82,7 +90,8 @@ export async function recordAgentView(
82
90
  id: nanoid(),
83
91
  recordingId,
84
92
  agentKey: agentKeyFor(userAgent, ip),
85
- agentLabel: agentLabelFromUserAgent(userAgent),
93
+ agentLabel,
94
+ userAgent: userAgent || null,
86
95
  viewSessionId: agentViewSessionId(nowMs),
87
96
  firstSeenAt: now,
88
97
  lastSeenAt: now,
@@ -96,6 +105,10 @@ export async function recordAgentView(
96
105
  ],
97
106
  set: {
98
107
  lastSeenAt: now,
108
+ // A later poll in the same session can arrive without the token that
109
+ // carried the name (public clips are readable without one), so an
110
+ // absent label must never erase a stored one — a new name still wins.
111
+ agentLabel: sql`COALESCE(excluded.agent_label, ${schema.recordingAgentViews.agentLabel})`,
99
112
  requestCount: sql`${schema.recordingAgentViews.requestCount} + 1`,
100
113
  },
101
114
  });
@@ -115,30 +128,44 @@ export async function countRecordingAgentViews(
115
128
  }
116
129
 
117
130
  export interface AgentViewerSummary {
118
- agentLabel: string;
131
+ /** Null when nothing named the agent — render it as unknown, don't invent one. */
132
+ agentLabel: string | null;
133
+ /** Raw user-agent, so an unnamed agent is still identifiable by a human. */
134
+ userAgent: string | null;
119
135
  views: number;
120
136
  lastSeenAt: string;
121
137
  }
122
138
 
123
- /** Agents that read this clip, most recent first, grouped by product label. */
139
+ /**
140
+ * Agents that read this clip, most recent first. Named agents group by label;
141
+ * unnamed ones group by user-agent so two different unknown readers stay two
142
+ * rows instead of collapsing into one meaningless "unknown" bucket.
143
+ */
124
144
  export async function listRecordingAgentViewers(
125
145
  recordingId: string,
126
146
  limit = 8,
127
147
  ): Promise<AgentViewerSummary[]> {
148
+ const groupKey = sql<string>`COALESCE(${schema.recordingAgentViews.agentLabel}, ${schema.recordingAgentViews.userAgent}, '')`;
128
149
  const rows = await getDb()
129
150
  .select({
130
- agentLabel: schema.recordingAgentViews.agentLabel,
151
+ agentLabel: sql<
152
+ string | null
153
+ >`MAX(${schema.recordingAgentViews.agentLabel})`,
154
+ userAgent: sql<
155
+ string | null
156
+ >`MAX(${schema.recordingAgentViews.userAgent})`,
131
157
  views: count(),
132
158
  lastSeenAt: sql<string>`MAX(${schema.recordingAgentViews.lastSeenAt})`,
133
159
  })
134
160
  .from(schema.recordingAgentViews)
135
161
  .where(eq(schema.recordingAgentViews.recordingId, recordingId))
136
- .groupBy(schema.recordingAgentViews.agentLabel)
162
+ .groupBy(groupKey)
137
163
  .orderBy(desc(sql`MAX(${schema.recordingAgentViews.lastSeenAt})`))
138
164
  .limit(limit);
139
165
 
140
166
  return rows.map((r) => ({
141
- agentLabel: r.agentLabel || UNKNOWN_AGENT_LABEL,
167
+ agentLabel: r.agentLabel || null,
168
+ userAgent: r.userAgent || null,
142
169
  views: Number(r.views ?? 0),
143
170
  lastSeenAt: r.lastSeenAt,
144
171
  }));
@@ -0,0 +1,16 @@
1
+ export function allowsLegacyS3ObjectForPersistedMedia(params: {
2
+ requestedUrl: string;
3
+ persistedUrl: string | null | undefined;
4
+ editsJson: string | null | undefined;
5
+ }): boolean {
6
+ if (!params.persistedUrl || params.persistedUrl !== params.requestedUrl) {
7
+ return false;
8
+ }
9
+ try {
10
+ const edits = JSON.parse(params.editsJson || "{}");
11
+ return edits?.mediaStorageLayout !== "external";
12
+ // coercion-ok: malformed provenance is an explicit deny, never successful authorization.
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
@@ -294,7 +294,9 @@ export async function loadPublicAgentAccess(
294
294
  // Every agent API route funnels through here, so this is the one place that
295
295
  // sees an outside agent read a clip. Owner requests are previews, not views.
296
296
  if (!viewerIsOwner) {
297
- await recordAgentView(event, recording.id);
297
+ await recordAgentView(event, recording.id, {
298
+ agentLabel: tokenAccess?.ok ? tokenAccess.agentLabel : null,
299
+ });
298
300
  }
299
301
 
300
302
  return {
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Monthly recap metrics — what an owner's clips did over one calendar month.
3
+ *
4
+ * Human and agent audiences are counted from separate tables on purpose
5
+ * (`recording_views` vs `recording_agent_views`), matching the split described
6
+ * in `agent-views.ts`: no human-view query can ever pick agents up.
7
+ *
8
+ * Months are closed on UTC boundaries. Per-user timezones are not yet stored,
9
+ * so a recap covers the same wall-clock window for everyone.
10
+ */
11
+
12
+ import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm";
13
+
14
+ import { getDb, schema } from "../db/index.js";
15
+ import { ownerEmailMatches } from "./recordings.js";
16
+
17
+ export interface RecapMonthRange {
18
+ month: string;
19
+ startAt: string;
20
+ endAt: string;
21
+ }
22
+
23
+ export interface RecapAgentBreakdownEntry {
24
+ /** Null when the reader could not be identified. */
25
+ agentLabel: string | null;
26
+ sessions: number;
27
+ }
28
+
29
+ export interface RecapTopClip {
30
+ recordingId: string;
31
+ title: string;
32
+ thumbnailUrl: string | null;
33
+ durationMs: number;
34
+ recordedAt: string;
35
+ humanViews: number;
36
+ agentSessions: number;
37
+ /** Mean completion across the humans who watched it this month, 0-100. */
38
+ completedPct: number;
39
+ /** Video-time position where watching last stopped, or null if unreported. */
40
+ dropOffMs: number | null;
41
+ agentBreakdown: RecapAgentBreakdownEntry[];
42
+ }
43
+
44
+ export interface MonthlyRecap {
45
+ month: string;
46
+ humanViews: number;
47
+ agentSessions: number;
48
+ topClip: RecapTopClip;
49
+ }
50
+
51
+ /** `2026-07` -> the half-open UTC range `[2026-07-01, 2026-08-01)`. */
52
+ export function recapMonthRange(month: string): RecapMonthRange {
53
+ const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(month);
54
+ if (!match) throw new Error(`Invalid recap month: ${month}`);
55
+ const year = Number(match[1]);
56
+ const monthIndex = Number(match[2]) - 1;
57
+ return {
58
+ month,
59
+ startAt: new Date(Date.UTC(year, monthIndex, 1)).toISOString(),
60
+ endAt: new Date(Date.UTC(year, monthIndex + 1, 1)).toISOString(),
61
+ };
62
+ }
63
+
64
+ /** The calendar month immediately before the one containing `now`, in UTC. */
65
+ export function previousRecapMonth(now: Date): string {
66
+ const year = now.getUTCFullYear();
67
+ const monthIndex = now.getUTCMonth();
68
+ const previous = new Date(Date.UTC(year, monthIndex - 1, 1));
69
+ return `${previous.getUTCFullYear()}-${String(previous.getUTCMonth() + 1).padStart(2, "0")}`;
70
+ }
71
+
72
+ export function recapMonthLabel(month: string): string {
73
+ const { startAt } = recapMonthRange(month);
74
+ return new Date(startAt).toLocaleString("en-US", {
75
+ month: "long",
76
+ timeZone: "UTC",
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Trashed, archived, and unfinished clips are excluded so the top clip is
82
+ * always one the recap can link to — the ranking is recomputed at send time,
83
+ * and a clip the owner deleted must not headline it.
84
+ */
85
+ async function ownerRecordingIds(ownerEmail: string): Promise<string[]> {
86
+ const rows = await getDb()
87
+ .select({ id: schema.recordings.id })
88
+ .from(schema.recordings)
89
+ .where(
90
+ and(
91
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
92
+ eq(schema.recordings.status, "ready"),
93
+ isNull(schema.recordings.archivedAt),
94
+ isNull(schema.recordings.trashedAt),
95
+ ),
96
+ );
97
+ return rows.map((row) => row.id);
98
+ }
99
+
100
+ /**
101
+ * Counts view rows, not distinct people. Deduping an identity across a month
102
+ * was both ambiguous — `viewerKey` is nullable on rows predating it — and
103
+ * expensive, so the recap reports human views and leaves unique audience out
104
+ * of scope. Summing these per-recording counts is therefore correct: one
105
+ * person watching two clips is genuinely two views.
106
+ */
107
+ async function humanViewCounts(
108
+ recordingIds: string[],
109
+ range: RecapMonthRange,
110
+ ): Promise<Map<string, number>> {
111
+ if (recordingIds.length === 0) return new Map();
112
+ const rows = await getDb()
113
+ .select({
114
+ recordingId: schema.recordingViews.recordingId,
115
+ views: sql<number>`count(*)`,
116
+ })
117
+ .from(schema.recordingViews)
118
+ .where(
119
+ and(
120
+ inArray(schema.recordingViews.recordingId, recordingIds),
121
+ gte(schema.recordingViews.viewedAt, range.startAt),
122
+ lt(schema.recordingViews.viewedAt, range.endAt),
123
+ ),
124
+ )
125
+ .groupBy(schema.recordingViews.recordingId);
126
+ return new Map(rows.map((row) => [row.recordingId, Number(row.views)]));
127
+ }
128
+
129
+ async function agentSessionCounts(
130
+ recordingIds: string[],
131
+ range: RecapMonthRange,
132
+ ): Promise<Map<string, number>> {
133
+ if (recordingIds.length === 0) return new Map();
134
+ const rows = await getDb()
135
+ .select({
136
+ recordingId: schema.recordingAgentViews.recordingId,
137
+ sessions: sql<number>`count(*)`,
138
+ })
139
+ .from(schema.recordingAgentViews)
140
+ .where(
141
+ and(
142
+ inArray(schema.recordingAgentViews.recordingId, recordingIds),
143
+ gte(schema.recordingAgentViews.firstSeenAt, range.startAt),
144
+ lt(schema.recordingAgentViews.firstSeenAt, range.endAt),
145
+ ),
146
+ )
147
+ .groupBy(schema.recordingAgentViews.recordingId);
148
+ return new Map(rows.map((row) => [row.recordingId, Number(row.sessions)]));
149
+ }
150
+
151
+ async function agentBreakdown(
152
+ recordingId: string,
153
+ range: RecapMonthRange,
154
+ ): Promise<RecapAgentBreakdownEntry[]> {
155
+ const rows = await getDb()
156
+ .select({
157
+ agentLabel: schema.recordingAgentViews.agentLabel,
158
+ sessions: sql<number>`count(*)`,
159
+ })
160
+ .from(schema.recordingAgentViews)
161
+ .where(
162
+ and(
163
+ eq(schema.recordingAgentViews.recordingId, recordingId),
164
+ gte(schema.recordingAgentViews.firstSeenAt, range.startAt),
165
+ lt(schema.recordingAgentViews.firstSeenAt, range.endAt),
166
+ ),
167
+ )
168
+ .groupBy(schema.recordingAgentViews.agentLabel)
169
+ .orderBy(desc(sql`count(*)`), asc(schema.recordingAgentViews.agentLabel));
170
+ return rows.map((row) => ({
171
+ agentLabel: row.agentLabel || null,
172
+ sessions: Number(row.sessions),
173
+ }));
174
+ }
175
+
176
+ /**
177
+ * Mean completion across viewers who watched this month, and the video-time
178
+ * position where watching last stopped.
179
+ *
180
+ * `recording_viewers` holds no per-month completion, so this reflects each
181
+ * viewer's lifetime progress on a clip they watched during the month. A viewer
182
+ * who finished it in June and reopened it in July reports June's completion.
183
+ */
184
+ async function watchDepth(
185
+ recordingId: string,
186
+ range: RecapMonthRange,
187
+ ): Promise<{ completedPct: number; dropOffMs: number | null }> {
188
+ const db = getDb();
189
+ const monthViewerIds = await db
190
+ .selectDistinct({ viewerId: schema.recordingViews.viewerId })
191
+ .from(schema.recordingViews)
192
+ .where(
193
+ and(
194
+ eq(schema.recordingViews.recordingId, recordingId),
195
+ gte(schema.recordingViews.viewedAt, range.startAt),
196
+ lt(schema.recordingViews.viewedAt, range.endAt),
197
+ ),
198
+ );
199
+ const viewerIds = monthViewerIds.map((row) => row.viewerId);
200
+ if (viewerIds.length === 0) return { completedPct: 0, dropOffMs: null };
201
+
202
+ const [completion] = await db
203
+ .select({ mean: sql<number>`avg(${schema.recordingViewers.completedPct})` })
204
+ .from(schema.recordingViewers)
205
+ .where(
206
+ and(
207
+ eq(schema.recordingViewers.recordingId, recordingId),
208
+ inArray(schema.recordingViewers.id, viewerIds),
209
+ ),
210
+ );
211
+
212
+ const [lastProgress] = await db
213
+ .select({ timestampMs: schema.recordingEvents.timestampMs })
214
+ .from(schema.recordingEvents)
215
+ .where(
216
+ and(
217
+ eq(schema.recordingEvents.recordingId, recordingId),
218
+ inArray(schema.recordingEvents.viewerId, viewerIds),
219
+ inArray(schema.recordingEvents.kind, ["watch-progress", "pause"]),
220
+ gte(schema.recordingEvents.createdAt, range.startAt),
221
+ lt(schema.recordingEvents.createdAt, range.endAt),
222
+ ),
223
+ )
224
+ .orderBy(desc(schema.recordingEvents.timestampMs))
225
+ .limit(1);
226
+
227
+ return {
228
+ completedPct: Math.round(Number(completion?.mean ?? 0)),
229
+ dropOffMs: lastProgress ? Number(lastProgress.timestampMs) : null,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Rank by total audience (human views + agent sessions), breaking
235
+ * ties on the more recently recorded clip.
236
+ */
237
+ export function rankTopClip<
238
+ T extends { recordingId: string; audience: number; recordedAt: string },
239
+ >(candidates: readonly T[]): T | null {
240
+ return (
241
+ [...candidates].sort(
242
+ (left, right) =>
243
+ right.audience - left.audience ||
244
+ right.recordedAt.localeCompare(left.recordedAt) ||
245
+ left.recordingId.localeCompare(right.recordingId),
246
+ )[0] ?? null
247
+ );
248
+ }
249
+
250
+ /**
251
+ * Returns null when the owner had no human or agent audience that month, which
252
+ * is also the signal not to send them a recap at all.
253
+ */
254
+ export async function computeMonthlyRecap(
255
+ ownerEmail: string,
256
+ month: string,
257
+ ): Promise<MonthlyRecap | null> {
258
+ const range = recapMonthRange(month);
259
+ const recordingIds = await ownerRecordingIds(ownerEmail);
260
+ if (recordingIds.length === 0) return null;
261
+
262
+ const [humansByRecording, agentsByRecording] = await Promise.all([
263
+ humanViewCounts(recordingIds, range),
264
+ agentSessionCounts(recordingIds, range),
265
+ ]);
266
+
267
+ const humanViews = [...humansByRecording.values()].reduce(
268
+ (total, value) => total + value,
269
+ 0,
270
+ );
271
+ const agentSessions = [...agentsByRecording.values()].reduce(
272
+ (total, value) => total + value,
273
+ 0,
274
+ );
275
+ if (humanViews === 0 && agentSessions === 0) return null;
276
+
277
+ const watchedIds = [
278
+ ...new Set([...humansByRecording.keys(), ...agentsByRecording.keys()]),
279
+ ];
280
+ const watched = await getDb()
281
+ .select({
282
+ id: schema.recordings.id,
283
+ title: schema.recordings.title,
284
+ thumbnailUrl: schema.recordings.thumbnailUrl,
285
+ durationMs: schema.recordings.durationMs,
286
+ createdAt: schema.recordings.createdAt,
287
+ })
288
+ .from(schema.recordings)
289
+ .where(
290
+ and(
291
+ inArray(schema.recordings.id, watchedIds),
292
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
293
+ ),
294
+ );
295
+
296
+ const ranked = rankTopClip(
297
+ watched.map((recording) => ({
298
+ recordingId: recording.id,
299
+ audience:
300
+ (humansByRecording.get(recording.id) ?? 0) +
301
+ (agentsByRecording.get(recording.id) ?? 0),
302
+ recordedAt: recording.createdAt,
303
+ recording,
304
+ })),
305
+ );
306
+ if (!ranked) return null;
307
+
308
+ const [depth, breakdown] = await Promise.all([
309
+ watchDepth(ranked.recordingId, range),
310
+ agentBreakdown(ranked.recordingId, range),
311
+ ]);
312
+
313
+ return {
314
+ month,
315
+ humanViews,
316
+ agentSessions,
317
+ topClip: {
318
+ recordingId: ranked.recordingId,
319
+ title: ranked.recording.title,
320
+ thumbnailUrl: ranked.recording.thumbnailUrl,
321
+ durationMs: ranked.recording.durationMs,
322
+ recordedAt: ranked.recording.createdAt,
323
+ humanViews: humansByRecording.get(ranked.recordingId) ?? 0,
324
+ agentSessions: agentsByRecording.get(ranked.recordingId) ?? 0,
325
+ completedPct: depth.completedPct,
326
+ dropOffMs: depth.dropOffMs,
327
+ agentBreakdown: breakdown,
328
+ },
329
+ };
330
+ }
331
+
332
+ /** Owners with any human or agent audience in the month, for reconciliation. */
333
+ export async function listOwnersWithMonthlyAudience(
334
+ month: string,
335
+ ): Promise<string[]> {
336
+ const range = recapMonthRange(month);
337
+ const db = getDb();
338
+ const [humanOwners, agentOwners] = await Promise.all([
339
+ db
340
+ .selectDistinct({ ownerEmail: schema.recordings.ownerEmail })
341
+ .from(schema.recordingViews)
342
+ .innerJoin(
343
+ schema.recordings,
344
+ eq(schema.recordings.id, schema.recordingViews.recordingId),
345
+ )
346
+ .where(
347
+ and(
348
+ gte(schema.recordingViews.viewedAt, range.startAt),
349
+ lt(schema.recordingViews.viewedAt, range.endAt),
350
+ ),
351
+ ),
352
+ db
353
+ .selectDistinct({ ownerEmail: schema.recordings.ownerEmail })
354
+ .from(schema.recordingAgentViews)
355
+ .innerJoin(
356
+ schema.recordings,
357
+ eq(schema.recordings.id, schema.recordingAgentViews.recordingId),
358
+ )
359
+ .where(
360
+ and(
361
+ gte(schema.recordingAgentViews.firstSeenAt, range.startAt),
362
+ lt(schema.recordingAgentViews.firstSeenAt, range.endAt),
363
+ ),
364
+ ),
365
+ ]);
366
+ return [
367
+ ...new Set(
368
+ [...humanOwners, ...agentOwners]
369
+ .map((row) => row.ownerEmail.trim().toLowerCase())
370
+ .filter(Boolean),
371
+ ),
372
+ ].sort();
373
+ }
@@ -54,14 +54,18 @@ async function fetchWithTimeout(
54
54
  });
55
55
  } catch (err) {
56
56
  if (err instanceof Error && err.name === "TimeoutError") {
57
- throw new Error(
57
+ const timeoutError = new Error(
58
58
  `S3 request timed out after ${timeoutMs}ms: ${init.method ?? "GET"} ${url}`,
59
59
  );
60
+ timeoutError.name = "TimeoutError";
61
+ throw timeoutError;
60
62
  }
61
63
  if (err instanceof Error && err.name === "AbortError") {
62
- throw new Error(
64
+ const abortError = new Error(
63
65
  `S3 request aborted (timeout ${timeoutMs}ms): ${init.method ?? "GET"} ${url}`,
64
66
  );
67
+ abortError.name = "AbortError";
68
+ throw abortError;
65
69
  }
66
70
  throw err;
67
71
  }
@@ -199,6 +203,7 @@ async function signedS3Request(
199
203
  query?: Record<string, string>;
200
204
  body?: Uint8Array;
201
205
  contentType?: string;
206
+ range?: string;
202
207
  timeoutMs: number;
203
208
  },
204
209
  ): Promise<Response> {
@@ -265,6 +270,9 @@ async function signedS3Request(
265
270
  ...(options.body
266
271
  ? { "Content-Length": String(options.body.byteLength) }
267
272
  : {}),
273
+ ...(options.range?.startsWith("bytes=")
274
+ ? { Range: options.range }
275
+ : {}),
268
276
  },
269
277
  ...(options.body
270
278
  ? {
@@ -350,6 +358,35 @@ function objectKeyFromUrl(cfg: S3Config, rawUrl: string): string | null {
350
358
  return decodeUrlPathSegment(encodedKey);
351
359
  }
352
360
 
361
+ function isCompletedClipObjectKey(key: string): boolean {
362
+ if (!key.startsWith("clips/") || key.length > 1024) return false;
363
+ if (key.includes("\\") || key.includes("\0")) return false;
364
+ const segments = key.split("/");
365
+ if (
366
+ segments.some((segment) => !segment || segment === "." || segment === "..")
367
+ ) {
368
+ return false;
369
+ }
370
+ return !key.startsWith("clips/.multipart/") && !key.endsWith(".pending");
371
+ }
372
+
373
+ function safeClipFilename(value: string): string {
374
+ return value.replace(/[^a-zA-Z0-9._-]/g, "-");
375
+ }
376
+
377
+ function isRecordingObjectKey(key: string, recordingId: string): boolean {
378
+ const safeRecordingId = safeClipFilename(recordingId);
379
+ if (key.startsWith(`clips/${safeRecordingId}/`)) return true;
380
+ const directObjectPrefix = `clips/${safeRecordingId}.`;
381
+ if (!key.startsWith(directObjectPrefix)) return false;
382
+ const extension = key.slice(directObjectPrefix.length);
383
+ return /^[a-zA-Z0-9_-]+$/.test(extension);
384
+ }
385
+
386
+ function isLegacyUnscopedObjectKey(key: string): boolean {
387
+ return /^clips\/\d{13}-[a-z0-9]{8}\.[a-zA-Z0-9_-]+$/.test(key);
388
+ }
389
+
353
390
  async function deleteObject(cfg: S3Config, key: string): Promise<void> {
354
391
  const res = await signedS3Request(cfg, key, {
355
392
  method: "DELETE",
@@ -561,6 +598,47 @@ export async function deleteS3ObjectByUrl(url: string): Promise<boolean> {
561
598
  return true;
562
599
  }
563
600
 
601
+ export async function isS3ObjectUrlBoundToRecording(
602
+ url: string,
603
+ recordingId: string,
604
+ ): Promise<boolean | null> {
605
+ const cfg = await readS3Config();
606
+ if (!cfg) return null;
607
+ const key = objectKeyFromUrl(cfg, url);
608
+ if (!key) return null;
609
+ return (
610
+ isCompletedClipObjectKey(key) && isRecordingObjectKey(key, recordingId)
611
+ );
612
+ }
613
+
614
+ export async function fetchS3ObjectByUrl(
615
+ url: string,
616
+ options: {
617
+ range?: string;
618
+ timeoutMs?: number;
619
+ recordingId: string;
620
+ allowLegacyObjectKey?: boolean;
621
+ },
622
+ ): Promise<Response | null> {
623
+ const cfg = await readS3Config();
624
+ if (!cfg) return null;
625
+ const key = objectKeyFromUrl(cfg, url);
626
+ if (
627
+ !key ||
628
+ !isCompletedClipObjectKey(key) ||
629
+ !options.recordingId ||
630
+ (!isRecordingObjectKey(key, options.recordingId) &&
631
+ !(options.allowLegacyObjectKey && isLegacyUnscopedObjectKey(key)))
632
+ ) {
633
+ return null;
634
+ }
635
+ return signedS3Request(cfg, key, {
636
+ method: "GET",
637
+ range: options.range,
638
+ timeoutMs: options.timeoutMs ?? S3_PUT_TIMEOUT_MS,
639
+ });
640
+ }
641
+
564
642
  // ── Provider ──────────────────────────────────────────────────────────
565
643
 
566
644
  export const s3FileUploadProvider: FileUploadProvider = {
@@ -573,9 +651,11 @@ export const s3FileUploadProvider: FileUploadProvider = {
573
651
  if (!cfg) throw new Error("S3 credentials are not configured");
574
652
 
575
653
  const ext = filename?.split(".").pop() ?? "bin";
654
+ const basename = filename?.slice(0, -(ext.length + 1)) || "file";
655
+ const safeBasename = safeClipFilename(basename);
576
656
  const stamp = Date.now();
577
657
  const rand = Math.random().toString(36).slice(2, 10);
578
- const objectKey = `clips/${stamp}-${rand}.${ext}`;
658
+ const objectKey = `clips/${safeBasename}/${stamp}-${rand}.${ext}`;
579
659
  const contentType = mimeType || "application/octet-stream";
580
660
 
581
661
  const bytes =
@@ -591,7 +671,7 @@ export const s3FileUploadProvider: FileUploadProvider = {
591
671
  const cfg = await readS3Config();
592
672
  if (!cfg) throw new Error("S3 credentials are not configured");
593
673
 
594
- const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "-");
674
+ const safeFilename = safeClipFilename(filename);
595
675
  if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
596
676
  throw new Error("S3 resumable upload requires a positive byte limit");
597
677
  }