@agent-native/core 0.109.0 → 0.109.2

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 (154) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/docs/content/integrations.mdx +87 -253
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/a2a/client.ts +24 -2
  6. package/corpus/core/src/a2a/correlation.ts +50 -0
  7. package/corpus/core/src/a2a/handlers.ts +140 -14
  8. package/corpus/core/src/a2a/index.ts +1 -0
  9. package/corpus/core/src/a2a/invoke.ts +10 -1
  10. package/corpus/core/src/a2a/task-store.ts +146 -6
  11. package/corpus/core/src/a2a/types.ts +16 -0
  12. package/corpus/core/src/action.ts +2 -0
  13. package/corpus/core/src/agent/production-agent.ts +23 -0
  14. package/corpus/core/src/client/blocks/library/diagram.config.ts +10 -1
  15. package/corpus/core/src/client/blocks/library/diagram.tsx +9 -4
  16. package/corpus/core/src/client/use-run-stuck-detection.ts +27 -1
  17. package/corpus/core/src/db/client.ts +55 -0
  18. package/corpus/core/src/deploy/build.ts +63 -3
  19. package/corpus/core/src/observability/traces.ts +172 -19
  20. package/corpus/core/src/scripts/call-agent.ts +53 -2
  21. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +72 -25
  22. package/corpus/core/src/server/agent-chat-plugin.ts +107 -6
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  24. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  25. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  26. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  27. package/corpus/templates/analytics/changelog/2026-07-17-analytics-now-includes-a-default-alert-for-spikes-in-stuck-agent-chats.md +6 -0
  28. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +23 -14
  29. package/corpus/templates/analytics/docs/schemas/tracked-events.md +5 -1
  30. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +3 -3
  31. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +209 -87
  32. package/corpus/templates/clips/.agents/skills/dictate/SKILL.md +28 -5
  33. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +7 -0
  34. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +23 -0
  35. package/corpus/templates/clips/AGENTS.md +10 -0
  36. package/corpus/templates/clips/actions/create-dictation.ts +29 -4
  37. package/corpus/templates/clips/actions/finalize-recording.ts +542 -65
  38. package/corpus/templates/clips/actions/get-recording-player-data.ts +16 -6
  39. package/corpus/templates/clips/actions/set-mobile-capture-state.ts +37 -0
  40. package/corpus/templates/clips/actions/update-dictation.ts +9 -1
  41. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +17 -2
  42. package/corpus/templates/clips/app/components/player/video-player.tsx +14 -2
  43. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +2 -2
  44. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +14 -3
  45. package/corpus/templates/clips/app/routes/record.tsx +3 -3
  46. package/corpus/templates/clips/app/routes/share.$shareId.tsx +19 -5
  47. package/corpus/templates/clips/changelog/2026-07-17-capture-dictation-background-meeting-audio-and-camera-videos.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-clips-now-retries-storage-verification-in-the-background-ins.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-17-delayed-upload-verification-keeps-local-backups-retryable.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-17-fixed-broken-thumbnail-icons-appearing-over-playable-clip-em.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-17-playback-comments-stay-readable-at-faster-playback-speeds.md +6 -0
  52. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +3 -0
  53. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +3 -3
  54. package/corpus/templates/clips/desktop/src/app.tsx +35 -6
  55. package/corpus/templates/clips/desktop/src/lib/processing-backup-recovery.ts +19 -0
  56. package/corpus/templates/clips/desktop/src/lib/recorder.ts +141 -18
  57. package/corpus/templates/clips/desktop/src/lib/upload-verification.ts +28 -7
  58. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -4
  59. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +32 -15
  60. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +59 -18
  61. package/corpus/templates/clips/server/db/schema.ts +9 -1
  62. package/corpus/templates/clips/server/jobs/media-verification.ts +130 -0
  63. package/corpus/templates/clips/server/lib/builder-media-compression.ts +74 -7
  64. package/corpus/templates/clips/server/lib/media-verification-state.ts +55 -0
  65. package/corpus/templates/clips/server/lib/player-thumbnail-url.ts +31 -0
  66. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -3
  67. package/corpus/templates/clips/server/plugins/auth.ts +5 -5
  68. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  69. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +12 -2
  70. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +13 -1
  71. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +340 -0
  72. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +24 -7
  73. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +91 -7
  74. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +22 -1
  75. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +14 -0
  76. package/corpus/templates/clips/shared/finalize-recovery.ts +61 -2
  77. package/corpus/templates/clips/shared/share-meta.ts +1 -1
  78. package/corpus/templates/plan/changelog/2026-07-17-diagrams-can-opt-into-a-polished-clean-rendering-mode-withou.md +6 -0
  79. package/corpus/templates/plan/shared/plan-content.ts +3 -0
  80. package/dist/a2a/client.d.ts +11 -2
  81. package/dist/a2a/client.d.ts.map +1 -1
  82. package/dist/a2a/client.js +16 -3
  83. package/dist/a2a/client.js.map +1 -1
  84. package/dist/a2a/correlation.d.ts +10 -0
  85. package/dist/a2a/correlation.d.ts.map +1 -0
  86. package/dist/a2a/correlation.js +40 -0
  87. package/dist/a2a/correlation.js.map +1 -0
  88. package/dist/a2a/handlers.d.ts.map +1 -1
  89. package/dist/a2a/handlers.js +100 -15
  90. package/dist/a2a/handlers.js.map +1 -1
  91. package/dist/a2a/index.d.ts +1 -1
  92. package/dist/a2a/index.d.ts.map +1 -1
  93. package/dist/a2a/index.js.map +1 -1
  94. package/dist/a2a/invoke.d.ts +4 -1
  95. package/dist/a2a/invoke.d.ts.map +1 -1
  96. package/dist/a2a/invoke.js +3 -0
  97. package/dist/a2a/invoke.js.map +1 -1
  98. package/dist/a2a/task-store.d.ts +12 -1
  99. package/dist/a2a/task-store.d.ts.map +1 -1
  100. package/dist/a2a/task-store.js +96 -6
  101. package/dist/a2a/task-store.js.map +1 -1
  102. package/dist/a2a/types.d.ts +15 -0
  103. package/dist/a2a/types.d.ts.map +1 -1
  104. package/dist/a2a/types.js.map +1 -1
  105. package/dist/action.d.ts +2 -0
  106. package/dist/action.d.ts.map +1 -1
  107. package/dist/action.js.map +1 -1
  108. package/dist/agent/production-agent.d.ts +9 -0
  109. package/dist/agent/production-agent.d.ts.map +1 -1
  110. package/dist/agent/production-agent.js +14 -0
  111. package/dist/agent/production-agent.js.map +1 -1
  112. package/dist/client/blocks/library/diagram.config.d.ts +2 -0
  113. package/dist/client/blocks/library/diagram.config.d.ts.map +1 -1
  114. package/dist/client/blocks/library/diagram.config.js +8 -1
  115. package/dist/client/blocks/library/diagram.config.js.map +1 -1
  116. package/dist/client/blocks/library/diagram.d.ts.map +1 -1
  117. package/dist/client/blocks/library/diagram.js +9 -4
  118. package/dist/client/blocks/library/diagram.js.map +1 -1
  119. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  120. package/dist/client/use-run-stuck-detection.js +25 -1
  121. package/dist/client/use-run-stuck-detection.js.map +1 -1
  122. package/dist/collab/routes.d.ts +1 -1
  123. package/dist/db/client.d.ts +7 -0
  124. package/dist/db/client.d.ts.map +1 -1
  125. package/dist/db/client.js +33 -0
  126. package/dist/db/client.js.map +1 -1
  127. package/dist/deploy/build.d.ts +3 -0
  128. package/dist/deploy/build.d.ts.map +1 -1
  129. package/dist/deploy/build.js +45 -1
  130. package/dist/deploy/build.js.map +1 -1
  131. package/dist/observability/routes.d.ts +1 -1
  132. package/dist/observability/traces.d.ts +9 -0
  133. package/dist/observability/traces.d.ts.map +1 -1
  134. package/dist/observability/traces.js +137 -19
  135. package/dist/observability/traces.js.map +1 -1
  136. package/dist/progress/routes.d.ts +1 -1
  137. package/dist/scripts/call-agent.d.ts.map +1 -1
  138. package/dist/scripts/call-agent.js +35 -3
  139. package/dist/scripts/call-agent.js.map +1 -1
  140. package/dist/server/agent-chat/action-filters-a2a.d.ts +18 -2
  141. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  142. package/dist/server/agent-chat/action-filters-a2a.js +35 -7
  143. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  144. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  145. package/dist/server/agent-chat-plugin.js +93 -6
  146. package/dist/server/agent-chat-plugin.js.map +1 -1
  147. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  148. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  149. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  150. package/docs/content/integrations.mdx +87 -253
  151. package/package.json +1 -1
  152. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  153. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  154. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
