@agent-native/core 0.84.22 → 0.84.23

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 (60) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/model-config.ts +1 -1
  5. package/corpus/core/src/cli/skills.ts +17 -3
  6. package/corpus/core/src/notifications/index.ts +1 -0
  7. package/corpus/core/src/notifications/registry.ts +13 -1
  8. package/corpus/core/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  9. package/corpus/core/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
  10. package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +2 -0
  11. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +18 -1
  12. package/corpus/templates/analytics/server/db/schema.ts +1 -1
  13. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +4 -1
  14. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +76 -7
  15. package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +2 -0
  16. package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +2 -0
  17. package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +2 -0
  18. package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +2 -0
  19. package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +2 -0
  20. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +30 -1
  21. package/corpus/templates/clips/actions/finalize-recording.ts +84 -4
  22. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +256 -0
  23. package/corpus/templates/clips/actions/reprocess-recording.ts +160 -0
  24. package/corpus/templates/clips/changelog/2026-07-01-shared-clips-now-start-playing-and-scrub-instantly-instead-o.md +6 -0
  25. package/corpus/templates/clips/server/lib/video-remux.ts +222 -0
  26. package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +2 -0
  27. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +75 -19
  28. package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +2 -0
  29. package/corpus/templates/design/app/pages/DesignEditor.tsx +11 -1
  30. package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-an-empty-state-selection-chip-in-the-composer.md +6 -0
  31. package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +2 -0
  32. package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +2 -0
  33. package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +2 -0
  34. package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +2 -0
  35. package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +2 -0
  36. package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +2 -0
  37. package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +2 -0
  38. package/dist/agent/model-config.js +1 -1
  39. package/dist/agent/model-config.js.map +1 -1
  40. package/dist/cli/skills.d.ts.map +1 -1
  41. package/dist/cli/skills.js +17 -3
  42. package/dist/cli/skills.js.map +1 -1
  43. package/dist/collab/awareness.d.ts +2 -2
  44. package/dist/collab/awareness.d.ts.map +1 -1
  45. package/dist/notifications/index.d.ts +1 -1
  46. package/dist/notifications/index.d.ts.map +1 -1
  47. package/dist/notifications/index.js +1 -1
  48. package/dist/notifications/index.js.map +1 -1
  49. package/dist/notifications/registry.d.ts +5 -0
  50. package/dist/notifications/registry.d.ts.map +1 -1
  51. package/dist/notifications/registry.js +4 -1
  52. package/dist/notifications/registry.js.map +1 -1
  53. package/dist/notifications/routes.d.ts +3 -3
  54. package/dist/observability/routes.d.ts +5 -5
  55. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  56. package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  57. package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
  58. package/package.json +1 -1
  59. package/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  60. package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4962
31
+ - template files: 4967
@@ -1,5 +1,13 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.84.23
4
+
5
+ ### Patch Changes
6
+
7
+ - fde28eb: Expose notification delivery channel results so callers can distinguish custom-channel delivery from inbox persistence.
8
+ - fde28eb: Re-enable Claude Sonnet 5 as the visible and default Sonnet model now that the Builder gateway supports it.
9
+ - fde28eb: Sharpen the exported Design skill's quality bar to defeat "AI slop": frame the variant flow as separating taste/exploration/spec/code, pair every banned default with a concrete alternative, warn about second-order convergence, and add explicit guidance for building on an existing design system or codebase.
10
+
3
11
  ## 0.84.22
4
12
 
5
13
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.84.22",
3
+ "version": "0.84.23",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -120,7 +120,7 @@ export function getContextWindowForModel(modelId: string): number {
120
120
  return DEFAULT_CONTEXT_WINDOW;
121
121
  }
122
122
 
123
- const ENABLE_CLAUDE_SONNET_5 = false;
123
+ const ENABLE_CLAUDE_SONNET_5 = true;
124
124
 
125
125
  export const CLAUDE_SONNET_MODEL_ID = ENABLE_CLAUDE_SONNET_5
126
126
  ? "claude-sonnet-5"
@@ -363,14 +363,28 @@ iteration, or a human-in-the-loop choice among design directions.
363
363
 
