@vellumai/assistant 0.11.5 → 0.11.6-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 (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -1,4 +1,5 @@
1
1
  import {
2
+ isUserTerminalHistoryError,
2
3
  ORDERING_ERROR_PATTERNS,
3
4
  WEB_SEARCH_ORDERING_PATTERNS,
4
5
  } from "../agent/history-repair/history-repair.js";
@@ -480,6 +481,14 @@ function classifyCore(
480
481
  errorCategory: "stale_web_search_content",
481
482
  };
482
483
  }
484
+ if (isUserTerminalHistoryError(message)) {
485
+ return {
486
+ code: "PROVIDER_ORDERING",
487
+ userMessage: "An internal error occurred. Please try again.",
488
+ retryable: true,
489
+ errorCategory: "history_user_terminal",
490
+ };
491
+ }
483
492
  if (isOrderingError(message)) {
484
493
  return {
485
494
  code: "PROVIDER_ORDERING",
@@ -645,13 +654,17 @@ function reasonToClassification(
645
654
  return contextTooLargeClassification();
646
655
  case "vision_unsupported":
647
656
  return visionNotSupportedClassification();
657
+ // Two producers share this reason: SDK transport failures that never got
658
+ // a response (OpenAI APIConnectionError), and Gemini responses whose empty
659
+ // body reveals a proxy/egress filter intercepting the request. The copy
660
+ // must stay broad enough for both.
648
661
  case "network_error":
649
662
  return {
650
663
  code: "PROVIDER_NETWORK",
651
664
  userMessage:
652
- "The provider returned an empty response with no body — this typically indicates a network proxy or egress filter intercepting the request, not a genuine provider error. Check your network configuration.",
665
+ "The request could not reach the provider: the connection failed before a real response arrived. Check that the endpoint is reachable and that no proxy or egress filter is intercepting the request, then try again.",
653
666
  retryable: true,
654
- errorCategory: "provider_network_proxy_intercepted",
667
+ errorCategory: "provider_network_error",
655
668
  };
656
669
  case "model_not_found":
657
670
  return {
@@ -1099,6 +1112,15 @@ function classifyByMessage(
1099
1112
  };
1100
1113
  }
1101
1114
 
1115
+ if (isUserTerminalHistoryError(message)) {
1116
+ return {
1117
+ code: "PROVIDER_ORDERING",
1118
+ userMessage: "An internal error occurred. Please try again.",
1119
+ retryable: true,
1120
+ errorCategory: "history_user_terminal",
1121
+ };
1122
+ }
1123
+
1102
1124
  // General tool_use/tool_result ordering errors
1103
1125
  if (isOrderingError(message)) {
1104
1126
  return {
@@ -295,6 +295,11 @@ function resolveStatusCommand(context: SlashContext): SlashResolution {
295
295
 
296
296
  const CLEAN_HELP_LINE =
297
297
  "/clean — Strip injected runtime context and reset memory injection state (no summarization)";
298
+ const FORK_CAPABLE_INTERFACES = new Set<InterfaceId>([
299
+ "macos",
300
+ "windows",
301
+ "ios",
302
+ ]);
298
303
 
299
304
  function resolveCommandsList(context?: SlashContext): string[] {
300
305
  const fallbackLines = [
@@ -315,21 +320,7 @@ function resolveCommandsList(context?: SlashContext): string[] {
315
320
  return fallbackLines;
316
321
  }
317
322
 
318
- if (context.userMessageInterface === "macos") {
319
- return [
320
- "/commands — List all available commands",
321
- "/compact — Force context compaction immediately",
322
- CLEAN_HELP_LINE,
323
- "/context — Show conversation context usage",
324
- "/model — List or switch inference profile",
325
- "/models — List all available models",
326
- "/status — Show conversation status and context usage",
327
- "/btw — Ask a side question while the assistant is working",
328
- "/fork — Fork the current conversation into a new branch",
329
- ];
330
- }
331
-
332
- if (context.userMessageInterface === "ios") {
323
+ if (FORK_CAPABLE_INTERFACES.has(context.userMessageInterface)) {
333
324
  return [
334
325
  "/commands — List all available commands",
335
326
  "/compact — Force context compaction immediately",
@@ -413,6 +413,7 @@ async function startDaemonLocked(): Promise<{
413
413
  const child = spawn(bunPath, ["run", mainPath], {
414
414
  detached: true,
415
415
  stdio: ["ignore", "ignore", stderrFd],
416
+ windowsHide: true,
416
417
  env: spawnEnv,
417
418
  });
418
419
 
@@ -57,7 +57,13 @@ const BACKGROUND_SOURCES = new Set([
57
57
  "schedule",
58
58
  "task",
59
59
  ]);
60
- const LOCAL_OWNER_INTERFACES = new Set(["macos", "web", "vellum", "cli"]);
60
+ const LOCAL_OWNER_INTERFACES = new Set([
61
+ "macos",
62
+ "windows",
63
+ "web",
64
+ "vellum",
65
+ "cli",
66
+ ]);
61
67
 
62
68
  export function classifyDiskPressureTurnPolicy(
63
69
  status: DiskPressureStatus,
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Unit tests for the ingress section of the workspace config: the records
3
+ * `vellum tunnel` leaves behind, who owns the public base URL, and when the
4
+ * records are dropped. The record truth table lives on the shared parser
5
+ * (`packages/service-contracts/src/__tests__/ingress.test.ts`); what is
6
+ * checked here is that the daemon reads the keys the CLI and the gateway
7
+ * write, and inherits that validation.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ let rawConfig: Record<string, unknown> = {};
13
+
14
+ mock.module("../../../config/loader.js", () => ({
15
+ loadRawConfig: () => rawConfig,
16
+ }));
17
+
18
+ import {
19
+ dropTunnelRecordsForNewUrl,
20
+ getIngressConfigResult,
21
+ isVelayManagedIngress,
22
+ loadLastTunnelRecord,
23
+ loadPairingTunnelRecord,
24
+ loadRecordedAssistantId,
25
+ } from "../config-ingress.js";
26
+
27
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
28
+ const PAIRING_URL = "https://assistant-1.pairing.example.ts.net";
29
+
30
+ describe("workspace tunnel records", () => {
31
+ beforeEach(() => {
32
+ rawConfig = {};
33
+ });
34
+
35
+ test("reads a well-formed record", () => {
36
+ rawConfig = {
37
+ ingress: {
38
+ assistantId: "assistant-1",
39
+ lastTunnel: { provider: "tailscale", publicBaseUrl: TUNNEL_URL },
40
+ },
41
+ };
42
+
43
+ expect(loadLastTunnelRecord()).toEqual({
44
+ provider: "tailscale",
45
+ publicBaseUrl: TUNNEL_URL,
46
+ });
47
+ expect(loadRecordedAssistantId()).toBe("assistant-1");
48
+ });
49
+
50
+ test("reads a pairing record beside the tunnel that owns the URL", () => {
51
+ rawConfig = {
52
+ ingress: {
53
+ publicBaseUrl: "https://webhooks.ngrok.app",
54
+ lastTunnel: {
55
+ provider: "ngrok",
56
+ publicBaseUrl: "https://webhooks.ngrok.app",
57
+ },
58
+ pairingTunnel: { provider: "tailscale", publicBaseUrl: PAIRING_URL },
59
+ },
60
+ };
61
+
62
+ expect(loadPairingTunnelRecord()).toEqual({
63
+ provider: "tailscale",
64
+ publicBaseUrl: PAIRING_URL,
65
+ });
66
+ expect(loadLastTunnelRecord()).toEqual({
67
+ provider: "ngrok",
68
+ publicBaseUrl: "https://webhooks.ngrok.app",
69
+ });
70
+ });
71
+
72
+ test("inherits the shared validation for the pairing record", () => {
73
+ rawConfig = {
74
+ ingress: {
75
+ pairingTunnel: { provider: "wireguard", publicBaseUrl: PAIRING_URL },
76
+ },
77
+ };
78
+ expect(loadPairingTunnelRecord()).toBeNull();
79
+
80
+ rawConfig = { ingress: { pairingTunnel: { provider: "tailscale" } } };
81
+ expect(loadPairingTunnelRecord()).toBeNull();
82
+ });
83
+
84
+ test("returns null when the ingress section is absent", () => {
85
+ expect(loadLastTunnelRecord()).toBeNull();
86
+ expect(loadPairingTunnelRecord()).toBeNull();
87
+ expect(loadRecordedAssistantId()).toBeNull();
88
+ });
89
+
90
+ test("returns null when the ingress section is not an object", () => {
91
+ rawConfig = { ingress: "nonsense" };
92
+
93
+ expect(loadLastTunnelRecord()).toBeNull();
94
+ expect(loadRecordedAssistantId()).toBeNull();
95
+ });
96
+
97
+ test("returns null for a provider outside the shared registry", () => {
98
+ // Readers render the provider into a shell command they tell the user to
99
+ // run, so an unknown one makes the whole record unusable.
100
+ rawConfig = {
101
+ ingress: {
102
+ lastTunnel: { provider: "wireguard", publicBaseUrl: TUNNEL_URL },
103
+ },
104
+ };
105
+
106
+ expect(loadLastTunnelRecord()).toBeNull();
107
+ });
108
+
109
+ test("returns null for a malformed record", () => {
110
+ rawConfig = { ingress: { lastTunnel: { provider: "ngrok" } } };
111
+ expect(loadLastTunnelRecord()).toBeNull();
112
+
113
+ rawConfig = {
114
+ ingress: {
115
+ lastTunnel: { provider: "ngrok", publicBaseUrl: "example.ts.net" },
116
+ },
117
+ };
118
+ expect(loadLastTunnelRecord()).toBeNull();
119
+ });
120
+
121
+ test("returns null for a blank recorded assistant id", () => {
122
+ rawConfig = { ingress: { assistantId: " " } };
123
+
124
+ expect(loadRecordedAssistantId()).toBeNull();
125
+ });
126
+
127
+ test("falls back to typed defaults for a mistyped ingress section", () => {
128
+ rawConfig = { ingress: { publicBaseUrl: 42, enabled: "yes" } };
129
+
130
+ const config = getIngressConfigResult();
131
+
132
+ expect(config.publicBaseUrl).toBe("");
133
+ expect(config.enabled).toBe(false);
134
+ expect(config.explicitlyDisabled).toBe(false);
135
+ });
136
+
137
+ test("reports Velay ownership of the public base URL", () => {
138
+ rawConfig = {
139
+ ingress: { publicBaseUrl: TUNNEL_URL, publicBaseUrlManagedBy: "velay" },
140
+ };
141
+ expect(isVelayManagedIngress()).toBe(true);
142
+
143
+ rawConfig = { ingress: { publicBaseUrl: TUNNEL_URL } };
144
+ expect(isVelayManagedIngress()).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("explicitlyDisabled", () => {
149
+ beforeEach(() => {
150
+ rawConfig = {};
151
+ });
152
+
153
+ test("only an explicit false is an opt-out", () => {
154
+ // `ingress.enabled` is opt-out across the assistant: a URL set through
155
+ // `config set ingress.publicBaseUrl` never writes the flag and is live.
156
+ rawConfig = { ingress: { publicBaseUrl: TUNNEL_URL } };
157
+ expect(getIngressConfigResult().explicitlyDisabled).toBe(false);
158
+
159
+ rawConfig = { ingress: { publicBaseUrl: TUNNEL_URL, enabled: true } };
160
+ expect(getIngressConfigResult().explicitlyDisabled).toBe(false);
161
+
162
+ rawConfig = { ingress: { publicBaseUrl: TUNNEL_URL, enabled: false } };
163
+ expect(getIngressConfigResult().explicitlyDisabled).toBe(true);
164
+ });
165
+ });
166
+
167
+ describe("dropTunnelRecordsForNewUrl", () => {
168
+ const records = () => ({
169
+ publicBaseUrl: TUNNEL_URL,
170
+ assistantId: "assistant-1",
171
+ lastTunnel: { provider: "tailscale", publicBaseUrl: TUNNEL_URL },
172
+ pairingTunnel: { provider: "tailscale", publicBaseUrl: PAIRING_URL },
173
+ });
174
+
175
+ test("keeps the records when the URL is unchanged", () => {
176
+ const ingress: Record<string, unknown> = records();
177
+
178
+ dropTunnelRecordsForNewUrl(ingress, ` ${TUNNEL_URL}/ `);
179
+
180
+ expect(ingress).toEqual(records());
181
+ });
182
+
183
+ test("drops the pairing record too, so a new URL is the one reported", () => {
184
+ // The pairing record outranks `publicBaseUrl` in the status route, so a
185
+ // leftover one would hide the address the user just set.
186
+ const retargeted: Record<string, unknown> = records();
187
+
188
+ dropTunnelRecordsForNewUrl(retargeted, "https://other.example.ts.net");
189
+
190
+ expect(retargeted.pairingTunnel).toBeUndefined();
191
+ });
192
+
193
+ test("drops the records when the URL is retargeted or cleared", () => {
194
+ const retargeted: Record<string, unknown> = records();
195
+ dropTunnelRecordsForNewUrl(retargeted, "https://other.example.ts.net");
196
+ expect(retargeted).toEqual({ publicBaseUrl: TUNNEL_URL });
197
+
198
+ const cleared: Record<string, unknown> = records();
199
+ dropTunnelRecordsForNewUrl(cleared, undefined);
200
+ expect(cleared).toEqual({ publicBaseUrl: TUNNEL_URL });
201
+ });
202
+
203
+ test("is a no-op without an ingress section", () => {
204
+ expect(() =>
205
+ dropTunnelRecordsForNewUrl(undefined, TUNNEL_URL),
206
+ ).not.toThrow();
207
+ });
208
+ });
@@ -1,3 +1,13 @@
1
+ import {
2
+ INGRESS_ASSISTANT_ID_KEY,
3
+ INGRESS_LAST_TUNNEL_KEY,
4
+ INGRESS_PAIRING_TUNNEL_KEY,
5
+ normalizePublicBaseUrl,
6
+ parseRecordedAssistantId,
7
+ parseTunnelRecord,
8
+ type TunnelRecord,
9
+ } from "@vellumai/service-contracts/ingress";
10
+
1
11
  import { updatePhoneNumberWebhooks } from "../../calls/twilio-rest.js";
2
12
  import {
3
13
  getGatewayInternalBaseUrl,
@@ -11,13 +21,105 @@ import {
11
21
  getTwilioStatusCallbackUrl,
12
22
  getTwilioVoiceWebhookUrl,
13
23
  type IngressConfig,
24
+ isPublicIngressDisabled,
14
25
  } from "../../inbound/public-ingress-urls.js";
26
+ import { isPlainObject } from "../../util/object.js";
15
27
  import { log } from "./shared.js";
16
28
 
17
29
  export function computeGatewayTarget(): string {
18
30
  return getGatewayInternalBaseUrl();
19
31
  }
20
32
 
33
+ /**
34
+ * The `ingress` section of the raw workspace config.
35
+ *
36
+ * `vellum tunnel` writes this section (`cli/src/lib/ingress-config.ts`). The
37
+ * CLI is a separate build unit the assistant does not depend on, so the key
38
+ * names and the record validation are shared through
39
+ * `@vellumai/service-contracts/ingress` instead.
40
+ */
41
+ function readIngressSection(): Record<string, unknown> {
42
+ const ingress = loadRawConfig().ingress;
43
+ return isPlainObject(ingress) ? ingress : {};
44
+ }
45
+
46
+ /** Read the last tunnel that ran, or null when it is absent or malformed. */
47
+ export function loadLastTunnelRecord(): TunnelRecord | null {
48
+ return parseTunnelRecord(readIngressSection()[INGRESS_LAST_TUNNEL_KEY]);
49
+ }
50
+
51
+ /**
52
+ * Read the tunnel published for device pairing alone, or null when it is
53
+ * absent or malformed.
54
+ *
55
+ * A tailnet-only tunnel started beside configured webhook integrations records
56
+ * itself here instead of replacing `publicBaseUrl`, which those callbacks need
57
+ * to keep resolving to an address their callers can reach.
58
+ */
59
+ export function loadPairingTunnelRecord(): TunnelRecord | null {
60
+ return parseTunnelRecord(readIngressSection()[INGRESS_PAIRING_TUNNEL_KEY]);
61
+ }
62
+
63
+ /**
64
+ * Read the assistant id the last tunnel fronted, or null when absent. The
65
+ * daemon cannot derive it: it scopes itself as `self` internally.
66
+ */
67
+ export function loadRecordedAssistantId(): string | null {
68
+ return parseRecordedAssistantId(
69
+ readIngressSection()[INGRESS_ASSISTANT_ID_KEY],
70
+ );
71
+ }
72
+
73
+ /** Key under `ingress` naming the component that owns `publicBaseUrl`. */
74
+ const INGRESS_MANAGED_BY_KEY = "publicBaseUrlManagedBy";
75
+
76
+ /** Value that key carries when a self-hosted gateway's Velay tunnel owns it. */
77
+ const VELAY_MANAGED_BY = "velay";
78
+
79
+ /**
80
+ * True when Velay owns `ingress.publicBaseUrl`.
81
+ *
82
+ * A gateway started with `VELAY_BASE_URL` writes the URL when its tunnel comes
83
+ * up, clears it when the tunnel drops, and reconnects on its own
84
+ * (`gateway/src/velay/client.ts`). Readers must not offer the user a tunnel
85
+ * command for an ingress they do not own.
86
+ */
87
+ export function isVelayManagedIngress(): boolean {
88
+ return readIngressSection()[INGRESS_MANAGED_BY_KEY] === VELAY_MANAGED_BY;
89
+ }
90
+
91
+ /**
92
+ * Drop `ingress.assistantId`, `ingress.lastTunnel` and `ingress.pairingTunnel`
93
+ * from `ingress` when `nextPublicBaseUrl` replaces a different address,
94
+ * mutating it in place.
95
+ *
96
+ * The first two describe the address being replaced, so keeping them makes the
97
+ * status route call a legitimate retarget `foreign` and name a tunnel that
98
+ * never fronted the new address. The pairing record goes with them because it
99
+ * outranks `publicBaseUrl` where the status route reports an address: a
100
+ * leftover one would hide the address the user just set behind a tunnel they
101
+ * are no longer running. Every writer of `ingress.publicBaseUrl` owes this
102
+ * cleanup, so it lives here rather than in each of them. An absent `ingress`
103
+ * section carries no records to drop.
104
+ */
105
+ export function dropTunnelRecordsForNewUrl(
106
+ ingress: Record<string, unknown> | undefined,
107
+ nextPublicBaseUrl: unknown,
108
+ ): void {
109
+ if (!ingress) {
110
+ return;
111
+ }
112
+ if (
113
+ normalizePublicBaseUrl(nextPublicBaseUrl) ===
114
+ normalizePublicBaseUrl(ingress.publicBaseUrl)
115
+ ) {
116
+ return;
117
+ }
118
+ delete ingress[INGRESS_ASSISTANT_ID_KEY];
119
+ delete ingress[INGRESS_LAST_TUNNEL_KEY];
120
+ delete ingress[INGRESS_PAIRING_TUNNEL_KEY];
121
+ }
122
+
21
123
  /**
22
124
  * Read the current ingress config from the raw workspace config file.
23
125
  * Extracted so it can be called from both the daemon message handler
@@ -25,6 +127,7 @@ export function computeGatewayTarget(): string {
25
127
  */
26
128
  export function getIngressConfigResult(): {
27
129
  enabled: boolean;
130
+ explicitlyDisabled: boolean;
28
131
  publicBaseUrl: string;
29
132
  localGatewayTarget: string;
30
133
  managedCallbacks: boolean;
@@ -40,6 +143,7 @@ export function getIngressConfigResult(): {
40
143
  if (assistantId) {
41
144
  return {
42
145
  enabled: true,
146
+ explicitlyDisabled: false,
43
147
  publicBaseUrl: `${platformBase}/gateway/callbacks/${assistantId}`,
44
148
  localGatewayTarget: computeGatewayTarget(),
45
149
  managedCallbacks: true,
@@ -48,12 +152,18 @@ export function getIngressConfigResult(): {
48
152
  }
49
153
  }
50
154
 
51
- const raw = loadRawConfig();
52
- const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
53
- const publicBaseUrl = (ingress.publicBaseUrl as string) ?? "";
54
- const enabled = (ingress.enabled as boolean | undefined) ?? false;
155
+ const ingress = readIngressSection();
156
+ // Typed reads, not casts: a hand-edited config must not hand callers a
157
+ // value whose type contradicts this function's return type.
158
+ const publicBaseUrl =
159
+ typeof ingress.publicBaseUrl === "string" ? ingress.publicBaseUrl : "";
160
+ const enabled =
161
+ typeof ingress.enabled === "boolean" ? ingress.enabled : undefined;
55
162
  return {
56
- enabled,
163
+ enabled: enabled === true,
164
+ // `enabled` is opt-out across the assistant, so an unset flag is not off:
165
+ // callers asking whether ingress is live read this, not `!enabled`.
166
+ explicitlyDisabled: isPublicIngressDisabled({ ingress: { enabled } }),
57
167
  publicBaseUrl,
58
168
  localGatewayTarget: computeGatewayTarget(),
59
169
  managedCallbacks: false,
@@ -1,5 +1,6 @@
1
1
  import { config as dotenvConfig } from "dotenv";
2
2
 
3
+ import { reconcileAppPins } from "../apps/app-pin-reconciler.js";
3
4
  import { reconcileCallsOnStartup } from "../calls/call-recovery.js";
4
5
  import { TwilioVoiceProvider } from "../calls/twilio-provider.js";
5
6
  import { expireInteractionBoundGuardianRequests } from "../channels/gateway-guardian-requests.js";
@@ -62,6 +63,7 @@ import {
62
63
  getWorkspaceDir,
63
64
  } from "../util/platform.js";
64
65
  import { APP_VERSION } from "../version.js";
66
+ import { drainOrphanedWatchTimelineEntries } from "../watch/watch-timeline.js";
65
67
  import { getWorkflowRunManager } from "../workflows/run-manager.js";
66
68
  import { repairAdaptiveThinkingOnManagedProfiles } from "../workspace/adaptive-thinking-repair.js";
67
69
  import { ensureByokDefaultProfiles } from "../workspace/byok-default-profile-ensure.js";
@@ -385,6 +387,29 @@ export async function runDaemon(): Promise<void> {
385
387
  log.warn({ err }, "Profiler retention sweep failed — continuing startup");
386
388
  }
387
389
 
390
+ // Reclaim watch-timeline entries whose conversation is gone. Their purge
391
+ // runs after the conversation row is already committed as deleted and
392
+ // cannot be retried by the caller, and nothing cascades into the table, so
393
+ // a failed purge or a crash between the two writes would otherwise keep
394
+ // narration, AX trees, and screenshots of the user's screen for as long as
395
+ // the database lives. Startup is the pass every install gets: the periodic
396
+ // pass runs from database maintenance on the memory plugin's jobs worker,
397
+ // which an install with that plugin disabled or `memory.enabled: false`
398
+ // never starts, so this is the only sweep that install's residue sees and
399
+ // it drains rather than taking one page. Best-effort inside the sweep,
400
+ // which reports zero rather than throwing.
401
+ try {
402
+ const sweptWatchEntries = await drainOrphanedWatchTimelineEntries();
403
+ if (sweptWatchEntries > 0) {
404
+ log.info(
405
+ { sweptWatchEntries },
406
+ "Swept watch timeline entries for deleted conversations on startup",
407
+ );
408
+ }
409
+ } catch (err) {
410
+ log.warn({ err }, "Watch timeline sweep failed, continuing startup");
411
+ }
412
+
388
413
  // Backfill oauth_connection rows for manual-token providers (Telegram,
389
414
  // Slack channel) that already have stored credentials from before the
390
415
  // oauth_connection migration. Safe to call on every startup.
@@ -722,6 +747,7 @@ export async function runDaemon(): Promise<void> {
722
747
  // the same shape as schedule recovery above.
723
748
  try {
724
749
  await reconcilePluginSchedules();
750
+ reconcileAppPins();
725
751
  } catch (err) {
726
752
  log.error({ err }, "Plugin schedule reconcile failed, continuing startup");
727
753
  }
@@ -11,10 +11,11 @@ export type WebSearchProviderId =
11
11
  | "perplexity"
12
12
  | "tavily"
13
13
  | "keenable"
14
- | "firecrawl";
14
+ | "firecrawl"
15
+ | "fastcrw";
15
16
 
16
17
  /** Provider that backed a `web_fetch` call. `default` is the built-in fetcher. */
17
- export type WebFetchProviderId = "default" | "firecrawl";
18
+ export type WebFetchProviderId = "default" | "firecrawl" | "fastcrw";
18
19
 
19
20
  export interface WebSearchResultItem {
20
21
  rank: number; // 1-indexed
@@ -4,7 +4,6 @@
4
4
  * Extracted from conversation-runtime-assembly.ts to break circular
5
5
  * imports (memory/conversation-crud → daemon/conversation-runtime-assembly).
6
6
  */
7
- import type { ChannelConversationType } from "@vellumai/gateway-client";
8
7
 
9
8
  import { isHttpAuthDisabled } from "../config/env.js";
10
9
  import { canSeePersonalMemory } from "../runtime/effective-capabilities.js";
@@ -88,39 +87,3 @@ export function isPersonalMemoryAllowed(
88
87
  trustClass: resolveTrustClass(trustContext),
89
88
  });
90
89
  }
91
-
92
- /**
93
- * Map a channel-native chat type (Telegram `private`/`group`/`supergroup`,
94
- * Slack `im`/`mpim`/`channel`, Discord `dm`/`channel`) onto the
95
- * permission-matrix conversation-type axis. Slack and Discord both forward
96
- * every non-DM as `"channel"` without distinguishing public from private, so
97
- * `"channel"` maps to undefined: a permissive public-channel cell must not
98
- * silently govern private channels. The channel-type tier starts matching for
99
- * their non-DMs once the gateway forwards the distinct type.
100
- *
101
- * Every DM-capable channel must have an arm here. A missing one is silent:
102
- * the cell stays settable, because the adapter validates, and simply never
103
- * matches, so the guardian sets a rule for DMs and it does nothing.
104
- */
105
- export function mapChatTypeToConversationType(
106
- chatType?: string,
107
- ): ChannelConversationType | undefined {
108
- switch (chatType) {
109
- case "im": // Slack DM
110
- case "private": // Telegram DM
111
- // Discord has no string vocabulary for this: its API distinguishes a DM by
112
- // a numeric channel type, and the absence of a guild. The normalizer names
113
- // it for the axis rather than inventing a native-looking word.
114
- case "dm":
115
- return "dm";
116
- // "mpim" is Slack's multi-party DM, forwarded under its own name rather
117
- // than reported as "im": a room with several people in it takes the
118
- // tighter `private` cell and the group-chat etiquette, not the `dm` one.
119
- case "mpim":
120
- case "group": // Telegram group
121
- case "supergroup": // Telegram supergroup
122
- return "private";
123
- default:
124
- return undefined;
125
- }
126
- }