@vellumai/assistant 0.11.5 → 0.11.6-dev.202608252309.48526e7

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 (351) hide show
  1. package/AGENTS.md +5 -1
  2. package/ARCHITECTURE.md +1 -1
  3. package/docs/flux-turn-detection-spike.md +8 -7
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/contact-read-contracts.test.ts +32 -0
  9. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +18 -0
  10. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +0 -5
  11. package/node_modules/@vellumai/gateway-client/src/channel-permission-contract.ts +3 -1
  12. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +77 -0
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  14. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  15. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +129 -61
  16. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +11 -0
  17. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  18. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  19. package/openapi.yaml +495 -21
  20. package/package.json +1 -1
  21. package/scripts/sync-web-search-catalog.ts +6 -0
  22. package/src/__tests__/app-pin-store.test.ts +149 -0
  23. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  24. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  25. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  26. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  27. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  28. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  29. package/src/__tests__/compactor-call-site-logging.test.ts +61 -0
  30. package/src/__tests__/config-loader-backfill.test.ts +69 -0
  31. package/src/__tests__/config-schema.test.ts +0 -1
  32. package/src/__tests__/conversation-agent-loop.test.ts +238 -0
  33. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  34. package/src/__tests__/conversation-error.test.ts +61 -0
  35. package/src/__tests__/conversation-runtime-assembly.test.ts +23 -0
  36. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  37. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  38. package/src/__tests__/conversation-slash.test.ts +9 -0
  39. package/src/__tests__/conversation-title-service.test.ts +97 -0
  40. package/src/__tests__/credential-routes.test.ts +17 -1
  41. package/src/__tests__/default-profile-catalog-fallback.test.ts +289 -0
  42. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  43. package/src/__tests__/fallback-breaker.test.ts +1335 -0
  44. package/src/__tests__/gemini-provider.test.ts +138 -0
  45. package/src/__tests__/history-repair.test.ts +105 -3
  46. package/src/__tests__/identity-routes.test.ts +1 -0
  47. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  48. package/src/__tests__/llm-resolver.test.ts +20 -0
  49. package/src/__tests__/llm-schema-fallback-profile.test.ts +359 -0
  50. package/src/__tests__/managed-fallback-dispatch.test.ts +430 -0
  51. package/src/__tests__/managed-profile-guard.test.ts +28 -0
  52. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  53. package/src/__tests__/notification-decision-fallback.test.ts +106 -0
  54. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  55. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  56. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  57. package/src/__tests__/openai-provider.test.ts +18 -0
  58. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  59. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  60. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  61. package/src/__tests__/pricing.test.ts +2 -2
  62. package/src/__tests__/provider-usage-tracking.test.ts +56 -0
  63. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  64. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  65. package/src/__tests__/retry-fallback-escalation.test.ts +1093 -0
  66. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  67. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  68. package/src/__tests__/settings-routes.test.ts +85 -3
  69. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +108 -2
  70. package/src/__tests__/voice-config-update.test.ts +43 -0
  71. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  72. package/src/__tests__/workspace-migration-147-rename-colliding-backup-profile-names.test.ts +480 -0
  73. package/src/__tests__/workspace-migration-148-strip-unsupported-fallback-profiles.test.ts +167 -0
  74. package/src/__tests__/workspace-migration-149-repoint-backup-profile-selections.test.ts +144 -0
  75. package/src/agent/history-repair/history-repair.ts +45 -14
  76. package/src/agent/loop.ts +35 -3
  77. package/src/api/constants/profile-config-validation.ts +60 -0
  78. package/src/api/events/tool-result.ts +6 -1
  79. package/src/api/events/watch-retro-completed.ts +52 -0
  80. package/src/api/index.ts +11 -0
  81. package/src/apps/app-pin-reconciler.ts +92 -0
  82. package/src/apps/app-pin-store.ts +125 -0
  83. package/src/backup/__tests__/paths.test.ts +6 -2
  84. package/src/backup/paths.ts +6 -4
  85. package/src/channels/gateway-channel-socket-health.ts +32 -0
  86. package/src/channels/gateway-discord-admission.ts +32 -0
  87. package/src/channels/types.ts +20 -0
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  89. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  90. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  91. package/src/cli/commands/backup.help.ts +10 -13
  92. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  93. package/src/cli/commands/channels/index.ts +45 -31
  94. package/src/cli/commands/credentials.help.ts +1 -1
  95. package/src/cli/commands/inference-profiles.ts +56 -3
  96. package/src/cli/commands/inference-providers.ts +28 -2
  97. package/src/cli/commands/memory/memory-retrospective.ts +1 -0
  98. package/src/cli/commands/oauth/index.help.ts +7 -1
  99. package/src/cli/commands/oauth/request.test.ts +290 -0
  100. package/src/cli/commands/oauth/request.ts +57 -41
  101. package/src/cli/commands/stt.help.ts +2 -2
  102. package/src/cli/lib/bundled-marketplace.json +14 -1
  103. package/src/cli/lib/open-browser.test.ts +67 -0
  104. package/src/cli/lib/open-browser.ts +24 -5
  105. package/src/config/__tests__/backup-schema.test.ts +1 -1
  106. package/src/config/__tests__/default-profile-catalog.test.ts +77 -1
  107. package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +1 -1
  108. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  109. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  110. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  111. package/src/config/bundled-skills/settings/TOOLS.json +11 -5
  112. package/src/config/bundled-skills/settings/tools/open-system-settings.test.ts +85 -0
  113. package/src/config/bundled-skills/settings/tools/open-system-settings.ts +33 -8
  114. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +54 -3
  115. package/src/config/default-profile-catalog.ts +195 -10
  116. package/src/config/default-profile-names.ts +41 -0
  117. package/src/config/feature-flag-registry.json +17 -1
  118. package/src/config/inference-profile-validation.ts +11 -2
  119. package/src/config/llm-resolver.ts +1 -0
  120. package/src/config/loader.ts +3 -0
  121. package/src/config/managed-speech-defaults.ts +53 -8
  122. package/src/config/profile-materialization.ts +29 -0
  123. package/src/config/sanitize-for-transfer.ts +16 -0
  124. package/src/config/schemas/__tests__/live-voice.test.ts +4 -3
  125. package/src/config/schemas/backup.ts +1 -1
  126. package/src/config/schemas/live-voice.ts +5 -3
  127. package/src/config/schemas/llm.ts +263 -10
  128. package/src/config/schemas/services.ts +30 -0
  129. package/src/config/schemas/stt.ts +29 -5
  130. package/src/config/seed-inference-profiles.ts +32 -3
  131. package/src/context/compactor.ts +74 -1
  132. package/src/context/outbound-sanitize.ts +6 -0
  133. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  134. package/src/daemon/conversation-agent-loop-handlers.ts +15 -0
  135. package/src/daemon/conversation-agent-loop.ts +40 -3
  136. package/src/daemon/conversation-error.ts +49 -2
  137. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  138. package/src/daemon/conversation-slash.ts +8 -17
  139. package/src/daemon/daemon-control.ts +1 -0
  140. package/src/daemon/disk-pressure-policy.ts +7 -1
  141. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  142. package/src/daemon/handlers/config-ingress.ts +115 -5
  143. package/src/daemon/lifecycle.ts +26 -0
  144. package/src/daemon/message-types/web-activity.ts +3 -2
  145. package/src/daemon/trust-context.ts +0 -37
  146. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  147. package/src/inbound/platform-callback-registration.ts +28 -2
  148. package/src/inbound/public-ingress-urls.ts +12 -0
  149. package/src/inbound/tunnel-probe.ts +261 -0
  150. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  151. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +13 -4
  152. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +285 -21
  153. package/src/live-voice/__tests__/live-voice-session-preflight.test.ts +148 -2
  154. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  155. package/src/live-voice/__tests__/live-voice-stt.test.ts +3 -0
  156. package/src/live-voice/__tests__/live-voice-text-turn.test.ts +354 -0
  157. package/src/live-voice/__tests__/protocol.test.ts +169 -0
  158. package/src/live-voice/live-voice-connection.ts +46 -8
  159. package/src/live-voice/live-voice-manager.ts +25 -0
  160. package/src/live-voice/live-voice-session-manager.ts +357 -4
  161. package/src/live-voice/live-voice-session.ts +240 -8
  162. package/src/live-voice/protocol.ts +157 -1
  163. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  164. package/src/messaging/providers/channel-transport.ts +64 -47
  165. package/src/messaging/providers/discord/send.test.ts +46 -1
  166. package/src/messaging/providers/discord/send.ts +51 -0
  167. package/src/messaging/providers/discord/transport.ts +26 -3
  168. package/src/messaging/providers/index.ts +22 -47
  169. package/src/messaging/providers/slack/send.test.ts +83 -26
  170. package/src/messaging/providers/slack/send.ts +120 -51
  171. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  172. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  173. package/src/messaging/providers/slack/transport.ts +24 -22
  174. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  175. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  176. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  177. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  178. package/src/notifications/adapters/slack.ts +6 -1
  179. package/src/notifications/adapters/telegram.ts +48 -1
  180. package/src/notifications/approval-card-data.ts +94 -1
  181. package/src/notifications/assistant-reply-producer.ts +7 -7
  182. package/src/notifications/broadcaster.ts +3 -9
  183. package/src/notifications/conversation-seed-composer.ts +7 -2
  184. package/src/notifications/decision-engine.ts +55 -0
  185. package/src/notifications/guardian-question-mode.ts +57 -0
  186. package/src/oauth/byo-connection.test.ts +63 -0
  187. package/src/oauth/byo-connection.ts +16 -15
  188. package/src/oauth/connection.test.ts +111 -0
  189. package/src/oauth/connection.ts +142 -1
  190. package/src/oauth/platform-connection.test.ts +34 -0
  191. package/src/oauth/platform-connection.ts +28 -5
  192. package/src/oauth/seed-providers.ts +280 -1
  193. package/src/permissions/types.ts +3 -1
  194. package/src/persistence/conversation-crud.ts +46 -0
  195. package/src/persistence/conversation-title-service.ts +43 -29
  196. package/src/persistence/conversation-types.ts +11 -9
  197. package/src/persistence/db-async-query.ts +2 -1
  198. package/src/persistence/db-maintenance.ts +15 -0
  199. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  200. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  201. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  202. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  203. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  204. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  205. package/src/persistence/schema/index.ts +1 -0
  206. package/src/persistence/schema/infrastructure.ts +17 -0
  207. package/src/persistence/schema/watch.ts +29 -0
  208. package/src/persistence/steps.ts +6 -0
  209. package/src/plugin-api/model-profiles.test.ts +2 -2
  210. package/src/plugin-api/model-profiles.ts +7 -7
  211. package/src/plugins/defaults/compaction/window-manager.ts +4 -0
  212. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +125 -33
  213. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +21 -31
  214. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +13 -9
  215. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +31 -4
  216. package/src/plugins/defaults/memory/memory-retrospective-constants.ts +0 -11
  217. package/src/plugins/defaults/memory/memory-retrospective-job.ts +140 -107
  218. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +13 -13
  219. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +2 -2
  220. package/src/plugins/mtime-cache.ts +11 -0
  221. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  222. package/src/providers/connection-resolution.ts +25 -18
  223. package/src/providers/content-blocks.ts +9 -0
  224. package/src/providers/fallback-breaker.ts +389 -0
  225. package/src/providers/fetch-provider-catalog.ts +19 -0
  226. package/src/providers/gemini/client.ts +13 -5
  227. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  228. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  229. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  230. package/src/providers/inference/adapter-factory.ts +225 -12
  231. package/src/providers/inference/credential-rotation.ts +61 -0
  232. package/src/providers/inference/endpoint-probe.ts +115 -0
  233. package/src/providers/inference/profile-probe.ts +256 -0
  234. package/src/providers/model-catalog.ts +170 -125
  235. package/src/providers/openai/__tests__/api-error-normalization.test.ts +39 -1
  236. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  237. package/src/providers/openai/__tests__/chat-template-flatten.test.ts +198 -0
  238. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  239. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  240. package/src/providers/openai/api-error-normalization.ts +29 -2
  241. package/src/providers/openai/chat-completions-provider.ts +155 -29
  242. package/src/providers/openai/responses-provider.ts +5 -2
  243. package/src/providers/openrouter/client.ts +0 -1
  244. package/src/providers/provider-resolvability.ts +20 -0
  245. package/src/providers/provider-send-message.ts +11 -0
  246. package/src/providers/retry.ts +944 -68
  247. package/src/providers/search-provider-catalog.ts +20 -0
  248. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +42 -0
  249. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +136 -0
  250. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +120 -0
  251. package/src/providers/speech-to-text/__tests__/resolve.test.ts +97 -12
  252. package/src/providers/speech-to-text/deepgram-flux-frames.ts +78 -3
  253. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +132 -4
  254. package/src/providers/speech-to-text/provider-catalog.ts +159 -3
  255. package/src/providers/speech-to-text/resolve.ts +66 -15
  256. package/src/providers/speech-to-text/vellum-managed-flux-realtime.ts +296 -0
  257. package/src/providers/types.ts +31 -0
  258. package/src/providers/usage-tracking.ts +12 -1
  259. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  260. package/src/runtime/AGENTS.md +1 -0
  261. package/src/runtime/__tests__/agent-wake.test.ts +105 -3
  262. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  263. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  264. package/src/runtime/agent-wake.ts +44 -5
  265. package/src/runtime/channel-readiness-service.ts +214 -14
  266. package/src/runtime/channel-readiness-types.ts +49 -2
  267. package/src/runtime/channel-reply-delivery.ts +2 -2
  268. package/src/runtime/desktop-presence.ts +24 -21
  269. package/src/runtime/host-observe.ts +246 -0
  270. package/src/runtime/http-server.ts +181 -1
  271. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  272. package/src/runtime/migrations/staged-import-path.ts +116 -0
  273. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  274. package/src/runtime/routes/__tests__/backup-routes.test.ts +9 -5
  275. package/src/runtime/routes/__tests__/contact-routes.test.ts +7 -0
  276. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +176 -0
  277. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +155 -0
  278. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  279. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  280. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  281. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  282. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  283. package/src/runtime/routes/app-management-routes.ts +140 -29
  284. package/src/runtime/routes/backup-routes.ts +2 -2
  285. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  286. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  287. package/src/runtime/routes/contact-routes.ts +20 -15
  288. package/src/runtime/routes/conversation-query-routes.ts +108 -10
  289. package/src/runtime/routes/conversation-routes.ts +2 -2
  290. package/src/runtime/routes/credential-routes.ts +2 -1
  291. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  292. package/src/runtime/routes/host-cu-routes.ts +18 -0
  293. package/src/runtime/routes/identity-routes.ts +2 -0
  294. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  295. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  296. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  297. package/src/runtime/routes/index.ts +2 -0
  298. package/src/runtime/routes/inference-profile-session-handler.ts +2 -2
  299. package/src/runtime/routes/inference-profiles-routes.ts +244 -38
  300. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  301. package/src/runtime/routes/inference-send-routes.ts +2 -2
  302. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  303. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  304. package/src/runtime/routes/live-voice-routes.ts +34 -0
  305. package/src/runtime/routes/llm-call-sites-routes.ts +2 -2
  306. package/src/runtime/routes/migration-routes.ts +218 -10
  307. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  308. package/src/runtime/routes/plugins-routes.ts +12 -28
  309. package/src/runtime/routes/question-routes.ts +6 -0
  310. package/src/runtime/routes/secret-routes.ts +7 -27
  311. package/src/runtime/routes/settings-routes.ts +9 -6
  312. package/src/runtime/routes/stt-routes.ts +6 -1
  313. package/src/runtime/routes/wake-conversation-routes.ts +8 -1
  314. package/src/runtime/routes/watch-routes.ts +807 -0
  315. package/src/runtime/slack-reply-session.test.ts +230 -121
  316. package/src/runtime/slack-reply-session.ts +113 -81
  317. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  318. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  319. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  320. package/src/security/untrusted-content.ts +28 -9
  321. package/src/stt/daemon-batch-transcriber.ts +4 -1
  322. package/src/stt/types.ts +33 -3
  323. package/src/telemetry/__tests__/live-voice-funnel.test.ts +142 -0
  324. package/src/telemetry/live-voice-funnel.ts +85 -8
  325. package/src/tools/credentials/store.ts +18 -6
  326. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  327. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  328. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  329. package/src/tools/network/firecrawl-compat.ts +90 -0
  330. package/src/tools/network/web-fetch.ts +142 -62
  331. package/src/tools/network/web-search.ts +141 -55
  332. package/src/tools/types.ts +2 -1
  333. package/src/tools/workflows/manage-workflows.ts +2 -2
  334. package/src/tools/workflows/run-workflow.test.ts +2 -1
  335. package/src/util/errors.ts +1 -0
  336. package/src/util/oauth-request-body.test.ts +74 -0
  337. package/src/util/oauth-request-body.ts +60 -0
  338. package/src/util/provider-error-patterns.ts +20 -0
  339. package/src/util/worker-process.ts +1 -0
  340. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  341. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  342. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  343. package/src/watch/watch-retro.ts +480 -0
  344. package/src/watch/watch-session-manager.ts +575 -0
  345. package/src/watch/watch-timeline.ts +848 -0
  346. package/src/workspace/migrations/147-rename-colliding-backup-profile-names.ts +360 -0
  347. package/src/workspace/migrations/148-strip-unsupported-fallback-profiles.ts +117 -0
  348. package/src/workspace/migrations/149-repoint-backup-profile-selections.ts +204 -0
  349. package/src/workspace/migrations/150-stt-flux-provider-to-model-family.ts +139 -0
  350. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +160 -0
  351. package/src/workspace/migrations/registry.ts +8 -0
