@vellumai/assistant 0.11.3 → 0.11.4-dev.202608190019.b94dbf2

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 (596) hide show
  1. package/AGENTS.md +9 -3
  2. package/ARCHITECTURE.md +32 -10
  3. package/docs/architecture/memory.md +43 -5
  4. package/docs/architecture/security.md +96 -152
  5. package/docs/architecture/turn-actor.md +70 -0
  6. package/docs/browser-use-architecture-phase2.md +128 -56
  7. package/docs/flux-turn-detection-spike.md +248 -0
  8. package/docs/guardian-request-flow.md +35 -0
  9. package/docs/stt-provider-onboarding.md +3 -1
  10. package/knip.json +3 -0
  11. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  12. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  13. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  17. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  18. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  19. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  20. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  21. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  23. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  24. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +166 -1
  25. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  26. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  27. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  28. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  29. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  30. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  31. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  32. package/openapi.yaml +289 -113
  33. package/package.json +1 -1
  34. package/scripts/voice-ttft-spike.ts +3 -3
  35. package/scripts/write-plugin-api-shim.ts +10 -0
  36. package/src/__tests__/app-compiler.test.ts +38 -3
  37. package/src/__tests__/app-control-flow.test.ts +1 -0
  38. package/src/__tests__/approval-routes-http.test.ts +2 -2
  39. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  40. package/src/__tests__/attachments-store.test.ts +21 -12
  41. package/src/__tests__/byok-default-profile-ensure.test.ts +17 -0
  42. package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
  43. package/src/__tests__/call-site-routing-provider.test.ts +1 -1
  44. package/src/__tests__/channel-availability-routes.test.ts +14 -1
  45. package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
  46. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  47. package/src/__tests__/channel-inbound-disk-pressure.test.ts +4 -4
  48. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  49. package/src/__tests__/checker.test.ts +20 -314
  50. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +6 -2
  51. package/src/__tests__/compaction-events.test.ts +8 -10
  52. package/src/__tests__/config-loader-backfill.test.ts +3 -3
  53. package/src/__tests__/config-schema.test.ts +25 -10
  54. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  55. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
  56. package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
  57. package/src/__tests__/conversation-agent-loop.test.ts +32 -21
  58. package/src/__tests__/conversation-attachments.test.ts +0 -1
  59. package/src/__tests__/conversation-attention-store.test.ts +63 -0
  60. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  61. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
  62. package/src/__tests__/conversation-fork-crud.test.ts +69 -0
  63. package/src/__tests__/conversation-fork-referential.test.ts +67 -0
  64. package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
  65. package/src/__tests__/conversation-load-history-repair.test.ts +209 -0
  66. package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
  67. package/src/__tests__/conversation-queue.test.ts +154 -4
  68. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  69. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  70. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  71. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  72. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  73. package/src/__tests__/conversation-runtime-assembly.test.ts +187 -102
  74. package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
  75. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  76. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  77. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  78. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  79. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  80. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  81. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  82. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  83. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  84. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  85. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  86. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  87. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  88. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  89. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  90. package/src/__tests__/credential-prompt-route.test.ts +7 -10
  91. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  92. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  93. package/src/__tests__/custom-profile-ensure.test.ts +5 -1
  94. package/src/__tests__/default-plugin-names-guard.test.ts +12 -1
  95. package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
  96. package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
  97. package/src/__tests__/document-append-idempotency.test.ts +233 -0
  98. package/src/__tests__/document-sync-tags.test.ts +0 -75
  99. package/src/__tests__/dynamic-skill-background-guard.test.ts +0 -2
  100. package/src/__tests__/edit-propagation.test.ts +107 -11
  101. package/src/__tests__/file-ops-service.test.ts +163 -30
  102. package/src/__tests__/filesystem-tools.test.ts +23 -24
  103. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  104. package/src/__tests__/helpers/gateway-classify-mock.ts +27 -6
  105. package/src/__tests__/helpers/mock-actor-context.ts +49 -0
  106. package/src/__tests__/helpers/mock-conversation.ts +13 -1
  107. package/src/__tests__/host-file-read-tool.test.ts +16 -19
  108. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  109. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  110. package/src/__tests__/injector-chain.test.ts +63 -41
  111. package/src/__tests__/injector-disk-pressure.test.ts +11 -23
  112. package/src/__tests__/inline-skill-load-permissions.test.ts +47 -36
  113. package/src/__tests__/llm-context-resolution.test.ts +73 -1
  114. package/src/__tests__/llm-schema.test.ts +5 -2
  115. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  116. package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
  117. package/src/__tests__/media-generate-image.test.ts +131 -21
  118. package/src/__tests__/memory-retrieval-hook.test.ts +100 -7
  119. package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
  120. package/src/__tests__/mtime-cache.test.ts +3 -1
  121. package/src/__tests__/non-member-access-request.test.ts +0 -20
  122. package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
  123. package/src/__tests__/platform-bash-auto-approve.test.ts +0 -4
  124. package/src/__tests__/plugin-api-store-credential.test.ts +261 -0
  125. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  126. package/src/__tests__/plugin-disabled-state.test.ts +2 -0
  127. package/src/__tests__/plugin-effective-enabled-set.test.ts +55 -3
  128. package/src/__tests__/plugin-execution-context.test.ts +73 -0
  129. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -1
  130. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -4
  131. package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
  132. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
  133. package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
  134. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  135. package/src/__tests__/qdrant-manager.test.ts +14 -1
  136. package/src/__tests__/reaction-persistence.test.ts +200 -7
  137. package/src/__tests__/require-fresh-approval.test.ts +0 -4
  138. package/src/__tests__/risk-classification-boundary-guard.test.ts +115 -0
  139. package/src/__tests__/run-conversation-turn-persistence.test.ts +460 -105
  140. package/src/__tests__/run-due-schedules.test.ts +21 -0
  141. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  142. package/src/__tests__/schedule-routes.test.ts +23 -0
  143. package/src/__tests__/schedule-store.test.ts +17 -0
  144. package/src/__tests__/scoped-approval-grants.test.ts +11 -6
  145. package/src/__tests__/secret-ingress-channel.test.ts +0 -1
  146. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  147. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  148. package/src/__tests__/skills.test.ts +32 -0
  149. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  150. package/src/__tests__/starter-task-flow.test.ts +5 -4
  151. package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
  152. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  153. package/src/__tests__/subagent-spawn-and-await.test.ts +18 -17
  154. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  155. package/src/__tests__/subagent-tools.test.ts +81 -101
  156. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  157. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  158. package/src/__tests__/tool-executor-lifecycle-events.test.ts +132 -5
  159. package/src/__tests__/tool-executor.test.ts +68 -39
  160. package/src/__tests__/turn-events-store.test.ts +43 -0
  161. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  162. package/src/__tests__/ui-shape-teaching.test.ts +33 -0
  163. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  164. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  165. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  166. package/src/__tests__/user-plugin-loader.test.ts +3 -1
  167. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  168. package/src/__tests__/visible-app-context.test.ts +16 -9
  169. package/src/__tests__/voice-config-update.test.ts +40 -0
  170. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  171. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  172. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  173. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  174. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  175. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  176. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  177. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  178. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  179. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  180. package/src/acp/__tests__/auth-required.test.ts +161 -0
  181. package/src/acp/acp-claude-oauth.ts +19 -2
  182. package/src/acp/agent-process.test.ts +100 -0
  183. package/src/acp/agent-process.ts +29 -26
  184. package/src/acp/auth-required.ts +102 -0
  185. package/src/acp/session-manager.test.ts +119 -0
  186. package/src/acp/session-manager.ts +76 -3
  187. package/src/api/events/acp-auth-required.ts +55 -0
  188. package/src/api/events/host-file.ts +2 -2
  189. package/src/api/index.ts +7 -0
  190. package/src/api/surfaces.ts +12 -3
  191. package/src/apps/app-store.ts +3 -0
  192. package/src/bundler/package-resolver.ts +2 -30
  193. package/src/calls/__tests__/voice-session-bridge.test.ts +194 -11
  194. package/src/calls/__tests__/voice-triage-escalate.test.ts +102 -0
  195. package/src/calls/call-controller.ts +19 -3
  196. package/src/calls/call-setup-flow.ts +0 -1
  197. package/src/calls/media-stream-stt-session.ts +15 -0
  198. package/src/calls/voice-session-bridge.ts +115 -41
  199. package/src/calls/voice-triage-escalate.ts +105 -2
  200. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  201. package/src/channels/config.ts +13 -0
  202. package/src/channels/plugin-channel-declarations.ts +108 -0
  203. package/src/channels/types.ts +30 -0
  204. package/src/cli/AGENTS.md +5 -2
  205. package/src/cli/bundled-modules.ts +29 -0
  206. package/src/cli/commands/credentials.help.ts +2 -2
  207. package/src/cli/commands/db/repair.ts +4 -8
  208. package/src/cli/commands/domain.ts +6 -3
  209. package/src/cli/commands/email.ts +6 -3
  210. package/src/cli/commands/inference-providers.ts +1 -1
  211. package/src/cli/commands/keys.ts +8 -3
  212. package/src/cli/commands/mcp.help.ts +13 -4
  213. package/src/cli/commands/mcp.ts +9 -0
  214. package/src/cli/commands/memory/__tests__/memory-v2.test.ts +57 -7
  215. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  216. package/src/cli/commands/memory/index.help.ts +84 -28
  217. package/src/cli/commands/memory/index.ts +2 -0
  218. package/src/cli/commands/memory/memory-v2.ts +58 -54
  219. package/src/cli/commands/memory/memory-v3.ts +64 -0
  220. package/src/cli/commands/memory/memory-validate.ts +18 -0
  221. package/src/cli/commands/plugins.ts +85 -36
  222. package/src/cli/commands/schedules.ts +35 -1
  223. package/src/cli/commands/stt.help.ts +27 -2
  224. package/src/cli/commands/trust.ts +3 -14
  225. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  226. package/src/cli/lib/bundled-marketplace.json +14 -1
  227. package/src/cli/lib/upgrade-plugin.ts +42 -0
  228. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  229. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  230. package/src/config/__tests__/default-provider.test.ts +6 -1
  231. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  232. package/src/config/assistant-feature-flags.ts +36 -15
  233. package/src/config/balanced-model-experiment.ts +35 -0
  234. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  235. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  236. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  237. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  238. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  239. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  240. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  241. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  242. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  243. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  244. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  245. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  246. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  247. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  248. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  249. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  250. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  251. package/src/config/call-site-defaults.ts +11 -5
  252. package/src/config/default-profile-catalog.ts +179 -16
  253. package/src/config/default-profile-names.ts +4 -1
  254. package/src/config/default-provider-resolution.ts +4 -0
  255. package/src/config/feature-flag-registry.json +12 -12
  256. package/src/config/llm-context-resolution.ts +11 -3
  257. package/src/config/llm-resolver.ts +28 -1
  258. package/src/config/profile-materialization.ts +70 -22
  259. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  260. package/src/config/schemas/call-site-catalog.ts +4 -4
  261. package/src/config/schemas/live-voice.ts +57 -23
  262. package/src/config/schemas/llm.ts +59 -32
  263. package/src/config/schemas/mcp.ts +23 -0
  264. package/src/config/schemas/plugin-updates.ts +6 -2
  265. package/src/config/schemas/stt.ts +1 -0
  266. package/src/config/skills.ts +9 -2
  267. package/src/context/outbound-sanitize.ts +96 -1
  268. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  269. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  270. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  271. package/src/daemon/conversation-agent-loop.ts +31 -19
  272. package/src/daemon/conversation-messaging.ts +5 -1
  273. package/src/daemon/conversation-notifiers.ts +20 -10
  274. package/src/daemon/conversation-process.ts +20 -6
  275. package/src/daemon/conversation-runtime-assembly.ts +12 -6
  276. package/src/daemon/conversation-store.ts +14 -4
  277. package/src/daemon/conversation-surfaces.ts +75 -26
  278. package/src/daemon/conversation-tool-setup.ts +3 -6
  279. package/src/daemon/conversation.ts +154 -51
  280. package/src/daemon/doordash-steps.ts +2 -2
  281. package/src/daemon/lifecycle.ts +14 -1
  282. package/src/daemon/mcp-reload-service.ts +36 -6
  283. package/src/daemon/process-message.ts +13 -26
  284. package/src/daemon/providers-setup.ts +6 -3
  285. package/src/daemon/trust-context-types.ts +29 -0
  286. package/src/daemon/trust-context.ts +12 -7
  287. package/src/daemon/wake-conversation-ops.ts +3 -2
  288. package/src/daemon/windows-compiled-entry.ts +4 -0
  289. package/src/documents/document-store.ts +143 -240
  290. package/src/hooks/hook-loader.ts +3 -3
  291. package/src/hooks/registry.ts +103 -45
  292. package/src/hooks/types.ts +5 -0
  293. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  294. package/src/inbound/oauth-callback-url.ts +61 -0
  295. package/src/ipc/gateway-client.test.ts +1 -1
  296. package/src/ipc/gateway-client.ts +14 -21
  297. package/src/ipc/gateway-flag-listener.ts +17 -3
  298. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  299. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  300. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +1050 -0
  301. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  302. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  303. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  304. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  305. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  306. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  307. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  308. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  309. package/src/live-voice/live-voice-archive.ts +2 -0
  310. package/src/live-voice/live-voice-manager.ts +16 -3
  311. package/src/live-voice/live-voice-metrics.ts +57 -32
  312. package/src/live-voice/live-voice-photo.ts +1 -2
  313. package/src/live-voice/live-voice-session.ts +598 -323
  314. package/src/live-voice/progress-narration.ts +277 -0
  315. package/src/live-voice/protocol.ts +21 -1
  316. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  317. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  318. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  319. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  320. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  321. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  322. package/src/mcp/client.ts +16 -11
  323. package/src/mcp/effective-config.ts +113 -0
  324. package/src/mcp/manager.ts +11 -6
  325. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  326. package/src/mcp/mcp-oauth-provider.ts +205 -240
  327. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  328. package/src/monitoring/control.ts +1 -0
  329. package/src/monitoring/db-integrity-sample.ts +4 -5
  330. package/src/monitoring/plugin-auto-update.ts +128 -24
  331. package/src/notifications/AGENTS.md +2 -0
  332. package/src/notifications/approval-card-data.ts +33 -0
  333. package/src/notifications/signal.ts +1 -0
  334. package/src/permissions/AGENTS.md +16 -0
  335. package/src/permissions/checker.test.ts +75 -122
  336. package/src/permissions/checker.ts +44 -560
  337. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  338. package/src/permissions/confirmation-guardian-request.ts +2 -2
  339. package/src/permissions/prompter.ts +1 -5
  340. package/src/permissions/question-guardian-request.test.ts +14 -6
  341. package/src/permissions/question-guardian-request.ts +1 -2
  342. package/src/persistence/attachments-store.ts +8 -1
  343. package/src/persistence/bookmark-crud.ts +3 -7
  344. package/src/persistence/conversation-attention-store.ts +16 -45
  345. package/src/persistence/conversation-crud.ts +33 -4
  346. package/src/persistence/conversation-lineage.ts +9 -0
  347. package/src/persistence/conversation-queries.ts +182 -52
  348. package/src/persistence/delivery-crud.ts +150 -36
  349. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  350. package/src/persistence/external-conversation-store.ts +32 -4
  351. package/src/persistence/llm-request-log-store.ts +4 -10
  352. package/src/persistence/llm-usage-store.ts +8 -3
  353. package/src/persistence/message-reads.test.ts +197 -0
  354. package/src/persistence/message-reads.ts +211 -0
  355. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  356. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  357. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  358. package/src/persistence/real-user-turn-filter.ts +27 -3
  359. package/src/persistence/schema/documents.ts +4 -4
  360. package/src/persistence/steps.ts +9 -0
  361. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  362. package/src/plugin-api/constants.ts +8 -0
  363. package/src/plugin-api/conversation-turn.ts +195 -5
  364. package/src/plugin-api/index.ts +38 -6
  365. package/src/plugin-api/resolve-credential.ts +3 -2
  366. package/src/plugin-api/store-credential.ts +146 -0
  367. package/src/plugin-api/vision-support.test.ts +1 -1
  368. package/src/plugin-api/webhook-url.ts +13 -11
  369. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  370. package/src/plugins/defaults/main.ts +21 -7
  371. package/src/plugins/defaults/memory/AGENTS.md +15 -3
  372. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  373. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  374. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  375. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  376. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +0 -1
  377. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  378. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  379. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  380. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  381. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  382. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  383. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  384. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +17 -24
  385. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  386. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  387. package/src/plugins/defaults/memory/indexer.ts +3 -1
  388. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  389. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  390. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  391. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +12 -10
  392. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  393. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +147 -0
  394. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +8 -0
  395. package/src/plugins/defaults/memory/substrate/__tests__/edge-index.test.ts +0 -30
  396. package/src/plugins/defaults/memory/substrate/__tests__/ingest.test.ts +73 -1
  397. package/src/plugins/defaults/memory/substrate/__tests__/page-index.test.ts +37 -0
  398. package/src/plugins/defaults/memory/substrate/__tests__/page-links.test.ts +208 -0
  399. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +158 -1
  400. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +195 -58
  401. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +93 -24
  402. package/src/plugins/defaults/memory/substrate/edge-index.ts +0 -31
  403. package/src/plugins/defaults/memory/substrate/ingest.ts +59 -0
  404. package/src/plugins/defaults/memory/substrate/page-index.ts +35 -8
  405. package/src/plugins/defaults/memory/substrate/page-links.ts +133 -0
  406. package/src/plugins/defaults/memory/substrate/page-store.ts +10 -0
  407. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +124 -34
  408. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  409. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -33
  410. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  411. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  412. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  413. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  414. package/src/plugins/defaults/memory/v3/card.ts +9 -9
  415. package/src/plugins/defaults/memory/v3/core-set.test.ts +7 -0
  416. package/src/plugins/defaults/memory/v3/core-set.ts +5 -1
  417. package/src/plugins/defaults/memory/v3/edge.ts +13 -57
  418. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  419. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  420. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  421. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  422. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  423. package/src/plugins/defaults/memory/worker.ts +6 -3
  424. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  425. package/src/plugins/external-plugin-loader.ts +47 -0
  426. package/src/plugins/mcp-servers.ts +361 -0
  427. package/src/plugins/mtime-cache.ts +37 -46
  428. package/src/plugins/pipeline.ts +14 -7
  429. package/src/plugins/plugin-execution-context.ts +35 -11
  430. package/src/plugins/worker-plugin-surface.ts +33 -0
  431. package/src/prompts/templates/system-sections.ts +0 -7
  432. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  433. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  434. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  435. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  436. package/src/providers/__tests__/retry-callsite.test.ts +25 -2
  437. package/src/providers/anthropic/__tests__/pause-turn-continuation.test.ts +170 -0
  438. package/src/providers/anthropic/client.ts +332 -241
  439. package/src/providers/call-site-routing.ts +30 -3
  440. package/src/providers/connection-resolution.ts +194 -11
  441. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +4 -0
  442. package/src/providers/inference/adapter-factory.ts +3 -0
  443. package/src/providers/inference/auth.ts +6 -0
  444. package/src/providers/inference/connection-availability.ts +24 -2
  445. package/src/providers/inference/connections.ts +2 -0
  446. package/src/providers/model-catalog.ts +3 -3
  447. package/src/providers/model-intents.ts +28 -8
  448. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +163 -40
  449. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +125 -2
  450. package/src/providers/openai/chat-completions-provider.ts +133 -36
  451. package/src/providers/openai/codex-models.ts +2 -1
  452. package/src/providers/provider-send-message.ts +32 -3
  453. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  454. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +625 -0
  455. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  456. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  457. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  458. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +679 -0
  459. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  460. package/src/providers/speech-to-text/resolve.ts +25 -2
  461. package/src/routes/control.ts +1 -0
  462. package/src/routes/route-host-client.ts +1 -0
  463. package/src/routes/route-host-protocol.ts +7 -0
  464. package/src/routes/worker.ts +48 -15
  465. package/src/runtime/AGENTS.md +16 -17
  466. package/src/runtime/access-request-helper.ts +9 -12
  467. package/src/runtime/agent-wake.ts +18 -15
  468. package/src/runtime/effective-capabilities.test.ts +43 -0
  469. package/src/runtime/effective-capabilities.ts +27 -0
  470. package/src/runtime/guardian-reply-router.ts +6 -2
  471. package/src/runtime/pre-first-message-gate.ts +4 -0
  472. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  473. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +522 -2
  474. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  475. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  476. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  477. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  478. package/src/runtime/routes/__tests__/plugins-routes.test.ts +132 -35
  479. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  480. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  481. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +55 -6
  482. package/src/runtime/routes/__tests__/user-route-dispatcher-host.test.ts +52 -1
  483. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +133 -1
  484. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  485. package/src/runtime/routes/channel-route-shared.ts +0 -6
  486. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  487. package/src/runtime/routes/conversation-list-routes.ts +181 -23
  488. package/src/runtime/routes/conversation-management-routes.ts +17 -12
  489. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  490. package/src/runtime/routes/conversation-routes.ts +43 -29
  491. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  492. package/src/runtime/routes/credential-routes.ts +26 -82
  493. package/src/runtime/routes/default-provider-routes.ts +15 -0
  494. package/src/runtime/routes/documents-routes.ts +3 -222
  495. package/src/runtime/routes/inbound-message-handler.ts +21 -45
  496. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  497. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  498. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  499. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  500. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  501. package/src/runtime/routes/inbound-stages/edit-intercept.ts +101 -79
  502. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  503. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +12 -7
  504. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +91 -8
  505. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +72 -79
  506. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  507. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  508. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  509. package/src/runtime/routes/log-export-routes.ts +3 -0
  510. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  511. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  512. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  513. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  514. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  515. package/src/runtime/routes/plugins-routes.ts +38 -8
  516. package/src/runtime/routes/schedule-routes.ts +94 -7
  517. package/src/runtime/routes/settings-routes.ts +6 -6
  518. package/src/runtime/routes/stt-routes.ts +31 -25
  519. package/src/runtime/routes/surface-action-routes.ts +20 -19
  520. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  521. package/src/runtime/routes/user-route-dispatcher.ts +69 -15
  522. package/src/runtime/routes/user-route-import.ts +108 -0
  523. package/src/runtime/routes/user-route-resolution.ts +18 -1
  524. package/src/runtime/routes/workspace-routes.ts +0 -9
  525. package/src/runtime/routes/workspace-utils.ts +3 -13
  526. package/src/runtime/services/conversation-serializer.ts +7 -2
  527. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  528. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  529. package/src/schedule/plugin-schedule-availability.ts +58 -0
  530. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  531. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  532. package/src/schedule/schedule-store.ts +5 -1
  533. package/src/schedule/scheduler.ts +9 -4
  534. package/src/schedule/worker-control.ts +1 -0
  535. package/src/schedule/worker.ts +6 -0
  536. package/src/security/oauth2.ts +6 -22
  537. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  538. package/src/stt/__tests__/types.test.ts +94 -0
  539. package/src/stt/daemon-batch-transcriber.ts +10 -0
  540. package/src/stt/stt-stream-session.ts +8 -4
  541. package/src/stt/types.ts +103 -0
  542. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  543. package/src/subagent/consult-context.ts +11 -11
  544. package/src/subagent/consult-prompt.ts +26 -35
  545. package/src/subagent/manager.ts +21 -40
  546. package/src/subagent/notify.ts +7 -1
  547. package/src/subagent/types.ts +15 -12
  548. package/src/tools/__tests__/tool-input-schemas.test.ts +7 -7
  549. package/src/tools/acp/spawn.test.ts +97 -0
  550. package/src/tools/acp/spawn.ts +38 -4
  551. package/src/tools/credentials/ref-parse.ts +35 -0
  552. package/src/tools/credentials/resolve.ts +4 -10
  553. package/src/tools/credentials/store.ts +168 -0
  554. package/src/tools/document/document-tool.ts +12 -3
  555. package/src/tools/executor.ts +53 -15
  556. package/src/tools/filesystem/read.ts +27 -10
  557. package/src/tools/host-filesystem/read.ts +27 -15
  558. package/src/tools/network/url-safety.ts +9 -16
  559. package/src/tools/permission-checker.ts +43 -52
  560. package/src/tools/registry.ts +2 -1
  561. package/src/tools/shared/filesystem/file-ops-service.ts +63 -35
  562. package/src/tools/shared/filesystem/legacy-read-args.ts +22 -0
  563. package/src/tools/shared/filesystem/types.ts +5 -5
  564. package/src/tools/skills/scaffold-managed.ts +25 -7
  565. package/src/tools/subagent/spawn.ts +28 -88
  566. package/src/tools/tool-types.ts +26 -13
  567. package/src/tools/types.ts +6 -6
  568. package/src/tools/ui-surface/surface-shape-docs.ts +12 -1
  569. package/src/tools/workflows/run-workflow.ts +1 -2
  570. package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
  571. package/src/tts/reasoning-tag-filter.ts +89 -0
  572. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  573. package/src/util/logger.ts +16 -0
  574. package/src/util/think-tag-stream.ts +95 -0
  575. package/src/util/worker-process.ts +37 -4
  576. package/src/windows-compiled-cli.ts +32 -0
  577. package/src/windows-compiled-entry.ts +4 -0
  578. package/src/windows-compiled-logger.ts +6 -0
  579. package/src/windows-compiled-worker-entry.ts +29 -0
  580. package/src/workspace/byok-default-profile-ensure.ts +76 -24
  581. package/src/workspace/custom-profile-ensure.ts +4 -24
  582. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  583. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  584. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  585. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  586. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  587. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  588. package/src/workspace/migrations/registry.ts +10 -0
  589. package/src/workspace/provider-commit-message-generator.ts +7 -5
  590. package/src/__tests__/document-workspace-file.test.ts +0 -467
  591. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  592. package/src/live-voice/front-decision.ts +0 -476
  593. package/src/permissions/ipc-risk-types.ts +0 -143
  594. package/src/permissions/risk-types.ts +0 -76
  595. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  596. package/src/subagent/consult-transcript.ts +0 -90
