@vellumai/assistant 0.11.3 → 0.11.4-staging.1

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 (324) hide show
  1. package/ARCHITECTURE.md +11 -6
  2. package/docs/architecture/memory.md +11 -0
  3. package/docs/architecture/turn-actor.md +70 -0
  4. package/docs/flux-turn-detection-spike.md +243 -0
  5. package/docs/stt-provider-onboarding.md +3 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  8. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/openapi.yaml +140 -38
  12. package/package.json +1 -1
  13. package/scripts/voice-ttft-spike.ts +3 -3
  14. package/src/__tests__/app-compiler.test.ts +38 -3
  15. package/src/__tests__/attachments-store.test.ts +21 -12
  16. package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
  17. package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
  18. package/src/__tests__/call-site-routing-provider.test.ts +1 -1
  19. package/src/__tests__/channel-availability-routes.test.ts +14 -1
  20. package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
  21. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  22. package/src/__tests__/config-loader-backfill.test.ts +3 -3
  23. package/src/__tests__/config-schema.test.ts +25 -10
  24. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
  26. package/src/__tests__/conversation-agent-loop.test.ts +28 -20
  27. package/src/__tests__/conversation-attention-store.test.ts +63 -0
  28. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
  29. package/src/__tests__/conversation-fork-crud.test.ts +69 -0
  30. package/src/__tests__/conversation-fork-referential.test.ts +67 -0
  31. package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
  32. package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
  33. package/src/__tests__/conversation-queue.test.ts +55 -4
  34. package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
  35. package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
  36. package/src/__tests__/credential-prompt-route.test.ts +7 -10
  37. package/src/__tests__/custom-profile-ensure.test.ts +5 -1
  38. package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
  39. package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
  40. package/src/__tests__/document-append-idempotency.test.ts +233 -0
  41. package/src/__tests__/edit-propagation.test.ts +0 -7
  42. package/src/__tests__/helpers/mock-actor-context.ts +49 -0
  43. package/src/__tests__/helpers/mock-conversation.ts +13 -1
  44. package/src/__tests__/injector-chain.test.ts +63 -41
  45. package/src/__tests__/injector-disk-pressure.test.ts +11 -23
  46. package/src/__tests__/llm-context-resolution.test.ts +73 -1
  47. package/src/__tests__/llm-schema.test.ts +5 -2
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
  49. package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
  50. package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
  51. package/src/__tests__/mtime-cache.test.ts +1 -1
  52. package/src/__tests__/non-member-access-request.test.ts +0 -20
  53. package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
  55. package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
  56. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
  57. package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
  58. package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
  59. package/src/__tests__/scoped-approval-grants.test.ts +11 -6
  60. package/src/__tests__/secret-ingress-channel.test.ts +0 -1
  61. package/src/__tests__/skills.test.ts +32 -0
  62. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  63. package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
  64. package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
  65. package/src/__tests__/turn-events-store.test.ts +43 -0
  66. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  67. package/src/__tests__/visible-app-context.test.ts +16 -9
  68. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  69. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  70. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  71. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  72. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  73. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  74. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  75. package/src/acp/__tests__/auth-required.test.ts +161 -0
  76. package/src/acp/acp-claude-oauth.ts +19 -2
  77. package/src/acp/agent-process.test.ts +100 -0
  78. package/src/acp/agent-process.ts +29 -26
  79. package/src/acp/auth-required.ts +102 -0
  80. package/src/acp/session-manager.test.ts +119 -0
  81. package/src/acp/session-manager.ts +68 -2
  82. package/src/api/events/acp-auth-required.ts +55 -0
  83. package/src/api/index.ts +7 -0
  84. package/src/apps/app-store.ts +3 -0
  85. package/src/bundler/package-resolver.ts +2 -30
  86. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  87. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  88. package/src/calls/call-controller.ts +9 -2
  89. package/src/calls/call-setup-flow.ts +0 -1
  90. package/src/calls/media-stream-stt-session.ts +15 -0
  91. package/src/calls/voice-session-bridge.ts +71 -16
  92. package/src/calls/voice-triage-escalate.ts +104 -2
  93. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  94. package/src/channels/config.ts +13 -0
  95. package/src/channels/plugin-channel-declarations.ts +108 -0
  96. package/src/channels/types.ts +30 -0
  97. package/src/cli/AGENTS.md +5 -2
  98. package/src/cli/commands/credentials.help.ts +2 -2
  99. package/src/cli/commands/inference-providers.ts +1 -1
  100. package/src/cli/commands/mcp.help.ts +13 -4
  101. package/src/cli/commands/mcp.ts +9 -0
  102. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  103. package/src/cli/commands/memory/index.help.ts +43 -1
  104. package/src/cli/commands/memory/memory-v3.ts +64 -0
  105. package/src/cli/commands/stt.help.ts +27 -2
  106. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  107. package/src/cli/lib/bundled-marketplace.json +13 -0
  108. package/src/cli/lib/upgrade-plugin.ts +42 -0
  109. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  110. package/src/config/__tests__/default-provider.test.ts +6 -1
  111. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  112. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  113. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  114. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  115. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  116. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  117. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  118. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  119. package/src/config/call-site-defaults.ts +4 -5
  120. package/src/config/default-profile-catalog.ts +82 -11
  121. package/src/config/default-profile-names.ts +4 -1
  122. package/src/config/default-provider-resolution.ts +4 -0
  123. package/src/config/llm-context-resolution.ts +11 -3
  124. package/src/config/llm-resolver.ts +28 -1
  125. package/src/config/profile-materialization.ts +70 -22
  126. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  127. package/src/config/schemas/call-site-catalog.ts +4 -4
  128. package/src/config/schemas/live-voice.ts +57 -23
  129. package/src/config/schemas/llm.ts +59 -32
  130. package/src/config/schemas/mcp.ts +23 -0
  131. package/src/config/schemas/plugin-updates.ts +6 -2
  132. package/src/config/schemas/stt.ts +1 -0
  133. package/src/context/outbound-sanitize.ts +96 -1
  134. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  135. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  136. package/src/daemon/conversation-agent-loop.ts +17 -6
  137. package/src/daemon/conversation-messaging.ts +5 -1
  138. package/src/daemon/conversation-notifiers.ts +9 -1
  139. package/src/daemon/conversation-process.ts +9 -6
  140. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  141. package/src/daemon/conversation-tool-setup.ts +1 -2
  142. package/src/daemon/conversation.ts +48 -0
  143. package/src/daemon/mcp-reload-service.ts +36 -6
  144. package/src/daemon/process-message.ts +13 -3
  145. package/src/daemon/providers-setup.ts +6 -3
  146. package/src/daemon/trust-context-types.ts +29 -0
  147. package/src/daemon/wake-conversation-ops.ts +3 -2
  148. package/src/documents/document-store.ts +138 -5
  149. package/src/hooks/hook-loader.ts +3 -3
  150. package/src/hooks/registry.ts +50 -6
  151. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  152. package/src/inbound/oauth-callback-url.ts +61 -0
  153. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  154. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  155. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  156. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  157. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  158. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  159. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  160. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  161. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  162. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  163. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  164. package/src/live-voice/live-voice-archive.ts +2 -0
  165. package/src/live-voice/live-voice-metrics.ts +57 -32
  166. package/src/live-voice/live-voice-photo.ts +1 -2
  167. package/src/live-voice/live-voice-session.ts +535 -314
  168. package/src/live-voice/progress-narration.ts +277 -0
  169. package/src/live-voice/protocol.ts +21 -1
  170. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  171. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  172. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  173. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  174. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  175. package/src/mcp/client.ts +16 -11
  176. package/src/mcp/effective-config.ts +113 -0
  177. package/src/mcp/manager.ts +11 -6
  178. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  179. package/src/mcp/mcp-oauth-provider.ts +205 -240
  180. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  181. package/src/monitoring/plugin-auto-update.ts +128 -24
  182. package/src/notifications/signal.ts +1 -0
  183. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  184. package/src/permissions/confirmation-guardian-request.ts +2 -2
  185. package/src/permissions/question-guardian-request.test.ts +14 -6
  186. package/src/permissions/question-guardian-request.ts +1 -2
  187. package/src/persistence/attachments-store.ts +8 -1
  188. package/src/persistence/bookmark-crud.ts +3 -7
  189. package/src/persistence/conversation-attention-store.ts +16 -45
  190. package/src/persistence/conversation-crud.ts +33 -4
  191. package/src/persistence/conversation-lineage.ts +9 -0
  192. package/src/persistence/conversation-queries.ts +108 -41
  193. package/src/persistence/delivery-crud.ts +38 -29
  194. package/src/persistence/external-conversation-store.ts +32 -4
  195. package/src/persistence/llm-request-log-store.ts +4 -10
  196. package/src/persistence/llm-usage-store.ts +8 -3
  197. package/src/persistence/message-reads.test.ts +197 -0
  198. package/src/persistence/message-reads.ts +211 -0
  199. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  200. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  201. package/src/persistence/real-user-turn-filter.ts +27 -3
  202. package/src/persistence/steps.ts +9 -0
  203. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  204. package/src/plugin-api/conversation-turn.ts +168 -5
  205. package/src/plugin-api/index.ts +21 -5
  206. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  207. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  208. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  209. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  210. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  211. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  212. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  213. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  214. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  215. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  216. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  217. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  218. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  219. package/src/plugins/defaults/memory/indexer.ts +3 -1
  220. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  221. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  222. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  223. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  224. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  225. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  226. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  227. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  228. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  229. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  230. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  231. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  232. package/src/plugins/defaults/memory/worker.ts +6 -3
  233. package/src/plugins/external-plugin-loader.ts +47 -0
  234. package/src/plugins/mcp-servers.ts +361 -0
  235. package/src/plugins/mtime-cache.ts +23 -49
  236. package/src/plugins/worker-plugin-surface.ts +33 -0
  237. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  238. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  239. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  240. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  241. package/src/providers/call-site-routing.ts +30 -3
  242. package/src/providers/connection-resolution.ts +194 -11
  243. package/src/providers/inference/auth.ts +6 -0
  244. package/src/providers/inference/connection-availability.ts +24 -2
  245. package/src/providers/inference/connections.ts +2 -0
  246. package/src/providers/model-intents.ts +26 -6
  247. package/src/providers/openai/codex-models.ts +2 -1
  248. package/src/providers/provider-send-message.ts +32 -3
  249. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  250. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  251. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  252. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  253. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  254. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  255. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  256. package/src/providers/speech-to-text/resolve.ts +25 -2
  257. package/src/routes/worker.ts +17 -5
  258. package/src/runtime/access-request-helper.ts +9 -12
  259. package/src/runtime/agent-wake.ts +3 -3
  260. package/src/runtime/pre-first-message-gate.ts +4 -0
  261. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  262. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  263. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  264. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  265. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  266. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  267. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  268. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  269. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  270. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  271. package/src/runtime/routes/channel-route-shared.ts +0 -6
  272. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  273. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  274. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  275. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  276. package/src/runtime/routes/default-provider-routes.ts +15 -0
  277. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  278. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  279. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  280. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  281. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  282. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  283. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  284. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  285. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  286. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  287. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  288. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  289. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  290. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  291. package/src/runtime/routes/log-export-routes.ts +3 -0
  292. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  293. package/src/runtime/routes/plugins-routes.ts +21 -3
  294. package/src/runtime/routes/stt-routes.ts +31 -25
  295. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  296. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  297. package/src/runtime/routes/user-route-import.ts +108 -0
  298. package/src/schedule/worker.ts +6 -0
  299. package/src/security/oauth2.ts +6 -22
  300. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  301. package/src/stt/__tests__/types.test.ts +94 -0
  302. package/src/stt/daemon-batch-transcriber.ts +10 -0
  303. package/src/stt/stt-stream-session.ts +8 -4
  304. package/src/stt/types.ts +103 -0
  305. package/src/subagent/manager.ts +1 -3
  306. package/src/subagent/types.ts +7 -6
  307. package/src/tools/acp/spawn.test.ts +97 -0
  308. package/src/tools/acp/spawn.ts +32 -0
  309. package/src/tools/document/document-tool.ts +12 -3
  310. package/src/tools/registry.ts +2 -1
  311. package/src/tools/workflows/run-workflow.ts +1 -2
  312. package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
  313. package/src/tts/reasoning-tag-filter.ts +110 -0
  314. package/src/workspace/byok-default-profile-ensure.ts +61 -18
  315. package/src/workspace/custom-profile-ensure.ts +4 -24
  316. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  317. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  318. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  319. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  320. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  321. package/src/workspace/migrations/registry.ts +8 -0
  322. package/src/workspace/provider-commit-message-generator.ts +7 -5
  323. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  324. package/src/live-voice/front-decision.ts +0 -476