package/AGENTS.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  For error handling conventions (throw vs result objects vs null), see [docs/error-handling.md](docs/error-handling.md).
4
4
 
5
- Subdirectory-scoped rules live in local AGENTS.md files: `src/cli/`, `src/runtime/`, `src/approvals/`, `src/notifications/`, `src/permissions/`, `src/plugins/`, `src/workspace/migrations/`.
5
+ Subdirectory-scoped rules live in local AGENTS.md files throughout `src/`, some
6
+ of them nested several levels down. Read the one nearest the code you are
7
+ changing plus any above it, and follow the nearest where two disagree.
8
+ `git ls-files ':(top)assistant/src/**/AGENTS.md'` lists them from anywhere in
9
+ the tree.
6
10
 
7
11
  ## Adding new environment variables
8
12
 
package/ARCHITECTURE.md CHANGED
@@ -84,7 +84,7 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
84
84
 
85
85
  **Identity lifecycle:**
86
86
 
87
- 1. **Bootstrap (loopback-only, macOS)** — On first launch, the macOS client calls `POST /v1/guardian/init` with `{ platform, deviceId }`. The endpoint is loopback-only and mints a JWT access token + refresh token pair. Returns `{ guardianPrincipalId, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter, isNew }`. The CLI obtains its bearer token during `hatch` and does not perform a separate bootstrap step.
87
+ 1. **Bootstrap (loopback-only, desktop)**: On first launch, the desktop client calls `POST /v1/guardian/init` with `{ platform, deviceId }`. The endpoint is loopback-only and mints a JWT access token + refresh token pair. Returns `{ guardianPrincipalId, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter, isNew }`. The CLI obtains its bearer token during `hatch` and does not perform a separate bootstrap step.
88
88
 