@@ -17,7 +17,6 @@ import type {
17
17
  import {
18
18
  getConversationTurnTeardown,
19
19
  resolveProcessingWaitMs,
20
- VOICE_NO_SETUP_FLOWS_RULE,
21
20
  waitForPriorTurnTeardown,
22
21
  } from "../calls/voice-session-bridge.js";
23
22
  import {
@@ -33,6 +32,8 @@ import {
33
32
  } from "../calls/voice-triage-escalate.js";
34
33
  import { getConfig } from "../config/loader.js";
35
34
  import {
35
+ type LiveVoiceFluxConfig,
36
+ LiveVoiceFluxConfigSchema,
36
37
  type LiveVoiceFrontModelConfig,
37
38
  LiveVoiceFrontModelConfigSchema,
38
39
  } from "../config/schemas/live-voice.js";
@@ -49,6 +50,7 @@ import {
49
50
  listProviderIds,
50
51
  pinnedListeningLanguage,
51
52
  supportsBoundary,
53
+ supportsProviderTurnDetection,
52
54
  } from "../providers/speech-to-text/provider-catalog.js";
53
55
  import type { ResolveStreamingTranscriberOptions } from "../providers/speech-to-text/resolve.js";
54
56
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
@@ -71,6 +73,10 @@ import type {
71
73
  import { getSubagentManager } from "../subagent/index.js";
72
74
  import { liveVoiceEndScreen } from "../telemetry/live-voice-funnel.js";
73
75
  import { getToolOwner } from "../tools/registry.js";
76
+ import {
77
+ createReasoningTagFilter,
78
+ type ReasoningTagFilter,
79
+ } from "../tts/reasoning-tag-filter.js";
74
80
  import { extractSpeakableSegments } from "../tts/speakable-segments.js";
75
81
  import { createAbortReason } from "../util/abort-reasons.js";
76
82
  import { hasLocalizedEntry } from "../util/language-subtag.js";
@@ -81,11 +87,6 @@ import {
81
87
  dismissesUiSurface,
82
88
  revealsUiSurface,
83
89
  } from "./activity-label.js";
84
- import {
85
- createVoiceFrontDecider,
86
- type VoiceFrontDecider,
87
- type VoiceProgressTextInput,
88
- } from "./front-decision.js";
89
90
  import { LiveActivityReporter } from "./live-activity-reporter.js";
90
91
  import type {
91
92
  LiveVoiceAudioArchiveResult,
@@ -97,9 +98,9 @@ import {
97
98
  type LiveVoiceMetricsClock,
98
99
  LiveVoiceMetricsCollector,
99
100
  type LiveVoiceMetricsEvent,
100
- type LiveVoiceSpokenAckKind,
101
101
  type LiveVoiceTurnSeedMarks,
102
102
  type VoiceEndpointAction,
103
+ type VoiceEndpointSource,
103
104
  } from "./live-voice-metrics.js";
104
105
  import { persistLiveVoicePhoto } from "./live-voice-photo.js";
105
106
  import {
@@ -113,6 +114,11 @@ import type {
113
114
  LiveVoiceTtsOptions,
114
115
  LiveVoiceTtsResult,
115
116
  } from "./live-voice-tts.js";
117
+ import {
118
+ createVoiceProgressNarrator,
119
+ type VoiceProgressNarrator,
120
+ type VoiceProgressTextInput,
121
+ } from "./progress-narration.js";
116
122
  import {
117
123
  APPROVAL_PENDING_PHRASE_BY_LANGUAGE,
118
124
  approvalPendingPhraseFor,
@@ -161,6 +167,14 @@ const FINALIZE_GRACE_MS = 1_000;
161
167
  // cancellation cannot kill a reply mid-sentence. Mirrors the
162
168
  // liveVoice.vad.bargeInMinSpeechMs schema default; 0 disables the guard for
163
169
  // instant barge-in.
170
+ //
171
+ // Local energy detection owns barge-in in every mode, including when a
172
+ // turn-detecting provider owns the turn boundary: an interrupt during TTS has
173
+ // to feel instant, and no provider roundtrip beats a local gate on the audio
174
+ // already in hand. The echo-adaptive part of this guard also has to stay
175
+ // upstream of the provider, which hears only the microphone: it cannot tell
176
+ // our own TTS bleeding through imperfect echo cancellation from the caller,
177
+ // and would report a user turn nobody took.
164
178
  const DEFAULT_BARGE_IN_MIN_SPEECH_MS = 250;
165
179
  // The playback echo gate learns microphone energy while assistant audio is
166
180
  // expected at the speaker. Input must rise above the learned level by this
@@ -203,6 +217,12 @@ const BARGE_IN_GAP_TOLERANCE_MS = 200;
203
217
  // (1 / (1 + ratio) ≈ 20%): once the run is mostly silence it resets, so genuine
204
218
  // choppy speech still lands but periodic noise cannot accumulate into one.
205
219
  const BARGE_IN_MAX_TOLERATED_SILENCE_RATIO = 4;
220
+ // Slack added to the configured end-of-turn timeout before the session stops
221
+ // waiting for an event that is not coming and falls the utterance back onto
222
+ // the silence-boundary path. A turn-detecting provider force-ends its own turn
223
+ // at that timeout, so anything past it plus a frame's network and parse hop
224
+ // means the stream is gone, not slow.
225
+ const PROVIDER_TURN_END_FALLBACK_MARGIN_MS = 1_000;
206
226
  // At most this many TTS segment jobs are open (provider stream started,
207
227
  // frames not yet fully emitted) per turn: the emitting job plus one
208
228
  // prefetching job. The prefetch buffers its chunks in memory until promoted;
@@ -328,19 +348,24 @@ export interface LiveVoiceSessionOptions {
328
348
  */
329
349
  finalizeGraceMs?: number;
330
350
  /**
331
- * Front-model presence tuning (semantic endpointing + spoken acks). The
351
+ * Voice front-door endpointing and progress narration tuning. The
332
352
  * production factory seeds this from `liveVoice.frontModel` config when
333
353
  * unset; absent fields fall back to the schema defaults (the constructor
334
354
  * schema-parses the partial into a complete config).
335
355
  */
336
356
  frontModelConfig?: Partial<LiveVoiceFrontModelConfig>;
337
357
  /**
338
- * Front-model phrasing service: spoken acks and progress narration. The
339
- * production factory constructs it from `liveVoice.frontModel` config when
340
- * unset; `null` disables front-model LLM calls, so no ack is ever spoken
341
- * and narration falls back to its static phrases.
358
+ * Deepgram Flux turn-detection tuning. The production factory seeds this
359
+ * from `liveVoice.flux` config when unset; absent fields fall back to the
360
+ * schema defaults, which leave `turnEnd.enabled` false.
342
361
  */
343
- frontDecider?: VoiceFrontDecider | null;
362
+ fluxConfig?: Partial<LiveVoiceFluxConfig>;
363
+ /**
364
+ * Progress narration service. The production factory constructs it from
365
+ * `liveVoice.frontModel.progress` config when unset. `null` disables
366
+ * generated narration, so progress updates use their static fallback.
367
+ */
368
+ progressNarrator?: VoiceProgressNarrator | null;
344
369
  /**
345
370
  * Spawns the background continuation for a barged-in turn. The factory wires
346
371
  * the real SubagentManager-backed implementation; tests inject a stub.
@@ -418,6 +443,29 @@ interface UtteranceCycle {
418
443
  // Consecutive semantic-endpointing "hold" extensions this utterance has
419
444
  // consumed, bounded by `endpointMaxExtensions`.
420
445
  endpointExtensionCount: number;
446
+ // The provider's end-of-turn never arrived for this cycle, so it fell back
447
+ // to the silence-boundary path and stays there: a late event must not
448
+ // commit a boundary the fallback already owns.
449
+ providerTurnEndTimedOut: boolean;
450
+ // The provider's own end-of-turn released this cycle, so its transcript is
451
+ // already complete. A provider that decides turn ends commits the transcript
452
+ // in the same event, so such a release needs no caller-side flush and must
453
+ // not tear the stream down. False on every caller-side release (the fail-open
454
+ // deadline, a max-duration force-end, barge-in), where a turn may still be
455
+ // open provider-side and only a stream close can make it answer.
456
+ providerClosedTurn: boolean;
457
+ // `vadSpeechGeneration` as of the local silence boundary that handed this
458
+ // cycle to the provider, or null while no boundary has fired yet (a turn
459
+ // model routinely beats the trailing-silence countdown, and that fast commit
460
+ // is the point).
461
+ // The staleness signal of last resort: it stands in for the provider's own
462
+ // turn numbering when the provider sends none (see isStaleProviderTurnEnd).
463
+ turnBoundaryGeneration: number | null;
464
+ // Index of the newest provider turn opened in this cycle, from the turn
465
+ // model's own numbering, or null when the provider does not number its
466
+ // turns. An end-of-turn closing an older index describes a turn the
467
+ // provider has already superseded (see isStaleProviderTurnEnd).
468
+ openProviderTurnIndex: number | null;
421
469
  // The transcript the most recent hold verdict judged (unified front-door).
422
470
  // A final segment arriving during the extension window that extends this
423
471
  // text replays the boundary immediately — the hold was judged on stale
@@ -668,27 +716,11 @@ interface ActiveAssistantTurn {
668
716
  // context. A bump since then means a stop/interrupt/supersede deliberately
669
717
  // dropped the continuation, so a rollback must not resurrect it.
670
718
  pendingContextStopGeneration: number;
671
- // The agent run started a definitive tool use this turn — tool use implies
672
- // a guaranteed-slow turn, so acknowledgment logic can key off this.
673
- toolUseStarted: boolean;
674
- // The model's first streamed delta reached this session — the spoken-ack
675
- // timer is moot once real output is flowing.
676
- firstDeltaSeen: boolean;
677
719
  // Counts assistant text deltas seen this turn. A narration generation
678
720
  // captures it at launch and discards its result if it moved: text the model
679
721
  // produced mid-generation makes the narration stale, and proves the model
680
722
  // is alive — which is exactly what narration exists to paper over.
681
723
  deltaEpoch: number;
682
- // A spoken ack was enqueued this turn. Every ack trigger shares this
683
- // one-per-turn budget so a slow turn never stacks fillers.
684
- ackSpoken: boolean;
685
- // An ack generation is awaiting the decider. While true, the ack has not
686
- // yet stamped `lastFloorHolderAtMs`, so narration must treat it as a
687
- // floor-holder-in-waiting and stand down —
688
- // otherwise a progress phrase could land back-to-back with the ack.
689
- ackGenerationPending: boolean;
690
- // Pending slow-first-delta ack timer; null once cleared or fired.
691
- ackTimer: ReturnType<typeof setTimeout> | null;
692
724
  // Triage-and-escalate (Voice Mode): the front-door leg gave the escalate
693
725
  // verdict and the strong "escalated" leg has taken over this same turn.
694
726
  // Guards the
@@ -696,6 +728,10 @@ interface ActiveAssistantTurn {
696
728
  // the hand-off idempotent.
697
729
  escalationHandedOff: boolean;
698
730
  ttsBuffer: string;
731
+ // Strips inline <think> reasoning spans from the spoken stream. Stateful
732
+ // per turn because spans and tags cross delta boundaries; display frames
733
+ // keep the raw text.
734
+ ttsReasoningFilter: ReasoningTagFilter;
699
735
  // A non-empty speakable segment reached the TTS queue — gates the eager
700
736
  // first-segment flush that trades clause quality for speech onset.
701
737
  ttsSegmentEnqueued: boolean;
@@ -783,6 +819,20 @@ const LIVE_VOICE_CONTROL_PROMPT_BASE =
783
819
  const LIVE_VOICE_SCREEN_REVEAL_TEACHING =
784
820
  "The call renders as a full-screen overlay covering the app. Whenever you put something on screen, the overlay minimizes by itself as soon as you finish speaking, and the user is looking at what you made. So speak as if you are showing it to them right now (for example, close with something like: take a look), and never say you cannot show it, that this is a voice call, or that they should check it later. Never emit bracketed markers of any kind. ";
785
821
 
822
+ // The setup-flow case, spelled out because it is the one the model gets wrong
823
+ // on its own: connecting an account reads as something a call cannot do, so it
824
+ // declines and offers to do it later in text, which is the exact thing the base
825
+ // prompt above forbids.
826
+ //
827
+ // It can do it. The connect card is a ui surface like any other, so showing it
828
+ // minimizes the room on the same latch (`revealsUiSurface`), and the user is at
829
+ // the screen the browser window opens on. Appended alongside the screen-reveal
830
+ // teaching, to the same legs, for the same reason: the front-door leg is
831
+ // toolless, so a setup flow is not its to run, and its capability digest
832
+ // already tells it to escalate anything needing a tool rather than refuse.
833
+ const LIVE_VOICE_SETUP_FLOW_TEACHING =
834
+ "This includes connecting accounts. If a task needs an account connected or a sign-in completed, put the connection up on screen and let the user do it now. Do not decline it, and do not defer it to text chat. Say what you are connecting and that it is in front of them. ";
835
+
786
836
  // System-level guidance appended to a barge-in turn's control prompt so the
787
837
  // model treats the new utterance as a continuation of the request it was cut
788
838
  // off answering, rather than a fresh follow-up. Reaches the model only; it is
@@ -839,21 +889,21 @@ function buildLiveDeliveryNote(request: string, answer: string): string {
839
889
  return `The user interrupted you earlier, and in the background you finished ${what}. The call is still live and nobody is speaking, so tell them briefly that it is done and give them the result. Do not re-run any tool calls; the work is already complete, and do not repeat it verbatim if it no longer fits. What you produced was:\n\n${answer}`;
840
890
  }
841
891
 
842
- // Assemble a leg's model-facing control prompt: the base live-voice rules,
843
- // the screen-reveal teaching (withheld from the front-door leg, see
844
- // LIVE_VOICE_SCREEN_REVEAL_TEACHING), the shared no-setup-flows rule, plus
845
- // any pending barge-in merge context, completed-continuation context, and/or
846
- // the announcement instruction. A turn can carry several (a barge-in follow-up
847
- // that also has a continuation result waiting); the notes are model-only and
848
- // never render as user bubbles.
892
+ // Assemble a leg's model-facing control prompt: the base live-voice rules, the
893
+ // screen-reveal and setup-flow teaching (both withheld from the front-door leg,
894
+ // see LIVE_VOICE_SCREEN_REVEAL_TEACHING), plus any pending barge-in merge
895
+ // context, completed-continuation context, and/or the announcement instruction.
896
+ // A turn can carry several (a barge-in follow-up that also has a continuation
897
+ // result waiting); the notes are model-only and never render as user bubbles.
849
898
  function buildVoiceControlPrompt(
850
899
  turn: ActiveAssistantTurn,
851
900
  leg: { frontDoor?: boolean },
852
901
  ): string {
853
902
  let prompt =
854
903
  LIVE_VOICE_CONTROL_PROMPT_BASE +
855
- (leg.frontDoor === true ? "" : LIVE_VOICE_SCREEN_REVEAL_TEACHING) +
856
- VOICE_NO_SETUP_FLOWS_RULE;
904
+ (leg.frontDoor === true
905
+ ? ""
906
+ : LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING);
857
907
  if (turn.language !== undefined) {
858
908
  prompt = `${prompt}\n\nThe caller has been speaking the language with code "${turn.language}" this turn. Reply in that language unless they clearly switch to another.`;
859
909
  }
@@ -904,6 +954,10 @@ function createUtteranceCycle(): UtteranceCycle {
904
954
  finalTranscriptSegments: [],
905
955
  latestPartialText: null,
906
956
  endpointExtensionCount: 0,
957
+ providerTurnEndTimedOut: false,
958
+ providerClosedTurn: false,
959
+ turnBoundaryGeneration: null,
960
+ openProviderTurnIndex: null,
907
961
  heldSpeculativeContent: null,
908
962
  languageTally: new Map(),
909
963
  latestPartialLanguages: null,
@@ -1170,12 +1224,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1170
1224
  // replayed once the parked speech flushes into the next armed utterance.
1171
1225
  private vadPendingTurnEnd: "silence" | "max-duration" | null = null;
1172
1226
  private readonly metricsClock: LiveVoiceMetricsClock;
1173
- // Persistent mode: server-VAD sessions with a finalize-capable provider
1174
- // keep one streaming transcriber for the whole session. Utterance release
1175
- // flushes via finalizeUtterance() instead of tearing the stream down, and
1176
- // re-arm reuses this instance synchronously. Null in manual mode, for
1177
- // providers without finalizeUtterance, and after an unexpected stream
1178
- // close (the next arm resolves a fresh transcriber).
1227
+ // Persistent mode: a server-VAD session keeps one streaming transcriber for
1228
+ // the whole session when the stream can be sealed per utterance without
1229
+ // closing it. Either the provider is finalize-capable (release flushes via
1230
+ // finalizeUtterance()) or the provider decides turn ends itself (release is
1231
+ // already sealed by the end-of-turn that triggered it). Re-arm reuses this
1232
+ // instance synchronously. Null in manual mode, for a stream that offers
1233
+ // neither seal, and after an unexpected stream close (the next arm resolves
1234
+ // a fresh transcriber).
1179
1235
  private sharedTranscriber: StreamingTranscriber | null = null;
1180
1236
  // The `services.stt.language` value the shared stream was dialed with.
1181
1237
  // Providers pin the language per connection, so the persistent re-arm
@@ -1200,13 +1256,38 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1200
1256
  private readonly finalizeGraceMs: number;
1201
1257
  // Rotates through the progress fallback phrases across the session's turns.
1202
1258
  private progressPhraseCounter = 0;
1203
- // Front-model service: phrases spoken acks and progress narration; null
1204
- // disables both (the turn then holds the floor with silence).
1205
- private readonly frontDecider: VoiceFrontDecider | null;
1259
+ // Progress-only phrasing service. It never makes routing decisions and
1260
+ // never emits an answer.
1261
+ private readonly progressNarrator: VoiceProgressNarrator | null;
1206
1262
  // Complete front-model tunables: the constructor schema-parses the partial
1207
1263
  // option once, so every field carries its `liveVoice.frontModel` schema
1208
1264
  // default when unset.
1209
1265
  private readonly frontModelConfig: LiveVoiceFrontModelConfig;
1266
+ // Complete Flux tunables (the constructor schema-parses the partial option
1267
+ // once, so every field carries its `liveVoice.flux` schema default).
1268
+ private readonly fluxConfig: LiveVoiceFluxConfig;
1269
+ /**
1270
+ * Per-session latch: the provider's committed end-of-turn owns the turn
1271
+ * boundary instead of the silence boundary's front-door hold verdict. Set
1272
+ * when the config flag is on AND the resolved streaming provider declares
1273
+ * `turnDetection: "provider"` in the STT catalog AND the session runs server
1274
+ * VAD. Push-to-talk is excluded deliberately: there the client's release IS
1275
+ * the boundary, and answering while the caller still holds the button is not
1276
+ * turn detection, it is a bug. False leaves every other code path exactly as
1277
+ * it is, with no provider turn detection in the picture.
1278
+ */
1279
+ private providerTurnEndActive = false;
1280
+ // Wall-clock of the newest above-gate audio chunk, tracked in every
1281
+ // server-VAD session. It is the local VAD's speech-stop mark: the one anchor
1282
+ // the reported end-of-turn latency is measured from whichever decider
1283
+ // commits the turn, and the anchor for the provider fallback deadline. Null
1284
+ // in push-to-talk, where no local VAD runs.
1285
+ private localSpeechStopAtMs: number | null = null;
1286
+ // Fail-open deadline for a provider end-of-turn that never arrives. On
1287
+ // expiry the utterance falls back to the silence-boundary path, so a dead
1288
+ // provider stream degrades to the silence-path behavior instead of a hung
1289
+ // turn.
1290
+ private providerTurnEndTimer: ReturnType<typeof setTimeout> | null = null;
1210
1291
  // Effective trailing-silence threshold, mirroring the detector's private
1211
1292
  // copy (constructor seed + update_config), reported to the endpoint decider.
1212
1293
  private silenceThresholdMs: number;
@@ -1276,10 +1357,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1276
1357
  this.echoDrainSlackMs =
1277
1358
  options.echoDrainSlackMs ?? DEFAULT_ECHO_DRAIN_SLACK_MS;
1278
1359
  this.finalizeGraceMs = options.finalizeGraceMs ?? FINALIZE_GRACE_MS;
1279
- this.frontDecider = options.frontDecider ?? null;
1360
+ this.progressNarrator = options.progressNarrator ?? null;
1280
1361
  this.frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
1281
1362
  options.frontModelConfig ?? {},
1282
1363
  );
1364
+ this.fluxConfig = LiveVoiceFluxConfigSchema.parse(options.fluxConfig ?? {});
1283
1365
  const turnDetectorConfig: TurnDetectorConfig = {
1284
1366
  ...(options.turnDetectorConfig ?? {}),
1285
1367
  ...(context.startFrame.silenceThresholdMs !== undefined
@@ -1455,6 +1537,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1455
1537
  this.liveActivityReporter.end();
1456
1538
  this.turnDetector?.dispose();
1457
1539
  this.clearEndpointExtensionTimer();
1540
+ this.clearProviderTurnEndTimer();
1458
1541
  // There is no longer anyone on the call to speak to, so a queued
1459
1542
  // announcement cannot be spoken — but the answer behind it is finished
1460
1543
  // work the user asked for, so it takes the same conversation route a
@@ -1506,11 +1589,28 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1506
1589
  }
1507
1590
 
1508
1591
  try {
1509
- // One language snapshot serves both the dial and the re-arm
1510
- // comparison: passing it to the resolver keeps the stream's actual
1511
- // language and the recorded sharedTranscriberLanguage identical even
1512
- // when config changes while the resolver awaits credentials.
1513
- const sttLanguage = getConfig().services.stt.language;
1592
+ // One config snapshot serves the dial, the re-arm comparison and the
1593
+ // latch seed below: passing the language to the resolver keeps the
1594
+ // stream's actual language and the recorded sharedTranscriberLanguage
1595
+ // identical even when config changes while the resolver awaits
1596
+ // credentials.
1597
+ const stt = getConfig().services.stt;
1598
+ const sttLanguage = stt.language;
1599
+ // Seed the latch from the CONFIGURED provider before the dial, not
1600
+ // only from the resolved one after it. `start()` sends `ready` without
1601
+ // waiting on this resolve, so the caller can speak and close an entire
1602
+ // silence boundary while the provider handshake is still in flight. A
1603
+ // latch still on its default `false` at that boundary hands the first
1604
+ // turn of the session to the silence path and then ignores the
1605
+ // provider end-of-turn that follows, which is invisible from the
1606
+ // outside: the session looks like a turn-detecting session while its
1607
+ // opening turn is not one. The resolved provider reconciles this guess
1608
+ // below.
1609
+ this.setProviderTurnEndActive(
1610
+ this.fluxConfig.turnEnd.enabled &&
1611
+ supportsProviderTurnDetection(stt.provider as SttProviderId) &&
1612
+ this.turnDetector !== null,
1613
+ );
1514
1614
  const transcriber = await this.resolveTranscriber({
1515
1615
  sampleRate: this.context.startFrame.audio.sampleRate,
1516
1616
  ...(sttLanguage ? { language: sttLanguage } : {}),
@@ -1522,6 +1622,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1522
1622
  }
1523
1623
 
1524
1624
  if (!transcriber) {
1625
+ // No stream answered, so no end-of-turn ever will: the guess above
1626
+ // must not outlive the dial that disproved it.
1627
+ this.setProviderTurnEndActive(false);
1525
1628
  return {
1526
1629
  status: "unavailable",
1527
1630
  message: unavailableTranscriberMessage(),
@@ -1530,9 +1633,27 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1530
1633
 
1531
1634
  utterance.transcriber = transcriber;
1532
1635
  utterance.dialedSttProvider = transcriber.providerId;
1636
+ // Reconcile the pre-dial guess with the provider that actually
1637
+ // answered. The resolver reads the provider from config, which cannot
1638
+ // change under a live session, so this normally confirms the guess; it
1639
+ // clears it when the dial fell back to another provider or resolved
1640
+ // one the config did not name.
1641
+ this.setProviderTurnEndActive(
1642
+ this.fluxConfig.turnEnd.enabled &&
1643
+ supportsProviderTurnDetection(transcriber.providerId) &&
1644
+ this.turnDetector !== null,
1645
+ );
1533
1646
  if (
1534
1647
  this.turnDetector &&
1535
- typeof transcriber.finalizeUtterance === "function"
1648
+ (typeof transcriber.finalizeUtterance === "function" ||
1649
+ // A provider that owns the turn boundary needs no caller-side flush
1650
+ // on the normal path: its end-of-turn IS the flush, and it commits
1651
+ // the transcript in the same event. Gating persistence on
1652
+ // `finalizeUtterance` alone asks "can the caller flush this stream"
1653
+ // when the question is "will the caller ever have to", and answering
1654
+ // it wrong costs a socket per utterance, which drops the audio that
1655
+ // arrives while the next one dials.
1656
+ this.providerTurnEndActive)
1536
1657
  ) {
1537
1658
  // Adopt persistent mode: this stream serves the whole session, so
1538
1659
  // its events route by cycle ownership instead of binding to this
@@ -1563,6 +1684,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1563
1684
  if (this.isUtteranceStale(utterance)) {
1564
1685
  return { status: "stale" };
1565
1686
  }
1687
+ // The dial threw, so the pre-dial guess is disproved the same way the
1688
+ // unavailable case disproves it: nothing will send an end-of-turn.
1689
+ this.setProviderTurnEndActive(false);
1566
1690
  return {
1567
1691
  status: "error",
1568
1692
  message: `Live voice transcription could not be started: ${errorMessage(
@@ -1690,6 +1814,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1690
1814
 
1691
1815
  this.state = "failed";
1692
1816
  this.clearEndpointExtensionTimer();
1817
+ this.clearProviderTurnEndTimer();
1693
1818
  await this.sendFrame({
1694
1819
  type: "error",
1695
1820
  code:
@@ -1763,6 +1888,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1763
1888
  const hasSpeech = energyClassification === "speech";
1764
1889
  detector.onMediaChunk(hasSpeech);
1765
1890
  this.trackBargeInGuard(energyClassification, chunk);
1891
+ if (hasSpeech) {
1892
+ this.localSpeechStopAtMs = Date.now();
1893
+ }
1766
1894
 
1767
1895
  // Playback echo is neither user audio nor useful pre-roll. Dropping it
1768
1896
  // prevents the assistant's reply from reaching transcription as a ghost
@@ -2163,6 +2291,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2163
2291
  // utterance keeps accumulating and the detector fires a fresh turn-end.
2164
2292
  this.vadSpeechGeneration += 1;
2165
2293
  this.clearEndpointExtensionTimer();
2294
+ // ...and so is a fallback deadline armed for the boundary the caller just
2295
+ // spoke through. The next silence boundary arms a fresh one.
2296
+ this.clearProviderTurnEndTimer();
2166
2297
 
2167
2298
  // Speech resumed while a speculative leg was awaiting its verdict: the
2168
2299
  // pause was mid-thought after all. Discard silently (no frames were ever
@@ -2319,7 +2450,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2319
2450
  // Tagged reason: provider catch-sites classify untagged caller aborts as
2320
2451
  // retryable transport failures (ERROR log + futile retry against the
2321
2452
  // aborted signal). This signal reaches the brain leg and any in-flight
2322
- // ack generation.
2453
+ // progress narration.
2323
2454
  turn.abortController.abort(
2324
2455
  createAbortReason("voice_session_aborted", "live-voice-barge-in"),
2325
2456
  );
@@ -3072,8 +3203,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3072
3203
  this.pendingBargeIn = null;
3073
3204
  if (reason === "max-duration") {
3074
3205
  // A max-duration boundary always releases: drop any pending hold
3075
- // replay so it cannot re-fire a boundary this one already owns.
3206
+ // replay (or provider fallback deadline) so it cannot re-fire a
3207
+ // boundary this one already owns.
3076
3208
  this.clearEndpointExtensionTimer();
3209
+ this.clearProviderTurnEndTimer();
3077
3210
  }
3078
3211
  const utterance = this.currentUtterance;
3079
3212
  if (!utterance || utterance.released || utterance.completed) {
@@ -3095,6 +3228,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3095
3228
  // release (ptt_release forced the boundary) is the user saying "answer
3096
3229
  // now" — never second-guess it.
3097
3230
  if (reason === "silence" && !manualRelease) {
3231
+ // The provider owns this boundary: its end-of-turn commits the
3232
+ // utterance, so the silence timer only arms the fail-open deadline. An
3233
+ // utterance whose deadline already elapsed falls through to the hold
3234
+ // path below, which is the whole point of the fallback.
3235
+ if (this.providerTurnEndActive && !utterance.providerTurnEndTimedOut) {
3236
+ // Stamp the speech run this boundary closed. This is the staleness
3237
+ // signal of last resort, used only for a provider that does not
3238
+ // number its turns: where the provider's own turn index is on the
3239
+ // wire it answers the question directly (see
3240
+ // isStaleProviderTurnEnd).
3241
+ utterance.turnBoundaryGeneration = this.vadSpeechGeneration;
3242
+ this.armProviderTurnEndFallbackTimer(utterance);
3243
+ return;
3244
+ }
3098
3245
  if (await this.launchSpeculativeAssistantTurn(utterance)) {
3099
3246
  return;
3100
3247
  }
@@ -3125,6 +3272,221 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3125
3272
  }, this.frontModelConfig.endpointExtensionMs);
3126
3273
  }
3127
3274
 
3275
+ // Arms the fail-open deadline for a provider end-of-turn. A turn-detecting
3276
+ // provider force-ends its own turn after `eotTimeoutMs` of silence, so once
3277
+ // that budget plus a margin has passed since the caller stopped speaking, no
3278
+ // end-of-turn is coming and the utterance replays this boundary on the
3279
+ // silence path.
3280
+ // `waitMsOverride` collapses that wait when the caller already knows no
3281
+ // end-of-turn is coming (see setProviderTurnEndActive).
3282
+ private armProviderTurnEndFallbackTimer(
3283
+ utterance: UtteranceCycle,
3284
+ waitMsOverride?: number,
3285
+ ): void {
3286
+ this.clearProviderTurnEndTimer();
3287
+ const budgetMs =
3288
+ this.fluxConfig.eotTimeoutMs + PROVIDER_TURN_END_FALLBACK_MARGIN_MS;
3289
+ const waitMs =
3290
+ waitMsOverride ?? Math.max(0, budgetMs - this.msSinceLocalSpeechStop());
3291
+ this.providerTurnEndTimer = setTimeout(() => {
3292
+ this.providerTurnEndTimer = null;
3293
+ if (
3294
+ this.currentUtterance !== utterance ||
3295
+ utterance.released ||
3296
+ utterance.completed ||
3297
+ utterance.assistantTurnStarted ||
3298
+ // Speech resumed (the detector owns the next boundary). Onset also
3299
+ // clears this timer, so this is a belt to that suspender.
3300
+ this.turnDetector?.isActive
3301
+ ) {
3302
+ return;
3303
+ }
3304
+ utterance.providerTurnEndTimedOut = true;
3305
+ log.warn(
3306
+ { budgetMs, waitMs },
3307
+ "No provider end-of-turn is coming; falling back to the silence boundary for this utterance",
3308
+ );
3309
+ this.handleVadUtteranceEnd("silence");
3310
+ }, waitMs);
3311
+ }
3312
+
3313
+ /**
3314
+ * Flips the provider end-of-turn latch, unwinding an optimistic arm.
3315
+ *
3316
+ * `beginUtterance` arms the latch from the configured provider before the
3317
+ * dial resolves, so a silence boundary can already have deferred to the
3318
+ * provider by the time the resolved one says otherwise. That deferred
3319
+ * boundary is parked on the fail-open deadline, a whole end-of-turn budget
3320
+ * away, waiting for an event that will now never arrive. Collapse the wait to
3321
+ * zero rather than burn the budget: the deadline body re-checks the cycle
3322
+ * and replays the silence boundary, which with the latch down takes the
3323
+ * ordinary hold path. Replaying through the deadline instead of calling
3324
+ * `handleVadUtteranceEnd` directly keeps the release off the arming
3325
+ * caller's stack, which is still mid-dial.
3326
+ *
3327
+ * A cleared latch with no deadline armed needs no unwind: either no
3328
+ * boundary ever deferred, or the caller resumed speaking and
3329
+ * `handleVadSpeechStart` already dropped the deadline, leaving the detector
3330
+ * owning the next boundary. The cycle's `turnBoundaryGeneration` stamp is
3331
+ * left as it is: `isStaleProviderTurnEnd` is only ever consulted from
3332
+ * `handleProviderTurnEnd`, which returns immediately once the latch is down.
3333
+ */
3334
+ private setProviderTurnEndActive(active: boolean): void {
3335
+ if (active === this.providerTurnEndActive) {
3336
+ return;
3337
+ }
3338
+ this.providerTurnEndActive = active;
3339
+ if (active || this.providerTurnEndTimer === null) {
3340
+ return;
3341
+ }
3342
+ const utterance = this.currentUtterance;
3343
+ this.clearProviderTurnEndTimer();
3344
+ if (utterance && !utterance.released && !utterance.completed) {
3345
+ this.armProviderTurnEndFallbackTimer(utterance, 0);
3346
+ }
3347
+ }
3348
+
3349
+ /**
3350
+ * Record the provider turn a cycle is currently inside. Providers without
3351
+ * turn numbering send no index, which leaves the cycle on the local speech
3352
+ * generation as its only staleness signal (see isStaleProviderTurnEnd).
3353
+ */
3354
+ private recordProviderTurnStart(
3355
+ utterance: UtteranceCycle,
3356
+ turnIndex: number | undefined,
3357
+ ): void {
3358
+ if (turnIndex === undefined) {
3359
+ return;
3360
+ }
3361
+ utterance.openProviderTurnIndex = turnIndex;
3362
+ }
3363
+
3364
+ /**
3365
+ * A provider end-of-turn that lost its race with the caller's next breath:
3366
+ * it describes speech the caller has already spoken past. Acting on one
3367
+ * would send utterance_end and stop the transcriber while local VAD is
3368
+ * still routing the resumed speech into this same cycle, cutting off the
3369
+ * next words or folding them into the wrong turn.
3370
+ *
3371
+ * The provider's own turn numbering answers this directly and is preferred
3372
+ * wherever it is available: the cycle records the newest turn the provider
3373
+ * has opened, so an end-of-turn for an older index closes a turn the
3374
+ * provider itself has already superseded. An end-of-turn for the turn still
3375
+ * in progress is never stale, however many times the caller drew breath
3376
+ * inside it: the mid-thought pause is exactly the case a provider's turn
3377
+ * model exists to judge, and its verdict covers the resumed speech too.
3378
+ *
3379
+ * With no turn numbering the local speech generation is the only signal
3380
+ * left. The silence boundary stamps the cycle with the generation it
3381
+ * closed and `handleVadSpeechStart` bumps that generation when the caller
3382
+ * resumes, so a turn-end arriving on a newer generation is treated as
3383
+ * stale. That is conservative: it also drops the fast end-of-turn this
3384
+ * feature exists for, which the turn index would have committed. Before
3385
+ * any boundary has fired the stamp is null and nothing is stale.
3386
+ */
3387
+ private isStaleProviderTurnEnd(
3388
+ utterance: UtteranceCycle,
3389
+ turnIndex: number | undefined,
3390
+ ): boolean {
3391
+ if (turnIndex !== undefined && utterance.openProviderTurnIndex !== null) {
3392
+ return turnIndex < utterance.openProviderTurnIndex;
3393
+ }
3394
+ return (
3395
+ utterance.turnBoundaryGeneration !== null &&
3396
+ utterance.turnBoundaryGeneration !== this.vadSpeechGeneration
3397
+ );
3398
+ }
3399
+
3400
+ private clearProviderTurnEndTimer(): void {
3401
+ if (this.providerTurnEndTimer !== null) {
3402
+ clearTimeout(this.providerTurnEndTimer);
3403
+ this.providerTurnEndTimer = null;
3404
+ }
3405
+ }
3406
+
3407
+ // Time since the local VAD last heard above-gate audio: the speech-stop
3408
+ // mark both the fallback deadline and the reported end-of-turn latency are
3409
+ // measured from. Zero when no speech has been heard at all.
3410
+ private msSinceLocalSpeechStop(): number {
3411
+ if (this.localSpeechStopAtMs === null) {
3412
+ return 0;
3413
+ }
3414
+ return Math.max(0, Date.now() - this.localSpeechStopAtMs);
3415
+ }
3416
+
3417
+ /**
3418
+ * The provider committed an end of turn: the caller has finished, so the utterance
3419
+ * commits now instead of waiting out the trailing-silence timer and asking
3420
+ * the front door whether the pause was mid-thought. This runs the ordinary
3421
+ * post-release path (utterance_end, then release), so the front-door leg it
3422
+ * dispatches is non-speculative: `speculativeHoldAllowed` is false, its
3423
+ * decision rule is built with `includeHold: false`, and the model is told
3424
+ * the caller has finished. Escalation and progress narration are untouched;
3425
+ * only the hold verdict is bypassed.
3426
+ */
3427
+ private async handleProviderTurnEnd(
3428
+ utterance: UtteranceCycle,
3429
+ turnIndex: number | undefined,
3430
+ ): Promise<void> {
3431
+ if (
3432
+ !this.providerTurnEndActive ||
3433
+ this.currentUtterance !== utterance ||
3434
+ utterance.released ||
3435
+ utterance.completed ||
3436
+ utterance.assistantTurnStarted ||
3437
+ // The fallback already took this utterance back to the silence path.
3438
+ utterance.providerTurnEndTimedOut
3439
+ ) {
3440
+ return;
3441
+ }
3442
+ if (this.isStaleProviderTurnEnd(utterance, turnIndex)) {
3443
+ // At `info`, not `debug`: a drop is rare and is the only signal of the
3444
+ // one known latency-outlier mode, so an operator measuring the two
3445
+ // paths has to be able to see it at the default log level.
3446
+ log.info(
3447
+ {
3448
+ turnIndex,
3449
+ openTurnIndex: utterance.openProviderTurnIndex,
3450
+ boundaryGeneration: utterance.turnBoundaryGeneration,
3451
+ speechGeneration: this.vadSpeechGeneration,
3452
+ },
3453
+ "Dropping a stale provider end-of-turn: the caller resumed speaking past the boundary it closed",
3454
+ );
3455
+ // The cycle stays open and local VAD keeps routing the resumed speech
3456
+ // into it. The detector's next silence boundary re-stamps the
3457
+ // generation and re-arms the fail-open deadline, so the turn still
3458
+ // commits: on the end-of-turn for the resumed speech, or on that
3459
+ // deadline. Re-arm here only if the detector has somehow already gone
3460
+ // idle, so the deadline handleVadSpeechStart cleared can never strand
3461
+ // the cycle with nothing left to close it.
3462
+ if (this.turnDetector?.isActive !== true) {
3463
+ utterance.turnBoundaryGeneration = this.vadSpeechGeneration;
3464
+ this.armProviderTurnEndFallbackTimer(utterance);
3465
+ }
3466
+ return;
3467
+ }
3468
+ this.clearProviderTurnEndTimer();
3469
+ this.markEndpointDecision(
3470
+ utterance,
3471
+ "release",
3472
+ this.msSinceLocalSpeechStop(),
3473
+ "provider",
3474
+ );
3475
+ await this.sendFrame({ type: "utterance_end", reason: "silence" });
3476
+ // The provider committed this cycle's transcript in the event being
3477
+ // handled, so the release below seals it in place instead of flushing or
3478
+ // closing the stream.
3479
+ utterance.providerClosedTurn = true;
3480
+ await this.releaseUtterance();
3481
+ // Leave the local detector idle, which is where every other commit path
3482
+ // leaves it. A provider can close a turn while the trailing-silence
3483
+ // countdown is still running, and barge-in fires from the detector's speech
3484
+ // ONSET: a detector left mid-turn reports no onset, so the caller could
3485
+ // not interrupt the reply they just triggered. The forced boundary
3486
+ // reaches an already-released utterance and returns.
3487
+ this.turnDetector?.forceEnd();
3488
+ }
3489
+
3128
3490
  /**
3129
3491
  * Unified front-door: dispatch the assistant turn speculatively at the
3130
3492
  * silence boundary, judging the transcript accumulated so far — the same
@@ -3204,13 +3566,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3204
3566
  if (!alreadyReleased) {
3205
3567
  void this.releaseUtterance();
3206
3568
  }
3207
- if (this.streamTtsAudio) {
3208
- this.armAckTimer(turn);
3209
- }
3210
3569
  if (
3211
3570
  this.frontModelConfig.progress.enabled &&
3212
3571
  this.streamTtsAudio &&
3213
- this.frontDecider
3572
+ this.progressNarrator
3214
3573
  ) {
3215
3574
  this.armProgressIdleTimer(turn);
3216
3575
  }
@@ -3344,6 +3703,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3344
3703
  !utterance.completed
3345
3704
  ) {
3346
3705
  this.clearEndpointExtensionTimer();
3706
+ this.clearProviderTurnEndTimer();
3347
3707
  await this.sendFrame({ type: "utterance_end", reason: "silence" });
3348
3708
  }
3349
3709
  await this.releaseUtterance();
@@ -3358,6 +3718,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3358
3718
  if (utterance.phase === "transcriber_closed") {
3359
3719
  utterance.released = true;
3360
3720
  this.markUtteranceReleased(utterance);
3721
+ this.markEndpointCommit(utterance);
3361
3722
  await this.startAssistantTurnIfReady();
3362
3723
  await this.drainOutboundFrames();
3363
3724
  return;
@@ -3369,6 +3730,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3369
3730
 
3370
3731
  utterance.released = true;
3371
3732
  this.markUtteranceReleased(utterance);
3733
+ this.markEndpointCommit(utterance);
3372
3734
 
3373
3735
  if (utterance.phase === "pending") {
3374
3736
  // The transcriber is still starting; beginUtterance completes the release.
@@ -3383,8 +3745,24 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3383
3745
  ): Promise<void> {
3384
3746
  const shared = this.sharedTranscriber;
3385
3747
  if (shared && utterance.transcriber === shared) {
3386
- await this.finalizeUtteranceForRelease(utterance);
3387
- return;
3748
+ if (typeof shared.finalizeUtterance === "function") {
3749
+ await this.finalizeUtteranceForRelease(utterance);
3750
+ return;
3751
+ }
3752
+ if (utterance.providerClosedTurn) {
3753
+ await this.sealProviderClosedUtterance(utterance);
3754
+ return;
3755
+ }
3756
+ // A caller-side boundary on a stream with no flush: only closing it can
3757
+ // make the provider answer for a turn still open, so this one release
3758
+ // costs the stream. The stream stays installed as the shared one across
3759
+ // the close: its flush `final` and its `closed` both arrive after
3760
+ // `stop()`, and the shared router drops events from a stream it no
3761
+ // longer recognizes. `closed` is what retires it, seals this cycle and
3762
+ // dispatches the turn, so clearing the reference here would strand the
3763
+ // cycle in `released` with the flushed transcript discarded. Nothing can
3764
+ // re-arm onto the closing stream in the meantime: the next arm waits on
3765
+ // the assistant turn, which waits on that same `closed`.
3388
3766
  }
3389
3767
 
3390
3768
  utterance.phase = "released";
@@ -3404,6 +3782,22 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3404
3782
  await this.drainOutboundFrames();
3405
3783
  }
3406
3784
 
3785
+ // Persistent-mode release for a provider-decided turn end: the event that
3786
+ // released this cycle already carried its final transcript, so there is
3787
+ // nothing to flush and no reason to close the stream. Sealing the phase is
3788
+ // the whole of the teardown: the same "transcript complete, stream open"
3789
+ // state the `finalized` path reaches, arrived at without a round trip.
3790
+ private async sealProviderClosedUtterance(
3791
+ utterance: UtteranceCycle,
3792
+ ): Promise<void> {
3793
+ if (utterance.phase === "transcriber_closed") {
3794
+ return;
3795
+ }
3796
+ utterance.phase = "transcriber_closed";
3797
+ await this.startAssistantTurnIfReady();
3798
+ await this.drainOutboundFrames();
3799
+ }
3800
+
3407
3801
  // Persistent-mode release: flush the shared stream's buffered audio with
3408
3802
  // finalizeUtterance() instead of tearing the stream down. The assistant
3409
3803
  // turn starts on the `finalized` event; the grace timer bounds a flush
@@ -3546,6 +3940,24 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3546
3940
  // Per-cycle transcribers are torn down with stop(); the finalize
3547
3941
  // completion signal has no cycle to advance here.
3548
3942
  return;
3943
+ case "turn-start":
3944
+ // Barge-in is deliberately untouched: local VAD still owns it,
3945
+ // because a provider roundtrip cannot beat a local energy gate on an
3946
+ // interrupt during playback (see DEFAULT_BARGE_IN_MIN_SPEECH_MS). The
3947
+ // index is recorded so a later end-of-turn can be told apart from one
3948
+ // this turn superseded (see isStaleProviderTurnEnd).
3949
+ this.recordProviderTurnStart(utterance, event.turnIndex);
3950
+ return;
3951
+ case "eager-turn-end":
3952
+ case "turn-resumed":
3953
+ // Speculative end-of-turn stays off (the config leaves
3954
+ // `eagerEotThreshold` unset, so Deepgram never emits these). Wiring
3955
+ // them onto the speculative dispatch machinery is a deferred
3956
+ // follow-up.
3957
+ return;
3958
+ case "turn-end":
3959
+ await this.handleProviderTurnEnd(utterance, event.turnIndex);
3960
+ return;
3549
3961
  case "error":
3550
3962
  await this.sendTranscriberErrorFrame(event);
3551
3963
  return;
@@ -3567,6 +3979,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3567
3979
  }
3568
3980
  await this.startAssistantTurnIfReady();
3569
3981
  return;
3982
+ default: {
3983
+ const _exhaustive: never = event;
3984
+ return;
3985
+ }
3570
3986
  }
3571
3987
  }
3572
3988
 
@@ -3670,6 +4086,34 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3670
4086
  await this.startAssistantTurnIfReady();
3671
4087
  return;
3672
4088
  }
4089
+ case "turn-start": {
4090
+ // Barge-in is deliberately untouched: local VAD still owns it,
4091
+ // because a provider roundtrip cannot beat a local energy gate on an
4092
+ // interrupt during playback (see DEFAULT_BARGE_IN_MIN_SPEECH_MS). The
4093
+ // index is recorded so a later end-of-turn can be told apart from one
4094
+ // this turn superseded (see isStaleProviderTurnEnd).
4095
+ const target = this.pendingTranscriptCycle();
4096
+ if (target) {
4097
+ this.recordProviderTurnStart(target, event.turnIndex);
4098
+ }
4099
+ return;
4100
+ }
4101
+ case "eager-turn-end":
4102
+ case "turn-resumed":
4103
+ // Speculative end-of-turn stays off (the config leaves
4104
+ // `eagerEotThreshold` unset, so Deepgram never emits these). Wiring
4105
+ // them onto the speculative dispatch machinery is a deferred
4106
+ // follow-up.
4107
+ return;
4108
+ case "turn-end": {
4109
+ // Same routing as transcript events: the cycle awaiting its
4110
+ // transcript owns the turn the provider just closed.
4111
+ const target = this.pendingTranscriptCycle();
4112
+ if (target) {
4113
+ await this.handleProviderTurnEnd(target, event.turnIndex);
4114
+ }
4115
+ return;
4116
+ }
3673
4117
  case "error":
3674
4118
  await this.sendTranscriberErrorFrame(event);
3675
4119
  return;
@@ -3709,6 +4153,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3709
4153
  await this.startAssistantTurnIfReady();
3710
4154
  return;
3711
4155
  }
4156
+ default: {
4157
+ const _exhaustive: never = event;
4158
+ return;
4159
+ }
3712
4160
  }
3713
4161
  }
3714
4162
 
@@ -3844,8 +4292,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3844
4292
  this.assistantPlaybackTailUntilMs = 0;
3845
4293
  this.takeVadPreRoll();
3846
4294
  this.vadPendingTurnEnd = null;
3847
- // ...and abandons any semantic-endpointing hold still awaiting replay.
4295
+ // ...and abandons any semantic-endpointing hold still awaiting replay,
4296
+ // along with any provider end-of-turn the session was still waiting on.
3848
4297
  this.clearEndpointExtensionTimer();
4298
+ this.clearProviderTurnEndTimer();
3849
4299
  // A client interrupt is a hard reset: any barge-in merge context waiting for
3850
4300
  // the next turn is now stale (the interrupted utterance may be discarded
3851
4301
  // without ever reaching finalizePendingUtterance).
@@ -4112,14 +4562,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4112
4562
  consumedAnnouncement: pending?.announcement ?? null,
4113
4563
  pendingContextStopGeneration: this.detachStopGeneration,
4114
4564
  continuationDelivery: opts?.continuationDelivery ?? null,
4115
- toolUseStarted: false,
4116
- firstDeltaSeen: false,
4117
4565
  deltaEpoch: 0,
4118
- ackSpoken: false,
4119
- ackGenerationPending: false,
4120
- ackTimer: null,
4121
4566
  escalationHandedOff: false,
4122
4567
  ttsBuffer: "",
4568
+ ttsReasoningFilter: createReasoningTagFilter(),
4123
4569
  ttsSegmentEnqueued: false,
4124
4570
  ttsJobs: [],
4125
4571
  ttsQueue: Promise.resolve(),
@@ -4142,8 +4588,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4142
4588
  // Verdict-deadline fail-open: the deferred-everything window is only
4143
4589
  // safe while the verdict is fast. If the leg produces no verdict
4144
4590
  // within the endpoint budget (provider TTFT tail), commit anyway so
4145
- // the thinking frame shows and the ack timer arms — bounded dead air
4146
- // instead of unbounded structural silence. A verdict that arrives
4591
+ // the thinking frame shows and progress narration can begin, bounding
4592
+ // the structural silence. A verdict that arrives
4147
4593
  // after the commit still works: escalate hands off normally, and a
4148
4594
  // late hold token is stripped like any stray token (the utterance is
4149
4595
  // already released, so holding is no longer possible).
@@ -4166,22 +4612,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4166
4612
  }, this.frontModelConfig.endpointDecisionTimeoutMs);
4167
4613
  }
4168
4614
 
4169
- // Slow-first-delta spoken ack: if the model produces no delta within the
4170
- // budget, a short front-model-phrased sentence holds the floor. Only
4171
- // meaningful on TTS sessions — a text-only session has no audio gap to
4172
- // bridge.
4173
- if (this.streamTtsAudio && !opts?.speculative) {
4174
- this.armAckTimer(activeTurn);
4175
- }
4176
-
4177
4615
  // Progress narration speaks into the turn's audible dead air on a
4178
- // cadence, wherever in the turn it occurs; without TTS or a decider there
4616
+ // cadence, wherever in the turn it occurs; without TTS or a narrator there
4179
4617
  // is nothing to speak (the idle trigger's static fallback still needs a
4180
4618
  // generation attempt to fall back from).
4181
4619
  if (
4182
4620
  this.frontModelConfig.progress.enabled &&
4183
4621
  this.streamTtsAudio &&
4184
- this.frontDecider &&
4622
+ this.progressNarrator &&
4185
4623
  !opts?.speculative
4186
4624
  ) {
4187
4625
  this.armProgressIdleTimer(activeTurn);
@@ -4218,9 +4656,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4218
4656
  * escalate verdict the post-verdict stream buffers into the capped bridge
4219
4657
  * and `escalateTurn` starts a second "escalated" leg that shares this same
4220
4658
  * ActiveAssistantTurn. The persisted assistant row is reduced to the capped
4221
- * bridge by the bridge's teardown transcript-hygiene pass; the shared
4222
- * conversation-hub broadcast still streams the raw verdict deltas mid-turn
4223
- * until the turn-end resync (the remaining scope of issue #37850).
4659
+ * bridge by the bridge's teardown transcript-hygiene pass, and the shared
4660
+ * conversation-hub broadcast releases the same capped bridge through the
4661
+ * bridge's front-door stream gate, so no verdict token reaches a hub
4662
+ * subscriber mid-turn.
4224
4663
  */
4225
4664
  private async startAssistantLeg(
4226
4665
  activeTurn: ActiveAssistantTurn,
@@ -4254,7 +4693,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4254
4693
  return;
4255
4694
  }
4256
4695
  this.markFirstAssistantDelta(utterance, turnId);
4257
- this.markFirstDeltaForAck(activeTurn);
4696
+ this.markAssistantDelta(activeTurn);
4258
4697
  // Send-time abort gate: a delta queued behind a backed-up outbound
4259
4698
  // frame must not be written once barge-in aborts the turn, or the
4260
4699
  // cancelled reply's text leaks ahead of turn_cancelled. Key off this
@@ -4492,7 +4931,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4492
4931
  if (current?.token !== token) {
4493
4932
  return;
4494
4933
  }
4495
- current.toolUseStarted = true;
4496
4934
  // Foreground wins the workspace: the continuation runs with full
4497
4935
  // subagent abilities (it can write files, run commands), so the
4498
4936
  // moment a live turn starts a consequential tool the two could
@@ -4541,21 +4979,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4541
4979
  current.progress.stateEpoch += 1;
4542
4980
  this.refreshActivity(current);
4543
4981
  log.debug({ turnId, toolName }, "Live voice turn started tool use");
4544
- // Definitive tool use means the turn is guaranteed slow: speak
4545
- // the floor-holding ack now instead of waiting out the
4546
- // first-delta timer (same one-ack-per-turn `ackSpoken` budget).
4547
- // Post-handoff the escalation bridge phrase holds the floor and
4548
- // front-door deltas never set firstDeltaSeen, so the escalated
4549
- // leg's tool starts must not stack another filler on the bridge.
4550
- if (
4551
- this.streamTtsAudio &&
4552
- !current.firstDeltaSeen &&
4553
- !current.ackSpoken &&
4554
- !current.escalationHandedOff
4555
- ) {
4556
- this.clearAckTimer(current);
4557
- this.speakAck(current, "tool_use", toolName);
4558
- }
4559
4982
  this.maybeNarrateProgress(current, "ops");
4560
4983
  },
4561
4984
  tool_result: (event) => {
@@ -4709,9 +5132,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4709
5132
  return;
4710
5133
  }
4711
5134
  activeTurn.escalationHandedOff = true;
4712
- // The escalation bridge below holds the floor, so a pending
4713
- // slow-first-delta ack or progress narration would only stack a second
4714
- // filler on top of it.
5135
+ // The escalation bridge below holds the floor, so pending progress
5136
+ // narration would only stack a second filler on top of it.
4715
5137
  this.clearFillerTimers(activeTurn);
4716
5138
  // Abort the front-door leg so a model that keeps generating past the
4717
5139
  // bridge cap adds no latency before the escalated leg starts.
@@ -4728,7 +5150,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4728
5150
  : cappedBridge;
4729
5151
  if (!usesFallbackBridge) {
4730
5152
  this.markFirstAssistantDelta(activeTurn.utterance, activeTurn.turnId);
4731
- this.markFirstDeltaForAck(activeTurn);
5153
+ this.markAssistantDelta(activeTurn);
4732
5154
  void this.sendFrame(
4733
5155
  { type: "assistant_text_delta", text: spokenBridge },
4734
5156
  () => !activeTurn.abortController.signal.aborted && !this.isClosed,
@@ -4774,7 +5196,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4774
5196
  if (
4775
5197
  this.frontModelConfig.progress.enabled &&
4776
5198
  this.streamTtsAudio &&
4777
- this.frontDecider
5199
+ this.progressNarrator
4778
5200
  ) {
4779
5201
  this.armProgressIdleTimer(activeTurn);
4780
5202
  }
@@ -4846,42 +5268,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4846
5268
  );
4847
5269
  }
4848
5270
 
4849
- // Arms the slow-first-delta ack timer for a just-launched turn: on expiry,
4850
- // if no assistant delta has arrived and the turn is still live, one short
4851
- // phrase is spoken to hold the floor.
4852
- private armAckTimer(activeTurn: ActiveAssistantTurn): void {
4853
- const { token } = activeTurn;
4854
- activeTurn.ackTimer = setTimeout(() => {
4855
- activeTurn.ackTimer = null;
4856
- if (
4857
- !this.isActiveAssistantTurn(token) ||
4858
- activeTurn.firstDeltaSeen ||
4859
- activeTurn.ackSpoken
4860
- ) {
4861
- return;
4862
- }
4863
- this.speakAck(activeTurn, "first_delta");
4864
- }, this.frontModelConfig.ackFirstDeltaTimeoutMs);
4865
- }
4866
-
4867
- private clearAckTimer(turn: ActiveAssistantTurn): void {
4868
- if (turn.ackTimer !== null) {
4869
- clearTimeout(turn.ackTimer);
4870
- turn.ackTimer = null;
4871
- }
4872
- }
4873
-
4874
- // The model produced real output for this turn: the pending slow-first-delta
4875
- // ack is moot, and any in-flight narration generation is stale (it captures
4876
- // deltaEpoch to notice). The narration idle timer stays armed — the dead air
4877
- // narration exists to fill is almost always MID-turn (between tool phases,
4878
- // during long silent LLM calls after the model's opening words), so it must
4879
- // keep watching after the model speaks and re-trigger once the audio drains
4880
- // and silence stretches again.
4881
- private markFirstDeltaForAck(turn: ActiveAssistantTurn): void {
4882
- turn.firstDeltaSeen = true;
5271
+ // A real assistant delta makes any in-flight progress narration stale. The
5272
+ // idle timer stays armed so it can detect a later stretch of mid-turn
5273
+ // silence.
5274
+ private markAssistantDelta(turn: ActiveAssistantTurn): void {
4883
5275
  turn.deltaEpoch += 1;
4884
- this.clearAckTimer(turn);
4885
5276
  }
4886
5277
 
4887
5278
  // Arms (or re-arms) the dead-air narration timer. The countdown measures
@@ -4957,14 +5348,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4957
5348
  }
4958
5349
  }
4959
5350
 
4960
- // Clears the turn's filler timers (slow-first-delta ack, dead-air
4961
- // narration, verdict deadline) for events that end the current filler
4962
- // lifecycle: escalation hand-off (which then re-arms narration for the
4963
- // escalated leg), barge-in, cancellation, tts-completion, finalize. Real
4964
- // output moots only the ack (markFirstDeltaForAck) — the narration timer
4965
- // keeps watching for mid-turn dead air.
5351
+ // Clears progress narration and verdict timers for events that end the
5352
+ // current filler lifecycle.
4966
5353
  private clearFillerTimers(turn: ActiveAssistantTurn): void {
4967
- this.clearAckTimer(turn);
4968
5354
  this.clearProgressIdleTimer(turn);
4969
5355
  if (turn.verdictDeadlineTimer !== null) {
4970
5356
  clearTimeout(turn.verdictDeadlineTimer);
@@ -4972,26 +5358,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4972
5358
  }
4973
5359
  }
4974
5360
 
4975
- // A spoken ack may only speak into a live turn's pre-first-delta dead air.
4976
- // Once the turn is stale, the brain's first delta has arrived, an
4977
- // escalation hand-off has enqueued its bridge phrase (which holds the floor
4978
- // itself), or the turn has completed outright — a tool-only/no-text turn
4979
- // completes with firstDeltaSeen still false — an ack is moot and must not
4980
- // speak over real output, stack on the escalation bridge, or voice a
4981
- // finished turn.
4982
- private turnCanSpeakFiller(turn: ActiveAssistantTurn): boolean {
4983
- return (
4984
- this.isActiveAssistantTurn(turn.token) &&
4985
- !turn.firstDeltaSeen &&
4986
- !turn.escalationHandedOff &&
4987
- !turn.assistantCompleted
4988
- );
4989
- }
4990
-
4991
- // Narration, unlike the ack, is not confined to the pre-first-delta window:
4992
- // the dead air it exists to fill is almost always mid-turn, after the
4993
- // model's opening words. It may speak whenever the live turn is audibly
4994
- // silent right now — nothing streaming, queued, or still playing — and has
5361
+ // Narration may speak whenever the live turn is audibly silent and has
4995
5362
  // not completed. Escalated turns qualify too: the bridge phrase holds the
4996
5363
  // floor only while its audio plays (covered by the audible-idle check),
4997
5364
  // and the strong leg's tool loops are the longest dead air in the system.
@@ -5028,31 +5395,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5028
5395
  return false;
5029
5396
  }
5030
5397
 
5031
- // Gatekeeper for spoken progress narration: it speaks only while the turn
5032
- // is audibly silent, spaced `minGapMs` from any spoken floor-holder (ack or
5033
- // narration), one generation at a time, and — per trigger — only once the
5034
- // ops trigger has `opsThreshold` ops accumulated or the idle trigger has
5035
- // something new to report. No per-turn count cap: the cadence guards bound
5036
- // the rate, and going quiet deep into a long turn is the failure mode
5037
- // narration exists to prevent. Every failing guard short-circuits silently;
5038
- // a skipped ops trigger keeps its accumulated count, so the next tool event
5039
- // or idle tick retries.
5398
+ // Gatekeeper for progress narration. It speaks only while the turn is
5399
+ // audibly silent, with one generation at a time and the configured spacing.
5040
5400
  private maybeNarrateProgress(
5041
5401
  turn: ActiveAssistantTurn,
5042
5402
  trigger: "ops" | "idle" | "op_complete",
5043
5403
  ): void {
5044
5404
  const cfg = this.frontModelConfig.progress;
5045
5405
  const { progress } = turn;
5046
- const frontDecider = this.frontDecider;
5406
+ const progressNarrator = this.progressNarrator;
5047
5407
  if (
5048
5408
  !cfg.enabled ||
5049
5409
  !this.streamTtsAudio ||
5050
- !frontDecider ||
5410
+ !progressNarrator ||
5051
5411
  !this.turnCanNarrateProgress(turn) ||
5052
- // A pending ack generation is a floor-holder-in-waiting: starting a
5053
- // narration generation now would only be discarded by the post-await
5054
- // re-check once the ack enqueues — a guaranteed wasted provider call.
5055
- turn.ackGenerationPending ||
5056
5412
  progress.narrationInFlight ||
5057
5413
  (progress.lastFloorHolderAtMs !== null &&
5058
5414
  Date.now() - progress.lastFloorHolderAtMs < cfg.minGapMs) ||
@@ -5061,25 +5417,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5061
5417
  ) {
5062
5418
  return;
5063
5419
  }
5064
- void this.speakProgressUpdate(turn, frontDecider, trigger);
5420
+ void this.speakProgressUpdate(turn, progressNarrator, trigger);
5065
5421
  }
5066
5422
 
5067
- // Generate and speak one progress narration. Like the ack, it is audio-only
5068
- // — no assistant_text_delta frame — and enqueues as a standalone sentence
5069
- // on the turn's ordered TTS queue. The decider internally bounds the call
5070
- // by `progress.generationTimeoutMs` and resolves null on every failure
5071
- // mode; on null the idle trigger falls back to a static phrase (silence is
5072
- // actively harmful there) while the tool-activity triggers stay silent — a
5073
- // generic filler is not worth it when narration was merely opportunistic.
5423
+ // Generate and speak one audio-only progress narration. On a null result,
5424
+ // idle narration falls back to a static phrase while activity-triggered
5425
+ // narration stays silent.
5074
5426
  private async speakProgressUpdate(
5075
5427
  turn: ActiveAssistantTurn,
5076
- frontDecider: VoiceFrontDecider,
5428
+ progressNarrator: VoiceProgressNarrator,
5077
5429
  trigger: "ops" | "idle" | "op_complete",
5078
5430
  ): Promise<void> {
5079
5431
  const { progress } = turn;
5080
5432
  progress.narrationInFlight = true;
5081
- // Any delta that lands while the decider call is in flight makes the
5082
- // generated text stale — and proves the model is speaking again.
5433
+ // Any delta that lands while generation is in flight makes the text stale.
5083
5434
  const deltaEpochAtLaunch = turn.deltaEpoch;
5084
5435
  // The activity this update describes. Tool events that land mid-generation
5085
5436
  // are news the generated text cannot carry, so they must leave the idle
@@ -5097,7 +5448,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5097
5448
  (op): op is TurnProgressOp & { completedAtMs: number } =>
5098
5449
  op.completedAtMs !== undefined,
5099
5450
  )
5100
- // `ops` is in start order; the decider contract wants completion
5451
+ // `ops` is in start order; the narrator wants completion
5101
5452
  // order, which differs when parallel tools finish out of order.
5102
5453
  .sort((a, b) => a.completedAtMs - b.completedAtMs)
5103
5454
  .map((op) => ({
@@ -5117,26 +5468,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5117
5468
  updateIndex: progress.updatesSpoken + 1,
5118
5469
  ...(turn.language !== undefined ? { languageHint: turn.language } : {}),
5119
5470
  };
5120
- const generated = await frontDecider
5471
+ const generated = await progressNarrator
5121
5472
  .generateProgressText(input, turn.abortController.signal)
5122
- // The decider contract never rejects; belt-and-braces for a stub.
5473
+ // The narrator contract never rejects. Keep test stubs fail-soft too.
5123
5474
  .catch(() => null);
5124
- // Liveness re-check after the await: any turnCanNarrateProgress
5125
- // condition may have flipped while generating, and a delta that landed
5126
- // mid-generation (deltaEpoch moved) makes the text stale even when the
5127
- // resulting audio has already drained by now. The ack-generation and
5128
- // floor re-checks close the race with a generated ack that
5129
- // STARTED after this generation did — the entry guard rejects a
5130
- // narration while an ack generation is already pending, but cannot see
5131
- // one that begins mid-generation: while that ack is pending it has not
5132
- // yet stamped `lastFloorHolderAtMs`, and once it enqueues the minGapMs
5133
- // spacing must be re-applied from that stamp — either way, enqueueing
5134
- // now would stack back-to-back filler. A bail here is silent and keeps
5135
- // the update budget, exactly like the stale-turn bail.
5475
+ // Re-check liveness and staleness after the provider call.
5136
5476
  if (
5137
5477
  !this.turnCanNarrateProgress(turn) ||
5138
5478
  turn.deltaEpoch !== deltaEpochAtLaunch ||
5139
- turn.ackGenerationPending ||
5140
5479
  (progress.lastFloorHolderAtMs !== null &&
5141
5480
  Date.now() - progress.lastFloorHolderAtMs <
5142
5481
  this.frontModelConfig.progress.minGapMs)
@@ -5144,7 +5483,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5144
5483
  return;
5145
5484
  }
5146
5485
  let raw = generated;
5147
- // Decider text is generated in the turn's language; only the static
5486
+ // Generated text is in the turn's language; only the static
5148
5487
  // fallback comes from a localized table and may need the "en" override.
5149
5488
  let fillerLanguage: string | undefined;
5150
5489
  if (raw === null) {
@@ -5163,8 +5502,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5163
5502
  progress.opsSinceNarration = 0;
5164
5503
  progress.narratedEpoch = stateEpochAtLaunch;
5165
5504
  progress.updatesSpoken += 1;
5166
- // Like the ack mark, recorded only when narration audio actually
5167
- // enqueued (decider text or static fallback alike).
5505
+ // Record only when narration audio actually enqueued.
5168
5506
  this.markProgressSpoken(turn);
5169
5507
  // Restart the dead-air countdown from this narration.
5170
5508
  this.armProgressIdleTimer(turn);
@@ -5173,93 +5511,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5173
5511
  }
5174
5512
  }
5175
5513
 
5176
- // Speak the turn's one floor-holding ack (every ack trigger shares the
5177
- // `ackSpoken` budget). The ack is audio-only — no assistant_text_delta frame
5178
- // — so captions and the persisted assistant message carry only the model's
5179
- // own output. It enqueues as a complete standalone sentence on the turn's
5180
- // ordered TTS queue, so the model's audio always follows it cleanly; it
5181
- // never consumes the eager first-clause flush reserved for the model's real
5182
- // first segment.
5183
- private speakAck(
5184
- activeTurn: ActiveAssistantTurn,
5185
- kind: LiveVoiceSpokenAckKind,
5186
- toolName?: string,
5187
- ): void {
5188
- if (!this.frontDecider) {
5189
- // No decider, no ack: a canned line is worse than the silence, which
5190
- // progress narration already covers once the turn runs long.
5191
- return;
5192
- }
5193
- activeTurn.ackSpoken = true;
5194
- // Optimistic ackSpoken above keeps the one-per-turn budget honest for
5195
- // any other ack trigger while the generation is in flight; the async
5196
- // path undoes it if the ack turns out moot.
5197
- void this.speakGeneratedAck(activeTurn, this.frontDecider, kind, toolName);
5198
- }
5199
-
5200
- // Ask the front model for one short contextual sentence. The decider
5201
- // internally bounds the call by `ackGenerationTimeoutMs` and resolves null
5202
- // on every failure mode, so the enqueue never waits beyond that budget; a
5203
- // null generation speaks nothing.
5204
- private async speakGeneratedAck(
5205
- activeTurn: ActiveAssistantTurn,
5206
- frontDecider: VoiceFrontDecider,
5207
- kind: LiveVoiceSpokenAckKind,
5208
- toolName?: string,
5209
- ): Promise<void> {
5210
- activeTurn.ackGenerationPending = true;
5211
- try {
5212
- const transcript = activeTurn.utterance.finalTranscriptSegments
5213
- .join(" ")
5214
- .trim();
5215
- const generated = await frontDecider
5216
- .generateAckText(
5217
- {
5218
- transcriptSoFar: transcript,
5219
- toolName,
5220
- ...(activeTurn.language !== undefined
5221
- ? { languageHint: activeTurn.language }
5222
- : {}),
5223
- },
5224
- activeTurn.abortController.signal,
5225
- )
5226
- // The decider contract never rejects; belt-and-braces for a stub.
5227
- .catch(() => null);
5228
- // Liveness re-check after the await: any turnCanSpeakFiller condition
5229
- // may have flipped while generating, mooting the ack. A null generation
5230
- // (no provider, timeout, unusable output) speaks nothing. Either way,
5231
- // undo the optimistic ackSpoken so the flag reflects reality and a
5232
- // later trigger this turn can still hold the floor.
5233
- if (generated === null || !this.turnCanSpeakFiller(activeTurn)) {
5234
- activeTurn.ackSpoken = false;
5235
- return;
5236
- }
5237
- this.enqueueAckPhrase(activeTurn, generated, kind);
5238
- } finally {
5239
- activeTurn.ackGenerationPending = false;
5240
- }
5241
- }
5242
-
5243
- // Records the ack-spoken metric only when a phrase actually enqueues; a
5244
- // phrase that sanitizes away releases the turn's ack budget again.
5245
- private enqueueAckPhrase(
5246
- activeTurn: ActiveAssistantTurn,
5247
- raw: string,
5248
- kind: LiveVoiceSpokenAckKind,
5249
- ): void {
5250
- if (this.enqueueFillerPhrase(activeTurn, raw)) {
5251
- this.markAckSpoken(activeTurn, kind);
5252
- return;
5253
- }
5254
- activeTurn.ackSpoken = false;
5255
- }
5256
-
5257
- // Sanitize and enqueue one filler sentence (spoken ack or progress
5258
- // narration) on the turn's ordered TTS queue — the shared tail of every
5259
- // filler path. Returns whether a phrase actually enqueued; per-kind metric
5260
- // marks and bookkeeping are the caller's. `language` is a per-segment
5261
- // hint override (see fixedPhraseLanguage); omit it for generated text,
5262
- // which is already in the turn's language.
5514
+ // Sanitize and enqueue one progress sentence on the ordered TTS queue.
5263
5515
  private enqueueFillerPhrase(
5264
5516
  turn: ActiveAssistantTurn,
5265
5517
  raw: string,
@@ -5303,7 +5555,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5303
5555
  return;
5304
5556
  }
5305
5557
 
5306
- activeTurn.ttsBuffer += text;
5558
+ activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.push(text);
5307
5559
  this.flushTtsBuffer(token, false);
5308
5560
  }
5309
5561
 
@@ -5314,6 +5566,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5314
5566
  }
5315
5567
 
5316
5568
  this.clearFillerTimers(activeTurn);
5569
+ activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.flush();
5317
5570
  this.flushTtsBuffer(token, true);
5318
5571
  activeTurn.ttsQueue = activeTurn.ttsQueue
5319
5572
  .catch(() => {})
@@ -5420,8 +5673,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5420
5673
  return;
5421
5674
  }
5422
5675
 
5423
- // A spoken ack (countsAsFirstSegment: false) leaves the eager
5424
- // first-clause flush available for the model's real first segment.
5676
+ // Progress narration leaves the eager first-clause flush available for
5677
+ // the model's real first segment.
5425
5678
  if (options.countsAsFirstSegment ?? true) {
5426
5679
  activeTurn.ttsSegmentEnqueued = true;
5427
5680
  }
@@ -5724,23 +5977,42 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5724
5977
  utterance: UtteranceCycle,
5725
5978
  action: VoiceEndpointAction,
5726
5979
  latencyMs: number,
5980
+ // Absent means the front door decided, which is what the collector
5981
+ // defaults to; the provider path names itself so one metrics frame can
5982
+ // compare the two.
5983
+ source?: VoiceEndpointSource,
5727
5984
  ): void {
5728
5985
  const turnId = this.ensureTurnId(utterance);
5729
5986
  if (!this.startMetricsTurnIfNeeded(utterance, turnId)) {
5730
5987
  return;
5731
5988
  }
5732
- this.metrics.markEndpointDecision(turnId, { action, latencyMs });
5989
+ this.metrics.markEndpointDecision(turnId, {
5990
+ action,
5991
+ latencyMs,
5992
+ ...(source ? { source } : {}),
5993
+ });
5733
5994
  }
5734
5995
 
5735
- private markAckSpoken(
5736
- activeTurn: ActiveAssistantTurn,
5737
- kind: LiveVoiceSpokenAckKind,
5738
- ): void {
5739
- const { utterance } = activeTurn;
5740
- if (!this.startMetricsTurnIfNeeded(utterance, activeTurn.turnId)) {
5996
+ /**
5997
+ * The turn's end-of-turn latency, stamped at the moment it commits. Called
5998
+ * from `releaseUtterance`, which every committed turn passes through
5999
+ * whichever decider released it, so the front-door and provider samples span
6000
+ * the same thing from the same anchor over the same population. That is
6001
+ * what `endpointDecisionMaxLatencyMs` cannot offer, and why this exists
6002
+ * alongside it.
6003
+ *
6004
+ * Skipped with no speech-stop mark, which is push-to-talk: there the
6005
+ * client's release is the boundary and no local VAD runs.
6006
+ */
6007
+ private markEndpointCommit(utterance: UtteranceCycle): void {
6008
+ if (this.localSpeechStopAtMs === null) {
6009
+ return;
6010
+ }
6011
+ const turnId = this.ensureTurnId(utterance);
6012
+ if (!this.startMetricsTurnIfNeeded(utterance, turnId)) {
5741
6013
  return;
5742
6014
  }
5743
- this.metrics.markAckSpoken(activeTurn.turnId, kind);
6015
+ this.metrics.markEndpointCommit(turnId, this.msSinceLocalSpeechStop());
5744
6016
  }
5745
6017
 
5746
6018
  private markProgressSpoken(activeTurn: ActiveAssistantTurn): void {
@@ -6096,14 +6368,16 @@ export function createLiveVoiceSession(
6096
6368
  options: LiveVoiceSessionOptions = {},
6097
6369
  ): LiveVoiceSession {
6098
6370
  // Workspace-tunable server-VAD thresholds. The `liveVoice.vad` schema
6099
- // defaults match the code defaults (800 energy / 800 ms silence / 30 s max
6100
- // turn / 60 ms barge-in guard), so an unset config leaves behavior
6101
- // unchanged. Optional-chained
6102
- // because hand-built test configs may predate the liveVoice namespace;
6103
- // absent config falls through to the in-code defaults.
6371
+ // defaults are 800 energy / 1200 ms silence / 30 s max turn / 250 ms
6372
+ // barge-in guard. Three of the four match their in-code fallback; the
6373
+ // trailing-silence threshold does not, so a session built with a
6374
+ // `liveVoice` config waits 1200 ms and one built without waits the in-code
6375
+ // DEFAULT_SILENCE_THRESHOLD_MS of 800. Optional-chained because hand-built
6376
+ // test configs may predate the liveVoice namespace; absent config falls
6377
+ // through to the in-code defaults.
6104
6378
  const liveVoiceConfig = getConfig().liveVoice;
6105
6379
  const vadConfig = liveVoiceConfig?.vad;
6106
- // Parsed once here into a complete config, shared by the decider and the
6380
+ // Parsed once here into a complete config, shared by endpointing and the
6107
6381
  // session (the constructor's own parse is then a no-op re-validation).
6108
6382
  const frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
6109
6383
  options.frontModelConfig ?? liveVoiceConfig?.frontModel ?? {},
@@ -6128,13 +6402,14 @@ export function createLiveVoiceSession(
6128
6402
  options.echoEmaHalfLifeMs ?? vadConfig?.echoEmaHalfLifeMs,
6129
6403
  echoDrainSlackMs: options.echoDrainSlackMs ?? vadConfig?.echoDrainSlackMs,
6130
6404
  frontModelConfig,
6131
- // Eager construction is safe even when the `liveVoice.frontModel` config
6132
- // namespace is absent — schema defaults fill the tunables. An explicit
6133
- // option (incl. `null`) always wins — the test seam.
6134
- frontDecider:
6135
- options.frontDecider !== undefined
6136
- ? options.frontDecider
6137
- : createVoiceFrontDecider({ config: frontModelConfig }),
6405
+ // Absent config leaves the schema defaults, which keep provider turn
6406
+ // detection off.
6407
+ fluxConfig: options.fluxConfig ?? liveVoiceConfig?.flux,
6408
+ // An explicit option, including null, always wins for the test seam.
6409
+ progressNarrator:
6410
+ options.progressNarrator !== undefined
6411
+ ? options.progressNarrator
6412
+ : createVoiceProgressNarrator({ config: frontModelConfig.progress }),
6138
6413
  resolveCredentialReadiness:
6139
6414
  options.resolveCredentialReadiness === undefined
6140
6415
  ? defaultResolveLiveVoiceCredentialReadiness