@agent-native/core 0.98.0 → 0.98.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 (345) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +32 -0
  3. package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +25 -1
  4. package/corpus/core/docs/content/locales/ar-SA/template-plan.mdx +1 -1
  5. package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +25 -1
  6. package/corpus/core/docs/content/locales/de-DE/template-plan.mdx +1 -1
  7. package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +25 -1
  8. package/corpus/core/docs/content/locales/es-ES/template-plan.mdx +1 -1
  9. package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +25 -1
  10. package/corpus/core/docs/content/locales/fr-FR/template-plan.mdx +1 -1
  11. package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +25 -1
  12. package/corpus/core/docs/content/locales/hi-IN/template-plan.mdx +1 -1
  13. package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +25 -1
  14. package/corpus/core/docs/content/locales/ja-JP/template-plan.mdx +1 -1
  15. package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +25 -1
  16. package/corpus/core/docs/content/locales/ko-KR/template-plan.mdx +1 -1
  17. package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +25 -1
  18. package/corpus/core/docs/content/locales/pt-BR/template-plan.mdx +1 -1
  19. package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +25 -1
  20. package/corpus/core/docs/content/locales/zh-CN/template-plan.mdx +1 -1
  21. package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +25 -1
  22. package/corpus/core/docs/content/locales/zh-TW/template-plan.mdx +1 -1
  23. package/corpus/core/docs/content/pr-visual-recap.mdx +50 -1
  24. package/corpus/core/docs/content/template-plan.mdx +1 -1
  25. package/corpus/core/package.json +2 -2
  26. package/corpus/core/src/action-change-marker.ts +8 -1
  27. package/corpus/core/src/changelog/parse.ts +8 -3
  28. package/corpus/core/src/cli/plan-local.ts +43 -11
  29. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +2 -9
  30. package/corpus/core/src/cli/recap.ts +356 -4
  31. package/corpus/core/src/cli/skills-content/local-files.ts +17 -12
  32. package/corpus/core/src/client/AgentPanel.tsx +2 -3
  33. package/corpus/core/src/client/AssistantChat.tsx +83 -39
  34. package/corpus/core/src/client/analytics.ts +125 -14
  35. package/corpus/core/src/client/blocks/library/wireframe-kit.tsx +16 -2
  36. package/corpus/core/src/client/chat-model-groups.ts +28 -0
  37. package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +442 -85
  38. package/corpus/core/src/client/composer/TiptapComposer.tsx +46 -29
  39. package/corpus/core/src/client/composer/VoiceButton.tsx +2 -1
  40. package/corpus/core/src/client/composer/index.ts +5 -0
  41. package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +1206 -67
  42. package/corpus/core/src/client/index.ts +10 -0
  43. package/corpus/core/src/client/progress/RunsTray.tsx +13 -1
  44. package/corpus/core/src/client/session-replay.ts +10 -0
  45. package/corpus/core/src/client/settings/DemoModeSection.tsx +4 -28
  46. package/corpus/core/src/client/settings/SecretsSection.tsx +387 -266
  47. package/corpus/core/src/client/settings/SettingsPanel.tsx +117 -38
  48. package/corpus/core/src/client/url-scrub.ts +13 -0
  49. package/corpus/core/src/client/use-action.ts +12 -0
  50. package/corpus/core/src/client/use-agent-chat-home-handoff.ts +8 -1
  51. package/corpus/core/src/client/use-agent-engine-configured.ts +17 -3
  52. package/corpus/core/src/client/use-db-sync.ts +84 -34
  53. package/corpus/core/src/client/use-demo-mode-status.ts +44 -0
  54. package/corpus/core/src/client/use-pausing-interval.ts +14 -4
  55. package/corpus/core/src/integrations/scope-store.ts +1 -1
  56. package/corpus/core/src/integrations/usage-budget-store.ts +1 -1
  57. package/corpus/core/src/localization/default-messages.ts +44 -1
  58. package/corpus/core/src/mcp-client/workspace-servers.ts +4 -4
  59. package/corpus/core/src/secrets/crypto.ts +28 -8
  60. package/corpus/core/src/server/action-change.ts +5 -1
  61. package/corpus/core/src/server/action-routes.ts +8 -0
  62. package/corpus/core/src/server/poll.ts +1 -0
  63. package/corpus/core/src/server/realtime-voice.ts +299 -13
  64. package/corpus/core/src/styles/agent-native.css +2 -2
  65. package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  66. package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  67. package/corpus/core/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  68. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
  69. package/corpus/core/src/vite/client.ts +9 -1
  70. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +2 -2
  71. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +10 -5
  72. package/corpus/templates/analytics/AGENTS.md +17 -13
  73. package/corpus/templates/analytics/actions/navigate.ts +59 -5
  74. package/corpus/templates/analytics/actions/view-screen.ts +3 -6
  75. package/corpus/templates/analytics/app/components/ProviderCorpusJobNotifier.tsx +20 -3
  76. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +20 -5
  77. package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -1
  78. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +21 -6
  79. package/corpus/templates/analytics/app/global.css +19 -20
  80. package/corpus/templates/analytics/app/lib/demo-chart-trend.ts +156 -0
  81. package/corpus/templates/analytics/app/lib/provider-corpus-job-sync.ts +26 -0
  82. package/corpus/templates/analytics/app/pages/Settings.tsx +35 -341
  83. package/corpus/templates/analytics/app/pages/monitoring/ErrorsPanel.tsx +38 -7
  84. package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +1 -1
  85. package/corpus/templates/analytics/app/pages/monitoring/errors/i18n.ts +3 -0
  86. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +333 -272
  87. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +72 -17
  88. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +297 -108
  89. package/corpus/templates/analytics/app/root.tsx +28 -8
  90. package/corpus/templates/analytics/changelog/2026-06-24-first-party-signup-and-pageview-panels-now-honor-dashboard-filters.md +2 -1
  91. package/corpus/templates/analytics/changelog/2026-06-25-referrer-dashboard-panels-show-external-domains.md +2 -1
  92. package/corpus/templates/analytics/changelog/2026-07-06-mobile-chat-header-is-more-compact-and-the-menu-button-opens-navigation.md +1 -0
  93. package/corpus/templates/analytics/changelog/2026-07-08-dashboard-history-and-chat-context.md +1 -0
  94. package/corpus/templates/analytics/changelog/2026-07-08-settings-are-cleaner-searchable-and-alerts-have-their-own-tab.md +1 -0
  95. package/corpus/templates/analytics/changelog/2026-07-08-sidebar-sections-stay-collapsed-until-opened.md +1 -0
  96. package/corpus/templates/analytics/changelog/2026-07-11-analytics-no-longer-repeatedly-restarts-background-requests-.md +6 -0
  97. package/corpus/templates/analytics/changelog/2026-07-11-analytics-only-asks-for-replay-storage-when-you-enable-sessi.md +6 -0
  98. package/corpus/templates/analytics/changelog/2026-07-11-charts-now-show-their-loading-skeleton-while-refreshing.md +6 -0
  99. package/corpus/templates/analytics/changelog/2026-07-11-realtime-voice-now-shows-a-clear-connection-indicator-comple.md +6 -0
  100. package/corpus/templates/analytics/changelog/2026-07-11-replay-event-counts-stay-hidden-until-recordings-finish-load.md +6 -0
  101. package/corpus/templates/analytics/changelog/2026-07-11-replay-storage-key-mismatches-show-actionable-setup-guidance.md +6 -0
  102. package/corpus/templates/analytics/changelog/2026-07-11-replay-timelines-group-continuous-scrolling-and-dev-tools-co.md +6 -0
  103. package/corpus/templates/analytics/changelog/2026-07-11-session-filters-now-stay-compact-in-a-settings-menu-at-mediu.md +6 -0
  104. package/corpus/templates/analytics/changelog/2026-07-11-session-replay-console-errors-now-link-to-matching-issues-or.md +6 -0
  105. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-load-large-recordings-faster-and-surface-fai.md +6 -0
  106. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-preserve-recorded-styles-and-viewport-layout.md +6 -0
  107. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-stay-centered-instead-of-clipping-wide-recor.md +6 -0
  108. package/corpus/templates/analytics/changelog/2026-07-12-daily-dashboard-emails-reliably-include-the-complete-dashboa.md +6 -0
  109. package/corpus/templates/analytics/changelog/2026-07-12-demo-mode-charts-trend-upward.md +6 -0
  110. package/corpus/templates/analytics/package.json +1 -1
  111. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +1 -303
  112. package/corpus/templates/analytics/server/handlers/session-replay.ts +57 -0
  113. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +2 -0
  114. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +1 -1
  115. package/corpus/templates/analytics/server/lib/dashboard-report.ts +17 -33
  116. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +16 -3
  117. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +12 -0
  118. package/corpus/templates/analytics/server/lib/session-replay.ts +208 -2
  119. package/corpus/templates/analytics/server/plugins/onboarding.ts +7 -79
  120. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/chunks.get.ts +1 -0
  121. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +2 -2
  122. package/corpus/templates/assets/app/global.css +4 -4
  123. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +0 -1
  124. package/corpus/templates/assets/app/routes/library.tsx +0 -1
  125. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +2 -2
  126. package/corpus/templates/brain/app/global.css +5 -5
  127. package/corpus/templates/brain/app/root.tsx +0 -3
  128. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +2 -2
  129. package/corpus/templates/calendar/app/root.tsx +1 -0
  130. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +2 -2
  131. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -1
  132. package/corpus/templates/clips/app/hooks/use-auto-title.ts +74 -8
  133. package/corpus/templates/clips/app/hooks/use-library.ts +12 -25
  134. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +20 -22
  135. package/corpus/templates/clips/changelog/2026-07-11-clips-no-longer-stay-stuck-on-preparing-clip-when-they-are-r.md +6 -0
  136. package/corpus/templates/clips/changelog/2026-07-11-generated-titles-no-longer-cause-continuous-background-refreshes.md +6 -0
  137. package/corpus/templates/clips/changelog/2026-07-11-s3-storage-setup-stays-collapsed-until-you-choose-it-as-the-.md +6 -0
  138. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +2 -2
  139. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +2 -2
  140. package/corpus/templates/content/app/hooks/use-comments.ts +0 -1
  141. package/corpus/templates/content/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
  142. package/corpus/templates/content/changelog/2026-07-12-the-left-sidebar-stays-fixed-horizontally-while-scrolling-on.md +6 -0
  143. package/corpus/templates/design/app/components/layout/Header.tsx +1 -1
  144. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +2 -2
  145. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +1 -2
  146. package/corpus/templates/macros/app/root.tsx +0 -3
  147. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +2 -2
  148. package/corpus/templates/mail/app/hooks/use-compose-state.ts +1 -0
  149. package/corpus/templates/mail/app/root.tsx +1 -12
  150. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +2 -2
  151. package/corpus/templates/plan/.agents/skills/visual-plan/references/local-files.md +17 -12
  152. package/corpus/templates/plan/.agents/skills/visual-recap/references/local-files.md +17 -12
  153. package/corpus/templates/plan/actions/validate-local-plan-source.ts +4 -4
  154. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +2 -1
  155. package/corpus/templates/plan/app/hooks/use-navigation-state.ts +5 -2
  156. package/corpus/templates/plan/app/hooks/use-plans.ts +6 -13
  157. package/corpus/templates/plan/app/lib/plan-local-bridge.ts +41 -0
  158. package/corpus/templates/plan/app/lib/plan-tracking.ts +3 -0
  159. package/corpus/templates/plan/app/pages/PlansPage.tsx +14 -36
  160. package/corpus/templates/plan/app/root.tsx +13 -3
  161. package/corpus/templates/plan/changelog/2026-07-11-plan-refreshes-only-when-content-changes.md +6 -0
  162. package/corpus/templates/plan/changelog/2026-07-12-local-plan-source-now-stays-on-device.md +6 -0
  163. package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +4 -1
  164. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +1 -1
  165. package/corpus/templates/slides/app/components/layout/Header.tsx +1 -1
  166. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -0
  167. package/corpus/templates/slides/app/hooks/use-slide-comments.ts +7 -31
  168. package/corpus/templates/slides/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
  169. package/dist/action-change-marker.d.ts +1 -0
  170. package/dist/action-change-marker.d.ts.map +1 -1
  171. package/dist/action-change-marker.js +7 -1
  172. package/dist/action-change-marker.js.map +1 -1
  173. package/dist/changelog/parse.d.ts +4 -2
  174. package/dist/changelog/parse.d.ts.map +1 -1
  175. package/dist/changelog/parse.js +5 -3
  176. package/dist/changelog/parse.js.map +1 -1
  177. package/dist/cli/plan-local.d.ts +6 -6
  178. package/dist/cli/plan-local.d.ts.map +1 -1
  179. package/dist/cli/plan-local.js +36 -7
  180. package/dist/cli/plan-local.js.map +1 -1
  181. package/dist/cli/pr-visual-recap-workflow.d.ts +2 -8
  182. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  183. package/dist/cli/pr-visual-recap-workflow.js +2 -8
  184. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  185. package/dist/cli/recap.d.ts +22 -1
  186. package/dist/cli/recap.d.ts.map +1 -1
  187. package/dist/cli/recap.js +286 -2
  188. package/dist/cli/recap.js.map +1 -1
  189. package/dist/cli/skills-content/local-files.d.ts +1 -1
  190. package/dist/cli/skills-content/local-files.d.ts.map +1 -1
  191. package/dist/cli/skills-content/local-files.js +17 -12
  192. package/dist/cli/skills-content/local-files.js.map +1 -1
  193. package/dist/client/AgentPanel.d.ts.map +1 -1
  194. package/dist/client/AgentPanel.js +2 -3
  195. package/dist/client/AgentPanel.js.map +1 -1
  196. package/dist/client/AssistantChat.d.ts +7 -0
  197. package/dist/client/AssistantChat.d.ts.map +1 -1
  198. package/dist/client/AssistantChat.js +62 -40
  199. package/dist/client/AssistantChat.js.map +1 -1
  200. package/dist/client/analytics.d.ts +8 -0
  201. package/dist/client/analytics.d.ts.map +1 -1
  202. package/dist/client/analytics.js +103 -13
  203. package/dist/client/analytics.js.map +1 -1
  204. package/dist/client/blocks/library/wireframe-kit.d.ts +2 -0
  205. package/dist/client/blocks/library/wireframe-kit.d.ts.map +1 -1
  206. package/dist/client/blocks/library/wireframe-kit.js +9 -2
  207. package/dist/client/blocks/library/wireframe-kit.js.map +1 -1
  208. package/dist/client/chat-model-groups.d.ts.map +1 -1
  209. package/dist/client/chat-model-groups.js +24 -0
  210. package/dist/client/chat-model-groups.js.map +1 -1
  211. package/dist/client/composer/RealtimeVoiceMode.d.ts +40 -4
  212. package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
  213. package/dist/client/composer/RealtimeVoiceMode.js +172 -42
  214. package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
  215. package/dist/client/composer/TiptapComposer.d.ts +4 -0
  216. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  217. package/dist/client/composer/TiptapComposer.js +47 -29
  218. package/dist/client/composer/TiptapComposer.js.map +1 -1
  219. package/dist/client/composer/VoiceButton.d.ts.map +1 -1
  220. package/dist/client/composer/VoiceButton.js +2 -2
  221. package/dist/client/composer/VoiceButton.js.map +1 -1
  222. package/dist/client/composer/index.d.ts +2 -2
  223. package/dist/client/composer/index.d.ts.map +1 -1
  224. package/dist/client/composer/index.js +1 -1
  225. package/dist/client/composer/index.js.map +1 -1
  226. package/dist/client/composer/useRealtimeVoiceMode.d.ts +100 -3
  227. package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  228. package/dist/client/composer/useRealtimeVoiceMode.js +952 -51
  229. package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
  230. package/dist/client/index.d.ts +3 -2
  231. package/dist/client/index.d.ts.map +1 -1
  232. package/dist/client/index.js +3 -2
  233. package/dist/client/index.js.map +1 -1
  234. package/dist/client/progress/RunsTray.d.ts.map +1 -1
  235. package/dist/client/progress/RunsTray.js +10 -1
  236. package/dist/client/progress/RunsTray.js.map +1 -1
  237. package/dist/client/session-replay.d.ts +2 -0
  238. package/dist/client/session-replay.d.ts.map +1 -1
  239. package/dist/client/session-replay.js +7 -0
  240. package/dist/client/session-replay.js.map +1 -1
  241. package/dist/client/settings/DemoModeSection.d.ts.map +1 -1
  242. package/dist/client/settings/DemoModeSection.js +4 -23
  243. package/dist/client/settings/DemoModeSection.js.map +1 -1
  244. package/dist/client/settings/SecretsSection.d.ts +2 -3
  245. package/dist/client/settings/SecretsSection.d.ts.map +1 -1
  246. package/dist/client/settings/SecretsSection.js +46 -22
  247. package/dist/client/settings/SecretsSection.js.map +1 -1
  248. package/dist/client/settings/SettingsPanel.d.ts +9 -0
  249. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  250. package/dist/client/settings/SettingsPanel.js +46 -12
  251. package/dist/client/settings/SettingsPanel.js.map +1 -1
  252. package/dist/client/url-scrub.d.ts.map +1 -1
  253. package/dist/client/url-scrub.js +14 -0
  254. package/dist/client/url-scrub.js.map +1 -1
  255. package/dist/client/use-action.d.ts +2 -0
  256. package/dist/client/use-action.d.ts.map +1 -1
  257. package/dist/client/use-action.js +10 -0
  258. package/dist/client/use-action.js.map +1 -1
  259. package/dist/client/use-agent-chat-home-handoff.d.ts +2 -0
  260. package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
  261. package/dist/client/use-agent-chat-home-handoff.js +7 -1
  262. package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
  263. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  264. package/dist/client/use-agent-engine-configured.js +17 -3
  265. package/dist/client/use-agent-engine-configured.js.map +1 -1
  266. package/dist/client/use-db-sync.d.ts +8 -2
  267. package/dist/client/use-db-sync.d.ts.map +1 -1
  268. package/dist/client/use-db-sync.js +57 -32
  269. package/dist/client/use-db-sync.js.map +1 -1
  270. package/dist/client/use-demo-mode-status.d.ts +12 -0
  271. package/dist/client/use-demo-mode-status.d.ts.map +1 -0
  272. package/dist/client/use-demo-mode-status.js +30 -0
  273. package/dist/client/use-demo-mode-status.js.map +1 -0
  274. package/dist/client/use-pausing-interval.d.ts.map +1 -1
  275. package/dist/client/use-pausing-interval.js +16 -4
  276. package/dist/client/use-pausing-interval.js.map +1 -1
  277. package/dist/collab/struct-routes.d.ts +1 -1
  278. package/dist/integrations/scope-store.js +1 -1
  279. package/dist/integrations/scope-store.js.map +1 -1
  280. package/dist/integrations/usage-budget-store.js +1 -1
  281. package/dist/integrations/usage-budget-store.js.map +1 -1
  282. package/dist/localization/default-messages.d.ts +41 -1
  283. package/dist/localization/default-messages.d.ts.map +1 -1
  284. package/dist/localization/default-messages.js +41 -1
  285. package/dist/localization/default-messages.js.map +1 -1
  286. package/dist/mcp-client/workspace-servers.js +4 -4
  287. package/dist/mcp-client/workspace-servers.js.map +1 -1
  288. package/dist/notifications/routes.d.ts +3 -3
  289. package/dist/progress/routes.d.ts +1 -1
  290. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  291. package/dist/resources/handlers.d.ts +3 -3
  292. package/dist/secrets/crypto.d.ts +7 -6
  293. package/dist/secrets/crypto.d.ts.map +1 -1
  294. package/dist/secrets/crypto.js +30 -11
  295. package/dist/secrets/crypto.js.map +1 -1
  296. package/dist/server/action-change.d.ts +1 -0
  297. package/dist/server/action-change.d.ts.map +1 -1
  298. package/dist/server/action-change.js +4 -1
  299. package/dist/server/action-change.js.map +1 -1
  300. package/dist/server/action-routes.d.ts.map +1 -1
  301. package/dist/server/action-routes.js +5 -0
  302. package/dist/server/action-routes.js.map +1 -1
  303. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  304. package/dist/server/poll.d.ts.map +1 -1
  305. package/dist/server/poll.js +1 -0
  306. package/dist/server/poll.js.map +1 -1
  307. package/dist/server/realtime-voice.d.ts +7 -0
  308. package/dist/server/realtime-voice.d.ts.map +1 -1
  309. package/dist/server/realtime-voice.js +222 -12
  310. package/dist/server/realtime-voice.js.map +1 -1
  311. package/dist/styles/agent-native.css +2 -2
  312. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  313. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  314. package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  315. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
  316. package/dist/vite/client.d.ts.map +1 -1
  317. package/dist/vite/client.js +6 -1
  318. package/dist/vite/client.js.map +1 -1
  319. package/docs/content/locales/ar-SA/pr-visual-recap.mdx +25 -1
  320. package/docs/content/locales/ar-SA/template-plan.mdx +1 -1
  321. package/docs/content/locales/de-DE/pr-visual-recap.mdx +25 -1
  322. package/docs/content/locales/de-DE/template-plan.mdx +1 -1
  323. package/docs/content/locales/es-ES/pr-visual-recap.mdx +25 -1
  324. package/docs/content/locales/es-ES/template-plan.mdx +1 -1
  325. package/docs/content/locales/fr-FR/pr-visual-recap.mdx +25 -1
  326. package/docs/content/locales/fr-FR/template-plan.mdx +1 -1
  327. package/docs/content/locales/hi-IN/pr-visual-recap.mdx +25 -1
  328. package/docs/content/locales/hi-IN/template-plan.mdx +1 -1
  329. package/docs/content/locales/ja-JP/pr-visual-recap.mdx +25 -1
  330. package/docs/content/locales/ja-JP/template-plan.mdx +1 -1
  331. package/docs/content/locales/ko-KR/pr-visual-recap.mdx +25 -1
  332. package/docs/content/locales/ko-KR/template-plan.mdx +1 -1
  333. package/docs/content/locales/pt-BR/pr-visual-recap.mdx +25 -1
  334. package/docs/content/locales/pt-BR/template-plan.mdx +1 -1
  335. package/docs/content/locales/zh-CN/pr-visual-recap.mdx +25 -1
  336. package/docs/content/locales/zh-CN/template-plan.mdx +1 -1
  337. package/docs/content/locales/zh-TW/pr-visual-recap.mdx +25 -1
  338. package/docs/content/locales/zh-TW/template-plan.mdx +1 -1
  339. package/docs/content/pr-visual-recap.mdx +50 -1
  340. package/docs/content/template-plan.mdx +1 -1
  341. package/package.json +2 -2
  342. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  343. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  344. package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  345. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