@@ -73,7 +73,11 @@ mock.module("../../persistence/conversation-crud.js", () => ({
73
73
  import { setConfig } from "../../__tests__/helpers/set-config.js";
74
74
  import { ABORT_WATCHDOG_MS } from "../../daemon/abort-watchdog.js";
75
75
  import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
76
- import { CALL_OPENING_MARKER } from "../voice-control-protocol.js";
76
+ import {
77
+ CALL_OPENING_MARKER,
78
+ ESCALATE_VERDICT_TOKEN,
79
+ HOLD_VERDICT_TOKEN,
80
+ } from "../voice-control-protocol.js";
77
81
  import {
78
82
  cutFrontDoorContentAtVerdict,
79
83
  preSpeechLanguageRuleFragment,
@@ -1701,6 +1705,174 @@ describe("cutFrontDoorContentAtVerdict", () => {
1701
1705
  });
1702
1706
  });
1703
1707
 
1708
+ /**
1709
+ * The front-door leg's raw stream is a control plane: its leading tokens are
1710
+ * the routing verdict, not speech. The hub broadcast (web / passive devices)
1711
+ * must never carry those tokens, and must still carry every word of real
1712
+ * assistant content: an over-broad filter would leave the shared transcript
1713
+ * silent or truncated, which is worse than the leak it fixes.
1714
+ */
1715
+ describe("front-door hub stream gate", () => {
1716
+ beforeEach(resetCrudLog);
1717
+
1718
+ /**
1719
+ * Conversation whose scripted agent loop announces a reserved row, streams
1720
+ * `deltas` in order, then ends the leg with `finalEvent`.
1721
+ */
1722
+ function makeStreamingConversation(
1723
+ deltas: string[],
1724
+ finalEvent:
1725
+ | "message_complete"
1726
+ | "generation_cancelled" = "message_complete",
1727
+ ): void {
1728
+ const fake = makeFakeConversation({ processing: false });
1729
+ fake.conversation.runAgentLoop = async (...args: unknown[]) => {
1730
+ const { onEvent } = args[2] as { onEvent: (msg: unknown) => void };
1731
+ onEvent({
1732
+ type: "assistant_turn_start",
1733
+ messageId: "assistant-row-1",
1734
+ conversationId: "conv-voice-bridge-test",
1735
+ });
1736
+ for (const text of deltas) {
1737
+ onEvent({
1738
+ type: "assistant_text_delta",
1739
+ text,
1740
+ messageId: "assistant-row-1",
1741
+ conversationId: "conv-voice-bridge-test",
1742
+ });
1743
+ }
1744
+ onEvent({
1745
+ type: finalEvent,
1746
+ ...(finalEvent === "message_complete"
1747
+ ? { messageId: "assistant-row-1" }
1748
+ : {}),
1749
+ conversationId: "conv-voice-bridge-test",
1750
+ });
1751
+ };
1752
+ fakeConversation = fake.conversation;
1753
+ }
1754
+
1755
+ /** The text of every `assistant_text_delta` `turn` publishes to the hub. */
1756
+ async function collectBroadcastText(
1757
+ turn: () => Promise<unknown>,
1758
+ ): Promise<string[]> {
1759
+ const texts: string[] = [];
1760
+ const subscription = assistantEventHub.subscribe({
1761
+ type: "process",
1762
+ filter: { conversationId: "conv-voice-bridge-test" },
1763
+ callback: (event) => {
1764
+ const msg = event.message as { type: string; text?: string };
1765
+ if (msg.type === "assistant_text_delta") {
1766
+ texts.push(msg.text ?? "");
1767
+ }
1768
+ },
1769
+ });
1770
+ try {
1771
+ await turn();
1772
+ // The hub publishes off a promise chain, so every broadcast costs a
1773
+ // few microtask hops before it reaches a subscriber.
1774
+ await new Promise((resolve) => setTimeout(resolve, 0));
1775
+ await flushMicrotasks();
1776
+ } finally {
1777
+ subscription.dispose();
1778
+ }
1779
+ return texts;
1780
+ }
1781
+
1782
+ test("an escalating leg broadcasts only the capped bridge, never the verdict token", async () => {
1783
+ makeStreamingConversation([
1784
+ "[1]",
1785
+ " Let me check your calendar.",
1786
+ " weak answer past the cap",
1787
+ ]);
1788
+
1789
+ const texts = await collectBroadcastText(() =>
1790
+ startVoiceTurn({ ...makeTurnOptions(), routingLeg: "front-door" }),
1791
+ );
1792
+
1793
+ expect(texts).toEqual(["Let me check your calendar."]);
1794
+ expect(texts.join("")).not.toContain(ESCALATE_VERDICT_TOKEN);
1795
+ });
1796
+
1797
+ test("a front-door answer reaches the hub in full", async () => {
1798
+ makeStreamingConversation(["It is ", "Tuesday", ", and it is sunny."]);
1799
+
1800
+ const texts = await collectBroadcastText(() =>
1801
+ startVoiceTurn({ ...makeTurnOptions(), routingLeg: "front-door" }),
1802
+ );
1803
+
1804
+ expect(texts.join("")).toBe("It is Tuesday, and it is sunny.");
1805
+ });
1806
+
1807
+ test("an answer that merely opens with a bracket is released in full", async () => {
1808
+ // "[" alone could still become the escalate token, so the gate holds it;
1809
+ // the next delta disproves the token and the whole prefix must come out.
1810
+ makeStreamingConversation(["[", "A] is the option to pick."]);
1811
+
1812
+ const texts = await collectBroadcastText(() =>
1813
+ startVoiceTurn({ ...makeTurnOptions(), routingLeg: "front-door" }),
1814
+ );
1815
+
1816
+ expect(texts.join("")).toBe("[A] is the option to pick.");
1817
+ });
1818
+
1819
+ test("a hold verdict broadcasts nothing", async () => {
1820
+ makeStreamingConversation([HOLD_VERDICT_TOKEN]);
1821
+
1822
+ const texts = await collectBroadcastText(() =>
1823
+ startVoiceTurn({
1824
+ ...makeTurnOptions(),
1825
+ routingLeg: "front-door",
1826
+ unifiedVerdict: true,
1827
+ }),
1828
+ );
1829
+
1830
+ expect(texts).toEqual([]);
1831
+ });
1832
+
1833
+ test("a leg that completes mid-bridge broadcasts what it handed off with", async () => {
1834
+ makeStreamingConversation(["[1] Let me check"]);
1835
+
1836
+ const texts = await collectBroadcastText(() =>
1837
+ startVoiceTurn({ ...makeTurnOptions(), routingLeg: "front-door" }),
1838
+ );
1839
+
1840
+ expect(texts).toEqual(["Let me check"]);
1841
+ });
1842
+
1843
+ test("a leg cancelled mid-bridge never hands off, so it broadcasts nothing", async () => {
1844
+ makeStreamingConversation(["[1] Let me check"], "generation_cancelled");
1845
+
1846
+ const texts = await collectBroadcastText(() =>
1847
+ startVoiceTurn({ ...makeTurnOptions(), routingLeg: "front-door" }),
1848
+ );
1849
+
1850
+ expect(texts).toEqual([]);
1851
+ });
1852
+
1853
+ test("the escalated continuation streams to the hub untouched", async () => {
1854
+ // The leg that produces the real answer is never gated: its deltas are
1855
+ // assistant speech from the first token.
1856
+ makeStreamingConversation(["Your next meeting ", "is at four."]);
1857
+
1858
+ const texts = await collectBroadcastText(() =>
1859
+ startVoiceTurn({ ...makeTurnOptions(), routingLeg: "escalated" }),
1860
+ );
1861
+
1862
+ expect(texts).toEqual(["Your next meeting ", "is at four."]);
1863
+ });
1864
+
1865
+ test("an un-routed voice leg streams to the hub untouched", async () => {
1866
+ makeStreamingConversation(["[1] not a verdict here"]);
1867
+
1868
+ const texts = await collectBroadcastText(() =>
1869
+ startVoiceTurn(makeTurnOptions()),
1870
+ );
1871
+
1872
+ expect(texts).toEqual(["[1] not a verdict here"]);
1873
+ });
1874
+ });
1875
+
1704
1876
  describe("transcript hygiene (teardown pass)", () => {
1705
1877
  beforeEach(resetCrudLog);
1706
1878
 
@@ -4,6 +4,7 @@ import { DEEPGRAM_MULTI_LANGUAGE_CODES } from "../../providers/speech-to-text/de
4
4
  import {
5
5
  capEscalationBridge,
6
6
  classifyFrontDoorLeading,
7
+ createFrontDoorStreamGate,
7
8
  ESCALATE_VERDICT_TOKEN,
8
9
  escalatedContinuationRule,
9
10
  ESCALATION_CONTINUATION_CONTENT,
@@ -439,3 +440,96 @@ describe("live-voice escalation orchestration (end-to-end — TODO)", () => {
439
440
  () => {},
440
441
  );
441
442
  });
443
+
444
+ describe("createFrontDoorStreamGate", () => {
445
+ /** Feed `deltas` through a gate and return what it released, in order. */
446
+ function release(
447
+ deltas: string[],
448
+ opts?: { holdEnabled?: boolean; finish?: boolean },
449
+ ): string[] {
450
+ const gate = createFrontDoorStreamGate(opts?.holdEnabled === true);
451
+ const out = deltas
452
+ .map((delta) => gate.push(delta))
453
+ .filter((chunk) => chunk.length > 0);
454
+ if (opts?.finish !== false) {
455
+ const trailing = gate.finish();
456
+ if (trailing.length > 0) {
457
+ out.push(trailing);
458
+ }
459
+ }
460
+ return out;
461
+ }
462
+
463
+ test("an answer passes through delta by delta", () => {
464
+ expect(release(["It is ", "Tuesday."])).toEqual(["It is ", "Tuesday."]);
465
+ });
466
+
467
+ test("leading text held while the verdict was pending is not lost", () => {
468
+ expect(release(["[", "A] is the option."])).toEqual(["[A] is the option."]);
469
+ });
470
+
471
+ test("an escalation releases the capped bridge and nothing else", () => {
472
+ expect(
473
+ release([
474
+ ESCALATE_VERDICT_TOKEN,
475
+ " Let me check your calendar.",
476
+ " rambling past the cap",
477
+ ]),
478
+ ).toEqual(["Let me check your calendar."]);
479
+ });
480
+
481
+ test("a verdict token split across deltas still never escapes", () => {
482
+ expect(release(["[", "1", "] One moment.", " more"])).toEqual([
483
+ "One moment.",
484
+ ]);
485
+ });
486
+
487
+ test("a too-short bridge releases nothing, matching the audio-only fallback", () => {
488
+ // Under MIN_SPOKEN_BRIDGE_CHARS the session discards the model's bridge
489
+ // and plays a canned fallback with no caption and no persisted row, so
490
+ // releasing the capped text would show words the caller never heard.
491
+ expect(release([ESCALATE_VERDICT_TOKEN, " ok"])).toEqual([]);
492
+ expect(release([ESCALATE_VERDICT_TOKEN, " ."])).toEqual([]);
493
+ });
494
+
495
+ test("a too-short bridge releases nothing via finish either", () => {
496
+ const gate = createFrontDoorStreamGate(false);
497
+ expect(gate.push(`${ESCALATE_VERDICT_TOKEN} ok`)).toBe("");
498
+ expect(gate.finish()).toBe("");
499
+ });
500
+
501
+ test("a bridge at the spoken threshold is still released", () => {
502
+ // The boundary belongs to the spoken side: the session speaks anything
503
+ // that is not BELOW the threshold, so the gate must release it too.
504
+ expect(release([ESCALATE_VERDICT_TOKEN, " Sure."])).toEqual(["Sure."]);
505
+ });
506
+
507
+ test("a bridge with no terminator is released by finish", () => {
508
+ const gate = createFrontDoorStreamGate(false);
509
+ expect(gate.push("[1] Let me check")).toBe("");
510
+ expect(gate.finish()).toBe("Let me check");
511
+ });
512
+
513
+ test("a bridge is dropped when the leg never finishes (cancellation)", () => {
514
+ expect(release(["[1] Let me check"], { finish: false })).toEqual([]);
515
+ });
516
+
517
+ test("a hold verdict releases nothing", () => {
518
+ expect(release([HOLD_VERDICT_TOKEN], { holdEnabled: true })).toEqual([]);
519
+ });
520
+
521
+ test("the hold token is ordinary text on a leg that was never taught it", () => {
522
+ // A non-speculative leg's prompt has no hold branch, so its output must
523
+ // not be swallowed by a token it could only have parroted.
524
+ expect(release([`${HOLD_VERDICT_TOKEN} is the index.`])).toEqual([
525
+ `${HOLD_VERDICT_TOKEN} is the index.`,
526
+ ]);
527
+ });
528
+
529
+ test("nothing more escapes after the bridge is released", () => {
530
+ const gate = createFrontDoorStreamGate(false);
531
+ expect(gate.push("[1] One moment.")).toBe("One moment.");
532
+ expect(gate.push(" and a leaked continuation")).toBe("");
533
+ expect(gate.finish()).toBe("");
534
+ });
535
+ });
@@ -21,6 +21,7 @@ import type { TrustContext } from "../daemon/trust-context-types.js";
21
21
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
22
22
  import { computeToolApprovalDigest } from "../security/tool-approval-digest.js";
23
23
  import { getCatalogProvider } from "../tts/provider-catalog.js";
24
+ import { createReasoningTagFilter } from "../tts/reasoning-tag-filter.js";
24
25
  import { extractSpeakableSegments } from "../tts/speakable-segments.js";
25
26
  import {
26
27
  type AudioStoreSink,
@@ -728,6 +729,10 @@ export class CallController {
728
729
  // could be the start of a control marker.
729
730
  let ttsBuffer = "";
730
731
  let fullResponseText = "";
732
+ // Reasoning models can inline <think> spans in the content stream when a
733
+ // profile has not opted into parseThinkTags; the spoken path must never
734
+ // read them aloud. fullResponseText keeps the raw stream.
735
+ const reasoningFilter = createReasoningTagFilter();
731
736
 
732
737
  // Synthesized path: text is split at speakable boundaries as it streams
733
738
  // and each segment is synthesized while the LLM keeps generating. The
@@ -931,7 +936,7 @@ export class CallController {
931
936
  return;
932
937
  }
933
938
  fullResponseText += text;
934
- ttsBuffer += text;
939
+ ttsBuffer += reasoningFilter.push(text);
935
940
  ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
936
941
  flushSafeText();
937
942
  };
@@ -1010,7 +1015,9 @@ export class CallController {
1010
1015
  return fullResponseText;
1011
1016
  }
1012
1017
 
1013
- // Final sweep: strip any remaining control markers from the buffer
1018
+ // Final sweep: release any held-back partial tag, then strip any
1019
+ // remaining control markers from the buffer.
1020
+ ttsBuffer += reasoningFilter.flush();
1014
1021
  ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
1015
1022
  if (ttsBuffer.length > 0) {
1016
1023
  emitSafeChunk(ttsBuffer);
@@ -1393,7 +1393,6 @@ export class CallSetupFlow {
1393
1393
 
1394
1394
  try {
1395
1395
  const accessResult = await ndeps.notifyGuardianOfAccessRequest({
1396
- canonicalAssistantId: accessRequest.assistantId,
1397
1396
  sourceChannel: "phone",
1398
1397
  conversationExternalId: accessRequest.fromNumber,
1399
1398
  actorExternalId: accessRequest.fromNumber,
@@ -538,6 +538,17 @@ export class MediaStreamSttSession {
538
538
  switch (event.type) {
539
539
  case "partial":
540
540
  return;
541
+ case "finalized":
542
+ return;
543
+ case "turn-start":
544
+ case "eager-turn-end":
545
+ case "turn-resumed":
546
+ case "turn-end":
547
+ // The local VAD and the provider's boundary finals own turn
548
+ // taking. Listed case-by-case rather than under `default` so the
549
+ // exhaustiveness check flags this site when turn detection is
550
+ // wired in.
551
+ return;
541
552
  case "final": {
542
553
  const durationMs = Math.round(this.utteranceAudioMs);
543
554
  this.utteranceAudioMs = 0;
@@ -570,6 +581,10 @@ export class MediaStreamSttSession {
570
581
  this.enterBatchMode();
571
582
  }
572
583
  return;
584
+ default: {
585
+ const _exhaustive: never = event;
586
+ return;
587
+ }
573
588
  }
574
589
  }
575
590
 
@@ -55,6 +55,7 @@ import {
55
55
  stripInternalSpeechMarkers,
56
56
  } from "./voice-control-protocol.js";
57
57
  import {
58
+ createFrontDoorStreamGate,
58
59
  escalatedContinuationRule,
59
60
  ESCALATION_CONTINUATION_CONTENT,
60
61
  frontDoorCapabilityDigest,
@@ -407,18 +408,6 @@ export interface VoiceTurnHandle {
407
408
  * on a live phone call" framing (the session system prompt already
408
409
  * provides assistant identity) and guardian context (injected separately).
409
410
  */
410
- /**
411
- * Steering shared by every voice channel. A sign-in flow opens a browser
412
- * window mid-call that the caller may be unable to see or complete, whether it
413
- * is reached through a ui-surface tool or through shell and CLI tools (e.g.
414
- * `assistant oauth connect`). Tell the model to speak the limitation and defer
415
- * the flow to text chat instead.
416
- *
417
- * This outlives the ui-surface restriction it was written alongside: a
418
- * live-voice call can now show surfaces, but a browser window handing control
419
- * to a third party mid-call is a different problem, and one a minimized room
420
- * does not solve.
421
- */
422
411
  /**
423
412
  * How long a live-voice call waits on an approval before deciding it itself.
424
413
  *
@@ -429,8 +418,23 @@ export interface VoiceTurnHandle {
429
418
  */
430
419
  const VOICE_APPROVAL_TIMEOUT_MS = 45_000;
431
420
 
432
- export const VOICE_NO_SETUP_FLOWS_RULE =
433
- "Never start account connections, OAuth or sign-in flows, or any other action that opens a browser window or needs the user's screen during this call — not even through shell or CLI tools. If the task needs one, say so briefly and offer to finish it in text chat after the call.";
421
+ /**
422
+ * Telephony-only steering. A sign-in flow opens a browser window on a screen
423
+ * the caller does not have in front of them, whether it is reached through a
424
+ * ui-surface tool or through shell and CLI tools (e.g. `assistant oauth
425
+ * connect`). Tell the model to speak the limitation and defer the flow to text
426
+ * chat instead.
427
+ *
428
+ * Scoped to the phone because the screen is what decides it. A phone call has
429
+ * no screen, so the `open_url` signal a CLI tool can reach lands somewhere the
430
+ * caller will never see, and that signal bus carries no capability or
431
+ * conversation context, so this rule is the only thing standing in front of it
432
+ * here. A live-voice call is the opposite case: the user is holding the screen,
433
+ * and the room minimizes itself to hand it back (see
434
+ * LIVE_VOICE_SETUP_FLOW_TEACHING).
435
+ */
436
+ const PHONE_NO_SETUP_FLOWS_RULE =
437
+ "Never start account connections, OAuth or sign-in flows, or any other action that opens a browser window or needs the user's screen during this call, not even through shell or CLI tools. If the task needs one, say so briefly and offer to finish it in text chat after the call.";
434
438
 
435
439
  /**
436
440
  * The pre-speech tail of the speak-the-caller's-language rule. A monolingual
@@ -551,7 +555,7 @@ function buildVoiceCallControlPrompt(opts: {
551
555
  '10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian. For tool permission requests, use [ASK_GUARDIAN_APPROVAL: {"question":"...","toolName":"...","input":{...}}].',
552
556
  `11. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links) or emojis in your spoken responses. Write plain conversational text only. Protocol markers like ${opts.isCallerGuardian ? "[END_CALL]" : "[ASK_GUARDIAN: ...] and [END_CALL]"} are not spoken text and should still be used normally.`,
553
557
  `12. Speak the caller's language: reply in the language of the caller's most recent actual speech, and follow them if they switch languages mid-call. Synthetic user turns (parenthetical markers like the call-connected and verification-completed notices) are not caller speech and never set the language. Before the caller has spoken, such as on the opening greeting turn, ${preSpeechLanguageRuleFragment(config.services.stt.language, config.services.stt.provider)}.`,
554
- `13. ${VOICE_NO_SETUP_FLOWS_RULE}`,
558
+ `13. ${PHONE_NO_SETUP_FLOWS_RULE}`,
555
559
  );
556
560
 
557
561
  // Triage-and-escalate routing rules. The front-door leg decides and may
@@ -1447,6 +1451,34 @@ export async function startVoiceTurn(
1447
1451
  // Set by the handle's discard(): the whole leg must leave no trace.
1448
1452
  let discarded = false;
1449
1453
 
1454
+ // Verdict-first gate on the hub broadcast. A front-door leg's raw stream
1455
+ // carries its routing verdict, so hub subscribers (web, passive devices)
1456
+ // read it through the gate and see only the text the caller heard. Every
1457
+ // other leg, including the escalated continuation that answers for real,
1458
+ // broadcasts its deltas untouched.
1459
+ const frontDoorStreamGate =
1460
+ opts.routingLeg === "front-door"
1461
+ ? createFrontDoorStreamGate(opts.unifiedVerdict === true)
1462
+ : null;
1463
+
1464
+ /**
1465
+ * Broadcast one agent-loop event to hub subscribers, holding a front-door
1466
+ * leg's control-plane text back at the boundary rather than emitting it and
1467
+ * repairing the transcript afterwards. Text released by the gate travels as
1468
+ * an ordinary delta on the leg's own reserved row, so a client that renders
1469
+ * the stream lands on the same text the teardown hygiene pass persists.
1470
+ */
1471
+ const broadcastLegEvent = (msg: AssistantEvent): void => {
1472
+ if (frontDoorStreamGate === null || msg.type !== "assistant_text_delta") {
1473
+ broadcastMessage(msg);
1474
+ return;
1475
+ }
1476
+ const released = frontDoorStreamGate.push(msg.text);
1477
+ if (released.length > 0) {
1478
+ broadcastMessage({ ...msg, text: released });
1479
+ }
1480
+ };
1481
+
1450
1482
  /**
1451
1483
  * Teardown transcript hygiene. Runs after the agent loop has fully
1452
1484
  * settled — including the stranded-content fold that finalizes an aborted
@@ -1474,6 +1506,12 @@ export async function startVoiceTurn(
1474
1506
  * the escalated leg — blocked on this turn's teardown — snapshots it, so
1475
1507
  * the quality model never sees the marker text either. Best-effort: a
1476
1508
  * hiccup here must not escalate into a turn-level failure.
1509
+ *
1510
+ * This pass owns the PERSISTED row, which the agent loop writes from the
1511
+ * model's raw output regardless of what was broadcast. The live hub stream
1512
+ * is gated separately by `broadcastLegEvent`, so the refetch this pass
1513
+ * publishes confirms text a subscriber already holds instead of correcting
1514
+ * it.
1477
1515
  */
1478
1516
  const finalizeVoiceLegTranscript = async (): Promise<void> => {
1479
1517
  if (reservedAssistantRowId == null) {
@@ -1617,7 +1655,24 @@ export async function startVoiceTurn(
1617
1655
  } else if (msg.type === "conversation_error") {
1618
1656
  lastError = msg.userMessage;
1619
1657
  }
1620
- broadcastMessage(msg);
1658
+ if (frontDoorStreamGate !== null && msg.type === "message_complete") {
1659
+ // A leg that completed mid-bridge (a holding phrase with no
1660
+ // sentence terminator) still hands off and speaks what arrived,
1661
+ // so release it ahead of the completion frame. A cancelled leg
1662
+ // never hands off, and correspondingly never flushes.
1663
+ const trailing = frontDoorStreamGate.finish();
1664
+ if (trailing.length > 0) {
1665
+ broadcastMessage({
1666
+ type: "assistant_text_delta",
1667
+ text: trailing,
1668
+ ...(reservedAssistantRowId !== null
1669
+ ? { messageId: reservedAssistantRowId }
1670
+ : {}),
1671
+ conversationId: opts.conversationId,
1672
+ });
1673
+ }
1674
+ }
1675
+ broadcastLegEvent(msg);
1621
1676
 
1622
1677
  // Forward voice-relevant events to the real-time event sink
1623
1678
  if (msg.type === "assistant_text_delta") {
@@ -221,6 +221,108 @@ export function classifyFrontDoorLeading(
221
221
  return "answer";
222
222
  }
223
223
 
224
+ /**
225
+ * Verdict-first gate over a front-door leg's delta stream: given the leg's
226
+ * raw deltas in order, it releases only the text the caller actually hears.
227
+ * A front-door leg's raw stream is a control plane, not assistant speech,
228
+ * until its leading tokens classify, so anything downstream of the model
229
+ * that shows text to a person reads the stream through this gate.
230
+ *
231
+ * `push` returns the text released by that delta (empty while the gate is
232
+ * holding). `finish` is called when the leg completes normally and releases
233
+ * a bridge that stopped short of a sentence terminator. A leg that is
234
+ * cancelled instead spoke nothing past what `push` already released, so it
235
+ * simply never calls `finish`.
236
+ */
237
+ export interface FrontDoorStreamGate {
238
+ push(deltaText: string): string;
239
+ finish(): string;
240
+ }
241
+
242
+ /**
243
+ * Build a {@link FrontDoorStreamGate}. `holdEnabled` mirrors
244
+ * {@link classifyFrontDoorLeading}: true only for speculative (unified
245
+ * front-door) legs, whose decision rule is the only one that teaches the
246
+ * hold token.
247
+ *
248
+ * The three verdicts release differently, matching what the caller hears:
249
+ *
250
+ * - `hold`: the leg is discarded and its row deleted, so nothing is ever
251
+ * released.
252
+ * - `escalate`: the only spoken text is the capped holding phrase, released
253
+ * in one piece once the bridge is complete (exactly what
254
+ * {@link capEscalationBridge} yields, so the released text, the audio, and
255
+ * the persisted row agree). The verdict token and anything streamed past
256
+ * the cap are dropped. A bridge shorter than
257
+ * {@link MIN_SPOKEN_BRIDGE_CHARS} releases nothing at all: the session
258
+ * substitutes an audio-only canned fallback for it, so there is no
259
+ * displayed text for the gate to agree with.
260
+ * - `answer`: the leg's output IS the reply, so every delta passes through,
261
+ * including the leading text held back while the verdict was pending.
262
+ */
263
+ export function createFrontDoorStreamGate(
264
+ holdEnabled: boolean,
265
+ ): FrontDoorStreamGate {
266
+ let raw = "";
267
+ let stage: "deciding" | "answer" | "bridging" | "done" = "deciding";
268
+ let bridgeRaw = "";
269
+ let releasedChars = 0;
270
+
271
+ const releaseBridge = (): string => {
272
+ stage = "done";
273
+ const capped = capEscalationBridge(bridgeRaw);
274
+ // Below the spoken threshold the session throws the model's bridge away
275
+ // and plays a canned fallback that is audio-only, deleting the row rather
276
+ // than persisting a phrase the model never really produced (see
277
+ // `usesFallbackBridge` in `live-voice-session.ts`). Releasing the capped
278
+ // text here would put words on a subscriber's screen that the caller never
279
+ // heard, which is the same spoken/displayed divergence this gate exists to
280
+ // prevent.
281
+ return capped.length < MIN_SPOKEN_BRIDGE_CHARS ? "" : capped;
282
+ };
283
+
284
+ return {
285
+ push(deltaText: string): string {
286
+ raw += deltaText;
287
+ if (stage === "done") {
288
+ return "";
289
+ }
290
+ if (stage === "bridging") {
291
+ bridgeRaw += deltaText;
292
+ return isEscalationBridgeComplete(bridgeRaw) ? releaseBridge() : "";
293
+ }
294
+ if (stage === "deciding") {
295
+ const verdict = classifyFrontDoorLeading(raw.trimStart(), holdEnabled);
296
+ if (verdict === "pending") {
297
+ return "";
298
+ }
299
+ if (verdict === "hold") {
300
+ stage = "done";
301
+ return "";
302
+ }
303
+ if (verdict === "escalate") {
304
+ stage = "bridging";
305
+ bridgeRaw = raw.trimStart().slice(ESCALATE_VERDICT_TOKEN.length);
306
+ return isEscalationBridgeComplete(bridgeRaw) ? releaseBridge() : "";
307
+ }
308
+ stage = "answer";
309
+ }
310
+ // Answer stage: release everything not yet released, which on the
311
+ // transition includes the leading text the pending verdict held.
312
+ const chunk = raw.slice(releasedChars);
313
+ releasedChars = raw.length;
314
+ return chunk;
315
+ },
316
+ finish(): string {
317
+ if (stage === "bridging") {
318
+ return releaseBridge();
319
+ }
320
+ stage = "done";
321
+ return "";
322
+ },
323
+ };
324
+ }
325
+
224
326
  /**
225
327
  * The escalated leg runs as its own voice turn. Rather than re-persist the
226
328
  * caller's utterance (it is already in history from the front-door leg), the
@@ -273,8 +375,8 @@ export function frontDoorDecisionRule(opts?: {
273
375
  // Hold requires positive evidence of an unfinished sentence, never
274
376
  // mere uncertainty, because the two mistakes cost differently. A
275
377
  // false hold is silent: the verdict, the extension window, and the
276
- // replay dispatch all elapse with the thinking frame and ack
277
- // deferred until commit, roughly tripling felt latency. A false
378
+ // replay dispatch all elapse before the turn commits, roughly
379
+ // tripling felt latency. A false
278
380
  // release only answers a beat early, which barge-in absorbs.
279
381
  `- If the caller's words are visibly unfinished (a trailing conjunction, a dangling clause, a list still being dictated) output ONLY ${HOLD_VERDICT_TOKEN} and stop, no other text. Judge the words themselves: a complete question or statement means they are done, even when it is short or leans on earlier context ("What do you think?", "Why?", "And then?"). Callers may speak any language: those examples are English exemplars only, and completeness is judged by the grammar of the language being spoken. In verb-final languages such as Hindi, Japanese, or Korean the sentence-final verb usually marks completion, so a missing final verb is the unfinished signal, not a missing conjunction. Never hold merely because more could follow.`,
280
382
  ]