89
89
  2. **Refresh** — `POST /v1/guardian/refresh` accepts `{ refreshToken }` and returns a new access/refresh token pair. Single-use rotation with replay detection and family-based revocation.
90
90
 
@@ -59,7 +59,7 @@ If it does not, set the Deepgram key the ordinary way (client Settings, Speech-t
59
59
 
60
60
  ## 2. Enable Flux
61
61
 
62
- > **Setting `services.stt.provider` to `deepgram-flux` turns off batch transcription for the whole workspace, not just live voice.** `services.stt.provider` is the single source of truth for every STT route, and Flux is the only provider in the catalog with no `daemon-batch` boundary. For as long as it is set, these all stop working: voice-message and inbound-attachment transcription, the `transcribe` skill, the `media-processing` skill's audio segments, `POST /v1/stt/transcribe`, and phone-call transcription. Each reports a message naming Flux as the cause rather than failing silently, but they do not fall back to `deepgram` on their own. **Set `services.stt.provider` back to `deepgram` when the spike is over**, and do not run the spike on an assistant that is also taking calls or handling voice messages.
62
+ > **Putting a provider on the `flux` model family turns off batch transcription for the whole workspace, not just live voice.** `services.stt.provider` plus `services.stt.providers.<provider>.model` is the single source of truth for every STT route, and Flux is the only family in the catalog with no `daemon-batch` boundary. For as long as it is set, these all stop working: voice-message and inbound-attachment transcription, the `transcribe` skill, the `media-processing` skill's audio segments, `POST /v1/stt/transcribe`, and phone-call transcription. Each reports a message naming Flux as the cause rather than failing silently, but they do not fall back to `deepgram` on their own. **Set `services.stt.providers.deepgram.model` back to `nova-3` when the spike is over**, and do not run the spike on an assistant that is also taking calls or handling voice messages.
63
63
 
64
64
  Two keys in `config.json`, which lives at `$VELLUM_WORKSPACE_DIR/config.json` (default `~/.vellum/workspace/config.json`):
65
65
 
@@ -67,7 +67,8 @@ Two keys in `config.json`, which lives at `$VELLUM_WORKSPACE_DIR/config.json` (d
67
67
  {
68
68
  "services": {
69
69
  "stt": {
70
- "provider": "deepgram-flux"
70
+ "provider": "deepgram",
71
+ "providers": { "deepgram": { "model": "flux" } }
71
72
  }
72
73
  },
73
74
  "liveVoice": {
@@ -93,12 +94,12 @@ The rest of `liveVoice.flux` is optional and defaulted (`config/schemas/live-voi
93
94
 
94
95
  ## 3. Run the A/B
95
96
 
96
- **Flip `turnEnd.enabled` between runs and leave `services.stt.provider` on `deepgram-flux` in both arms.** That holds the STT engine, the model, the socket, and the transcriber lifecycle constant, so the only thing that changes is which signal commits the turn.
97
+ **Flip `turnEnd.enabled` between runs and leave the `flux` model family selected in both arms.** That holds the STT engine, the model, the socket, and the transcriber lifecycle constant, so the only thing that changes is which signal commits the turn.
97
98
 
98
- - **Arm A (control):** `provider: "deepgram-flux"`, `turnEnd.enabled: false`. Flux transcribes; the four turn-detection events are ignored; the local silence boundary and the front-door hold path run exactly as they do today.
99
- - **Arm B (treatment):** `provider: "deepgram-flux"`, `turnEnd.enabled: true`.
99
+ - **Arm A (control):** `providers.deepgram.model: "flux"`, `turnEnd.enabled: false`. Flux transcribes; the four turn-detection events are ignored; the local silence boundary and the front-door hold path run exactly as they do today.
100
+ - **Arm B (treatment):** `providers.deepgram.model: "flux"`, `turnEnd.enabled: true`.
100
101
 
101
- **Do not A/B by switching the provider between `deepgram` and `deepgram-flux`.** That confounds two changes at once:
102
+ **Do not A/B by switching the model family between `nova-3` and `flux`.** That confounds two changes at once:
102
103
 
103
104
  1. It swaps the STT model. `deepgram` runs `nova-2` (`DEFAULT_MODEL`, `deepgram-realtime.ts`), so any transcript-quality or first-partial difference lands in your latency numbers.
104
105
  2. It swaps the transcriber lifecycle. `deepgram` implements `finalizeUtterance`, so the session adopts it as a persistent stream shared across the whole session (`sharedTranscriber`) and never tears it down between turns. Flux implements no `finalizeUtterance`, so every utterance owns its own `/v2/listen` socket and every release closes it. That per-turn socket churn is inside `roundTripMs` and `totalMs` on the Flux side and absent on the `deepgram` side, so a naive provider-swap A/B attributes it to turn detection. The next section is what it costs and why it cannot be removed.
@@ -230,7 +231,7 @@ Check these if you port the spike anywhere else, because a run that violates eit
230
231
 
231
232
  ## 8. Falling back
232
233
 
233
- Set `liveVoice.flux.turnEnd.enabled` back to `false` (or delete the key) and restart. That is the whole rollback: the latch goes down, the turn-detection events become no-ops, and the silence-boundary path runs unchanged. You can leave `services.stt.provider` on `deepgram-flux` or move it back to `deepgram`; either is a working configuration.
234
+ Set `liveVoice.flux.turnEnd.enabled` back to `false` (or delete the key) and restart. That is the whole rollback: the latch goes down, the turn-detection events become no-ops, and the silence-boundary path runs unchanged. You can leave the `flux` family selected or move back to `nova-3`; either is a working configuration.
234
235
 
235
236
  Runtime fallback needs no action. A Flux stream that never emits `turn-end` is caught by the fail-open deadline and the utterance replays onto the silence path, so an outage degrades to today's behavior rather than to a hung turn.
236
237
 
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  normalizeHttpPublicBaseUrl,
5
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash,
5
6
  normalizePublicBaseUrl,
7
+ parseRecordedAssistantId,
8
+ parseTunnelRecord,
9
+ trimmedNonEmptyString,
10
+ TUNNEL_PROVIDERS,
6
11
  velayHostForPlatformHost,
7
12
  } from "../ingress.js";
8
13
  import {
@@ -85,6 +90,35 @@ describe("normalizeHttpPublicBaseUrl", () => {
85
90
  });
86
91
  });
87
92
 
93
+ describe("normalizeHttpPublicBaseUrlWithoutTrailingSlash", () => {
94
+ test("drops the root path its sibling always emits", () => {
95
+ expect(
96
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test"),
97
+ ).toBe("https://x.test");
98
+ expect(
99
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test/"),
100
+ ).toBe("https://x.test");
101
+ expect(
102
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(" https://x.test/api/// "),
103
+ ).toBe("https://x.test/api");
104
+ });
105
+
106
+ test("rejects everything its sibling rejects", () => {
107
+ for (const value of [
108
+ "",
109
+ " ",
110
+ "notaurl",
111
+ "ftp://x.test",
112
+ "https://x.test?a=b",
113
+ 42,
114
+ ]) {
115
+ expect(
116
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(value),
117
+ ).toBeUndefined();
118
+ }
119
+ });
120
+ });
121
+
88
122
  describe("Twilio ingress helpers", () => {
89
123
  test("resolves public base URL with fallback", () => {
90
124
  expect(
@@ -123,3 +157,87 @@ describe("Twilio ingress helpers", () => {
123
157
  });
124
158
  });
125
159
  });
160
+
161
+ describe("parseTunnelRecord", () => {
162
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
163
+
164
+ test("accepts every provider in the registry", () => {
165
+ for (const provider of TUNNEL_PROVIDERS) {
166
+ expect(
167
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
168
+ ).toEqual({ provider, publicBaseUrl: TUNNEL_URL });
169
+ }
170
+ });
171
+
172
+ test("returns the URL in the shape its own validator produces", () => {
173
+ // Padding and trailing slashes are normalized away rather than handed
174
+ // back, so readers never re-normalize what they were given.
175
+ for (const publicBaseUrl of [
176
+ ` ${TUNNEL_URL} `,
177
+ `${TUNNEL_URL}/`,
178
+ `${TUNNEL_URL}///`,
179
+ ]) {
180
+ expect(
181
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
182
+ ).toEqual({ provider: "ngrok", publicBaseUrl: TUNNEL_URL });
183
+ }
184
+ });
185
+
186
+ test("rejects values that are not a record", () => {
187
+ for (const value of [undefined, null, "nonsense", 42, []]) {
188
+ expect(parseTunnelRecord(value)).toBeNull();
189
+ }
190
+ });
191
+
192
+ test("rejects a record missing either field", () => {
193
+ expect(parseTunnelRecord({ provider: "ngrok" })).toBeNull();
194
+ expect(parseTunnelRecord({ publicBaseUrl: TUNNEL_URL })).toBeNull();
195
+ });
196
+
197
+ test("rejects a provider outside the registry", () => {
198
+ // Readers render the provider into a `vellum tunnel --provider <name>`
199
+ // command, so a hand-edited config must not reach a terminal.
200
+ for (const provider of ["wireguard", "; rm -rf /", "", 42]) {
201
+ expect(
202
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
203
+ ).toBeNull();
204
+ }
205
+ });
206
+
207
+ test("rejects a URL that is not absolute HTTP(S)", () => {
208
+ for (const publicBaseUrl of [
209
+ "",
210
+ " ",
211
+ "not-a-url",
212
+ "example.ts.net",
213
+ "ftp://x.test",
214
+ "https://one.ngrok.app?token=x",
215
+ 42,
216
+ ]) {
217
+ expect(
218
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
219
+ ).toBeNull();
220
+ }
221
+ });
222
+ });
223
+
224
+ describe("trimmedNonEmptyString", () => {
225
+ test("trims and rejects blank or non-string values", () => {
226
+ expect(trimmedNonEmptyString(" assistant-1 ")).toBe("assistant-1");
227
+ for (const value of ["", " ", undefined, null, 42]) {
228
+ expect(trimmedNonEmptyString(value)).toBeUndefined();
229
+ }
230
+ });
231
+ });
232
+
233
+ describe("parseRecordedAssistantId", () => {
234
+ test("trims a recorded id", () => {
235
+ expect(parseRecordedAssistantId(" assistant-1 ")).toBe("assistant-1");
236
+ });
237
+
238
+ test("rejects blank and non-string values", () => {
239
+ for (const value of ["", " ", undefined, null, 42]) {
240
+ expect(parseRecordedAssistantId(value)).toBeNull();
241
+ }
242
+ });
243
+ });
@@ -48,3 +48,106 @@ export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
48
48
  return undefined;
