@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +31 -5
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +7 -0
- package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +52 -19
- package/corpus/templates/analytics/.agents/skills/provider-api/SKILL.md +25 -3
- package/corpus/templates/analytics/AGENTS.md +6 -7
- package/corpus/templates/analytics/actions/gong-calls.ts +3 -3
- package/corpus/templates/analytics/actions/provider-api-catalog.ts +1 -1
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
- package/corpus/templates/analytics/app/i18n-data.ts +46 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +67 -1
- package/corpus/templates/analytics/app/pages/settings/settings-search.ts +6 -0
- package/corpus/templates/analytics/changelog/2026-08-03-dashboard-builds-now-finish-without-an-extra-confirmation.md +6 -0
- package/corpus/templates/analytics/changelog/2026-08-03-error-alert-emails-are-opt-in.md +6 -0
- package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +10 -9
- package/corpus/templates/analytics/server/lib/error-capture.ts +38 -10
- package/corpus/templates/analytics/server/lib/gong.ts +27 -4
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +64 -4
- package/corpus/templates/analytics/shared/analytics-user-prefs.ts +7 -0
- package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +15 -6
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +9 -0
- package/corpus/templates/clips/actions/cleanup-dictation.ts +2 -2
- package/corpus/templates/clips/actions/cleanup-transcript.ts +8 -11
- package/corpus/templates/clips/actions/clear-edits.ts +15 -2
- package/corpus/templates/clips/actions/create-recording-agent-link.ts +10 -0
- package/corpus/templates/clips/actions/finalize-meeting.ts +6 -6
- package/corpus/templates/clips/actions/finalize-recording.ts +33 -8
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +1 -3
- package/corpus/templates/clips/actions/list-recordings.ts +39 -24
- package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +4 -1
- package/corpus/templates/clips/actions/regenerate-title.ts +2 -2
- package/corpus/templates/clips/actions/stitch-recordings.ts +36 -7
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +1 -4
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +3 -1
- package/corpus/templates/clips/app/components/library/recording-card.tsx +9 -0
- package/corpus/templates/clips/app/components/player/recording-views-badge.tsx +52 -7
- package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
- package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +17 -10
- package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +5 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -0
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
- 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
- package/corpus/templates/clips/changelog/2026-07-31-clip-cards-and-the-watch-share-page-header-now-show-agent-vi.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-01-recordings-stored-in-private-s3-compatible-buckets-finalize-.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-clip-cleanup-titles-and-meeting-summaries-now-use-luna-by-de.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-get-a-monthly-recap-email-showing-how-many-people-and-ai-age.md +6 -0
- 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
- package/corpus/templates/clips/learnings.defaults.md +2 -2
- package/corpus/templates/clips/package.json +1 -1
- package/corpus/templates/clips/scripts/send-real-recap.ts +165 -0
- package/corpus/templates/clips/scripts/send-test-emails.ts +60 -0
- package/corpus/templates/clips/server/db/schema.ts +4 -0
- package/corpus/templates/clips/server/jobs/transactional-emails.ts +256 -0
- package/corpus/templates/clips/server/lib/agent-views.ts +38 -11
- package/corpus/templates/clips/server/lib/media-storage-provenance.ts +16 -0
- package/corpus/templates/clips/server/lib/public-agent-context.ts +3 -1
- package/corpus/templates/clips/server/lib/recap-metrics.ts +373 -0
- package/corpus/templates/clips/server/lib/s3-upload-provider.ts +84 -4
- package/corpus/templates/clips/server/lib/transactional-email-store.ts +41 -2
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +245 -3
- package/corpus/templates/clips/server/plugins/db.ts +16 -0
- package/corpus/templates/clips/server/register-secrets.ts +2 -2
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +7 -2
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +42 -1
- package/corpus/templates/design/actions/take-design-screenshot.ts +5 -3
- package/corpus/templates/design/package.json +1 -1
- package/corpus/templates/design/server/lib/playwright-runtime.ts +13 -5
- package/corpus/templates/mail/actions/get-automation-settings.ts +4 -12
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +1 -1
- package/corpus/templates/mail/changelog/2026-08-03-inbox-automations-now-prefer-the-lowest-cost-luna-model-when.md +6 -0
- package/corpus/templates/mail/server/lib/automation-engine.ts +19 -14
- package/corpus/templates/mail/server/lib/automation-model.ts +110 -0
- package/corpus/templates/mail/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/slides/actions/generate-slides-ai.ts +92 -24
- package/corpus/templates/slides/changelog/2026-08-03-legacy-slide-outlines-now-prefer-the-lowest-cost-luna-model-.md +6 -0
- package/corpus/templates/tasks/package.json +2 -2
- package/dist/agent/production-agent.d.ts +2 -2
- package/dist/agent/production-agent.js +1 -1
- package/dist/catalog.json +2 -0
- package/dist/client/AgentTaskCard.js +4 -2
- package/dist/client/i18n.js +3 -0
- package/dist/client/settings/VoiceTranscriptionSection.js +2 -2
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +5 -0
- package/dist/localization/default-messages.js +5 -0
- package/dist/mcp/oauth-client-metadata.d.ts +7 -0
- package/dist/mcp/oauth-client-metadata.js +47 -0
- package/dist/mcp/oauth-route.js +7 -3
- package/dist/mcp/oauth-store.d.ts +2 -0
- package/dist/mcp/oauth-store.js +25 -3
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/observability/traces.d.ts +1 -1
- package/dist/observability/traces.js +2 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/index.js +29 -1
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-access.d.ts +3 -1
- package/dist/server/agent-access.js +2 -1
- package/dist/server/agent-chat/browser-team-tools.d.ts +1 -0
- package/dist/server/agent-chat/browser-team-tools.js +5 -4
- package/dist/server/agent-chat/context-tools.js +2 -2
- package/dist/server/agent-chat-plugin.js +13 -6
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-teams-run-queue.d.ts +2 -0
- package/dist/server/agent-teams.d.ts +2 -0
- package/dist/server/agent-teams.js +45 -2
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/short-lived-token.d.ts +7 -0
- package/dist/server/short-lived-token.js +7 -1
- package/dist/server/ssr-handler.js +19 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/server/transcribe-voice.js +24 -16
- package/dist/vite/client.d.ts +37 -1
- package/dist/vite/client.js +110 -1
- package/docs/content/template-assets.mdx +14 -4
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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(
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|