@@ -135,6 +135,12 @@ function nowIso(): string {
135
135
 
136
136
  const ALERT_RULE_RUNNING_STALE_MS = 15 * 60 * 1000;
137
137
  const DEFAULT_HTTP_5XX_ALERT_ID_PREFIX = "default-http-5xx-spike";
138
+ const DEFAULT_AGENT_CHAT_STUCK_ALERT_ID_PREFIX =
139
+ "default-agent-chat-stuck-spike";
140
+ const DEFAULT_AGENT_CHAT_STUCK_ALERT_THRESHOLD = 3;
141
+ const DEFAULT_AGENT_CHAT_STUCK_ALERT_WINDOW_MINUTES = 10;
142
+ const DEFAULT_AGENT_CHAT_STUCK_ALERT_COOLDOWN_MINUTES = 60;
143
+ const DEFAULT_ALERT_SCOPE_PAGE_SIZE = 500;
138
144
  const ALERT_RULE_DEFAULTS_KEY = "analytics-alert-rule-defaults";
139
145
 
140
146
  function safeJsonParse<T>(raw: unknown, fallback: T): T {
@@ -296,6 +302,14 @@ function defaultHttp5xxAlertEnabled(): boolean {
296
302
  return currentDeployHostname() === "analytics.agent-native.com";
297
303
  }
298
304
 
305
+ function defaultAgentChatStuckAlertEnabled(): boolean {
306
+ const configured = boolEnv(
307
+ "ANALYTICS_DEFAULT_AGENT_CHAT_STUCK_ALERT_ENABLED",
308
+ );
309
+ if (configured !== null) return configured;
310
+ return currentDeployHostname() === "analytics.agent-native.com";
311
+ }
312
+
299
313
  function envInt(name: string, fallback: number, min: number, max: number) {
300
314
  const raw = process.env[name]?.trim();
301
315
  if (!raw) return fallback;
@@ -305,18 +319,30 @@ function envInt(name: string, fallback: number, min: number, max: number) {
305
319
  : fallback;
306
320
  }
307
321
 
308
- function defaultHttp5xxAlertId(ownerEmail: string, orgId: string | null) {
322
+ function defaultAlertId(
323
+ prefix: string,
324
+ ownerEmail: string,
325
+ orgId: string | null,
326
+ ) {
309
327
  const hash = createHash("sha256")
310
328
  .update(`${ownerEmail.toLowerCase()}|${orgId ?? ""}`)
311
329
  .digest("hex")
312
330
  .slice(0, 10);
313
- return `${DEFAULT_HTTP_5XX_ALERT_ID_PREFIX}-${hash}`;
331
+ return `${prefix}-${hash}`;
314
332
  }
315
333
 
316
- async function defaultHttp5xxAlertScopes(): Promise<AccessCtx[]> {
334
+ async function defaultAnalyticsAlertScopePage(offset: number): Promise<{
335
+ scopes: AccessCtx[];
336
+ hasMore: boolean;
337
+ }> {
317
338
  const ownerEmail = process.env.ANALYTICS_DEFAULT_ALERT_OWNER_EMAIL?.trim();
318
339
  const orgId = process.env.ANALYTICS_DEFAULT_ALERT_ORG_ID?.trim() || null;
319
- if (ownerEmail) return [{ email: ownerEmail, orgId }];
340
+ if (ownerEmail) {
341
+ return {
342
+ scopes: offset === 0 ? [{ email: ownerEmail, orgId }] : [],
343
+ hasMore: false,
344
+ };
345
+ }
320
346
 
321
347
  const db = getDb() as any;
322
348
  const rows = await db
@@ -326,7 +352,16 @@ async function defaultHttp5xxAlertScopes(): Promise<AccessCtx[]> {
326
352
  })
327
353
  .from(schema.analyticsPublicKeys)
328
354
  .where(isNull(schema.analyticsPublicKeys.revokedAt))
329
- .limit(1000);
355
+ .groupBy(
356
+ schema.analyticsPublicKeys.ownerEmail,
357
+ schema.analyticsPublicKeys.orgId,
358
+ )
359
+ .orderBy(
360
+ asc(schema.analyticsPublicKeys.ownerEmail),
361
+ asc(schema.analyticsPublicKeys.orgId),
362
+ )
363
+ .limit(DEFAULT_ALERT_SCOPE_PAGE_SIZE)
364
+ .offset(offset);
330
365
 
331
366
  const seen = new Set<string>();
332
367
  const scopes: AccessCtx[] = [];
@@ -342,7 +377,10 @@ async function defaultHttp5xxAlertScopes(): Promise<AccessCtx[]> {
342
377
  seen.add(key);
343
378
  scopes.push({ email, orgId: scopeOrgId });
344
379
  }
345
- return scopes;
380
+ return {
381
+ scopes,
382
+ hasMore: rows.length === DEFAULT_ALERT_SCOPE_PAGE_SIZE,
383
+ };
346
384
  }
347
385
 
348
386
  function rowToRule(row: any): AnalyticsAlertRule {
@@ -517,10 +555,20 @@ export async function deleteAnalyticsAlertRule(
517
555
  });
518
556
  }
519
557
  const db = getDb() as any;
520
- await db.delete(schema.analyticsAlertRules).where(ownerWhere(ctx, id));
558
+ const seededDefault =
559
+ id.startsWith(`${DEFAULT_HTTP_5XX_ALERT_ID_PREFIX}-`) ||
560
+ id.startsWith(`${DEFAULT_AGENT_CHAT_STUCK_ALERT_ID_PREFIX}-`);
561
+ if (seededDefault) {
562
+ await db
563
+ .update(schema.analyticsAlertRules)
564
+ .set({ enabled: false, updatedAt: nowIso() })
565
+ .where(ownerWhere(ctx, id));
566
+ } else {
567
+ await db.delete(schema.analyticsAlertRules).where(ownerWhere(ctx, id));
568
+ }
521
569
  recordChange({
522
570
  source: "analytics-alert-rules",
523
- type: "delete",
571
+ type: seededDefault ? "change" : "delete",
524
572
  key: existing.id,
525
573
  owner: existing.ownerEmail,
526
574
  orgId: existing.orgId ?? undefined,
@@ -529,6 +577,20 @@ export async function deleteAnalyticsAlertRule(
529
577
 
530
578
  const DEFAULT_ALERT_SEED_CHUNK_SIZE = 500;
531
579
 
580
+ interface DefaultAnalyticsAlertDefinition {
581
+ idPrefix: string;
582
+ name: string;
583
+ description: string;
584
+ eventName: string;
585
+ filters: AnalyticsAlertFilter[];
586
+ threshold: number;
587
+ thresholdMode?: AnalyticsAlertThresholdMode;
588
+ distinctBy?: string | null;
589
+ windowMinutes: number;
590
+ cooldownMinutes: number;
591
+ severity: AnalyticsAlertSeverity;
592
+ }
593
+
532
594
  function chunkArray<T>(items: T[], size: number): T[][] {
533
595
  const chunks: T[][] = [];
534
596
  for (let i = 0; i < items.length; i += size) {
@@ -537,95 +599,155 @@ function chunkArray<T>(items: T[], size: number): T[][] {
537
599
  return chunks;
538
600
  }
539
601
 
540
- export async function ensureDefaultHttp5xxSpikeAlertRules(): Promise<{
602
+ function defaultAnalyticsAlertDefinitions(): DefaultAnalyticsAlertDefinition[] {
603
+ const definitions: DefaultAnalyticsAlertDefinition[] = [];
604
+
605
+ if (defaultHttp5xxAlertEnabled()) {
606
+ definitions.push({
607
+ idPrefix: DEFAULT_HTTP_5XX_ALERT_ID_PREFIX,
608
+ name: "Hosted app HTTP 5xx spike",
609
+ description:
610
+ "Default Agent Native alert for a spike in server responses with 5xx status codes.",
611
+ eventName: "http.response",
612
+ filters: [{ field: "properties.status_class", value: "5xx" }],
613
+ threshold: envInt(
614
+ "ANALYTICS_DEFAULT_HTTP_5XX_ALERT_THRESHOLD",
615
+ 5,
616
+ 1,
617
+ 1000,
618
+ ),
619
+ windowMinutes: envInt(
620
+ "ANALYTICS_DEFAULT_HTTP_5XX_ALERT_WINDOW_MINUTES",
621
+ 5,
622
+ 1,
623
+ 60,
624
+ ),
625
+ cooldownMinutes: envInt(
626
+ "ANALYTICS_DEFAULT_HTTP_5XX_ALERT_COOLDOWN_MINUTES",
627
+ 30,
628
+ 0,
629
+ 24 * 60,
630
+ ),
631
+ severity: "critical",
632
+ });
633
+ }
634
+
635
+ if (defaultAgentChatStuckAlertEnabled()) {
636
+ definitions.push({
637
+ idPrefix: DEFAULT_AGENT_CHAT_STUCK_ALERT_ID_PREFIX,
638
+ name: "Hosted agent chat stuck spike",
639
+ description:
640
+ "Default Agent Native alert for a spike in agent chats detected as stuck.",
641
+ eventName: "agent_chat_stuck_detected",
642
+ filters: [],
643
+ threshold: envInt(
644
+ "ANALYTICS_DEFAULT_AGENT_CHAT_STUCK_ALERT_THRESHOLD",
645
+ DEFAULT_AGENT_CHAT_STUCK_ALERT_THRESHOLD,
646
+ 1,
647
+ 1000,
648
+ ),
649
+ thresholdMode: "distinct_count",
650
+ distinctBy: "properties.runId",
651
+ windowMinutes: envInt(
652
+ "ANALYTICS_DEFAULT_AGENT_CHAT_STUCK_ALERT_WINDOW_MINUTES",
653
+ DEFAULT_AGENT_CHAT_STUCK_ALERT_WINDOW_MINUTES,
654
+ 1,
655
+ 60,
656
+ ),
657
+ cooldownMinutes: envInt(
658
+ "ANALYTICS_DEFAULT_AGENT_CHAT_STUCK_ALERT_COOLDOWN_MINUTES",
659
+ DEFAULT_AGENT_CHAT_STUCK_ALERT_COOLDOWN_MINUTES,
660
+ 0,
661
+ 24 * 60,
662
+ ),
663
+ severity: "critical",
664
+ });
665
+ }
666
+
667
+ return definitions;
668
+ }
669
+
670
+ export async function ensureDefaultAnalyticsAlertRules(): Promise<{
541
671
  checked: number;
542
672
  created: number;
543
673
  }> {
544
- if (!defaultHttp5xxAlertEnabled()) return { checked: 0, created: 0 };
545
-
546
- const scopes = await defaultHttp5xxAlertScopes();
547
- if (!scopes.length) return { checked: 0, created: 0 };
674
+ const definitions = defaultAnalyticsAlertDefinitions();
675
+ if (!definitions.length) return { checked: 0, created: 0 };
548
676
 
549
677
  const db = getDb() as any;
550
- const threshold = envInt(
551
- "ANALYTICS_DEFAULT_HTTP_5XX_ALERT_THRESHOLD",
552
- 5,
553
- 1,
554
- 1000,
555
- );
556
- const windowMinutes = envInt(
557
- "ANALYTICS_DEFAULT_HTTP_5XX_ALERT_WINDOW_MINUTES",
558
- 5,
559
- 1,
560
- 60,
561
- );
562
- const cooldownMinutes = envInt(
563
- "ANALYTICS_DEFAULT_HTTP_5XX_ALERT_COOLDOWN_MINUTES",
564
- 30,
565
- 0,
566
- 24 * 60,
567
- );
568
-
569
- const scopesById = new Map<string, AccessCtx>();
570
- for (const scope of scopes) {
571
- scopesById.set(defaultHttp5xxAlertId(scope.email, scope.orgId), scope);
572
- }
573
- const allIds = Array.from(scopesById.keys());
574
-
575
- const existingIds = new Set<string>();
576
- for (const idChunk of chunkArray(allIds, DEFAULT_ALERT_SEED_CHUNK_SIZE)) {
577
- const rows = await db
578
- .select({ id: schema.analyticsAlertRules.id })
579
- .from(schema.analyticsAlertRules)
580
- .where(inArray(schema.analyticsAlertRules.id, idChunk));
581
- for (const row of rows) existingIds.add(row.id);
582
- }
678
+ let checked = 0;
679
+ let created = 0;
680
+ let offset = 0;
681
+ while (true) {
682
+ const page = await defaultAnalyticsAlertScopePage(offset);
683
+ const candidatesById = new Map<
684
+ string,
685
+ { definition: DefaultAnalyticsAlertDefinition; scope: AccessCtx }
686
+ >();
687
+ for (const scope of page.scopes) {
688
+ for (const definition of definitions) {
689
+ candidatesById.set(
690
+ defaultAlertId(definition.idPrefix, scope.email, scope.orgId),
691
+ { definition, scope },
692
+ );
693
+ }
694
+ }
695
+ const allIds = Array.from(candidatesById.keys());
696
+ checked += allIds.length;
697
+
698
+ const existingIds = new Set<string>();
699
+ for (const idChunk of chunkArray(allIds, DEFAULT_ALERT_SEED_CHUNK_SIZE)) {
700
+ const rows = await db
701
+ .select({ id: schema.analyticsAlertRules.id })
702
+ .from(schema.analyticsAlertRules)
703
+ .where(inArray(schema.analyticsAlertRules.id, idChunk));
704
+ for (const row of rows) existingIds.add(row.id);
705
+ }
583
706
 
584
- const missingIds = allIds.filter((id) => !existingIds.has(id));
585
- if (!missingIds.length) return { checked: scopes.length, created: 0 };
707
+ const now = nowIso();
708
+ const rowsToInsert = allIds
709
+ .filter((id) => !existingIds.has(id))
710
+ .map((id) => {
711
+ const { definition, scope } = candidatesById.get(id)!;
712
+ return {
713
+ id,
714
+ name: definition.name,
715
+ description: definition.description,
716
+ eventName: definition.eventName,
717
+ filters: JSON.stringify(definition.filters),
718
+ thresholdMode: definition.thresholdMode ?? ("event_count" as const),
719
+ distinctBy: definition.distinctBy ?? null,
720
+ threshold: definition.threshold,
721
+ windowMinutes: definition.windowMinutes,
722
+ cooldownMinutes: definition.cooldownMinutes,
723
+ severity: definition.severity,
724
+ channels: JSON.stringify(["inbox"]),
725
+ emailRecipients: JSON.stringify([]),
726
+ enabled: true,
727
+ createdAt: now,
728
+ updatedAt: now,
729
+ ownerEmail: scope.email,
730
+ orgId: scope.orgId,
731
+ };
732
+ });
586
733
 
587
- const now = nowIso();
588
- const rowsToInsert = missingIds.map((id) => {
589
- const scope = scopesById.get(id)!;
590
- return {
591
- id,
592
- name: "Hosted app HTTP 5xx spike",
593
- description:
594
- "Default Agent Native alert for a spike in server responses with 5xx status codes.",
595
- eventName: "http.response",
596
- filters: JSON.stringify([
597
- { field: "properties.status_class", value: "5xx" },
598
- ]),
599
- thresholdMode: "event_count" as const,
600
- distinctBy: null,
601
- threshold,
602
- windowMinutes,
603
- cooldownMinutes,
604
- severity: "critical" as const,
605
- channels: JSON.stringify(["inbox"]),
606
- emailRecipients: JSON.stringify([]),
607
- enabled: true,
608
- createdAt: now,
609
- updatedAt: now,
610
- ownerEmail: scope.email,
611
- orgId: scope.orgId,
612
- };
613
- });
734
+ for (const rowChunk of chunkArray(
735
+ rowsToInsert,
736
+ DEFAULT_ALERT_SEED_CHUNK_SIZE,
737
+ )) {
738
+ const inserted = await db
739
+ .insert(schema.analyticsAlertRules)
740
+ .values(rowChunk)
741
+ .onConflictDoNothing()
742
+ .returning({ id: schema.analyticsAlertRules.id });
743
+ created += inserted.length;
744
+ }
614
745
 
615
- let created = 0;
616
- for (const rowChunk of chunkArray(
617
- rowsToInsert,
618
- DEFAULT_ALERT_SEED_CHUNK_SIZE,
619
- )) {
620
- const inserted = await db
621
- .insert(schema.analyticsAlertRules)
622
- .values(rowChunk)
623
- .onConflictDoNothing()
624
- .returning({ id: schema.analyticsAlertRules.id });
625
- created += inserted.length;
746
+ if (!page.hasMore) break;
747
+ offset += DEFAULT_ALERT_SCOPE_PAGE_SIZE;
626
748
  }
627
749
 
628
- return { checked: scopes.length, created };
750
+ return { checked, created };
629
751
  }
630
752
 
631
753
  export async function listEnabledAnalyticsAlertRules(options: {
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: dictate
3
3
  description: >-
4
- The Dictate tab in Clips — press-and-hold dictation history (Hold-Fn / Cmd+Shift+Space),
5
- AI cleanup, and the desktop-app integration that captures the audio. Use when listing
6
- past dictations, polishing dictation text, or wiring the desktop tray hand-off.
4
+ The Dictate tab in Clips — press-and-hold desktop dictation, mobile voice
5
+ dictation, history, AI cleanup, and native capture hand-offs. Use when
6
+ listing past dictations, polishing dictation text, or wiring native capture.
7
7
  ---
8
8
 
9
9
  # Dictate
@@ -25,7 +25,9 @@ The press-and-hold UX intentionally mirrors **Wispr Flow**. See `templates/clips
25
25
 
26
26
  ## Data model touched
27
27
 
28
- - **`dictations`** — `id`, `fullText`, `cleanedText`, `durationMs`, `audioUrl`, `source` (`hold-fn` | `hotkey` | `manual`), `createdAt`, `ownableColumns()`.
28
+ - **`dictations`** — `id`, `fullText`, `cleanedText`, `durationMs`, `audioUrl`,
29
+ `source` (desktop shortcuts, `manual`, or `mobile`), `createdAt`,
30
+ `ownableColumns()`.
29
31
 
30
32
  `fullText` is the raw native transcript captured on-device (macOS Speech / Web Speech). `cleanedText` is the optional AI-polished version produced by the shared `cleanup-transcript` pipeline. UI shows `cleanedText ?? fullText`.
31
33
 
@@ -81,7 +83,28 @@ The press-and-hold flow is **owned by the desktop app** (`src-tauri/`). On Hold-
81
83
  3. The dictation row is created via the framework HTTP layer — the agent does not start/stop dictations.
82
84
  4. AI cleanup is a background pass via `cleanup-dictation`, not in the hot path — never block paste-on-release on a network round-trip.
83
85
 
84
- Agents must **never** wire dictation start/stop server-side. The native key listener is the only entry point.
86
+ Agents must **never** wire dictation start/stop server-side. Desktop key
87
+ listeners and the mobile capture UI own those user gestures.
88
+
89
+ ## Mobile dictation
90
+
91
+ The Agent Native iOS/Android app exposes Dictate from native Home, deep links,
92
+ and OS quick actions. Mobile is click-to-toggle rather than hold-to-talk:
93
+
94
+ 1. `expo-audio` records mic-only M4A and persists it under the app documents
95
+ directory before any network request.
96
+ 2. A durable capture-queue row protects the audio across interruptions, app
97
+ restarts, and upload failures.
98
+ 3. The named mobile voice client posts multipart audio to the authenticated
99
+ `/_agent-native/transcribe-voice` route. Provider keys never enter the app.
100
+ 4. The cleaned transcript is editable, copied to the OS clipboard, and saved
101
+ with `create-dictation --source=mobile`; edits use `update-dictation`.
102
+ 5. The audio also syncs to Clips through the resumable recording upload path so
103
+ a failed transcription never destroys the captured speech.
104
+
105
+ Do not auto-send mobile dictation to an agent or another app. iOS/Android quick
106
+ actions open capture; clipboard is the cross-app fallback until platform
107
+ keyboard/accessibility insertion is enabled.
85
108
 
86
109
  ## UI conventions (don't break)
87
110
 
@@ -47,6 +47,13 @@ Meeting capture records **two streams** and tags transcript segments by source:
47
47
 
48
48
  Each transcript segment carries a `source: "mic" | "system"` tag, which we use to attribute action items to the right attendee. This is why **per-attendee action items only work reliably with mic + system capture** — mic-only recordings make remote attendees silent. Document this caveat whenever you surface action items.
49
49
 
50
+ The iOS/Android companion is intentionally different: mobile background meeting
51
+ capture is microphone-only because the operating systems do not expose another
52
+ phone app's Zoom/Meet/Teams audio. It is appropriate for in-person rooms and for
53
+ capturing audio played from a separate device. Mobile recordings still receive
54
+ Clips transcription and summaries, but do not promise desktop-quality remote
55
+ speaker attribution or per-attendee action items.
56
+
50
57
  ## Bidirectional recording ↔ meeting link
51
58
 
52
59
  A meeting can have an associated recording, and a recording can be linked back to a meeting:
@@ -38,6 +38,27 @@ Some recordings are linked to a meeting — when `meeting_id` is non-null on the
38
38
  `processing` to `ready`. Do not wait for the upload/finalize response before
39
39
  opening the page.
40
40
 
41
+ ## Mobile companion lifecycle
42
+
43
+ The Agent Native mobile app uses the same recording rows and binary upload
44
+ routes with native capture primitives:
45
+
46
+ 1. Camera video/import uses `expo-camera` / the system photo picker; meeting
47
+ audio uses `expo-audio` with background recording enabled.
48
+ 2. The native file is copied into the documents directory and a typed
49
+ AsyncStorage capture job is written before upload starts.
50
+ 3. `create-recording` receives a stable client-generated id,
51
+ `sourceAppName: "Agent Native Mobile"`, and the container MIME type.
52
+ 4. Upload reads at most 3 MiB through an Expo FileHandle and persists the next
53
+ chunk index after every acknowledged POST. The 4 MiB server cap still
54
+ applies.
55
+ 5. Foreground/resume reconciliation polls `/api/uploads/:id/status`; retryable
56
+ failures back off and never discard the local file. Completion can post a
57
+ local notification and the recording appears in Clips everywhere.
58
+
59
+ Mobile audio M4A is an MP4 container and uses the recording route's MP4 MIME
60
+ alias. The bytes are not converted or loaded whole into JavaScript memory.
61
+
41
62
  ## Seekable playback (don't ship raw MediaRecorder output)
42
63
 
43
64
  Raw `MediaRecorder` files are not friendly to progressive HTTP playback, which
@@ -191,6 +212,8 @@ export function useRecorder() {
191
212
  - **Never** start a `MediaRecorder` without a user gesture (or a user-initiated `record-intent`).
192
213
  - **Never** re-prompt for permissions on pause/resume — reuse the stream.
193
214
  - **Never** fire the upload from the main thread if the chunks are large — prefer a web worker for anything longer than ~60s.
215
+ - **Never** read a complete mobile recording into JavaScript memory. Use bounded
216
+ FileHandle reads and checkpoint every acknowledged chunk.
194
217
  - The `recordings` row must exist **before** the first chunk is sent.
195
218
  - On every lifecycle change, write `navigation` → `{ view: "record" }` → `{ view: "recording", recordingId }` so the agent can see what's happening.
196
219
  - All AI generated during/after recording goes through the agent chat — see `ai-video-tools`.
@@ -51,6 +51,16 @@ Detailed media, meeting, dictation, editing, and sharing rules live in
51
51
  `previewTruncated` is true, it may end mid-sentence and does not show where
52
52
  transcription ended. Call `get-recording-player-data` before judging
53
53
  completeness or quoting the full transcript.
54
+ - Mobile captures originate in the Agent Native iOS/Android app. The phone
55
+ persists each audio/video file before network work, creates the recording
56
+ with `sourceAppName: "Agent Native Mobile"`, and resumes bounded chunk
57
+ uploads from its durable queue. Do not ask users to keep a capture screen
58
+ open or re-record after a transient upload failure; reconnect Clips and retry
59
+ the saved job from mobile Home.
60
+ - Mobile meeting capture is microphone-only. It can record an in-person room or
61
+ audio playing from another device, but iOS and Android do not expose another
62
+ phone app's Zoom/Meet/Teams call audio to this flow. Do not claim mobile
63
+ attendee attribution has the desktop mic-plus-system-audio fidelity.
54
64
  - Dictation cleanup, Clip title/cleanup, and meeting summaries should pass
55
65
  bounded `voiceContext` to the shared cleanup/transcription path when active
56
66
  app context, learned vocabulary, user notes, or AGENTS.md preferences are
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Create a dictation row from browser or desktop voice capture.
2
+ * Create a dictation row from browser, desktop, or mobile voice capture.
3
3
  */
4
4
 
5
5
  import { defineAction } from "@agent-native/core";
6
6
  import { writeAppState } from "@agent-native/core/application-state";
7
+ import { and, eq } from "drizzle-orm";
7
8
  import { z } from "zod";
8
9
 
9
10
  import { getDb, schema } from "../server/db/index.js";
@@ -11,18 +12,28 @@ import {
11
12
  getActiveOrganizationId,
12
13
  getCurrentOwnerEmail,
13
14
  nanoid,
15
+ ownerEmailMatches,
14
16
  } from "../server/lib/recordings.js";
15
17
 
16
18
  export default defineAction({
17
19
  description:
18
- "Create a dictation history row from captured voice text. Used by browser Dictate and desktop voice capture.",
20
+ "Create a dictation history row from captured voice text. Used by browser, desktop, and mobile voice capture.",
19
21
  schema: z.object({
22
+ id: z.string().min(1).optional().describe("Stable client-generated id"),
20
23
  fullText: z.string().min(1).describe("Raw dictation text"),
21
24
  cleanedText: z.string().nullable().optional(),
22
25
  durationMs: z.coerce.number().int().min(0).optional(),
23
26
  audioUrl: z.string().url().nullable().optional(),
24
27
  source: z
25
- .enum(["fn-hold", "cmd-shift-space", "manual", "other", "fn", "custom"])
28
+ .enum([
29
+ "fn-hold",
30
+ "cmd-shift-space",
31
+ "manual",
32
+ "mobile",
33
+ "other",
34
+ "fn",
35
+ "custom",
36
+ ])
26
37
  .default("manual"),
27
38
  targetApp: z.string().nullable().optional(),
28
39
  startedAt: z.string().datetime().optional(),
@@ -32,10 +43,24 @@ export default defineAction({
32
43
  const ownerEmail = getCurrentOwnerEmail();
33
44
  const orgId = await getActiveOrganizationId().catch(() => null);
34
45
  const now = new Date().toISOString();
35
- const id = nanoid();
46
+ const id = args.id ?? nanoid();
36
47
  const fullText = args.fullText.trim();
37
48
  if (!fullText) throw new Error("Dictation text is required");
38
49
 
50
+ if (args.id) {
51
+ const [existing] = await db
52
+ .select()
53
+ .from(schema.dictations)
54
+ .where(
55
+ and(
56
+ eq(schema.dictations.id, id),
57
+ ownerEmailMatches(schema.dictations.ownerEmail, ownerEmail),
58
+ ),
59
+ )
60
+ .limit(1);
61
+ if (existing) return existing;
62
+ }
63
+
39
64
  await db.insert(schema.dictations).values({
40
65
  id,
41
66
  fullText,