49
49
  }
50
50
  }
51
+
52
+ /**
53
+ * The same validation as `normalizeHttpPublicBaseUrl`, minus the root path it
54
+ * always emits (`https://x` becomes `https://x/`). Callers that append a path
55
+ * or persist the result want this shape, so they neither join through a double
56
+ * slash nor re-strip what the normalizer just added.
57
+ */
58
+ export function normalizeHttpPublicBaseUrlWithoutTrailingSlash(
59
+ value: unknown,
60
+ ): string | undefined {
61
+ return normalizePublicBaseUrl(normalizeHttpPublicBaseUrl(value));
62
+ }
63
+
64
+ /**
65
+ * A trimmed string, or undefined when the value is not a non-blank string.
66
+ *
67
+ * Exported because the identity check in the daemon's tunnel probe compares an
68
+ * id recorded here against one an edge serves, and the two sides have to trim
69
+ * alike: a padded served id must read as a match, not as a different
70
+ * assistant.
71
+ */
72
+ export function trimmedNonEmptyString(value: unknown): string | undefined {
73
+ if (typeof value !== "string") {
74
+ return undefined;
75
+ }
76
+ const trimmed = value.trim();
77
+ return trimmed.length > 0 ? trimmed : undefined;
78
+ }
79
+
80
+ // Records `vellum tunnel` leaves in the workspace config's `ingress` section.
81
+ // The CLI writes them and the daemon reads them, and the two are separate
82
+ // build units that cannot import each other, so the key names, the provider
83
+ // allowlist, and the validation all live here rather than in a copy on each
84
+ // side.
85
+
86
+ /** Key under `ingress` holding the tunnel that most recently ran. */
87
+ export const INGRESS_LAST_TUNNEL_KEY = "lastTunnel";
88
+
89
+ /**
90
+ * Key under `ingress` holding a tunnel published for device pairing alone.
91
+ *
92
+ * A tailnet-only tunnel started while webhook integrations are configured
93
+ * cannot carry their callbacks, so it leaves `publicBaseUrl` (the callback
94
+ * base) as it is and records itself here. Readers that answer the pairing card
95
+ * prefer this address over `publicBaseUrl`; teardown drops the key.
96
+ */
97
+ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
98
+
99
+ /** Key under `ingress` holding the assistant id that tunnel fronted. */
100
+ export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
+
102
+ /**
103
+ * The local tunnel providers the CLI can start, in the order they are offered.
104
+ * `vellum tunnel`'s accepted `--provider` values and the persisted-record
105
+ * validation below both derive from it.
106
+ */
107
+ export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+
109
+ export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
+
111
+ /** A recorded tunnel, kept after teardown so UIs can name the command to restart. */
112
+ export interface TunnelRecord {
113
+ provider: TunnelProviderName;
114
+ publicBaseUrl: string;
115
+ }
116
+
117
+ function isTunnelProviderName(value: unknown): value is TunnelProviderName {
118
+ return (
119
+ typeof value === "string" &&
120
+ (TUNNEL_PROVIDERS as readonly string[]).includes(value)
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Parse an `ingress.lastTunnel` or `ingress.pairingTunnel` value, or null when
126
+ * it is absent or malformed.
127
+ *
128
+ * A hand-edited config must not hand callers an unusable address or a provider
129
+ * name no command accepts: the provider is checked against the allowlist
130
+ * because readers render it into a restart command, and the URL faces the same
131
+ * absolute HTTP(S) constraint as every other public base URL. The URL comes
132
+ * back in the shape the validator produced, so readers never re-normalize it.
133
+ */
134
+ export function parseTunnelRecord(value: unknown): TunnelRecord | null {
135
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
136
+ return null;
137
+ }
138
+ const { provider, publicBaseUrl } = value as Record<string, unknown>;
139
+ if (!isTunnelProviderName(provider)) {
140
+ return null;
141
+ }
142
+ const normalized =
143
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(publicBaseUrl);
144
+ if (normalized === undefined) {
145
+ return null;
146
+ }
147
+ return { provider, publicBaseUrl: normalized };
148
+ }
149
+
150
+ /** Parse an `ingress.assistantId` value, or null when it is absent or blank. */
151
+ export function parseRecordedAssistantId(value: unknown): string | null {
152
+ return trimmedNonEmptyString(value) ?? null;
153
+ }
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  normalizeHttpPublicBaseUrl,
5
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash,
5
6
  normalizePublicBaseUrl,
7
+ parseRecordedAssistantId,
8
+ parseTunnelRecord,
9
+ trimmedNonEmptyString,
10
+ TUNNEL_PROVIDERS,
6
11
  velayHostForPlatformHost,
7
12
  } from "../ingress.js";
