@agent-native/core 0.136.0 → 0.136.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +31 -5
  3. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +7 -0
  4. package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +52 -19
  5. package/corpus/templates/analytics/.agents/skills/provider-api/SKILL.md +25 -3
  6. package/corpus/templates/analytics/AGENTS.md +6 -7
  7. package/corpus/templates/analytics/actions/gong-calls.ts +3 -3
  8. package/corpus/templates/analytics/actions/provider-api-catalog.ts +1 -1
  9. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  10. package/corpus/templates/analytics/app/i18n-data.ts +46 -0
  11. package/corpus/templates/analytics/app/pages/Settings.tsx +67 -1
  12. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +6 -0
  13. package/corpus/templates/analytics/changelog/2026-08-03-dashboard-builds-now-finish-without-an-extra-confirmation.md +6 -0
  14. package/corpus/templates/analytics/changelog/2026-08-03-error-alert-emails-are-opt-in.md +6 -0
  15. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +10 -9
  16. package/corpus/templates/analytics/server/lib/error-capture.ts +38 -10
  17. package/corpus/templates/analytics/server/lib/gong.ts +27 -4
  18. package/corpus/templates/analytics/server/plugins/agent-chat.ts +64 -4
  19. package/corpus/templates/analytics/shared/analytics-user-prefs.ts +7 -0
  20. package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +15 -6
  21. package/corpus/templates/clips/actions/apply-rewind-extension.ts +9 -0
  22. package/corpus/templates/clips/actions/cleanup-dictation.ts +2 -2
  23. package/corpus/templates/clips/actions/cleanup-transcript.ts +8 -11
  24. package/corpus/templates/clips/actions/clear-edits.ts +15 -2
  25. package/corpus/templates/clips/actions/create-recording-agent-link.ts +10 -0
  26. package/corpus/templates/clips/actions/finalize-meeting.ts +6 -6
  27. package/corpus/templates/clips/actions/finalize-recording.ts +33 -8
  28. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +1 -3
  29. package/corpus/templates/clips/actions/list-recordings.ts +39 -24
  30. package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +4 -1
  31. package/corpus/templates/clips/actions/regenerate-title.ts +2 -2
  32. package/corpus/templates/clips/actions/stitch-recordings.ts +36 -7
  33. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +1 -4
  34. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +3 -1
  35. package/corpus/templates/clips/app/components/library/recording-card.tsx +9 -0
  36. package/corpus/templates/clips/app/components/player/recording-views-badge.tsx +52 -7
  37. package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
  38. package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +17 -10
  39. package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
  40. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +5 -0
  41. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -0
  42. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  43. package/corpus/templates/clips/changelog/2026-07-31-agent-views-now-show-the-agent-s-name-from-the-link-it-was-g.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-31-clip-cards-and-the-watch-share-page-header-now-show-agent-vi.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-08-01-recordings-stored-in-private-s3-compatible-buckets-finalize-.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-08-03-clip-cleanup-titles-and-meeting-summaries-now-use-luna-by-de.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-08-03-get-a-monthly-recap-email-showing-how-many-people-and-ai-age.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-08-03-get-an-email-the-first-time-an-ai-agent-reads-one-of-your-cl.md +6 -0
  49. package/corpus/templates/clips/learnings.defaults.md +2 -2
  50. package/corpus/templates/clips/package.json +1 -1
  51. package/corpus/templates/clips/scripts/send-real-recap.ts +165 -0
  52. package/corpus/templates/clips/scripts/send-test-emails.ts +60 -0
  53. package/corpus/templates/clips/server/db/schema.ts +4 -0
  54. package/corpus/templates/clips/server/jobs/transactional-emails.ts +256 -0
  55. package/corpus/templates/clips/server/lib/agent-views.ts +38 -11
  56. package/corpus/templates/clips/server/lib/media-storage-provenance.ts +16 -0
  57. package/corpus/templates/clips/server/lib/public-agent-context.ts +3 -1
  58. package/corpus/templates/clips/server/lib/recap-metrics.ts +373 -0
  59. package/corpus/templates/clips/server/lib/s3-upload-provider.ts +84 -4
  60. package/corpus/templates/clips/server/lib/transactional-email-store.ts +41 -2
  61. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +245 -3
  62. package/corpus/templates/clips/server/plugins/db.ts +16 -0
  63. package/corpus/templates/clips/server/register-secrets.ts +2 -2
  64. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +7 -2
  65. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +42 -1
  66. package/corpus/templates/design/actions/take-design-screenshot.ts +5 -3
  67. package/corpus/templates/design/package.json +1 -1
  68. package/corpus/templates/design/server/lib/playwright-runtime.ts +13 -5
  69. package/corpus/templates/mail/actions/get-automation-settings.ts +4 -12
  70. package/corpus/templates/mail/app/pages/SettingsPage.tsx +1 -1
  71. package/corpus/templates/mail/changelog/2026-08-03-inbox-automations-now-prefer-the-lowest-cost-luna-model-when.md +6 -0
  72. package/corpus/templates/mail/server/lib/automation-engine.ts +19 -14
  73. package/corpus/templates/mail/server/lib/automation-model.ts +110 -0
  74. package/corpus/templates/mail/server/plugins/agent-chat.ts +1 -1
  75. package/corpus/templates/slides/actions/generate-slides-ai.ts +92 -24
  76. package/corpus/templates/slides/changelog/2026-08-03-legacy-slide-outlines-now-prefer-the-lowest-cost-luna-model-.md +6 -0
  77. package/corpus/templates/tasks/package.json +2 -2
  78. package/dist/agent/production-agent.d.ts +2 -2
  79. package/dist/agent/production-agent.js +1 -1
  80. package/dist/catalog.json +2 -0
  81. package/dist/client/AgentTaskCard.js +4 -2
  82. package/dist/client/i18n.js +3 -0
  83. package/dist/client/settings/VoiceTranscriptionSection.js +2 -2
  84. package/dist/collab/awareness.d.ts +2 -2
  85. package/dist/collab/routes.d.ts +1 -1
  86. package/dist/collab/struct-routes.d.ts +1 -1
  87. package/dist/localization/default-messages.d.ts +5 -0
  88. package/dist/localization/default-messages.js +5 -0
  89. package/dist/mcp/oauth-client-metadata.d.ts +7 -0
  90. package/dist/mcp/oauth-client-metadata.js +47 -0
  91. package/dist/mcp/oauth-route.js +7 -3
  92. package/dist/mcp/oauth-store.d.ts +2 -0
  93. package/dist/mcp/oauth-store.js +25 -3
  94. package/dist/notifications/routes.d.ts +1 -1
  95. package/dist/observability/routes.d.ts +3 -3
  96. package/dist/observability/traces.d.ts +1 -1
  97. package/dist/observability/traces.js +2 -1
  98. package/dist/progress/routes.d.ts +1 -1
  99. package/dist/provider-api/index.js +29 -1
  100. package/dist/secrets/routes.d.ts +6 -6
  101. package/dist/server/agent-access.d.ts +3 -1
  102. package/dist/server/agent-access.js +2 -1
  103. package/dist/server/agent-chat/browser-team-tools.d.ts +1 -0
  104. package/dist/server/agent-chat/browser-team-tools.js +5 -4
  105. package/dist/server/agent-chat/context-tools.js +2 -2
  106. package/dist/server/agent-chat-plugin.js +13 -6
  107. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  108. package/dist/server/agent-teams-run-queue.d.ts +2 -0
  109. package/dist/server/agent-teams.d.ts +2 -0
  110. package/dist/server/agent-teams.js +45 -2
  111. package/dist/server/request-context.d.ts +2 -0
  112. package/dist/server/short-lived-token.d.ts +7 -0
  113. package/dist/server/short-lived-token.js +7 -1
  114. package/dist/server/ssr-handler.js +19 -1
  115. package/dist/server/transcribe-voice.d.ts +1 -1
  116. package/dist/server/transcribe-voice.js +24 -16
  117. package/dist/vite/client.d.ts +37 -1
  118. package/dist/vite/client.js +110 -1
  119. package/docs/content/template-assets.mdx +14 -4
  120. package/package.json +3 -4
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
20
20
  */