3
3
  import { createPortal } from "react-dom";
4
4
  import { requestAgentChatThreadOpen } from "../agent-chat.js";
5
5
  import { SIDEBAR_STATE_CHANGE_EVENT, } from "../agent-sidebar-state.js";
6
- import { agentNativePath, appPath } from "../api-path.js";
6
+ import { agentNativePath } from "../api-path.js";
7
7
  import { readClientAppState, setClientAppState } from "../application-state.js";
8
8
  import { getBrowserTabId } from "../browser-tab-id.js";
9
9
  import { useT } from "../i18n.js";
@@ -11,10 +11,59 @@ import { createRealtimeVoiceAudioLevelStore, normalizeRealtimeVoiceRms, smoothRe
11
11
  import { realtimeVoiceTranscriptRegistry } from "./realtime-voice-transcript.js";
12
12
  import { RealtimeVoiceModeDock, } from "./RealtimeVoiceMode.js";
13
13
  const REALTIME_VOICE_STATE_KEY = "realtime-voice-session";
14
+ const REALTIME_VOICE_PREFERENCES_KEY = "realtime-voice-prefs";
14
15
  const REALTIME_VOICE_REQUEST_SOURCE = "realtime-voice";
15
16
  const REALTIME_VOICE_SESSION_PATH = "/_agent-native/realtime-voice/session";
16
17
  const REALTIME_VOICE_TOOL_PATH = "/_agent-native/realtime-voice/tool";
18
+ const REALTIME_VOICE_CAPABILITY_HEADER = "X-Agent-Native-Realtime-Capability";
17
19
  const REALTIME_VOICE_CONNECTION_TIMEOUT_MS = 15_000;
20
+ const REALTIME_VOICE_MAX_TOOLS = 32;
21
+ const REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES = 32_000;
22
+ const REALTIME_VOICE_MAX_SESSION_BYTES = 64_000;
23
+ const REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS = 5_000;
24
+ const REALTIME_VOICE_PRIORITY_TOOL_NAMES = [
25
+ "navigate",
26
+ "set-url-path",
27
+ "set-search-params",
28
+ "view-screen",
29
+ "tool-search",
30
+ ];
31
+ const REALTIME_VOICE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
32
+ const REALTIME_VOICE_MICROPHONE_STORAGE_KEY = "agent-native:realtime-voice-microphone";
33
+ export const REALTIME_VOICE_LANGUAGES = [
34
+ "auto",
35
+ "en",
36
+ "es",
37
+ "fr",
38
+ "de",
39
+ "it",
40
+ "pt",
41
+ "ja",
42
+ "ko",
43
+ "zh",
44
+ ];
45
+ export const REALTIME_VOICE_INTELLIGENCE_LEVELS = [
46
+ "instant",
47
+ "balanced",
48
+ "deep",
49
+ ];
50
+ export const REALTIME_VOICES = [
51
+ "marin",
52
+ "cedar",
53
+ "coral",
54
+ "sage",
55
+ "verse",
56
+ "alloy",
57
+ "ash",
58
+ "ballad",
59
+ "echo",
60
+ "shimmer",
61
+ ];
62
+ export const DEFAULT_REALTIME_VOICE_PREFERENCES = {
63
+ language: "auto",
64
+ intelligence: "instant",
65
+ voice: "marin",
66
+ };
18
67
  export const REALTIME_VOICE_AUDIO_CONSTRAINTS = {
19
68
  // Prefer the browser/OS-selected input instead of whichever physical device
20
69
  // happens to be enumerated first. `ideal` keeps browsers without the
@@ -24,6 +73,271 @@ export const REALTIME_VOICE_AUDIO_CONSTRAINTS = {
24
73
  noiseSuppression: true,
25
74
  autoGainControl: true,
26
75
  };
76
+ export function createRealtimeVoiceAudioConstraints(deviceId = "default", exact = false) {
77
+ return {
78
+ ...REALTIME_VOICE_AUDIO_CONSTRAINTS,
79
+ deviceId: deviceId === "default"
80
+ ? { ideal: "default" }
81
+ : exact
82
+ ? { exact: deviceId }
83
+ : { ideal: deviceId },
84
+ };
85
+ }
86
+ export async function replaceRealtimeVoiceMicrophone(options) {
87
+ const replacementStream = await options.mediaDevices.getUserMedia({
88
+ audio: createRealtimeVoiceAudioConstraints(options.deviceId, true),
89
+ });
90
+ try {
91
+ const replacementTrack = replacementStream.getAudioTracks()[0];
92
+ const sender = options.peer
93
+ .getSenders()
94
+ .find((candidate) => candidate.track?.kind === "audio");
95
+ if (!replacementTrack || !sender) {
96
+ throw new Error("No active microphone track is available.");
97
+ }
98
+ await sender.replaceTrack(replacementTrack);
99
+ for (const track of options.currentStream?.getTracks() ?? []) {
100
+ track.onended = null;
101
+ track.stop();
102
+ }
103
+ return replacementStream;
104
+ }
105
+ catch (error) {
106
+ for (const track of replacementStream.getTracks())
107
+ track.stop();
108
+ throw error;
109
+ }
110
+ }
111
+ function readRealtimeVoiceMicrophoneId() {
112
+ if (typeof window === "undefined")
113
+ return "default";
114
+ try {
115
+ return (window.localStorage.getItem(REALTIME_VOICE_MICROPHONE_STORAGE_KEY) ||
116
+ "default");
117
+ }
118
+ catch {
119
+ return "default";
120
+ }
121
+ }
122
+ function writeRealtimeVoiceMicrophoneId(deviceId) {
123
+ try {
124
+ window.localStorage.setItem(REALTIME_VOICE_MICROPHONE_STORAGE_KEY, deviceId);
125
+ }
126
+ catch {
127
+ // A remembered local device is optional and must never block voice mode.
128
+ }
129
+ }
130
+ function isOneOf(values, value) {
131
+ return typeof value === "string" && values.includes(value);
132
+ }
133
+ export function normalizeRealtimeVoicePreferences(value) {
134
+ const record = value && typeof value === "object" && !Array.isArray(value)
135
+ ? value
136
+ : {};
137
+ return {
138
+ language: isOneOf(REALTIME_VOICE_LANGUAGES, record.language)
139
+ ? record.language
140
+ : DEFAULT_REALTIME_VOICE_PREFERENCES.language,
141
+ intelligence: isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, record.intelligence)
142
+ ? record.intelligence
143
+ : DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
144
+ voice: isOneOf(REALTIME_VOICES, record.voice)
145
+ ? record.voice
146
+ : DEFAULT_REALTIME_VOICE_PREFERENCES.voice,
147
+ };
148
+ }
149
+ export function resolveRealtimeVoiceLanguage(language, browserLanguages = []) {
150
+ if (language !== "auto")
151
+ return language;
152
+ for (const locale of browserLanguages) {
153
+ const primary = locale.trim().split("-")[0]?.toLowerCase();
154
+ if (isOneOf(REALTIME_VOICE_LANGUAGES, primary) && primary !== "auto") {
155
+ return primary;
156
+ }
157
+ }
158
+ return "en";
159
+ }
160
+ export function realtimeVoiceReasoningEffort(intelligence) {
161
+ if (intelligence === "balanced")
162
+ return "low";
163
+ if (intelligence === "deep")
164
+ return "medium";
165
+ return "minimal";
166
+ }
167
+ export function createRealtimeVoicePreferenceUpdate(preferences, options = {}) {
168
+ return {
169
+ type: "session.update",
170
+ session: {
171
+ type: "realtime",
172
+ reasoning: {
173
+ effort: realtimeVoiceReasoningEffort(preferences.intelligence),
174
+ },
175
+ audio: {
176
+ input: {
177
+ transcription: {
178
+ model: "gpt-4o-mini-transcribe",
179
+ language: resolveRealtimeVoiceLanguage(preferences.language, options.browserLanguages),
180
+ },
181
+ },
182
+ ...(options.includeVoice
183
+ ? { output: { voice: preferences.voice } }
184
+ : {}),
185
+ },
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * Input transcription is produced by a separate ASR model and can finish after
191
+ * the assistant response. Reserve each message's position when OpenAI adds it
192
+ * to the conversation, then publish only the contiguous completed prefix.
193
+ */
194
+ export function createRealtimeVoiceTranscriptSequencer(publish) {
195
+ const order = [];
196
+ const items = new Map();
197
+ const settledItemIds = new Set();
198
+ const receivedTranscriptIds = new Set();
199
+ const reserve = (id, role) => {
200
+ if (settledItemIds.has(id))
201
+ return;
202
+ const existing = items.get(id);
203
+ if (existing) {
204
+ existing.role ??= role;
205
+ return;
206
+ }
207
+ order.push(id);
208
+ items.set(id, { status: "pending", role });
209
+ };
210
+ const drain = () => {
211
+ while (order.length > 0) {
212
+ const id = order[0];
213
+ const entry = items.get(id);
214
+ if (!entry || entry.status === "pending")
215
+ return;
216
+ order.shift();
217
+ items.delete(id);
218
+ settledItemIds.add(id);
219
+ if (entry.status === "completed" && entry.transcript) {
220
+ publish(entry.transcript);
221
+ }
222
+ }
223
+ };
224
+ const skip = (id) => {
225
+ if (typeof id !== "string" || !id)
226
+ return;
227
+ reserve(id);
228
+ const entry = items.get(id);
229
+ if (entry.status === "pending")
230
+ entry.status = "skipped";
231
+ };
232
+ return {
233
+ handle(event) {
234
+ if (event.type === "conversation.item.added" ||
235
+ event.type === "conversation.item.created") {
236
+ const item = event.item;
237
+ if (item && typeof item === "object") {
238
+ const record = item;
239
+ if (record.type === "message" &&
240
+ (record.role === "user" || record.role === "assistant") &&
241
+ typeof record.id === "string") {
242
+ reserve(record.id, record.role);
243
+ }
244
+ }
245
+ }
246
+ const transcript = extractCompletedRealtimeVoiceTranscript(event);
247
+ if (transcript) {
248
+ const transcriptId = transcript.providerId
249
+ ? `${transcript.role}:${transcript.providerId}`
250
+ : undefined;
251
+ if (transcriptId && receivedTranscriptIds.has(transcriptId))
252
+ return;
253
+ if (transcriptId)
254
+ receivedTranscriptIds.add(transcriptId);
255
+ const itemId = typeof event.item_id === "string" ? event.item_id : undefined;
256
+ if (!itemId) {
257
+ // Older providers may omit item_id. Match the first reserved slot for
258
+ // that role so one incomplete legacy event cannot strand the queue.
259
+ const reservedId = order.find((id) => {
260
+ const entry = items.get(id);
261
+ return (entry?.status === "pending" && entry.role === transcript.role);
262
+ });
263
+ if (reservedId) {
264
+ items.set(reservedId, {
265
+ status: "completed",
266
+ role: transcript.role,
267
+ transcript,
268
+ });
269
+ }
270
+ else {
271
+ publish(transcript);
272
+ }
273
+ }
274
+ else {
275
+ if (settledItemIds.has(itemId))
276
+ return;
277
+ reserve(itemId, transcript.role);
278
+ items.set(itemId, {
279
+ status: "completed",
280
+ role: transcript.role,
281
+ transcript,
282
+ });
283
+ }
284
+ }
285
+ else if (event.type === "conversation.item.input_audio_transcription.failed" ||
286
+ event.type === "conversation.item.deleted") {
287
+ skip(event.item_id);
288
+ }
289
+ else if (event.type === "response.done") {
290
+ const response = event.response;
291
+ const output = response && typeof response === "object"
292
+ ? response.output
293
+ : undefined;
294
+ if (Array.isArray(output)) {
295
+ for (const item of output) {
296
+ if (!item || typeof item !== "object")
297
+ continue;
298
+ const record = item;
299
+ if (record.type === "message")
300
+ skip(record.id);
301
+ }
302
+ }
303
+ }
304
+ drain();
305
+ },
306
+ reset() {
307
+ order.length = 0;
308
+ items.clear();
309
+ settledItemIds.clear();
310
+ receivedTranscriptIds.clear();
311
+ },
312
+ };
313
+ }
314
+ export function createRealtimeVoiceGreetingEvent() {
315
+ return {
316
+ type: "response.create",
317
+ response: {
318
+ output_modalities: ["audio"],
319
+ instructions: 'Say exactly: "How can I help you?" Do not add anything else.',
320
+ // Do not set max_output_tokens here. Realtime counts assistant audio at
321
+ // roughly one token per 50 ms, so a text-sized cap can cut speech off.
322
+ // The exact-response instruction already keeps this greeting bounded.
323
+ },
324
+ };
325
+ }
326
+ export function createRealtimeVoiceGreetingStarter(send) {
327
+ let started = false;
328
+ return {
329
+ start() {
330
+ if (started)
331
+ return false;
332
+ started = true;
333
+ send(createRealtimeVoiceGreetingEvent());
334
+ return true;
335
+ },
336
+ reset() {
337
+ started = false;
338
+ },
339
+ };
340
+ }
27
341
  /**
28
342
  * Voice mode owns the chat only temporarily. Restore the captured transcript
29
343
  * when it is still the user's active thread (or the chat has no active thread)
@@ -56,10 +370,25 @@ export function isRealtimeVoiceAbortError(error) {
56
370
  typeof error === "object" &&
57
371
  error.name === "AbortError");
58
372
  }
373
+ export function isRealtimeVoiceSetupRequiredError(error) {
374
+ return Boolean(error &&
375
+ typeof error === "object" &&
376
+ error.status === 409);
377
+ }
59
378
  export function createRealtimeVoiceConnectionTimeout(onTimeout, timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS) {
60
379
  const timeout = window.setTimeout(onTimeout, timeoutMs);
61
380
  return () => window.clearTimeout(timeout);
62
381
  }
382
+ export function createRealtimeVoiceConnectionGate(onTimeout, timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS) {
383
+ const cancel = createRealtimeVoiceConnectionTimeout(onTimeout, timeoutMs);
384
+ return {
385
+ // RTC connectivity alone is not enough: the session may never finish its
386
+ // Realtime handshake. Keep the deadline armed until session.created.
387
+ markTransportReady() { },
388
+ markSessionCreated: cancel,
389
+ cancel,
390
+ };
391
+ }
63
392
  async function readErrorResponse(response) {
64
393
  const raw = await response.text().catch(() => "");
65
394
  if (!raw)
@@ -72,7 +401,8 @@ async function readErrorResponse(response) {
72
401
  return raw.slice(0, 500);
73
402
  }
74
403
  }
75
- export async function createRealtimeVoiceSession(offerSdp, options = {}) {
404
+ export async function createRealtimeVoiceSessionWithCapability(offerSdp, options = {}) {
405
+ const preferences = options.preferences;
76
406
  const response = await fetch(agentNativePath(REALTIME_VOICE_SESSION_PATH), {
77
407
  method: "POST",
78
408
  credentials: "same-origin",
@@ -81,6 +411,13 @@ export async function createRealtimeVoiceSession(offerSdp, options = {}) {
81
411
  ...(options.browserTabId
82
412
  ? { "X-Agent-Native-Browser-Tab": options.browserTabId }
83
413
  : {}),
414
+ ...(preferences
415
+ ? {
416
+ "X-Agent-Native-Realtime-Language": resolveRealtimeVoiceLanguage(preferences.language, options.browserLanguages),
417
+ "X-Agent-Native-Realtime-Intelligence": preferences.intelligence,
418
+ "X-Agent-Native-Realtime-Voice": preferences.voice,
419
+ }
420
+ : {}),
84
421
  },
85
422
  body: offerSdp,
86
423
  signal: options.signal,
@@ -91,7 +428,17 @@ export async function createRealtimeVoiceSession(offerSdp, options = {}) {
91
428
  error.status = response.status;
92
429
  throw error;
93
430
  }
94
- return response.text();
431
+ const sdp = await response.text();
432
+ const capability = response.headers.get(REALTIME_VOICE_CAPABILITY_HEADER);
433
+ return {
434
+ sdp,
435
+ ...(capability ? { capability } : {}),
436
+ };
437
+ }
438
+ /** Backwards-compatible SDP-only session helper for existing client callers. */
439
+ export async function createRealtimeVoiceSession(offerSdp, options = {}) {
440
+ return (await createRealtimeVoiceSessionWithCapability(offerSdp, options))
441
+ .sdp;
95
442
  }
96
443
  export async function executeRealtimeVoiceTool(input) {
97
444
  const response = await fetch(agentNativePath(REALTIME_VOICE_TOOL_PATH), {
@@ -102,6 +449,9 @@ export async function executeRealtimeVoiceTool(input) {
102
449
  ...(input.browserTabId
103
450
  ? { "X-Agent-Native-Browser-Tab": input.browserTabId }
104
451
  : {}),
452
+ ...(input.capability
453
+ ? { [REALTIME_VOICE_CAPABILITY_HEADER]: input.capability }
454
+ : {}),
105
455
  },
106
456
  body: JSON.stringify({
107
457
  name: input.name,
@@ -117,6 +467,217 @@ export async function executeRealtimeVoiceTool(input) {
117
467
  }
118
468
  return (await response.json());
119
469
  }
470
+ function jsonByteLength(value) {
471
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
472
+ }
473
+ function normalizeRealtimeVoiceFunctionTool(value) {
474
+ if (!value || typeof value !== "object" || Array.isArray(value))
475
+ return null;
476
+ const record = value;
477
+ if (record.type !== "function" ||
478
+ typeof record.name !== "string" ||
479
+ !REALTIME_VOICE_TOOL_NAME.test(record.name)) {
480
+ return null;
481
+ }
482
+ if (record.description !== undefined &&
483
+ typeof record.description !== "string") {
484
+ return null;
485
+ }
486
+ if (record.parameters !== undefined &&
487
+ (!record.parameters ||
488
+ typeof record.parameters !== "object" ||
489
+ Array.isArray(record.parameters))) {
490
+ return null;
491
+ }
492
+ try {
493
+ if (jsonByteLength(record) > REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES) {
494
+ return null;
495
+ }
496
+ return JSON.parse(JSON.stringify(record));
497
+ }
498
+ catch {
499
+ return null;
500
+ }
501
+ }
502
+ export function extractRealtimeVoiceSessionTools(event) {
503
+ if (event.type !== "session.created" && event.type !== "session.updated") {
504
+ return null;
505
+ }
506
+ const session = event.session;
507
+ if (!session || typeof session !== "object")
508
+ return null;
509
+ const tools = session.tools;
510
+ if (!Array.isArray(tools))
511
+ return null;
512
+ return tools
513
+ .map(normalizeRealtimeVoiceFunctionTool)
514
+ .filter((tool) => Boolean(tool));
515
+ }
516
+ function createRealtimeVoiceToolManifestUpdate(tools, eventId) {
517
+ return {
518
+ type: "session.update",
519
+ ...(eventId ? { event_id: eventId } : {}),
520
+ session: {
521
+ type: "realtime",
522
+ tools,
523
+ tool_choice: "auto",
524
+ },
525
+ };
526
+ }
527
+ /**
528
+ * Merge search-discovered schemas into the live manifest. Pinned navigation
529
+ * and discovery tools stay available, then the newest discoveries take the
530
+ * remaining slots ahead of lower-priority tools from the original manifest.
531
+ */
532
+ export function mergeRealtimeVoiceToolManifest(currentTools, expandedTools) {
533
+ const current = new Map();
534
+ for (const value of currentTools) {
535
+ const tool = normalizeRealtimeVoiceFunctionTool(value);
536
+ if (tool)
537
+ current.set(tool.name, tool);
538
+ }
539
+ const expanded = new Map();
540
+ for (const value of expandedTools) {
541
+ const tool = normalizeRealtimeVoiceFunctionTool(value);
542
+ if (tool)
543
+ expanded.set(tool.name, tool);
544
+ }
545
+ const candidates = [];
546
+ const seen = new Set();
547
+ const add = (tool) => {
548
+ if (!tool || seen.has(tool.name))
549
+ return;
550
+ seen.add(tool.name);
551
+ candidates.push(tool);
552
+ };
553
+ for (const name of REALTIME_VOICE_PRIORITY_TOOL_NAMES) {
554
+ add(expanded.get(name) ?? current.get(name));
555
+ }
556
+ for (const tool of expanded.values())
557
+ add(tool);
558
+ for (const tool of current.values())
559
+ add(tool);
560
+ const packed = [];
561
+ for (const tool of candidates) {
562
+ if (packed.length >= REALTIME_VOICE_MAX_TOOLS)
563
+ break;
564
+ const candidate = [...packed, tool];
565
+ if (jsonByteLength(createRealtimeVoiceToolManifestUpdate(candidate, "realtime_tool_manifest_999999999")) <= REALTIME_VOICE_MAX_SESSION_BYTES) {
566
+ packed.push(tool);
567
+ }
568
+ }
569
+ return packed;
570
+ }
571
+ function createRealtimeVoiceToolResultEvent(result) {
572
+ const modelResult = {
573
+ callId: result.callId,
574
+ status: result.status,
575
+ output: result.output,
576
+ ...(result.approvalKey ? { approvalKey: result.approvalKey } : {}),
577
+ };
578
+ return {
579
+ type: "conversation.item.create",
580
+ item: {
581
+ type: "function_call_output",
582
+ call_id: result.callId,
583
+ output: JSON.stringify(modelResult),
584
+ },
585
+ };
586
+ }
587
+ /**
588
+ * Realtime treats `session.update.tools` as a full replacement. Serialize
589
+ * discovery updates and wait for a confirming `session.updated` manifest
590
+ * before returning the search result and allowing the next model response.
591
+ */
592
+ export function createRealtimeVoiceToolManifestCoordinator(send, timeoutMs = REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS) {
593
+ let currentTools = [];
594
+ const queue = [];
595
+ let updateSequence = 0;
596
+ let active;
597
+ const finish = (failureMessage) => {
598
+ if (!active)
599
+ return;
600
+ const { result, timer } = active;
601
+ clearTimeout(timer);
602
+ active = undefined;
603
+ send(createRealtimeVoiceToolResultEvent(failureMessage
604
+ ? {
605
+ callId: result.callId,
606
+ status: "failed",
607
+ output: `${failureMessage} The discovered tools were not added to this voice session. Original tool result: ${result.output}`,
608
+ }
609
+ : result));
610
+ send({ type: "response.create" });
611
+ drain();
612
+ };
613
+ const drain = () => {
614
+ if (active)
615
+ return;
616
+ const result = queue.shift();
617
+ if (!result)
618
+ return;
619
+ const expanded = Array.isArray(result.expandedTools)
620
+ ? result.expandedTools
621
+ .map(normalizeRealtimeVoiceFunctionTool)
622
+ .filter((tool) => Boolean(tool))
623
+ : [];
624
+ if (result.status !== "completed" || expanded.length === 0) {
625
+ send(createRealtimeVoiceToolResultEvent(result));
626
+ send({ type: "response.create" });
627
+ drain();
628
+ return;
629
+ }
630
+ const merged = mergeRealtimeVoiceToolManifest(currentTools, expanded);
631
+ const mergedNames = new Set(merged.map((tool) => tool.name));
632
+ const expectedNames = expanded
633
+ .map((tool) => tool.name)
634
+ .filter((name) => mergedNames.has(name));
635
+ if (expectedNames.length === 0) {
636
+ send(createRealtimeVoiceToolResultEvent(result));
637
+ send({ type: "response.create" });
638
+ drain();
639
+ return;
640
+ }
641
+ const eventId = `realtime_tool_manifest_${++updateSequence}`;
642
+ active = {
643
+ result,
644
+ expectedNames,
645
+ eventId,
646
+ timer: setTimeout(() => finish("Timed out while installing the discovered tools."), timeoutMs),
647
+ };
648
+ send(createRealtimeVoiceToolManifestUpdate(merged, eventId));
649
+ };
650
+ return {
651
+ enqueue(result) {
652
+ queue.push(result);
653
+ drain();
654
+ },
655
+ setSessionTools(tools) {
656
+ currentTools = mergeRealtimeVoiceToolManifest([], tools);
657
+ if (!active)
658
+ return;
659
+ const names = new Set(currentTools.map((tool) => tool.name));
660
+ if (active.expectedNames.every((name) => names.has(name)))
661
+ finish();
662
+ },
663
+ handleError(eventId, message) {
664
+ if (!active || !eventId || active.eventId !== eventId)
665
+ return false;
666
+ finish(message || "The provider rejected the discovered tool update.");
667
+ return true;
668
+ },
669
+ reset() {
670
+ if (active)
671
+ clearTimeout(active.timer);
672
+ active = undefined;
673
+ queue.length = 0;
674
+ currentTools = [];
675
+ },
676
+ getTools() {
677
+ return currentTools;
678
+ },
679
+ };
680
+ }
120
681
  export function extractRealtimeVoiceFunctionCalls(event) {
121
682
  if (event.type === "response.function_call_arguments.done") {
122
683
  const name = typeof event.name === "string" ? event.name : "";
@@ -179,15 +740,6 @@ function openOpenAiKeySettings() {
179
740
  detail: { section: "secrets" },
180
741
  }));
181
742
  }
182
- function openMicrophoneSettings() {
183
- if (typeof window === "undefined")
184
- return;
185
- window.location.assign(`${appPath("/settings")}#voice`);
186
- }
187
- export function endRealtimeVoiceBeforeNavigation(end, navigate) {
188
- end();
189
- window.setTimeout(navigate, 0);
190
- }
191
743
  export function listenForRealtimeVoicePageHide(cleanup) {
192
744
  window.addEventListener("pagehide", cleanup);
193
745
  return () => window.removeEventListener("pagehide", cleanup);
@@ -207,7 +759,45 @@ function voiceCopy(t) {
207
759
  showChat: t("agentPanel.voiceMode.showChat"),
208
760
  hideChat: t("agentPanel.voiceMode.hideChat"),
209
761
  endVoiceMode: t("agentPanel.voiceMode.end"),
210
- microphoneSettings: t("agentPanel.voiceMode.microphoneSettings"),
762
+ voiceSettings: t("agentPanel.voiceMode.voiceSettings"),
763
+ settings: {
764
+ language: t("agentPanel.voiceMode.settings.language"),
765
+ autoLanguage: t("agentPanel.voiceMode.settings.autoLanguage"),
766
+ languages: {
767
+ en: t("agentPanel.voiceMode.settings.languages.en"),
768
+ es: t("agentPanel.voiceMode.settings.languages.es"),
769
+ fr: t("agentPanel.voiceMode.settings.languages.fr"),
770
+ de: t("agentPanel.voiceMode.settings.languages.de"),
771
+ it: t("agentPanel.voiceMode.settings.languages.it"),
772
+ pt: t("agentPanel.voiceMode.settings.languages.pt"),
773
+ ja: t("agentPanel.voiceMode.settings.languages.ja"),
774
+ ko: t("agentPanel.voiceMode.settings.languages.ko"),
775
+ zh: t("agentPanel.voiceMode.settings.languages.zh"),
776
+ },
777
+ intelligence: t("agentPanel.voiceMode.settings.intelligence"),
778
+ intelligenceLevels: {
779
+ instant: t("agentPanel.voiceMode.settings.intelligenceLevels.instant"),
780
+ balanced: t("agentPanel.voiceMode.settings.intelligenceLevels.balanced"),
781
+ deep: t("agentPanel.voiceMode.settings.intelligenceLevels.deep"),
782
+ },
783
+ voiceStyle: t("agentPanel.voiceMode.settings.voiceStyle"),
784
+ microphone: t("agentPanel.voiceMode.settings.microphone"),
785
+ defaultMicrophone: t("agentPanel.voiceMode.settings.defaultMicrophone"),
786
+ microphoneSwitchFailed: t("agentPanel.voiceMode.settings.microphoneSwitchFailed"),
787
+ voiceChangePending: t("agentPanel.voiceMode.settings.voiceChangePending"),
788
+ voiceDescriptions: {
789
+ marin: t("agentPanel.voiceMode.settings.voiceDescriptions.marin"),
790
+ cedar: t("agentPanel.voiceMode.settings.voiceDescriptions.cedar"),
791
+ coral: t("agentPanel.voiceMode.settings.voiceDescriptions.coral"),
792
+ sage: t("agentPanel.voiceMode.settings.voiceDescriptions.sage"),
793
+ verse: t("agentPanel.voiceMode.settings.voiceDescriptions.verse"),
794
+ alloy: t("agentPanel.voiceMode.settings.voiceDescriptions.alloy"),
795
+ ash: t("agentPanel.voiceMode.settings.voiceDescriptions.ash"),
796
+ ballad: t("agentPanel.voiceMode.settings.voiceDescriptions.ballad"),
797
+ echo: t("agentPanel.voiceMode.settings.voiceDescriptions.echo"),
798
+ shimmer: t("agentPanel.voiceMode.settings.voiceDescriptions.shimmer"),
799
+ },
800
+ },
211
801
  status: {
212
802
  connecting: t("agentPanel.voiceMode.status.connecting"),
213
803
  listening: t("agentPanel.voiceMode.status.listening"),
@@ -221,6 +811,7 @@ function voiceCopy(t) {
221
811
  responseFailed: t("agentPanel.voiceMode.errors.responseFailed"),
222
812
  sessionFailed: t("agentPanel.voiceMode.errors.sessionFailed"),
223
813
  channelDisconnected: t("agentPanel.voiceMode.errors.channelDisconnected"),
814
+ connectionTimedOut: t("agentPanel.voiceMode.errors.connectionTimedOut"),
224
815
  connectionFailed: t("agentPanel.voiceMode.errors.connectionFailed"),
225
816
  offerFailed: t("agentPanel.voiceMode.errors.offerFailed"),
226
817
  },
@@ -234,8 +825,16 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
234
825
  const [state, setState] = useState("idle");
235
826
  const [error, setError] = useState(null);
236
827
  const [chatVisible, setChatVisible] = useState(false);
828
+ const [preferences, setPreferences] = useState(DEFAULT_REALTIME_VOICE_PREFERENCES);
829
+ const [voiceChangePending, setVoiceChangePending] = useState(false);
830
+ const [microphones, setMicrophones] = useState([]);
831
+ const [microphoneDeviceId, setMicrophoneDeviceId] = useState(readRealtimeVoiceMicrophoneId);
832
+ const [microphoneSwitching, setMicrophoneSwitching] = useState(false);
833
+ const [microphoneError, setMicrophoneError] = useState(null);
237
834
  const [audioLevels] = useState(createRealtimeVoiceAudioLevelStore);
238
835
  const stateRef = useRef(state);
836
+ const preferencesRef = useRef(preferences);
837
+ const hasOutputAudioRef = useRef(false);
239
838
  const peerRef = useRef(null);
240
839
  const channelRef = useRef(null);
241
840
  const streamRef = useRef(null);
@@ -250,17 +849,47 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
250
849
  const meterFrameRef = useRef(null);
251
850
  const lastMeterSampleRef = useRef(0);
252
851
  const abortRef = useRef(null);
253
- const cancelConnectionTimeoutRef = useRef(null);
852
+ const connectionGateRef = useRef(null);
254
853
  const handledCallsRef = useRef(new Set());
255
854
  const sessionIdRef = useRef(undefined);
855
+ const capabilityRef = useRef(undefined);
856
+ const transportGenerationRef = useRef(0);
256
857
  const startedAtRef = useRef(undefined);
257
858
  const lastUserTextRef = useRef("");
258
859
  const lastAssistantTextRef = useRef("");
259
860
  const transcriptThreadIdRef = useRef(undefined);
260
861
  const transcriptSequenceRef = useRef(0);
862
+ const preferencesHydratedRef = useRef(false);
863
+ const preferencesEditedRef = useRef(false);
864
+ const preferenceWriteChainRef = useRef(Promise.resolve());
865
+ const toolManifestCoordinator = useMemo(() => createRealtimeVoiceToolManifestCoordinator((event) => {
866
+ sendDataChannelEvent(channelRef.current, event);
867
+ }), []);
868
+ const switchMicrophoneRef = useRef(async () => undefined);
261
869
  useEffect(() => {
262
870
  stateRef.current = state;
263
871
  }, [state]);
872
+ useEffect(() => {
873
+ preferencesRef.current = preferences;
874
+ }, [preferences]);
875
+ const hydratePreferences = useCallback(async () => {
876
+ if (preferencesHydratedRef.current)
877
+ return;
878
+ try {
879
+ const stored = await readClientAppState(REALTIME_VOICE_PREFERENCES_KEY);
880
+ if (!preferencesEditedRef.current && stored != null) {
881
+ const next = normalizeRealtimeVoicePreferences(stored);
882
+ preferencesRef.current = next;
883
+ setPreferences(next);
884
+ }
885
+ }
886
+ catch {
887
+ // Preferences are optional; keep safe defaults when storage is unavailable.
888
+ }
889
+ finally {
890
+ preferencesHydratedRef.current = true;
891
+ }
892
+ }, []);
264
893
  const syncAppState = useCallback((nextState) => {
265
894
  const value = nextState === "idle"
266
895
  ? null
@@ -271,6 +900,7 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
271
900
  startedAt: startedAtRef.current,
272
901
  sessionId: sessionIdRef.current,
273
902
  browserTabId,
903
+ preferences: preferencesRef.current,
274
904
  lastUserText: lastUserTextRef.current || undefined,
275
905
  lastAssistantText: lastAssistantTextRef.current || undefined,
276
906
  };
@@ -283,6 +913,70 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
283
913
  setState(nextState);
284
914
  syncAppState(nextState);
285
915
  }, [syncAppState]);
916
+ const savePreferences = useCallback((next) => {
917
+ preferencesEditedRef.current = true;
918
+ preferencesHydratedRef.current = true;
919
+ preferencesRef.current = next;
920
+ setPreferences(next);
921
+ preferenceWriteChainRef.current = preferenceWriteChainRef.current
922
+ .catch(() => undefined)
923
+ .then(async () => {
924
+ await setClientAppState(REALTIME_VOICE_PREFERENCES_KEY, next, {
925
+ requestSource: REALTIME_VOICE_REQUEST_SOURCE,
926
+ });
927
+ })
928
+ .catch(() => undefined);
929
+ }, []);
930
+ const updateLivePreferences = useCallback((next, includeVoice = false) => {
931
+ sendDataChannelEvent(channelRef.current, createRealtimeVoicePreferenceUpdate(next, {
932
+ browserLanguages: typeof navigator === "undefined" ? [] : navigator.languages,
933
+ includeVoice,
934
+ }));
935
+ }, []);
936
+ const setLanguage = useCallback((language) => {
937
+ const next = { ...preferencesRef.current, language };
938
+ savePreferences(next);
939
+ updateLivePreferences(next);
940
+ }, [savePreferences, updateLivePreferences]);
941
+ const setIntelligence = useCallback((intelligence) => {
942
+ const next = { ...preferencesRef.current, intelligence };
943
+ savePreferences(next);
944
+ updateLivePreferences(next);
945
+ }, [savePreferences, updateLivePreferences]);
946
+ const setVoice = useCallback((voice) => {
947
+ const next = { ...preferencesRef.current, voice };
948
+ savePreferences(next);
949
+ if (hasOutputAudioRef.current) {
950
+ setVoiceChangePending(true);
951
+ updateLivePreferences(next);
952
+ return;
953
+ }
954
+ setVoiceChangePending(false);
955
+ updateLivePreferences(next, true);
956
+ }, [savePreferences, updateLivePreferences]);
957
+ const refreshMicrophones = useCallback(async () => {
958
+ const mediaDevices = navigator.mediaDevices;
959
+ if (!mediaDevices?.enumerateDevices)
960
+ return null;
961
+ try {
962
+ const devices = await mediaDevices.enumerateDevices();
963
+ const inputs = devices
964
+ .filter((device) => device.kind === "audioinput" &&
965
+ device.deviceId &&
966
+ device.deviceId !== "default" &&
967
+ device.deviceId !== "communications")
968
+ .map((device, index) => ({
969
+ deviceId: device.deviceId,
970
+ label: device.label || `Microphone ${index + 1}`,
971
+ }));
972
+ setMicrophones(inputs);
973
+ return inputs;
974
+ }
975
+ catch {
976
+ // Enumeration is progressive enhancement; system default remains usable.
977
+ return null;
978
+ }
979
+ }, []);
286
980
  const startMeterLoop = useCallback(() => {
287
981
  if (meterFrameRef.current !== null)
288
982
  return;
@@ -342,17 +1036,65 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
342
1036
  // Audio metering is visual-only; keep the realtime call healthy.
343
1037
  }
344
1038
  }, [startMeterLoop]);
1039
+ const setMicrophone = useCallback(async (deviceId, force = false) => {
1040
+ if ((!force && deviceId === microphoneDeviceId) || microphoneSwitching) {
1041
+ return;
1042
+ }
1043
+ const mediaDevices = navigator.mediaDevices;
1044
+ const peer = peerRef.current;
1045
+ if (!mediaDevices?.getUserMedia || !peer)
1046
+ return;
1047
+ setMicrophoneSwitching(true);
1048
+ setMicrophoneError(null);
1049
+ try {
1050
+ const replacementStream = await replaceRealtimeVoiceMicrophone({
1051
+ mediaDevices,
1052
+ peer,
1053
+ currentStream: streamRef.current,
1054
+ deviceId,
1055
+ });
1056
+ const replacementTrack = replacementStream.getAudioTracks()[0];
1057
+ streamRef.current = replacementStream;
1058
+ if (replacementTrack) {
1059
+ replacementTrack.onended = () => {
1060
+ void switchMicrophoneRef.current("default", true);
1061
+ };
1062
+ }
1063
+ attachAudioMeter(streamRef.current, "input");
1064
+ writeRealtimeVoiceMicrophoneId(deviceId);
1065
+ setMicrophoneDeviceId(deviceId);
1066
+ await refreshMicrophones();
1067
+ }
1068
+ catch {
1069
+ setMicrophoneError(copy?.settings.microphoneSwitchFailed ??
1070
+ "Could not switch microphones. Your current microphone is still active.");
1071
+ }
1072
+ finally {
1073
+ setMicrophoneSwitching(false);
1074
+ }
1075
+ }, [
1076
+ attachAudioMeter,
1077
+ copy,
1078
+ microphoneDeviceId,
1079
+ microphoneSwitching,
1080
+ refreshMicrophones,
1081
+ ]);
1082
+ switchMicrophoneRef.current = setMicrophone;
345
1083
  const cleanupTransport = useCallback(() => {
346
- cancelConnectionTimeoutRef.current?.();
347
- cancelConnectionTimeoutRef.current = null;
1084
+ connectionGateRef.current?.cancel();
1085
+ connectionGateRef.current = null;
1086
+ transportGenerationRef.current += 1;
1087
+ capabilityRef.current = undefined;
348
1088
  abortRef.current?.abort();
349
1089
  abortRef.current = null;
350
1090
  channelRef.current?.close();
351
1091
  channelRef.current = null;
352
1092
  peerRef.current?.close();
353
1093
  peerRef.current = null;
354
- for (const track of streamRef.current?.getTracks() ?? [])
1094
+ for (const track of streamRef.current?.getTracks() ?? []) {
1095
+ track.onended = null;
355
1096
  track.stop();
1097
+ }
356
1098
  streamRef.current = null;
357
1099
  if (audioRef.current) {
358
1100
  audioRef.current.pause();
@@ -378,7 +1120,8 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
378
1120
  void audioContext.close().catch(() => undefined);
379
1121
  audioLevels.reset();
380
1122
  handledCallsRef.current.clear();
381
- }, [audioLevels]);
1123
+ toolManifestCoordinator.reset();
1124
+ }, [audioLevels, toolManifestCoordinator]);
382
1125
  const fail = useCallback((message, options) => {
383
1126
  cleanupTransport();
384
1127
  setError(message);
@@ -390,6 +1133,8 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
390
1133
  if (handledCallsRef.current.has(call.callId))
391
1134
  return;
392
1135
  handledCallsRef.current.add(call.callId);
1136
+ const transportGeneration = transportGenerationRef.current;
1137
+ const transportChannel = channelRef.current;
393
1138
  transition("working");
394
1139
  let result;
395
1140
  try {
@@ -400,6 +1145,7 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
400
1145
  callId: call.callId,
401
1146
  sessionId: sessionIdRef.current,
402
1147
  browserTabId,
1148
+ capability: capabilityRef.current,
403
1149
  signal: abortRef.current?.signal,
404
1150
  });
405
1151
  }
@@ -410,20 +1156,15 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
410
1156
  output: errorMessage(toolError),
411
1157
  };
412
1158
  }
413
- sendDataChannelEvent(channelRef.current, {
414
- type: "conversation.item.create",
415
- item: {
416
- type: "function_call_output",
417
- call_id: call.callId,
418
- output: JSON.stringify(result),
419
- },
420
- });
421
- sendDataChannelEvent(channelRef.current, { type: "response.create" });
422
- }, [browserTabId, transition]);
423
- const persistCompletedTranscript = useCallback((event) => {
424
- const transcript = extractCompletedRealtimeVoiceTranscript(event);
1159
+ if (transportGeneration !== transportGenerationRef.current ||
1160
+ channelRef.current !== transportChannel) {
1161
+ return;
1162
+ }
1163
+ toolManifestCoordinator.enqueue(result);
1164
+ }, [browserTabId, toolManifestCoordinator, transition]);
1165
+ const publishCompletedTranscript = useCallback((transcript) => {
425
1166
  const threadId = transcriptThreadIdRef.current;
426
- if (!transcript || !threadId)
1167
+ if (!threadId)
427
1168
  return;
428
1169
  const sessionIdentity = sessionIdRef.current ?? startedAtRef.current ?? "pending";
429
1170
  const providerIdentity = transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
@@ -435,15 +1176,27 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
435
1176
  createdAt: new Date().toISOString(),
436
1177
  });
437
1178
  }, []);
1179
+ const transcriptSequencer = useMemo(() => createRealtimeVoiceTranscriptSequencer(publishCompletedTranscript), [publishCompletedTranscript]);
1180
+ const greetingStarter = useMemo(() => createRealtimeVoiceGreetingStarter((event) => {
1181
+ sendDataChannelEvent(channelRef.current, event);
1182
+ }), []);
438
1183
  const handleServerEvent = useCallback((event) => {
1184
+ transcriptSequencer.handle(event);
1185
+ const sessionTools = extractRealtimeVoiceSessionTools(event);
1186
+ if (sessionTools)
1187
+ toolManifestCoordinator.setSessionTools(sessionTools);
439
1188
  if (event.type === "session.created") {
1189
+ connectionGateRef.current?.markSessionCreated();
1190
+ connectionGateRef.current = null;
440
1191
  const session = event.session;
441
1192
  if (session && typeof session === "object") {
442
1193
  const id = session.id;
443
1194
  if (typeof id === "string")
444
1195
  sessionIdRef.current = id;
445
1196
  }
446
- transition("listening");
1197
+ updateLivePreferences(preferencesRef.current, true);
1198
+ greetingStarter.start();
1199
+ transition("working");
447
1200
  }
448
1201
  else if (event.type === "input_audio_buffer.speech_started") {
449
1202
  transition("listening");
@@ -452,10 +1205,14 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
452
1205
  transition("working");
453
1206
  }
454
1207
  else if (event.type === "response.created") {
1208
+ // From this point onward the response is committed to audio output, so
1209
+ // changing voices risks violating Realtime's per-session voice lock.
1210
+ hasOutputAudioRef.current = true;
455
1211
  lastAssistantTextRef.current = "";
456
1212
  transition("working");
457
1213
  }
458
1214
  else if (event.type === "response.output_audio_transcript.delta") {
1215
+ hasOutputAudioRef.current = true;
459
1216
  if (typeof event.delta === "string") {
460
1217
  lastAssistantTextRef.current += event.delta;
461
1218
  }
@@ -465,14 +1222,12 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
465
1222
  if (typeof event.transcript === "string") {
466
1223
  lastAssistantTextRef.current = event.transcript;
467
1224
  }
468
- persistCompletedTranscript(event);
469
1225
  syncAppState("speaking");
470
1226
  }
471
1227
  else if (event.type === "conversation.item.input_audio_transcription.completed") {
472
1228
  if (typeof event.transcript === "string") {
473
1229
  lastUserTextRef.current = event.transcript;
474
1230
  }
475
- persistCompletedTranscript(event);
476
1231
  syncAppState("working");
477
1232
  }
478
1233
  else if (event.type === "response.done") {
@@ -493,6 +1248,12 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
493
1248
  : typeof detail === "string"
494
1249
  ? detail
495
1250
  : "";
1251
+ const expansionEventId = detail && typeof detail === "object"
1252
+ ? (detail.event_id ?? event.event_id)
1253
+ : event.event_id;
1254
+ if (toolManifestCoordinator.handleError(typeof expansionEventId === "string" ? expansionEventId : undefined, message)) {
1255
+ return;
1256
+ }
496
1257
  fail(message ||
497
1258
  copy?.errors.sessionFailed ||
498
1259
  "The realtime voice session encountered an error.");
@@ -507,10 +1268,13 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
507
1268
  }, [
508
1269
  copy,
509
1270
  fail,
1271
+ greetingStarter,
510
1272
  handleFunctionCall,
511
- persistCompletedTranscript,
512
1273
  syncAppState,
1274
+ transcriptSequencer,
513
1275
  transition,
1276
+ toolManifestCoordinator,
1277
+ updateLivePreferences,
514
1278
  ]);
515
1279
  const start = useCallback(async () => {
516
1280
  if (stateRef.current !== "idle")
@@ -526,31 +1290,32 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
526
1290
  lastUserTextRef.current = "";
527
1291
  lastAssistantTextRef.current = "";
528
1292
  sessionIdRef.current = undefined;
1293
+ hasOutputAudioRef.current = false;
1294
+ greetingStarter.reset();
1295
+ setVoiceChangePending(false);
529
1296
  transcriptThreadIdRef.current =
530
1297
  realtimeVoiceTranscriptRegistry.activeThreadId();
531
1298
  transcriptSequenceRef.current = 0;
1299
+ transcriptSequencer.reset();
532
1300
  transition("connecting");
533
1301
  setChatVisible(false);
534
1302
  window.dispatchEvent(new Event("agent-panel:close"));
1303
+ await hydratePreferences();
1304
+ if (stateRef.current !== "connecting")
1305
+ return;
535
1306
  const abortController = new AbortController();
536
1307
  abortRef.current = abortController;
537
1308
  const isCurrentAttempt = () => abortRef.current === abortController && !abortController.signal.aborted;
538
- const markConnected = () => {
539
- if (!isCurrentAttempt())
540
- return;
541
- cancelConnectionTimeoutRef.current?.();
542
- cancelConnectionTimeoutRef.current = null;
543
- transition("listening");
544
- };
545
- cancelConnectionTimeoutRef.current = createRealtimeVoiceConnectionTimeout(() => {
1309
+ const connectionGate = createRealtimeVoiceConnectionGate(() => {
546
1310
  if (!isCurrentAttempt())
547
1311
  return;
548
- fail(copy?.errors.connectionFailed ??
1312
+ fail(copy?.errors.connectionTimedOut ??
549
1313
  "The realtime voice connection timed out.");
550
1314
  });
1315
+ connectionGateRef.current = connectionGate;
551
1316
  try {
552
1317
  const stream = await navigator.mediaDevices.getUserMedia({
553
- audio: REALTIME_VOICE_AUDIO_CONSTRAINTS,
1318
+ audio: createRealtimeVoiceAudioConstraints(readRealtimeVoiceMicrophoneId()),
554
1319
  });
555
1320
  if (!isCurrentAttempt()) {
556
1321
  for (const track of stream.getTracks())
@@ -558,7 +1323,23 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
558
1323
  return;
559
1324
  }
560
1325
  streamRef.current = stream;
1326
+ for (const track of stream.getAudioTracks()) {
1327
+ track.onended = () => {
1328
+ void switchMicrophoneRef.current("default", true);
1329
+ };
1330
+ }
561
1331
  attachAudioMeter(stream, "input");
1332
+ void refreshMicrophones().then((inputs) => {
1333
+ const selected = readRealtimeVoiceMicrophoneId();
1334
+ if (inputs &&
1335
+ selected !== "default" &&
1336
+ !inputs.some((device) => device.deviceId === selected)) {
1337
+ // The persisted device was only an ideal preference, so getUserMedia
1338
+ // already selected a usable fallback for this new session.
1339
+ writeRealtimeVoiceMicrophoneId("default");
1340
+ setMicrophoneDeviceId("default");
1341
+ }
1342
+ });
562
1343
  const peer = new RTCPeerConnection();
563
1344
  peerRef.current = peer;
564
1345
  for (const track of stream.getAudioTracks())
@@ -576,7 +1357,7 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
576
1357
  };
577
1358
  const channel = peer.createDataChannel("oai-events");
578
1359
  channelRef.current = channel;
579
- channel.onopen = markConnected;
1360
+ channel.onopen = connectionGate.markTransportReady;
580
1361
  channel.onmessage = (messageEvent) => {
581
1362
  if (!isCurrentAttempt())
582
1363
  return;
@@ -596,8 +1377,9 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
596
1377
  peer.onconnectionstatechange = () => {
597
1378
  if (!isCurrentAttempt())
598
1379
  return;
599
- if (peer.connectionState === "connected")
600
- markConnected();
1380
+ if (peer.connectionState === "connected") {
1381
+ connectionGate.markTransportReady();
1382
+ }
601
1383
  if (peer.connectionState === "failed") {
602
1384
  fail(copy?.errors.connectionFailed ??
603
1385
  "The realtime voice connection failed.");
@@ -613,13 +1395,16 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
613
1395
  throw new Error(copy?.errors.offerFailed ??
614
1396
  "The browser did not create an audio offer.");
615
1397
  }
616
- const answerSdp = await createRealtimeVoiceSession(offer.sdp, {
1398
+ const answer = await createRealtimeVoiceSessionWithCapability(offer.sdp, {
617
1399
  browserTabId,
618
1400
  signal: abortController.signal,
1401
+ preferences: preferencesRef.current,
1402
+ browserLanguages: navigator.languages,
619
1403
  });
620
1404
  if (!isCurrentAttempt())
621
1405
  return;
622
- await peer.setRemoteDescription({ type: "answer", sdp: answerSdp });
1406
+ capabilityRef.current = answer.capability;
1407
+ await peer.setRemoteDescription({ type: "answer", sdp: answer.sdp });
623
1408
  }
624
1409
  catch (startError) {
625
1410
  // Ending a connection aborts the SDP request by design. A superseded
@@ -630,15 +1415,36 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
630
1415
  abortRef.current !== abortController) {
631
1416
  return;
632
1417
  }
1418
+ if (isRealtimeVoiceSetupRequiredError(startError)) {
1419
+ // Credentials can disappear after the entry-point status check. Treat
1420
+ // the authoritative setup response as a gate, not as an active voice
1421
+ // session failure: clean up the mic/RTC attempt, reopen chat, and
1422
+ // refresh both the chat and voice provider setup surfaces.
1423
+ cleanupTransport();
1424
+ setError(null);
1425
+ startedAtRef.current = undefined;
1426
+ sessionIdRef.current = undefined;
1427
+ transcriptThreadIdRef.current = undefined;
1428
+ transition("idle");
1429
+ setChatVisible(true);
1430
+ window.dispatchEvent(new Event("agent-panel:open"));
1431
+ window.dispatchEvent(new Event("agent-engine:configured-changed"));
1432
+ return;
1433
+ }
633
1434
  const status = startError?.status;
634
1435
  fail(errorMessage(startError), { openKeySettings: status === 400 });
635
1436
  }
636
1437
  }, [
637
1438
  attachAudioMeter,
638
1439
  browserTabId,
1440
+ cleanupTransport,
639
1441
  copy,
640
1442
  fail,
1443
+ greetingStarter,
641
1444
  handleServerEvent,
1445
+ hydratePreferences,
1446
+ refreshMicrophones,
1447
+ transcriptSequencer,
642
1448
  transition,
643
1449
  ]);
644
1450
  const end = useCallback(() => {
@@ -671,6 +1477,23 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
671
1477
  setChatVisible((current) => !current);
672
1478
  window.dispatchEvent(new Event("agent-panel:toggle"));
673
1479
  }, []);
1480
+ useEffect(() => {
1481
+ const mediaDevices = navigator.mediaDevices;
1482
+ if (!mediaDevices?.addEventListener)
1483
+ return;
1484
+ const handleDeviceChange = () => {
1485
+ const selected = readRealtimeVoiceMicrophoneId();
1486
+ void refreshMicrophones().then((inputs) => {
1487
+ if (inputs &&
1488
+ selected !== "default" &&
1489
+ !inputs.some((device) => device.deviceId === selected)) {
1490
+ void setMicrophone("default");
1491
+ }
1492
+ });
1493
+ };
1494
+ mediaDevices.addEventListener("devicechange", handleDeviceChange);
1495
+ return () => mediaDevices.removeEventListener("devicechange", handleDeviceChange);
1496
+ }, [refreshMicrophones, setMicrophone]);
674
1497
  useEffect(() => {
675
1498
  const onSidebarState = (event) => {
676
1499
  const detail = event
@@ -695,6 +1518,16 @@ function useRealtimeVoiceModeController(browserTabId, copy) {
695
1518
  errorMessage: error,
696
1519
  chatVisible,
697
1520
  audioLevels,
1521
+ preferences,
1522
+ microphones,
1523
+ microphoneDeviceId,
1524
+ microphoneSwitching,
1525
+ microphoneError,
1526
+ voiceChangePending,
1527
+ setLanguage,
1528
+ setIntelligence,
1529
+ setVoice,
1530
+ setMicrophone,
698
1531
  start,
699
1532
  end,
700
1533
  toggleChat,
@@ -705,8 +1538,76 @@ export function RealtimeVoiceModeProvider({ children, browserTabId, }) {
705
1538
  const resolvedBrowserTabId = useMemo(() => browserTabId ?? getBrowserTabId(), [browserTabId]);
706
1539
  const copy = useRealtimeVoiceModeCopy();
707
1540
  const voice = useRealtimeVoiceModeController(resolvedBrowserTabId, copy);
1541
+ const inlineSettings = useMemo(() => ({
1542
+ dialogLabel: copy.voiceSettings,
1543
+ ...(voice.voiceChangePending
1544
+ ? { appliesNextConversationNote: copy.settings.voiceChangePending }
1545
+ : {}),
1546
+ ...(voice.microphoneError
1547
+ ? { microphoneError: voice.microphoneError }
1548
+ : {}),
1549
+ microphone: {
1550
+ label: copy.settings.microphone,
1551
+ value: voice.microphoneDeviceId,
1552
+ disabled: voice.microphoneSwitching,
1553
+ options: [
1554
+ {
1555
+ value: "default",
1556
+ label: copy.settings.defaultMicrophone,
1557
+ },
1558
+ ...voice.microphones.map((microphone) => ({
1559
+ value: microphone.deviceId,
1560
+ label: microphone.label,
1561
+ })),
1562
+ ],
1563
+ onValueChange: (value) => {
1564
+ void voice.setMicrophone(value);
1565
+ },
1566
+ },
1567
+ language: {
1568
+ label: copy.settings.language,
1569
+ value: voice.preferences.language,
1570
+ options: REALTIME_VOICE_LANGUAGES.map((language) => ({
1571
+ value: language,
1572
+ label: language === "auto"
1573
+ ? copy.settings.autoLanguage
1574
+ : copy.settings.languages[language],
1575
+ })),
1576
+ onValueChange: (value) => {
1577
+ if (isOneOf(REALTIME_VOICE_LANGUAGES, value)) {
1578
+ voice.setLanguage(value);
1579
+ }
1580
+ },
1581
+ },
1582
+ intelligence: {
1583
+ label: copy.settings.intelligence,
1584
+ value: voice.preferences.intelligence,
1585
+ options: REALTIME_VOICE_INTELLIGENCE_LEVELS.map((intelligence) => ({
1586
+ value: intelligence,
1587
+ label: copy.settings.intelligenceLevels[intelligence],
1588
+ })),
1589
+ onValueChange: (value) => {
1590
+ if (isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, value)) {
1591
+ voice.setIntelligence(value);
1592
+ }
1593
+ },
1594
+ },
1595
+ voiceStyle: {
1596
+ label: copy.settings.voiceStyle,
1597
+ value: voice.preferences.voice,
1598
+ options: REALTIME_VOICES.map((voiceName) => ({
1599
+ value: voiceName,
1600
+ label: `${voiceName[0].toUpperCase()}${voiceName.slice(1)}`,
1601
+ description: copy.settings.voiceDescriptions[voiceName],
1602
+ })),
1603
+ onValueChange: (value) => {
1604
+ if (isOneOf(REALTIME_VOICES, value))
1605
+ voice.setVoice(value);
1606
+ },
1607
+ },
1608
+ }), [copy, voice]);
708
1609
  return (_jsxs(RealtimeVoiceModeContext.Provider, { value: voice, children: [children, voice.active && typeof document !== "undefined"
709
- ? createPortal(_jsx(RealtimeVoiceModeDock, { state: voice.state === "idle" ? "ending" : voice.state, copy: copy, chatVisible: voice.chatVisible, audioLevels: voice.audioLevels, onToggleChat: voice.toggleChat, onEndVoiceMode: voice.end, onOpenMicrophoneSettings: () => endRealtimeVoiceBeforeNavigation(voice.end, openMicrophoneSettings), errorMessage: voice.errorMessage }), document.body)
1610
+ ? createPortal(_jsx(RealtimeVoiceModeDock, { state: voice.state === "idle" ? "ending" : voice.state, copy: copy, chatVisible: voice.chatVisible, audioLevels: voice.audioLevels, onToggleChat: voice.toggleChat, onEndVoiceMode: voice.end, settings: inlineSettings, errorMessage: voice.errorMessage }), document.body)
710
1611
  : null] }));
711
1612
  }
712
1613
  /**