8
13
  import {
@@ -85,6 +90,35 @@ describe("normalizeHttpPublicBaseUrl", () => {
85
90
  });
86
91
  });
87
92
 
93
+ describe("normalizeHttpPublicBaseUrlWithoutTrailingSlash", () => {
94
+ test("drops the root path its sibling always emits", () => {
95
+ expect(
96
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test"),
97
+ ).toBe("https://x.test");
98
+ expect(
99
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test/"),
100
+ ).toBe("https://x.test");
101
+ expect(
102
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(" https://x.test/api/// "),
103
+ ).toBe("https://x.test/api");
104
+ });
105
+
106
+ test("rejects everything its sibling rejects", () => {
107
+ for (const value of [
108
+ "",
109
+ " ",
110
+ "notaurl",
111
+ "ftp://x.test",
112
+ "https://x.test?a=b",
113
+ 42,
114
+ ]) {
115
+ expect(
116
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(value),
117
+ ).toBeUndefined();
118
+ }
119
+ });
120
+ });
121
+
88
122
  describe("Twilio ingress helpers", () => {
89
123
  test("resolves public base URL with fallback", () => {
90
124
  expect(
@@ -123,3 +157,87 @@ describe("Twilio ingress helpers", () => {
123
157
  });
124
158
  });
125
159
  });
160
+
161
+ describe("parseTunnelRecord", () => {
162
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
163
+
164
+ test("accepts every provider in the registry", () => {
165
+ for (const provider of TUNNEL_PROVIDERS) {
166
+ expect(
167
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
168
+ ).toEqual({ provider, publicBaseUrl: TUNNEL_URL });
169
+ }
170
+ });
171
+
172
+ test("returns the URL in the shape its own validator produces", () => {
173
+ // Padding and trailing slashes are normalized away rather than handed
174
+ // back, so readers never re-normalize what they were given.
175
+ for (const publicBaseUrl of [
176
+ ` ${TUNNEL_URL} `,
177
+ `${TUNNEL_URL}/`,
178
+ `${TUNNEL_URL}///`,
179
+ ]) {
180
+ expect(
181
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
182
+ ).toEqual({ provider: "ngrok", publicBaseUrl: TUNNEL_URL });
183
+ }
184
+ });
185
+
186
+ test("rejects values that are not a record", () => {
187
+ for (const value of [undefined, null, "nonsense", 42, []]) {
188
+ expect(parseTunnelRecord(value)).toBeNull();
189
+ }
190
+ });
191
+
192
+ test("rejects a record missing either field", () => {
193
+ expect(parseTunnelRecord({ provider: "ngrok" })).toBeNull();
194
+ expect(parseTunnelRecord({ publicBaseUrl: TUNNEL_URL })).toBeNull();
195
+ });
196
+
197
+ test("rejects a provider outside the registry", () => {
198
+ // Readers render the provider into a `vellum tunnel --provider <name>`
199
+ // command, so a hand-edited config must not reach a terminal.
200
+ for (const provider of ["wireguard", "; rm -rf /", "", 42]) {
201
+ expect(
202
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
203
+ ).toBeNull();
204
+ }
205
+ });
206
+
207
+ test("rejects a URL that is not absolute HTTP(S)", () => {
208
+ for (const publicBaseUrl of [
209
+ "",
210
+ " ",
211
+ "not-a-url",
212
+ "example.ts.net",
213
+ "ftp://x.test",
214
+ "https://one.ngrok.app?token=x",
215
+ 42,
216
+ ]) {
217
+ expect(
218
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
219
+ ).toBeNull();
220
+ }
221
+ });
222
+ });
223
+
224
+ describe("trimmedNonEmptyString", () => {
225
+ test("trims and rejects blank or non-string values", () => {
226
+ expect(trimmedNonEmptyString(" assistant-1 ")).toBe("assistant-1");
227
+ for (const value of ["", " ", undefined, null, 42]) {
228
+ expect(trimmedNonEmptyString(value)).toBeUndefined();
229
+ }
230
+ });
231
+ });
232
+
233
+ describe("parseRecordedAssistantId", () => {
234
+ test("trims a recorded id", () => {
235
+ expect(parseRecordedAssistantId(" assistant-1 ")).toBe("assistant-1");
236
+ });
237
+
238
+ test("rejects blank and non-string values", () => {
239
+ for (const value of ["", " ", undefined, null, 42]) {
240
+ expect(parseRecordedAssistantId(value)).toBeNull();
241
+ }
242
+ });
243
+ });
@@ -48,3 +48,106 @@ export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
48
48
  return undefined;
49
49
  }