21
21
  import { notifyWithDelivery } from "@agent-native/core/notifications";
22
22
  import { recordChange } from "@agent-native/core/server";
23
+ import { getUserSetting } from "@agent-native/core/settings";
23
24
  import { accessFilter } from "@agent-native/core/sharing";
24
25
  import {
25
26
  and,
@@ -33,6 +34,7 @@ import {
33
34
  sql,
34
35
  } from "drizzle-orm";
35
36
 
37
+ import { ANALYTICS_USER_PREFS_KEY } from "../../shared/analytics-user-prefs";
36
38
  import { getDb, schema } from "../db/index.js";
37
39
 
38
40
  export type ExceptionLevel = "fatal" | "error" | "warning" | "info" | "debug";
@@ -952,11 +954,14 @@ export async function ingestException(
952
954
  });
953
955
 
954
956
  if (isNewIssue) {
955
- await notifyNewIssue(scope, { issueId, title, level: raw.level }).catch(
956
- () => {
957
- // New-issue alerts are best-effort; never fail ingest on delivery.
958
- },
959
- );
957
+ const emailEnabled = await errorEmailNotificationsEnabled(scope);
958
+ await notifyNewIssue(
959
+ scope,
960
+ { issueId, title, level: raw.level },
961
+ emailEnabled,
962
+ ).catch(() => {
963
+ // New-issue alerts are best-effort; never fail ingest on delivery.
964
+ });
960
965
  }
961
966
 
962
967
  return { issueId, eventId, isNewIssue, sessionRecordingId };