364
364
  ## Design Quality Bar
365
365
 
366
+ Generic "AI slop" comes from letting one prompt set taste, explore, and emit code
367
+ at once — so the model returns the training-average (Inter, an indigo/violet
368
+ gradient, a centered hero, three rounded cards). The variant flow above exists to
369
+ separate those jobs; use it, and hold this bar:
370
+
366
371
  - Before generating, name the concrete audience, the screen's primary job, and
367
372
  the visual thesis. If the brief is vague, make a reasonable choice and state
368
373
  it instead of producing a generic dashboard/landing-page default.
369
- - For existing products, inspect the current screen, design system, tokens,
370
- component language, or codebase context before inventing a new direction.
374
+ - Refuse the defaults, and pair every "don't" with a "do" (banning Inter alone
375
+ just makes you reach for Roboto). Avoid Inter/Roboto/system fonts, the
376
+ indigo/violet slop palette (\`#6366F1\`/\`#8B5CF6\`/\`#A855F7\`) and purple-on-white
377
+ gradients, and centered-hero + three-icon-card layouts; instead pick a
378
+ distinctive font pairing, one non-default palette family with a single decisive
379
+ accent, and an asymmetric layout with a clear focal point.
371
380
  - Make each direction distinct in structure and behavior, not just palette.
372
381
  Give every variant one memorable signature choice, then keep the surrounding
373
- chrome disciplined.
382
+ chrome disciplined. Even your creative picks converge (Space Grotesk
383
+ everywhere) — vary deliberately so two directions never share a fingerprint.
384
+ - For existing products, inspect the current screen, design system, tokens, and
385
+ component language before inventing a new direction. Treat any drift back to a
386
+ default as a missing token to pin, and vary layout per screen so on-brand does
387
+ not become same-in-your-colors.
374
388
  - Treat copy, data, and imagery as design material. Use realistic domain
375
389
  content and first-party/generated assets when images matter; avoid lorem
376
390
  ipsum, vague SaaS filler, and decorative placeholder boxes.
@@ -8,6 +8,7 @@ export type {
8
8
 
9
9
  export {
10
10
  notify,
11
+ notifyWithDelivery,
11
12
  registerNotificationChannel,
12
13
  unregisterNotificationChannel,
13
14
  listNotificationChannels,
@@ -13,6 +13,11 @@ import {
13
13
  type Notification,
14
14
  } from "./types.js";
15
15
 
16
+ export interface NotificationDeliveryResult {
17
+ notification?: Notification;
18
+ deliveredChannels: string[];
19
+ }
20
+
16
21
  registerEvent({
17
22
  name: "notification.sent",
18
23
  description:
@@ -84,6 +89,13 @@ export async function notify(
84
89
  input: NotificationInput,
85
90
  meta: NotificationMeta,
86
91
  ): Promise<Notification | undefined> {
92
+ return (await notifyWithDelivery(input, meta)).notification;
93
+ }
94
+
95
+ export async function notifyWithDelivery(
96
+ input: NotificationInput,
97
+ meta: NotificationMeta,
98
+ ): Promise<NotificationDeliveryResult> {
87
99
  if (!meta?.owner) {
88
100
  throw new Error("notify: meta.owner is required");
89
101
  }
@@ -166,7 +178,7 @@ export async function notify(
166
178
  }
167
179
  }
168
180
 
169
- return stored;
181
+ return { notification: stored, deliveredChannels: delivered };
170
182
  }
171
183
 
172
184
  function selectChannels(allowlist?: string[]): NotificationChannel[] {
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -135,7 +135,24 @@ function siteOrigin(request) {
135
135
  return url.origin;
136
136
  }
137
137
 
138
+ async function readScheduledInvocation(request) {
139
+ if (request.method !== "POST") return null;
140
+ let body;
141
+ try {
142
+ body = await request.json();
143
+ } catch {
144
+ return null;
145
+ }
146
+ const nextRun = typeof body?.next_run === "string" ? body.next_run : "";
147
+ return nextRun && Number.isFinite(Date.parse(nextRun))
148
+ ? { nextRun }
149
+ : null;
150
+ }
151
+
138
152
  export default async function handler(request) {
153
+ const scheduled = await readScheduledInvocation(request);
154
+ if (!scheduled) return new Response("Not Found", { status: 404 });
155
+
139
156
  const url = new URL(WORKER_PATH, siteOrigin(request));
140
157
  const response = await fetch(url.toString(), {
141
158
  method: "POST",
@@ -143,7 +160,7 @@ export default async function handler(request) {
143
160
  "content-type": "application/json",
144
161
  "x-agent-native-analytics-alert-cron": CRON_TOKEN,
145
162
  },
146
- body: JSON.stringify({ scheduled: true }),
163
+ body: JSON.stringify({ scheduled: true, next_run: scheduled.nextRun }),
147
164
  });
148
165
 
149
166
  if (!response.ok && response.status !== 202) {
@@ -207,7 +207,7 @@ export const analyticsAlertRules = table("analytics_alert_rules", {
207
207
  lastEvaluatedAt: text("last_evaluated_at"),
208
208
  lastTriggeredAt: text("last_triggered_at"),
209
209
  lastStatus: text("last_status", {
210
- enum: ["ok", "triggered", "cooldown", "error"],
210
+ enum: ["ok", "triggered", "cooldown", "error", "running"],
211
211
  }),
212
212
  lastError: text("last_error"),
213
213
  createdAt: text("created_at").notNull().default(now()),
@@ -1,4 +1,5 @@
1
1
  import {
2
+ claimAnalyticsAlertRuleEvaluation,
2
3
  evaluateAndNotifyAnalyticsAlertRule,
3
4
  listEnabledAnalyticsAlertRules,
4
5
  markAnalyticsAlertRuleError,
@@ -50,8 +51,10 @@ export async function runAnalyticsAlertsOnce(
50
51
  remaining = rules.length >= sweepLimit ? 1 : 0;
51
52
 
52
53
  for (const rule of rules) {
53
- processed++;
54
54
  try {
55
+ const claimed = await claimAnalyticsAlertRuleEvaluation(rule);
56
+ if (!claimed) continue;
57
+ processed++;
55
58
  const result = await evaluateAndNotifyAnalyticsAlertRule(rule);
56
59
  if (result.status === "triggered") triggered++;
57
60
  } catch (err) {
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
- import { notify } from "@agent-native/core/notifications";
3
+ import { notifyWithDelivery } from "@agent-native/core/notifications";
4
4
  import { recordChange } from "@agent-native/core/server";
5
- import { and, asc, desc, eq, gte, isNull, lte, sql } from "drizzle-orm";
5
+ import { and, asc, desc, eq, gte, isNull, lte, or, sql } from "drizzle-orm";
6
6
 
7
7
  import { getDb, schema } from "../db/index.js";
8
8
 
@@ -21,7 +21,12 @@ export interface AnalyticsAlertFilter {
21
21
 
22
22
  export type AnalyticsAlertThresholdMode = "event_count" | "distinct_count";
23
23
  export type AnalyticsAlertSeverity = "warning" | "critical";
24
- export type AnalyticsAlertStatus = "ok" | "triggered" | "cooldown" | "error";
24
+ export type AnalyticsAlertStatus =
25
+ | "ok"
26
+ | "triggered"
27
+ | "cooldown"
28
+ | "error"
29
+ | "running";
25
30
 
26
31
  export interface AnalyticsAlertRuleInput {
27
32
  id?: string;
@@ -108,6 +113,8 @@ function nowIso(): string {
108
113
  return new Date().toISOString();
109
114
  }
110
115
 
116
+ const ALERT_RULE_RUNNING_STALE_MS = 15 * 60 * 1000;
117
+
111
118
  function safeJsonParse<T>(raw: unknown, fallback: T): T {
112
119
  if (typeof raw !== "string" || !raw.trim()) return fallback;
113
120
  try {
@@ -372,6 +379,7 @@ export async function listEnabledAnalyticsAlertRules(options: {
372
379
  options.orgId ? eq(table.orgId, options.orgId) : isNull(table.orgId),
373
380
  );
374
381
  }
382
+ clauses.push(alertRuleNotRunningWhere(new Date()));
375
383
  const rows = await db
376
384
  .select()
377
385
  .from(table)
@@ -385,6 +393,52 @@ export async function listEnabledAnalyticsAlertRules(options: {
385
393
  return rows.map(rowToRule);
386
394
  }
387
395
 
396
+ function alertRuleNotRunningWhere(now: Date) {
397
+ const table = schema.analyticsAlertRules;
398
+ const staleRunningBefore = new Date(
399
+ now.getTime() - ALERT_RULE_RUNNING_STALE_MS,
400
+ ).toISOString();
401
+ return or(
402
+ isNull(table.lastStatus),
403
+ sql`${table.lastStatus} <> 'running'`,
404
+ isNull(table.lastEvaluatedAt),
405
+ lte(table.lastEvaluatedAt, staleRunningBefore),
406
+ );
407
+ }
408
+
409
+ function alertRulePreviousEvaluationWhere(rule: AnalyticsAlertRule) {
410
+ const table = schema.analyticsAlertRules;
411
+ return rule.lastEvaluatedAt
412
+ ? eq(table.lastEvaluatedAt, rule.lastEvaluatedAt)
413
+ : isNull(table.lastEvaluatedAt);
414
+ }
415
+
416
+ export async function claimAnalyticsAlertRuleEvaluation(
417
+ rule: AnalyticsAlertRule,
418
+ now: Date = new Date(),
419
+ ): Promise<boolean> {
420
+ const db = getDb() as any;
421
+ const table = schema.analyticsAlertRules;
422
+ const claimedAt = now.toISOString();
423
+ const rows = await db
424
+ .update(table)
425
+ .set({
426
+ lastEvaluatedAt: claimedAt,
427
+ lastStatus: "running",
428
+ lastError: null,
429
+ })
430
+ .where(
431
+ and(
432
+ eq(table.id, rule.id),
433
+ eq(table.enabled, true),
434
+ alertRuleNotRunningWhere(now),
435
+ alertRulePreviousEvaluationWhere(rule),
436
+ ),
437
+ )
438
+ .returning({ id: table.id });
439
+ return rows.length > 0;
440
+ }
441
+
388
442
  export async function evaluateAndNotifyAnalyticsAlertRule(
389
443
  rule: AnalyticsAlertRule,
390
444
  now: Date = new Date(),
@@ -416,12 +470,13 @@ export async function evaluateAndNotifyAnalyticsAlertRule(
416
470
  }
417
471
 
418
472
  const body = alertBody(rule, evaluation);
419
- const stored = await notify(
473
+ const channels = ensureInboxNotificationChannel(rule.channels);
474
+ const delivery = await notifyWithDelivery(
420
475
  {
421
476
  severity: rule.severity,
422
477
  title: `Analytics alert: ${rule.name}`,
423
478
  body,
424
- channels: rule.channels,
479
+ channels,
425
480
  metadata: {
426
481
  kind: "analytics_alert",
427
482
  ruleId: rule.id,
@@ -438,13 +493,22 @@ export async function evaluateAndNotifyAnalyticsAlertRule(
438
493
  filters: rule.filters,
439
494
  sampleEvents: evaluation.sampleEvents,
440
495
  emailRecipients: rule.emailRecipients,
496
+ requestedChannels: rule.channels,
441
497
  },
442
498
  },
443
499
  { owner: rule.ownerEmail },
444
500
  );
501
+ if (delivery.deliveredChannels.length === 0) {
502
+ await markRuleStatus(rule.id, {
503
+ lastEvaluatedAt: evaluatedAt,
504
+ lastStatus: "error",
505
+ lastError: "Analytics alert notification was not delivered.",
506
+ });
507
+ return { ruleId: rule.id, status: "error", ...evaluation };
508
+ }
445
509
 
446
510
  await recordIncident(rule, {
447
- notificationId: stored?.id,
511
+ notificationId: delivery.notification?.id,
448
512
  triggeredAt: evaluatedAt,
449
513
  windowStart,
450
514
  windowEnd,
@@ -459,11 +523,16 @@ export async function evaluateAndNotifyAnalyticsAlertRule(
459
523
  return {
460
524
  ruleId: rule.id,
461
525
  status: "triggered",
462
- notificationId: stored?.id,
526
+ notificationId: delivery.notification?.id,
463
527
  ...evaluation,
464
528
  };
465
529
  }
466
530
 
531
+ function ensureInboxNotificationChannel(channels: string[]): string[] {
532
+ const normalized = channels.map((channel) => channel.trim()).filter(Boolean);
533
+ return normalized.includes("inbox") ? normalized : ["inbox", ...normalized];
534
+ }
535
+
467
536
  export async function markAnalyticsAlertRuleError(
468
537
  id: string,
469
538
  error: unknown,
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
53
53
  - **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
54
54
  - **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
55
55
 
56
+ **Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
57
+
56
58
  ## Agent-Native UI Rules
57
59
 
58
60
  - Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
@@ -31,9 +31,38 @@ Some recordings are linked to a meeting — when `meeting_id` is non-null on the
31
31
  4. **Record.** Start a `MediaRecorder` with `mimeType: "video/webm;codecs=vp9,opus"` (fallback to vp8, then browser default). Use `timeslice: 2000` so chunks arrive every 2s.
32
32
  5. **Upload each chunk.** `ondataavailable` POSTs the chunk bytes to `/api/uploads/chunk` with headers `X-Recording-Id` and `X-Chunk-Index`. Don't retry inline — buffer failed chunks in `IndexedDB` and let a background worker re-send.
33
33
  6. **Live transcription.** Alongside the MediaRecorder, `useLiveTranscription` runs the Web Speech API to accumulate transcript text in real time. On stop, the client calls `save-browser-transcript` to persist the result immediately — no API key needed. Desktop recordings use local Whisper/macOS speech first when available, and fall back to Web Speech in the webview on non-mac before relying on upload transcription.
34
- 7. **Finalize.** On stop, send the final chunk to `/api/uploads/:id/chunk?isFinal=1`. The route calls `finalize-recording`, which stitches chunks, uploads the finished media when storage is configured, transitions `status` to `ready`, then kicks off `request-transcript` for higher-quality output (see `ai-video-tools`).
34
+ 7. **Finalize.** On stop, send the final chunk to `/api/uploads/:id/chunk?isFinal=1`. The route calls `finalize-recording`, which stitches chunks, makes the media seekable (see below), uploads the finished media when storage is configured, transitions `status` to `ready`, then kicks off `request-transcript` for higher-quality output (see `ai-video-tools`).
35
35
  8. **Navigate.** Once the row is `ready` the UI navigates to `/r/:id`.
36
36
 
37
+ ## Seekable playback (don't ship raw MediaRecorder output)
38
+
39
+ Raw `MediaRecorder` files are not friendly to progressive HTTP playback, which
40
+ shows up as "clip takes minutes to load" and "re-buffers every time I seek"
41
+ even though the file downloads fine:
42
+
43
+ - **MP4** is written with the `moov` metadata atom *after* `mdat`, so a player
44
+ must fetch the whole file before it can start or seek.
45
+ - **WebM** is a live stream with no Cues (seek index) and an unknown Segment
46
+ duration, so Chrome won't honor `currentTime = X` and has to scan/download.
47
+
48
+ `finalize-recording` fixes this before upload: MP4 gets pure-TS faststart
49
+ (`server/lib/faststart.ts`), WebM gets a lossless `ffmpeg -c copy` remux that
50
+ writes a SeekHead + Cues + real duration (`server/lib/video-remux.ts`). Both are
51
+ best-effort — on failure we upload the original, never block finalize. Recordings
52
+ above `CLIPS_INLINE_REMUX_MAX_BYTES` (default 200 MB) skip the inline pass and
53
+ are repaired in the background.
54
+
55
+ The **streaming/resumable** upload path forwards raw bytes straight to the
56
+ provider and cannot rewrite them inline, so `finalize-recording` schedules a
57
+ background `ensureRecordingSeekable` pass for those.
58
+
59
+ To repair clips uploaded before this existed (or via streaming), call the
60
+ `reprocess-recording` action: `--id`, `--ids='[...]'`, or `--all --limit=N`. It
61
+ re-fetches provider media, rewrites it, re-uploads, and repoints the row. It's
62
+ idempotent (already-seekable clips are skipped unless `--force`) and only touches
63
+ provider-hosted clips owned by the caller. This is the right tool when a user
64
+ reports a specific slow/buffering clip.
65
+
37
66
  ## Loom import
38
67
 
39
68
  Use `import-loom-recording` for Loom share or embed URLs. The action validates
@@ -40,12 +40,27 @@ import {
40
40
  getResumableSession,
41
41
  } from "../server/lib/resumable-session.js";
42
42
  import { isStreamingUploadDisabled } from "../server/lib/streaming-upload-mode.js";
43
+ import { remuxWebmToSeekable } from "../server/lib/video-remux.js";
43
44
  import {
44
45
  requiresConfiguredVideoStorage,
45
46
  STORAGE_SETUP_REQUIRED_REASON,
46
47
  } from "../server/lib/video-storage.js";
48
+ import {
49
+ ensureRecordingSeekable,
50
+ markRecordingSeekable,
51
+ } from "./lib/ensure-seekable-video.js";
47
52
  import requestTranscript from "./request-transcript.js";
48
53
 
54
+ // Recordings up to this size get their seekable rewrite applied inline during
55
+ // finalize (we already hold the assembled bytes). Larger recordings are handed
56
+ // off to the background/reprocess path so we don't stretch the finalize
57
+ // request or exhaust serverless /tmp. Override with CLIPS_INLINE_REMUX_MAX_BYTES.
58
+ function inlineRemuxMaxBytes(): number {
59
+ const raw = Number(process.env.CLIPS_INLINE_REMUX_MAX_BYTES ?? "");
60
+ if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
61
+ return 200 * 1024 * 1024;
62
+ }
63
+
49
64
  /**
50
65
  * Decode a base64 string back into a Uint8Array.
51
66
  * We store chunks as base64 in application_state because the SQL JSON
@@ -118,6 +133,10 @@ async function markRecordingReady(params: {
118
133
  finalHasAudio: boolean;
119
134
  finalHasCamera: boolean;
120
135
  existingTitle: string;
136
+ // Whether a seekable rewrite (MP4 faststart / WebM Cues remux) was already
137
+ // applied to the uploaded bytes. When false, a best-effort background repair
138
+ // is triggered so streamed/raw uploads still become seekable.
139
+ seekableApplied: boolean;
121
140
  }) {
122
141
  const {
123
142
  id,
@@ -131,6 +150,7 @@ async function markRecordingReady(params: {
131
150
  finalHasAudio,
132
151
  finalHasCamera,
133
152
  existingTitle,
153
+ seekableApplied,
134
154
  } = params;
135
155
  const db = getDb();
136
156
  const now = new Date().toISOString();
@@ -181,6 +201,30 @@ async function markRecordingReady(params: {
181
201
  });
182
202
  await writeAppState("refresh-signal", { ts: Date.now() });
183
203
 
204
+ if (seekableApplied) {
205
+ // Uploaded bytes are already start-playable and seekable — remember it so
206
+ // later reprocess sweeps skip this clip.
207
+ await markRecordingSeekable(id, videoUrl).catch((err) => {
208
+ console.warn("[finalize] failed to write seekable marker", {
209
+ id,
210
+ error: err instanceof Error ? err.message : String(err),
211
+ });
212
+ });
213
+ } else {
214
+ // Streaming/resumable (or oversized) uploads shipped raw MediaRecorder
215
+ // bytes with no seekable rewrite: an MP4 with a trailing moov or a WebM
216
+ // without a Cues index buffers on load and re-buffers on every seek. Fix
217
+ // it in the background so playback is smooth without blocking finalize.
218
+ void Promise.resolve(
219
+ ensureRecordingSeekable({ recordingId: id, ownerEmail }),
220
+ ).catch((err: unknown) => {
221
+ console.warn("[finalize] background seekable remux failed", {
222
+ id,
223
+ error: err instanceof Error ? err.message : String(err),
224
+ });
225
+ });
226
+ }
227
+
184
228
  // Kick off transcription in the background — fire-and-forget so the chunk
185
229
  // endpoint gets a quick response. The request context (user email via
186
230
  // AsyncLocalStorage) carries through to async continuations.
@@ -375,6 +419,10 @@ export default defineAction({
375
419
  ...readyParams,
376
420
  videoUrl,
377
421
  videoSizeBytes: resumableSession.bytesUploaded,
422
+ // Streaming path forwards raw MediaRecorder bytes straight to the
423
+ // provider — no faststart/Cues rewrite happened. Repair in the
424
+ // background.
425
+ seekableApplied: false,
378
426
  });
379
427
  // Delete only after durable state is written — so a retry before
380
428
  // this point can still find the session and re-enter this path.
@@ -521,9 +569,14 @@ export default defineAction({
521
569
  // recordings.
522
570
  parts.length = 0;
523
571
 
524
- // Apply faststart to MP4 files moves the moov atom before mdat so
525
- // browsers can begin playback immediately via HTTP range requests.
572
+ // Make the assembled recording seekable before upload we already hold
573
+ // the full bytes, so a viewer never has to wait through a non-seekable
574
+ // first play. MP4: relocate moov ahead of mdat (pure TS). WebM: remux to
575
+ // add a Cues index + real duration (ffmpeg -c copy). When neither runs
576
+ // (unknown format, oversized, or ffmpeg unavailable) `seekableApplied`
577
+ // stays false and markRecordingReady schedules a background repair.
526
578
  let uploadData = assembled;
579
+ let seekableApplied = false;
527
580
  if (videoFormat === "mp4") {
528
581
  try {
529
582
  uploadData = applyFaststart(assembled);
@@ -561,6 +614,32 @@ export default defineAction({
561
614
  }
562
615
  throw err;
563
616
  }
617
+ // moov is present and validated — the MP4 is start-playable/seekable.
618
+ seekableApplied = true;
619
+ } else if (videoFormat === "webm") {
620
+ // MediaRecorder WebM has no Cues index and an unknown duration, so
621
+ // Chrome buffers on load and re-buffers on every seek. A lossless
622
+ // `ffmpeg -c copy` remux rewrites it with a SeekHead + Cues + real
623
+ // duration. Bounded by size so finalize stays fast; larger clips get a
624
+ // background pass. Best-effort: on any failure we upload the original.
625
+ if (assembled.byteLength <= inlineRemuxMaxBytes()) {
626
+ try {
627
+ const seekable = await remuxWebmToSeekable(uploadData);
628
+ if (seekable.changed) {
629
+ uploadData = seekable.bytes;
630
+ seekableApplied = true;
631
+ debugLog("[finalize] webm remux applied", {
632
+ id,
633
+ bytes: uploadData.byteLength,
634
+ });
635
+ }
636
+ } catch (err) {
637
+ console.warn("[finalize] webm remux failed, uploading as-is", {
638
+ id,
639
+ err: err instanceof Error ? err.message : String(err),
640
+ });
641
+ }
642
+ }
564
643
  }
565
644
 
566
645
  let upload: Awaited<ReturnType<typeof uploadFile>>;
@@ -721,12 +800,13 @@ export default defineAction({
721
800
  debugLog("[finalize] done", {
722
801
  id,
723
802
  videoUrl: upload.url,
724
- bytes: assembled.byteLength,
803
+ bytes: uploadData.byteLength,
725
804
  });
726
805
  return markRecordingReady({
727
806
  ...readyParams,
728
807
  videoUrl: upload.url,
729
- videoSizeBytes: assembled.byteLength,
808
+ videoSizeBytes: uploadData.byteLength,
809
+ seekableApplied,
730
810
  });
731
811
  } finally {
732
812
  // Unconditional chunk scratch-space cleanup. Runs on success AND on