50
50
  }
51
+
52
+ /**
53
+ * The same validation as `normalizeHttpPublicBaseUrl`, minus the root path it
54
+ * always emits (`https://x` becomes `https://x/`). Callers that append a path
55
+ * or persist the result want this shape, so they neither join through a double
56
+ * slash nor re-strip what the normalizer just added.
57
+ */
58
+ export function normalizeHttpPublicBaseUrlWithoutTrailingSlash(
59
+ value: unknown,
60
+ ): string | undefined {
61
+ return normalizePublicBaseUrl(normalizeHttpPublicBaseUrl(value));
62
+ }
63
+
64
+ /**
65
+ * A trimmed string, or undefined when the value is not a non-blank string.
66
+ *
67
+ * Exported because the identity check in the daemon's tunnel probe compares an
68
+ * id recorded here against one an edge serves, and the two sides have to trim
69
+ * alike: a padded served id must read as a match, not as a different
70
+ * assistant.
71
+ */
72
+ export function trimmedNonEmptyString(value: unknown): string | undefined {
73
+ if (typeof value !== "string") {
74
+ return undefined;
75
+ }
76
+ const trimmed = value.trim();
77
+ return trimmed.length > 0 ? trimmed : undefined;
78
+ }
79
+
80
+ // Records `vellum tunnel` leaves in the workspace config's `ingress` section.
81
+ // The CLI writes them and the daemon reads them, and the two are separate
82
+ // build units that cannot import each other, so the key names, the provider
83
+ // allowlist, and the validation all live here rather than in a copy on each
84
+ // side.
85
+
86
+ /** Key under `ingress` holding the tunnel that most recently ran. */
87
+ export const INGRESS_LAST_TUNNEL_KEY = "lastTunnel";
88
+
89
+ /**
90
+ * Key under `ingress` holding a tunnel published for device pairing alone.
91
+ *
92
+ * A tailnet-only tunnel started while webhook integrations are configured
93
+ * cannot carry their callbacks, so it leaves `publicBaseUrl` (the callback
94
+ * base) as it is and records itself here. Readers that answer the pairing card
95
+ * prefer this address over `publicBaseUrl`; teardown drops the key.
96
+ */
97
+ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
98
+
99
+ /** Key under `ingress` holding the assistant id that tunnel fronted. */
100
+ export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
+
102
+ /**
103
+ * The local tunnel providers the CLI can start, in the order they are offered.
104
+ * `vellum tunnel`'s accepted `--provider` values and the persisted-record
105
+ * validation below both derive from it.
106
+ */
107
+ export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+
109
+ export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
+
111
+ /** A recorded tunnel, kept after teardown so UIs can name the command to restart. */
112
+ export interface TunnelRecord {
113
+ provider: TunnelProviderName;
114
+ publicBaseUrl: string;
115
+ }
116
+
117
+ function isTunnelProviderName(value: unknown): value is TunnelProviderName {
118
+ return (
119
+ typeof value === "string" &&
120
+ (TUNNEL_PROVIDERS as readonly string[]).includes(value)
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Parse an `ingress.lastTunnel` or `ingress.pairingTunnel` value, or null when
126
+ * it is absent or malformed.
127
+ *
128
+ * A hand-edited config must not hand callers an unusable address or a provider
129
+ * name no command accepts: the provider is checked against the allowlist
130
+ * because readers render it into a restart command, and the URL faces the same
131
+ * absolute HTTP(S) constraint as every other public base URL. The URL comes
132
+ * back in the shape the validator produced, so readers never re-normalize it.
133
+ */
134
+ export function parseTunnelRecord(value: unknown): TunnelRecord | null {
135
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
136
+ return null;
137
+ }
138
+ const { provider, publicBaseUrl } = value as Record<string, unknown>;
139
+ if (!isTunnelProviderName(provider)) {
140
+ return null;
141
+ }
142
+ const normalized =
143
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(publicBaseUrl);
144
+ if (normalized === undefined) {
145
+ return null;
146
+ }
147
+ return { provider, publicBaseUrl: normalized };
148
+ }
149
+
150
+ /** Parse an `ingress.assistantId` value, or null when it is absent or blank. */
151
+ export function parseRecordedAssistantId(value: unknown): string | null {
152
+ return trimmedNonEmptyString(value) ?? null;
153
+ }
@@ -20,6 +20,7 @@ describe("contact read contracts", () => {
20
20
  contactType: "human",
21
21
  lastInteraction: 1700000000,
22
22
  interactionCount: 12,
23
+ autoApproveThreshold: "medium",
23
24
  createdAt: 1699000000,
24
25
  updatedAt: 1700000000,
25
26
  channels: [
@@ -46,6 +47,7 @@ describe("contact read contracts", () => {
46
47
  const parsed = ContactReadSchema.parse(contact);
47
48
  expect(parsed.createdAt).toBe(1699000000);
48
49
  expect(parsed.updatedAt).toBe(1700000000);
50
+ expect(parsed.autoApproveThreshold).toBe("medium");
49
51
  });
50
52
 
51
53
  test("ContactReadSchema accepts null interactionCount (daemon-native gateway-telemetry outage fail-soft)", () => {
@@ -82,6 +84,36 @@ describe("contact read contracts", () => {
82
84
  const parsed = ContactReadSchema.parse(contact);
83
85
  expect(parsed.interactionCount).toBeNull();
84
86
  expect(parsed.channels[0].interactionCount).toBeNull();
87
+ expect(parsed.autoApproveThreshold).toBeUndefined();
88
+ });
89
+
90
+ test("ContactReadSchema accepts a null auto-approve threshold", () => {
91
+ const contact = {
92
+ id: "c1",
93
+ displayName: "Example User",
94
+ role: "contact",
95
+ interactionCount: 0,
96
+ autoApproveThreshold: null,
97
+ createdAt: 1699000000,
98
+ updatedAt: 1700000000,
99
+ channels: [],
100
+ };
101
+ expect(ContactReadSchema.parse(contact).autoApproveThreshold).toBeNull();
102
+ });
103
+
104
+ test("ContactReadSchema rejects an unknown auto-approve threshold", () => {
105
+ expect(() =>
106
+ ContactReadSchema.parse({
107
+ id: "c1",
108
+ displayName: "Example User",
109
+ role: "contact",
110
+ interactionCount: 0,
111
+ autoApproveThreshold: "full",
112
+ createdAt: 1699000000,
113
+ updatedAt: 1700000000,
114
+ channels: [],
115
+ }),
116
+ ).toThrow();
85
117
  });
86
118
 
87
119
  test("ContactReadSchema requires createdAt/updatedAt", () => {
@@ -31,6 +31,7 @@ const fullVerdict: TrustVerdict = {
31
31
  verifiedAt: 1699999999,
32
32
  interactionCount: 12,
33
33
  memberDisplayName: "Member Name",
34
+ autoApproveThreshold: "high",
34
35
  };
35
36
 
36
37
  describe("TrustVerdictSchema", () => {
@@ -38,6 +39,23 @@ describe("TrustVerdictSchema", () => {
38
39
  expect(TrustVerdictSchema.parse(fullVerdict)).toEqual(fullVerdict);
39
40
  });
40
41
 
42
+ test("parses a verdict with a null contact auto-approve threshold", () => {
43
+ const verdict = {
44
+ ...fullVerdict,
45
+ autoApproveThreshold: null,
46
+ } satisfies TrustVerdict;
47
+ expect(TrustVerdictSchema.parse(verdict)).toEqual(verdict);
48
+ });
49
+
50
+ test("rejects an unknown contact auto-approve threshold", () => {
51
+ expect(() =>
52
+ TrustVerdictSchema.parse({
53
+ ...fullVerdict,
54
+ autoApproveThreshold: "full",
55
+ }),
56
+ ).toThrow();
57
+ });
58
+
41
59
  test("parses a minimal verdict", () => {
42
60
  const minimal = {
43
61
  trustClass: "unknown",
@@ -82,15 +82,10 @@ export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
82
82
  * `vellum` is the local desktop/web client surface; the guardian is always
83
83
  * max-rank there, so the seed default admits them regardless of the floor.
84
84
  *
85
- * `discord` has no ingress implementation, so there is nothing for a floor to
86
- * gate and nothing for the user to configure. Hiding keeps it pinned at the
87
- * seed default rather than offering a Channel Trust Floors row for a channel
88
- * that receives nothing.
89
85
  */
90
86
  export const ADMISSION_POLICY_HIDDEN_CHANNELS: ReadonlySet<string> = new Set([
91
87
  "vellum",
92
88
  "whatsapp",
93
- "discord",
94
89
  ]);
95
90
 
96
91
  export function isAdmissionPolicyHiddenChannel(channelType: string): boolean {
@@ -10,7 +10,9 @@
10
10
  *
11
11
  * Storage lives in the gateway DB (`channel_permission_overrides`) so the
12
12
  * assistant cannot tamper with it, matching `channel_admission_policy` and
13
- * `auto_approve_thresholds`.
13
+ * `auto_approve_thresholds`. A contact-level ceiling can live on
14
+ * `contacts.auto_approve_threshold` and is stamped on the trust verdict;
15
+ * that override is not a matrix cell.
14
16
  */
15
17
 
16
18
  import { z } from "zod";