@@ -990,22 +995,25 @@ export async function ingestAnalyticsExceptionEvents(
990
995
  async function notifyNewIssue(
991
996
  scope: IngestScope,
992
997
  issue: { issueId: string; title: string; level: ExceptionLevel },
998
+ emailEnabled: boolean,
993
999
  ): Promise<void> {
994
1000
  await notifyWithDelivery(
995
1001
  {
996
1002
  severity: issue.level === "fatal" ? "critical" : "warning",
997
1003
  title: `New error: ${issue.title}`,
998
1004
  body: "A new JavaScript error was captured in your app.",
999
- channels: ["inbox", "email"],
1005
+ channels: emailEnabled ? ["inbox", "email"] : ["inbox"],
1000
1006
  metadata: {
1001
1007
  kind: "error_issue",
1002
1008
  issueId: issue.issueId,
1003
1009
  level: issue.level,
1004
1010
  path: `/monitoring?view=errors&issue=${issue.issueId}`,
1005
- // The email channel is a no-op without explicit recipients, so a
1006
- // captured issue would otherwise never leave the in-app inbox.
1007
- emailRecipients: [scope.ownerEmail],
1008
- emailSubject: `New error in your app: ${issue.title}`,
1011
+ ...(emailEnabled
1012
+ ? {
1013
+ emailRecipients: [scope.ownerEmail],
1014
+ emailSubject: `New error in your app: ${issue.title}`,
1015
+ }
1016
+ : {}),
1009
1017
  },
1010
1018
  },
1011
1019
  // The notification inbox is owner-scoped; the issue's owner is the analytics
@@ -1015,6 +1023,26 @@ async function notifyNewIssue(
1015
1023
  );
1016
1024
  }
1017
1025
 
1026
+ async function errorEmailNotificationsEnabled(
1027
+ scope: IngestScope,
1028
+ ): Promise<boolean> {
1029
+ try {
1030
+ const prefs = await getUserSetting(
1031
+ scope.ownerEmail,
1032
+ ANALYTICS_USER_PREFS_KEY,
1033
+ );
1034
+ return prefs?.errorEmailNotifications === true;
1035
+ } catch (error) {
1036
+ // Error email delivery must fail closed when the owner preference cannot be
1037
+ // read; the in-app issue notification still remains available.
1038
+ console.warn(
1039
+ "[error-capture] Could not read error email preference; skipping email delivery:",
1040
+ error,
1041
+ );
1042
+ return false;
1043
+ }
1044
+ }
1045
+
1018
1046
  // ---------------------------------------------------------------------------
1019
1047
  // Reads + triage
1020
1048
  // ---------------------------------------------------------------------------
@@ -14,6 +14,8 @@ import {
14
14
  import { resolveAnalyticsGongCredentials } from "./provider-credentials";
15
15
 
16
16
  const DEFAULT_API_BASE = "https://api.gong.io/v2";
17
+ const MAX_GONG_SEARCH_PAGES = 50;
18
+ const MAX_GONG_EXHAUSTIVE_RECORDS = 500;
17
19
 
18
20
  const cache = new Map<string, { data: unknown; ts: number }>();
19
21
  const CACHE_TTL_MS = 10 * 60 * 1000;
@@ -541,6 +543,7 @@ export async function searchCallsForQueries(
541
543
  const matches = new Map<string, GongCall & { matchedQueries?: string[] }>();
542
544
  let searchedCallCount = 0;
543
545
  let cursor: string | undefined;
546
+ let pages = 0;
544
547
  do {
545
548
  const data = await apiPost<{
546
549
  calls?: unknown[];
@@ -553,12 +556,23 @@ export async function searchCallsForQueries(
553
556
  contentSelector: { exposedFields: { parties: true } },
554
557
  ...(cursor ? { cursor } : {}),
555
558
  });
559
+ pages += 1;
560
+ if (
561
+ options.exhaustive &&
562
+ typeof data.records?.totalRecords === "number" &&
563
+ data.records.totalRecords > MAX_GONG_EXHAUSTIVE_RECORDS
564
+ ) {
565
+ throw new Error(
566
+ `Gong exhaustive search found ${data.records.totalRecords.toLocaleString()} records in the requested window. ` +
567
+ "Use provider-corpus-job with staged call IDs for searches larger than 500 records so progress is checkpointed between batches.",
568
+ );
569
+ }
556
570
  const calls = (data.calls ?? [])
557
571
  .map(normalizeExtensiveCall)
558
572
  .filter((call): call is GongCall => Boolean(call))
559
573
  .filter(isExternalCall);
560
574
  searchedCallCount += calls.length;
561
- cursor = data.records?.cursor;
575
+ const nextCursor = data.records?.cursor;
562
576
 
563
577
  for (const call of calls) {
564
578
  const parties = call.parties ?? [];
@@ -573,7 +587,16 @@ export async function searchCallsForQueries(
573
587
  }
574
588
  if (!options.exhaustive && matches.size >= normalizedLimit) break;
575
589
  }
576
- } while (cursor && (options.exhaustive || matches.size < normalizedLimit));
590
+ if (!nextCursor || nextCursor === cursor) {
591
+ cursor = nextCursor;
592
+ break;
593
+ }
594
+ cursor = nextCursor;
595
+ } while (
596
+ cursor &&
597
+ pages < MAX_GONG_SEARCH_PAGES &&
598
+ (options.exhaustive || matches.size < normalizedLimit)
599
+ );
577
600
 
578
601
  const matchedCalls = Array.from(matches.values());
579
602
  return buildGongSearchResult(matchedCalls, normalizedLimit, {
@@ -611,11 +634,11 @@ export function buildGongSearchResult(
611
634
  return {
612
635
  calls: sorted,
613
636
  limit: sorted.length,
614
- truncated: false,
637
+ truncated: Boolean(meta.cursor),
615
638
  searchedCallCount: meta.searchedCallCount,
616
639
  matchedCallCount: matchedCalls.length,
617
640
  queryCount: meta.queryCount,
618
- coverageTruncated: false,
641
+ coverageTruncated: Boolean(meta.cursor),
619
642
  };
620
643
  }
621
644
  const limited = limitGongCalls(matchedCalls, normalizedLimit);
@@ -43,6 +43,45 @@ const ANALYTICS_DATA_SOURCES_LINK = buildDeepLink({
43
43
  to: "/data-sources",
44
44
  });
45
45
 
46
+ const DASHBOARD_BUILD_PAUSE_PATTERN =
47
+ /\b(?:want me to|would you like me to|shall i|should i|can i|may i|do you want me to)\b[\s\S]{0,160}\b(?:proceed|continue|seed|populate|save|embed|finish|run|apply|create|build)\b/i;
48
+
49
+ function hasSuccessfulDashboardSave(
50
+ toolResults: AgentLoopFinalResponseGuardContext["toolResults"],
51
+ ): boolean {
52
+ const saveActions = new Set([
53
+ "update-dashboard",
54
+ "mutate-dashboard",
55
+ "compose-dashboard",
56
+ "install-dashboard-template",
57
+ ]);
58
+ return (toolResults ?? []).some((result) => {
59
+ if (result.isError) return false;
60
+ const name = String(result.name ?? "")
61
+ .trim()
62
+ .toLowerCase()
63
+ .replace(/[\s_]+/g, "-");
64
+ return saveActions.has(name);
65
+ });
66
+ }
67
+
68
+ function hasPartialDashboardBuild(
69
+ toolResults: AgentLoopFinalResponseGuardContext["toolResults"],
70
+ ): boolean {
71
+ const partialBuildActions = new Set([
72
+ "create-extension",
73
+ "extension-data-set",
74
+ ]);
75
+ return (toolResults ?? []).some((result) => {
76
+ if (result.isError) return false;
77
+ const name = String(result.name ?? "")
78
+ .trim()
79
+ .toLowerCase()
80
+ .replace(/[\s_]+/g, "-");
81
+ return partialBuildActions.has(name);
82
+ });
83
+ }
84
+
46
85
  export const BOUNDED_STRUCTURED_LOOKUP_GUIDANCE =
47
86
  "BOUNDED STRUCTURED LOOKUP FAST PATH — Treat existing analytics work like an engineer treats existing code: grep before writing. For an ordinary count, aggregate, grouped metric, trend, or record lookup, first call `search-analytics-query-catalog` once with focused metric/entity terms. It searches accessible dashboard names, chart titles/descriptions/saved queries, shipped dashboard patterns, and data-dictionary definitions together. Prefer the strongest approved dictionary or saved-chart match, preserve its source and business logic, adapt only the requested filters and explicit time window, then run one bounded query against that source. A user-named source wins, but still use a matching saved definition when it supplies the source's proven query shape. If there is no useful match, inspect only the most likely source schema or ask one clarification; do not fan out across providers. Do not separately list every dashboard, call data-source status, browse the whole dictionary, load provider catalogs/corpus tools, or query a second source after a strong match. Once the query succeeds, answer immediately with its source, time window, filters, row count, and only necessary caveats. Do not enrich, cross-check, retry, or add breakdowns unless the user requested them, the first query failed, or its result conflicts with the known definition. The words `all`, `total`, or `exact` in a structured aggregate do not by themselves make it a corpus investigation. Never repeat an identical invalid or failed tool call; correct its arguments once or surface the error. This does not waive the real-data requirement: never answer from a guess, stale value, or unverified result. ";
48
87
 
@@ -541,7 +580,12 @@ export function realDataFinalGuard(
541
580
  context as AgentLoopFinalResponseGuardContext & { requestText?: string }
542
581
  ).requestText;
543
582
  const userText = stableRequestText ?? latestUserText(context.messages ?? []);
544
- if (!looksLikeAnalyticsDataRequest(userText)) {
583
+ const dashboardConstructionRequest =
584
+ looksLikeDashboardConstructionRequest(userText);
585
+ if (
586
+ !looksLikeAnalyticsDataRequest(userText) &&
587
+ !dashboardConstructionRequest
588
+ ) {
545
589
  // Deterministic backstop: the soft NON_ANALYTICS_REQUEST_GUIDANCE prompt
546
590
  // sentence is not always enough, and a model occasionally parrots the
547
591
  // canned no-grounded-data fallback even for ordinary conversation. Catch
@@ -612,7 +656,7 @@ export function realDataFinalGuard(
612
656
  ) {
613
657
  return {
614
658
  retryMessage:
615
- "The user asked a coverage-sensitive provider question, but the draft only used bounded convenience data actions. Do not finalize an exhaustive, all-records, or absence-sensitive answer from shortcut actions alone. Use the broad provider API/MCP surface and a corpus workflow now: provider-api-catalog/provider-api-docs when needed, provider-corpus-job for durable paginated or batched corpus scans, provider-api-request with fetchAllPages/stageAs/saveToFile for the exact provider endpoint/filter/body/pagination, then run-code or query-staged-dataset to join, grep, classify, and aggregate. If full coverage is not possible in this turn, finalize with explicit partial-coverage wording, inspected counts, filters, and remaining gaps.",
659
+ "The user asked a coverage-sensitive provider question, but the draft only used bounded convenience data actions. Do not finalize an exhaustive, all-records, or absence-sensitive answer from shortcut actions alone. Use the broad provider API/MCP surface and a staged analysis workflow now: provider-api-catalog/provider-api-docs when needed; for Gong, use configured tracker results from /calls/extensive when they cover the term, otherwise use provider-api-request as raw ingestion with stageAs/saveToFile followed by query-staged-dataset or a Data Program; use provider-corpus-job for durable batched raw-transcript scans. Never loop per call from run-code or a delegated agent. For more than 500 Gong records, gong-calls is not the broad-search path. If full coverage is not possible in this turn, finalize with explicit partial-coverage wording, inspected counts, filters, and remaining gaps.",
616
660
  fallbackMessage:
617
661
  "I can't make a confident coverage-sensitive provider claim from bounded shortcut actions alone. I need a provider API/corpus workflow, or I need to label the answer as partial with exact inspected counts and gaps.",
618
662
  };
@@ -626,7 +670,7 @@ export function realDataFinalGuard(
626
670
  ) {
627
671
  return {
628
672
  retryMessage:
629
- "The user asked to search source-record body text such as transcripts, messages, tickets, issues, notes, documents, or conversation logs, but the draft's corpus evidence does not show that the requested body records were actually searched. A parent/container metadata scan, title search, summary search, or call/ticket/message list is not enough for an absence-sensitive body-text claim. Retry with the provider's raw body endpoint or native search for the requested record type, using provider-corpus-job batch-search/paginated-search, provider-api-request with staging, or run-code over staged raw records. Then report source path/body field, inspected record count, hit count, and gaps.",
673
+ "The user asked to search source-record body text such as transcripts, messages, tickets, issues, notes, documents, or conversation logs, but the draft's corpus evidence does not show that the requested body records were actually searched. A parent/container metadata scan, title search, summary search, or call/ticket/message list is not enough for an absence-sensitive body-text claim. Retry with the provider's native search, indexed tracker result, or raw body endpoint for the requested record type, using provider-corpus-job batch-search/paginated-search, provider-api-request with staging, or a Data Program/query over staged raw records. Then report source path/body field, inspected record count, hit count, and gaps.",
630
674
  fallbackMessage:
631
675
  "I can't make a confident source-record body-text claim because the corpus evidence does not show that the requested raw records were searched.",
632
676
  };
@@ -652,6 +696,21 @@ export function realDataFinalGuard(
652
696
  // on tool evidence, not user wording, and still block any draft that states
653
697
  // invented numbers via draftClaimsAnalyticsMetrics. A saved SQL panel the
654
698
  // user runs themselves is not a fabricated metric.
699
+ if (
700
+ dashboardConstructionRequest &&
701
+ hasPartialDashboardBuild(context.toolResults) &&
702
+ !hasSuccessfulDashboardSave(context.toolResults) &&
703
+ DASHBOARD_BUILD_PAUSE_PATTERN.test(context.text)
704
+ ) {
705
+ return {
706
+ retryMessage:
707
+ "The user explicitly requested this dashboard or Custom Block. Continue the non-destructive build in this same turn: seed or refresh extension data when needed, save and embed the dashboard, and navigate to the result. Do not ask whether to proceed. Ask only about an ambiguous metric scope, a destructive change, or an external side effect such as sending email or outreach.",
708
+ fallbackMessage:
709
+ "I couldn't finish the requested dashboard build in this turn. Please retry and I'll continue from the saved artifact.",
710
+ maxRetries: 2,
711
+ expandToolSurface: true,
712
+ };
713
+ }
655
714
  if (
656
715
  hasDashboardMutationAttempt(context.toolResults) &&
657
716
  !draftClaimsAnalyticsMetrics(context.text)
@@ -664,7 +723,7 @@ export function realDataFinalGuard(
664
723
  // "no data query ran" fallback so a template-based extension clone is not
665
724
  // treated the same as an unanswerable analytics-result question.
666
725
  if (
667
- looksLikeDashboardConstructionRequest(userText) &&
726
+ dashboardConstructionRequest &&
668
727
  !draftClaimsAnalyticsMetrics(context.text)
669
728
  ) {
670
729
  if (
@@ -865,6 +924,7 @@ export default createAgentChatPlugin({
865
924
  const sourceGuidance =
866
925
  analyticsSourceGuidanceOpening() +
867
926
  "DASHBOARD CREATION RULE — You may create dashboard artifacts, SQL panels, or other resources only when the user explicitly asks you to (e.g. 'build me a dashboard for...', 'save this analysis', 'add a chart for...'). Treat a requested saved analysis or deep-dive report as a dashboard request. Never create any resource proactively during research, trend analysis, or answering questions. If you think a dashboard would be useful, suggest it and wait for explicit confirmation before creating anything. Never add new items to the sidebar or modify existing dashboards without an explicit user directive. " +
927
+ "EXECUTION CONTINUITY — An explicit request to build, create, save, or adapt a dashboard or one-off Custom Block authorizes all non-destructive in-app steps required to finish it in the same turn. After querying or scaffolding, continue through extension-data seeding/refresh, dashboard save/embed, and navigation. Do not ask 'want me to proceed?' or stop at an empty shell. Ask one clarification only when metric scope or grain materially changes the result, and pause for destructive changes or external side effects such as sending email or outreach. " +
868
928
  "DASHBOARD MUTATION RULE — For dashboard edits, default to `mutate-dashboard` with the typed `dashboard.*` script API so the main payload is a string and avoids native-array serialization traps. It can move panels by id, edit titles/SQL/config, insert, duplicate, remove, and patch dashboard fields in one atomic save. The script API is constrained: no variables/imports/loops/functions, only JSON-compatible arguments on documented dashboard methods. Do not count shifting `/panels/<index>` positions for ordinary dashboard edits unless the user specifically asks for low-level JSON-pointer operations. " +
869
929
  'CUSTOM BLOCK RULE — Analytics can embed sandboxed extensions as dashboard-scoped Custom Blocks, but native panels and Data Programs come first. Do not create one for an ordinary "put X in this dashboard" request. Use `config.extensionId` only for an explicitly requested one-off or bespoke visualization that the native dashboard model cannot represent faithfully. For each new block, set `config.customBlock` with `authoredBy: "agent"`, `intent: "one-off"`, `scope: "dashboard"`, and a categorical `nativeGapReason` of `custom-visualization`, `custom-interaction`, `custom-layout`, or `other`; never store prompt or customer text there. The embed is shared with the dashboard, appears in scheduled reports, and receives dashboard/panel/current-filter context. Use `config.extensionSlotId` only when the user explicitly asks for a personal/per-viewer slot. Slot ids use `analytics.dashboard.<dashboard-id>.panel.<panel-id>` and require `add-extension-slot-target` plus `install-extension`; installs are per-user, so viewers can see different content and report identities may see an empty slot. Use `get-sql-dashboard` panel summaries to inspect an existing Custom Block. ' +
870
930
  'EXTENSION DATA-REPAIR RULE — When fixing data in an existing extension-backed dashboard or migrated surface such as Risk Meeting, inspect the current dashboard and extension first, then call `update-extension` with exactly `id`, `operation="edit"`, and a `payloadJson` string containing focused patches/edits that change only the data-loading seam. Never send empty placeholder fields. Preserve the existing layout, CSS, copy, and interactions; never reconstruct the full HTML body for a data-only fix. A request that combines a visual rewrite such as compacting, removing sections, renaming, or changing padding with a data repair is a broad rewrite; after inspecting the current extension, use `operation="replace"` with the complete replacement in `payloadJson`. If a focused edit fails, change the target instead of retrying identical arguments. ' +
@@ -0,0 +1,7 @@
1
+ /** Per-user Analytics preferences shared by Settings and notification senders. */
2
+ export const ANALYTICS_USER_PREFS_KEY = "analytics-user-prefs";
3
+
4
+ export type AnalyticsUserPrefs = {
5
+ /** New JavaScript error emails are opt-in. */
6
+ errorEmailNotifications?: boolean;
7
+ };
@@ -289,9 +289,15 @@ clip never hits them), so a request on one is the signal.
289
289
 
290
290
  - **Table:** `recording_agent_views` — one row per `(recordingId, agentKey, viewSessionId)`.
291
291
  `agentKey` is a sha256 of user-agent + request IP, so an agent is countable
292
- across polls without ever storing its IP. `agentLabel` is the product name
293
- parsed from the user-agent (Claude, ChatGPT, Perplexity, …), falling back to
294
- `"Agent"` never the raw user-agent string.
292
+ across polls without ever storing its IP. `agentLabel` resolves in that order:
293
+ the label the agent link was minted with (a signed `agentLabel` claim on the
294
+ `agent_access` token, set via `create-recording-agent-link --agentLabel`), then
295
+ the product name parsed from the user-agent (Claude, ChatGPT, Perplexity, …),
296
+ then NULL. NULL means unnamed, not a name — render it as "Unknown agent" and
297
+ never write a placeholder string, or an agent we could not identify becomes
298
+ indistinguishable from one that identified itself. `userAgent` keeps the raw
299
+ (truncated) string so unnamed agents stay identifiable and new `AGENT_LABELS`
300
+ patterns come from real traffic.
295
301
  - **Where it's written:** `recordAgentView` in `server/lib/agent-views.ts`,
296
302
  called from `loadPublicAgentAccess` — the one choke point all three agent
297
303
  routes share. Owner requests are skipped (they're previews, not views), and the
@@ -300,9 +306,12 @@ clip never hits them), so a request on one is the signal.
300
306
  into a single view via a 30-minute window (`AGENT_VIEW_SESSION_MS`), with
301
307
  `requestCount` recording how many polls that view covered.
302
308
  - **Reads:** `countRecordingAgentViews` and `listRecordingAgentViewers`. Surfaced
303
- as `agentViews` / `agentViewers` on `get-recording-insights` and
304
- `agentViewCount` on `get-recording-player-data`, and rendered in the views pill
305
- popover (`RecordingViewsBadge`) next to human views.
309
+ as `agentViews` / `agentViewers` on `get-recording-insights`, `agentViewCount`
310
+ on `get-recording-player-data`, `list-recordings`, and the public
311
+ `/api/public-recording` payload. The count renders inline — on the views pill
312
+ itself (`RecordingViewsBadge`, watch and share headers) and on library cards —
313
+ so agent reads are visible without opening the popover. It is always a
314
+ separate icon-prefixed number, never summed into the human view total.
306
315
 
307
316
  Keeping this in its own table is deliberate: no human-view query can pick agents
308
317
  up by forgetting a filter. Do not add agent rows to `recording_viewers` or
@@ -13,6 +13,7 @@ import {
13
13
  getCurrentOwnerEmail,
14
14
  ownerEmailMatches,
15
15
  } from "../server/lib/recordings.js";
16
+ import { isS3ObjectUrlBoundToRecording } from "../server/lib/s3-upload-provider.js";
16
17
  import { parseTranscriptSegments } from "../shared/transcript-segments.js";
17
18
  import { assertNoDirectRecordingShares } from "./make-recording-private-for-rewind.js";
18
19
  import {
@@ -112,6 +113,13 @@ export default defineAction({
112
113
  if (Math.abs(args.durationMs - expectedDuration) > 2_000) {
113
114
  throw new Error("The combined Clip duration does not match its sources.");
114
115
  }
116
+ const rewindMediaIsBound = await isS3ObjectUrlBoundToRecording(
117
+ args.videoUrl,
118
+ args.recordingId,
119
+ );
120
+ if (rewindMediaIsBound === false) {
121
+ throw new Error("The Rewind upload is not bound to its recording.");
122
+ }
115
123
 
116
124
  const edits = parseEdits(recording.editsJson);
117
125
  edits.trims = edits.trims.map((trim) => ({
@@ -125,6 +133,7 @@ export default defineAction({
125
133
  endMs: blur.endMs + args.addedMs,
126
134
  }));
127
135
  edits.rewindOriginalStartMs = args.addedMs;
136
+ edits.mediaStorageLayout = "external";
128
137
 
129
138
  const [transcript] = await db
130
139
  .select()
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Run the Gemini cleanup pass on a dictation's raw transcript text.
2
+ * Run the text-model cleanup pass on a dictation's raw transcript text.
3
3
  * Persists `cleanedText` on the dictation row.
4
4
  */
5
5
 
@@ -15,7 +15,7 @@ import { loadAgentsMdContext } from "./lib/agents-md-context.js";
15
15
 
16
16
  export default defineAction({
17
17
  description:
18
- "Clean up a dictation's raw text via the Gemini 3.1 Flash-Lite cleanup pass. Stores the result on `dictations.cleanedText`. Editor access required.",
18
+ "Clean up a dictation's raw text via the low-cost text-model cleanup pass. Stores the result on `dictations.cleanedText`. Editor access required.",
19
19
  schema: z.object({
20
20
  id: z.string().describe("Dictation id"),
21
21
  }),
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared Gemini cleanup pass for raw native transcripts.
2
+ * Shared text-model cleanup pass for raw native transcripts.
3
3
  *
4
4
  * This action is the ONE narrow exception to the "all AI through agent chat"
5
5
  * rule (see CLAUDE.md rule 1 + 2). It is a media-pipeline path — input is a
@@ -13,11 +13,9 @@
13
13
  * - Meetings finalize (task='summary' → summary + bullets + action items)
14
14
  *
15
15
  * Provider routing:
16
- * 1. Builder.io Connect credentials → Builder engine with model
17
- * `gemini-3-1-flash-lite` (matches existing convention in
18
- * `transcribe-voice.ts`).
16
+ * 1. Builder.io Connect credentials → Builder engine with GPT-5.6 Luna.
19
17
  * 2. Fallback: user GEMINI_API_KEY direct to Google's generativelanguage
20
- * API.
18
+ * API with Gemini Flash-Lite.
21
19
  * 3. Otherwise → throw FeatureNotConfiguredError.
22
20
  *
23
21
  * Usage:
@@ -44,11 +42,10 @@ import {
44
42
  noteBuilderCreditsExhausted,
45
43
  } from "./lib/builder-credits-state.js";
46
44
 
47
- // Builder gateway maps this to Gemini 3.1 Flash-Lite (see transcribe-voice.ts:52).
48
- const BUILDER_MODEL = "gemini-3-1-flash-lite";
45
+ const BUILDER_MODEL = "gpt-5-6-luna";
49
46
 
50
- // BYOK direct-Google fallback keep on a stable public model id; Builder's
51
- // managed provider handles the 3.1 preview.
47
+ // BYOK direct-Google fallback keeps an explicit public model id; Builder's
48
+ // managed path can use its own model catalog.
52
49
  const GEMINI_BYOK_MODEL = "gemini-2.0-flash-lite";
53
50
  const GEMINI_BYOK_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_BYOK_MODEL}:generateContent`;
54
51
 
@@ -71,7 +68,7 @@ const CLIPS_TRANSCRIPT_AGENT_INSTRUCTIONS = [
71
68
  "Relevant Clips AGENTS.md rules:",
72
69
  "- User-facing language calls a recording a Clip.",
73
70
  "- Generated titles should be concise, specific, and human-editable.",
74
- "- Native Web Speech/macOS Speech text is the source transcript; Gemini only cleans or titles it.",
71
+ "- Native Web Speech/macOS Speech text is the source transcript; the text model only cleans or titles it.",
75
72
  "- Cleanup preserves the speaker's meaning and voice, fixes recognition errors, and must not invent facts.",
76
73
  ].join("\n");
77
74
 
@@ -95,7 +92,7 @@ export interface CleanupResult {
95
92
 
96
93
  export default defineAction({
97
94
  description:
98
- "Run a Gemini 3.1 Flash-Lite cleanup pass on a raw transcript. task='cleanup' returns a cleaned transcript; task='title' returns a short title; task='summary' returns a markdown summary plus structured bullets and action items. This is a server-side media-pipeline path — it does NOT delegate to the agent chat.",
95
+ "Run a low-cost text-model cleanup pass on a raw transcript, preferring GPT-5.6 Luna through Builder when available and falling back to Gemini BYOK. task='cleanup' returns a cleaned transcript; task='title' returns a short title; task='summary' returns a markdown summary plus structured bullets and action items. This is a server-side media-pipeline path — it does NOT delegate to the agent chat.",
99
96
  schema: z.object({
100
97
  transcript: z.string().min(1).describe("Raw transcript text to process"),
101
98
  task: z
@@ -16,7 +16,11 @@ import { assertAccess } from "@agent-native/core/sharing";
16
16
  import { and, eq, isNull } from "drizzle-orm";
17
17
  import { z } from "zod";
18
18
 
19
- import { DEFAULT_EDITS, serializeEdits } from "../app/lib/timestamp-mapping.js";
19
+ import {
20
+ DEFAULT_EDITS,
21
+ parseEdits,
22
+ serializeEdits,
23
+ } from "../app/lib/timestamp-mapping.js";
20
24
  import { getDb, schema } from "../server/db/index.js";
21
25
  import { assertNativeRecordingMedia } from "./lib/native-media.js";
22
26
 
@@ -44,11 +48,20 @@ export default defineAction({
44
48
  assertNativeRecordingMedia(existing);
45
49
 
46
50
  const previousEditsJson = existing.editsJson;
51
+ const existingEdits = parseEdits(previousEditsJson);
47
52
 
48
53
  const result = await db
49
54
  .update(schema.recordings)
50
55
  .set({
51
- editsJson: serializeEdits({ ...DEFAULT_EDITS }),
56
+ editsJson: serializeEdits({
57
+ ...DEFAULT_EDITS,
58
+ ...(existingEdits.stitchedFrom
59
+ ? { stitchedFrom: existingEdits.stitchedFrom }
60
+ : {}),
61
+ ...(existingEdits.mediaStorageLayout
62
+ ? { mediaStorageLayout: existingEdits.mediaStorageLayout }
63
+ : {}),
64
+ }),
52
65
  updatedAt: new Date().toISOString(),
53
66
  })
54
67
  .where(
@@ -36,6 +36,15 @@ export default defineAction({
36
36
  "Create a temporary private agent-readable link for one Clips recording. The URL is scoped to that recording and expires after two hours.",
37
37
  schema: z.object({
38
38
  recordingId: z.string().describe("Recording ID"),
39
+ agentLabel: z
40
+ .string()
41
+ .trim()
42
+ .min(1)
43
+ .max(60)
44
+ .optional()
45
+ .describe(
46
+ "Name of the agent that will read this link (e.g. 'Fusion', 'Claude Code'). Recorded against every read the link produces, so the owner sees a name instead of an unidentified agent.",
47
+ ),
39
48
  ttlSeconds: z
40
49
  .number()
41
50
  .int()
@@ -68,6 +77,7 @@ export default defineAction({
68
77
  resourceKind: CLIP_AGENT_ACCESS_TOKEN_PREFIX,
69
78
  resourceId: recording.id,
70
79
  viewerEmail: getRequestUserEmail() || undefined,
80
+ agentLabel: args.agentLabel,
71
81
  ttlSeconds: args.ttlSeconds ?? CLIPS_AGENT_ACCESS_TTL_SECONDS,
72
82
  });
73
83
  const origin = appOrigin();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Finalize a meeting — runs the Gemini cleanup pass on its transcript and
2
+ * Finalize a meeting — runs the text-model cleanup pass on its transcript and
3
3
  * persists summary, bullets, and action items.
4
4
  *
5
5
  * Reads the linked recording's transcript (`recording_transcripts`) and
@@ -24,7 +24,7 @@ import { loadAgentsMdContext } from "./lib/agents-md-context.js";
24
24
 
25
25
  // A forced claim still needs the same compare-and-set protection as the normal
26
26
  // path. It can recover a pending row only after that row has gone stale, which
27
- // handles crashed processes without stealing an active Gemini run.
27
+ // handles crashed processes without stealing an active text-model run.
28
28
  const PENDING_STALE_MS = 2 * 60 * 1000;
29
29
 
30
30
  function trimContextValue(value: string, maxChars: number): string {
@@ -35,7 +35,7 @@ function trimContextValue(value: string, maxChars: number): string {
35
35
 
36
36
  export default defineAction({
37
37
  description:
38
- "Finalize a meeting: run the Gemini 3.1 Flash-Lite cleanup pass on its transcript, persist summary + bullets + action items, and flip transcriptStatus to 'ready'. Editor access required.",
38
+ "Finalize a meeting: run the low-cost text-model cleanup pass on its transcript, persist summary + bullets + action items, and flip transcriptStatus to 'ready'. Editor access required.",
39
39
  schema: z.object({
40
40
  meetingId: z.string().describe("Meeting id"),
41
41
  overrideTranscript: z
@@ -88,12 +88,12 @@ export default defineAction({
88
88
  // Mark pending so the UI shows a spinner during the LLM call. This is
89
89
  // also the compare-and-swap that prevents two concurrent finalize calls
90
90
  // (e.g. the desktop stop-path and the web route's auto-finalize effect)
91
- // from both running Gemini and clobbering meeting_action_items: only a
91
+ // from both running the text model and clobbering meeting_action_items: only a
92
92
  // call that observes 'ready' or 'failed' gets to flip the row to
93
93
  // 'pending' and proceed. `force` (the manual "Regenerate notes" flow)
94
94
  // additionally allows claiming a 'pending' row only when that claim is
95
95
  // stale (see PENDING_STALE_MS). A fresh 'pending' row may still be an
96
- // active Gemini run, so it is left alone.
96
+ // active text-model run, so it is left alone.
97
97
  const staleBefore = new Date(Date.now() - PENDING_STALE_MS).toISOString();
98
98
  const claimPredicate = args.force
99
99
  ? or(
@@ -112,7 +112,7 @@ export default defineAction({
112
112
 
113
113
  if (!claimed.length) {
114
114
  // Another finalize is already in flight (status is 'pending') — no-op
115
- // quietly instead of re-running Gemini and re-writing action items.
115
+ // quietly instead of re-running the text model and re-writing action items.
116
116
  const [current] = await db
117
117
  .select()
118
118
  .from(schema.meetings)
@@ -30,6 +30,7 @@ import {
30
30
  applyFaststart,
31
31
  hasPlayableMp4Metadata,
32
32
  } from "../server/lib/faststart.js";
33
+ import { allowsLegacyS3ObjectForPersistedMedia } from "../server/lib/media-storage-provenance.js";
33
34
  import {
34
35
  mediaVerificationStateKey,
35
36
  parseMediaVerificationMarker,
@@ -48,6 +49,7 @@ import {
48
49
  getResumableSession,
49
50
  } from "../server/lib/resumable-session.js";
50
51
  import { resolveResumableUploadProvider } from "../server/lib/resumable-upload-provider.js";
52
+ import { fetchS3ObjectByUrl } from "../server/lib/s3-upload-provider.js";
51
53
  import { isStreamingUploadDisabled } from "../server/lib/streaming-upload-mode.js";
52
54
  import {
53
55
  probeHasAudioStream,
@@ -181,7 +183,11 @@ function servedMediaSizeBytes(response: Response): number | null {
181
183
  return null;
182
184
  }
183
185
 
184
- async function verifyServedMediaUrl(videoUrl: string): Promise<number | null> {
186
+ async function verifyServedMediaUrl(
187
+ recordingId: string,
188
+ videoUrl: string,
189
+ allowLegacyObjectKey = false,
190
+ ): Promise<number | null> {
185
191
  if (!shouldVerifyServedMediaUrl(videoUrl)) return null;
186
192
 
187
193
  let lastFailure = "media URL did not serve readable bytes";
@@ -196,11 +202,21 @@ async function verifyServedMediaUrl(videoUrl: string): Promise<number | null> {
196
202
  MEDIA_SERVE_VERIFICATION_TIMEOUT_MS,
197
203
  );
198
204
  try {
199
- const response = await fetch(videoUrl, {
200
- method: "GET",
201
- headers: { Range: "bytes=0-1023" },
202
- signal: controller.signal,
205
+ const signedS3Response = await fetchS3ObjectByUrl(videoUrl, {
206
+ range: "bytes=0-1023",
207
+ timeoutMs: MEDIA_SERVE_VERIFICATION_TIMEOUT_MS,
208
+ recordingId,
209
+ ...(allowLegacyObjectKey ? { allowLegacyObjectKey } : {}),
203
210
  });
211
+ let response = signedS3Response;
212
+ if (response?.status !== 200 && response?.status !== 206) {
213
+ await response?.body?.cancel().catch(() => undefined);
214
+ response = await fetch(videoUrl, {
215
+ method: "GET",
216
+ headers: { Range: "bytes=0-1023" },
217
+ signal: controller.signal,
218
+ });
219
+ }
204
220
  const statusOk = response.status === 200 || response.status === 206;
205
221
  if (statusOk) {
206
222
  const servedBytes = servedMediaSizeBytes(response);
@@ -727,6 +743,7 @@ async function retryPendingMediaVerification(params: {
727
743
  .select({
728
744
  status: schema.recordings.status,
729
745
  videoUrl: schema.recordings.videoUrl,
746
+ editsJson: schema.recordings.editsJson,
730
747
  })
731
748
  .from(schema.recordings)
732
749
  .where(
@@ -754,7 +771,15 @@ async function retryPendingMediaVerification(params: {
754
771
  videoUrl: recording.videoUrl || media.videoUrl,
755
772
  };
756
773
  try {
757
- const servedBytes = await verifyServedMediaUrl(candidate.videoUrl);
774
+ const servedBytes = await verifyServedMediaUrl(
775
+ id,
776
+ candidate.videoUrl,
777
+ allowsLegacyS3ObjectForPersistedMedia({
778
+ requestedUrl: candidate.videoUrl,
779
+ persistedUrl: recording.videoUrl,
780
+ editsJson: recording.editsJson,
781
+ }),
782
+ );
758
783
  const result = await markRecordingReady({
759
784
  id,
760
785
  ownerEmail,
@@ -1155,7 +1180,7 @@ export default defineAction({
1155
1180
  debugLog("[finalize] resumable upload completed", { id, videoUrl });
1156
1181
  let servedBytes: number | null;
1157
1182
  try {
1158
- servedBytes = await verifyServedMediaUrl(videoUrl);
1183
+ servedBytes = await verifyServedMediaUrl(id, videoUrl, true);
1159
1184
  } catch (err) {
1160
1185
  const failureReason =
1161
1186
  err instanceof Error ? err.message : String(err);
@@ -1703,7 +1728,7 @@ export default defineAction({
1703
1728
  });
1704
1729
  let servedBytes: number | null;
1705
1730
  try {
1706
- servedBytes = await verifyServedMediaUrl(upload.url);
1731
+ servedBytes = await verifyServedMediaUrl(id, upload.url, true);
1707
1732
  } catch (err) {
1708
1733
  const failureReason = err instanceof Error ? err.message : String(err);
1709
1734
  return await leaveRecordingProcessingForMediaVerification({