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