@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
@@ -21,6 +21,13 @@ import type {
21
21
  ToolExecutionResult,
22
22
  } from "../types.js";
23
23
  import { extractDomain } from "./domain-normalize.js";
24
+ import {
25
+ extractFirecrawlCompatSearchResults,
26
+ FASTCRW_DEFAULT_API_BASE,
27
+ type FirecrawlCompatSearchResult,
28
+ resolveProviderApiUrl,
29
+ searchResultSnippet,
30
+ } from "./firecrawl-compat.js";
24
31
  import type { ManagedSearchProxyResult } from "./managed-search-proxy.js";
25
32
  import {
26
33
  classifyWebSearchFailure,
@@ -35,6 +42,7 @@ const BRAVE_API_URL = `https://api.search.brave.com${BRAVE_SEARCH_PATH}`;
35
42
  const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
36
43
  const TAVILY_API_URL = "https://api.tavily.com/search";
37
44
  const FIRECRAWL_API_URL = "https://api.firecrawl.dev/v2/search";
45
+ const FASTCRW_SEARCH_PATH = "/v1/search";
38
46
  // Keenable is keyless by default: the public path needs no key (rate-limited);
39
47
  // a key switches to the authenticated path and lifts the cap.
40
48
  const KEENABLE_API_BASE_URL = "https://api.keenable.ai";
@@ -46,7 +54,8 @@ type WebSearchProvider =
46
54
  | "brave"
47
55
  | "tavily"
48
56
  | "firecrawl"
49
- | "keenable";
57
+ | "keenable"
58
+ | "fastcrw";
50
59
 
51
60
  /**
52
61
  * Arguments passed to every {@link WebSearchAdapter}. The full superset is
@@ -123,25 +132,21 @@ interface TavilySearchResponse {
123
132
  results?: TavilySearchResult[];
124
133
  }
125
134
 
126
- interface FirecrawlSearchResult {
127
- title?: string;
128
- description?: string;
129
- url?: string;
130
- category?: string;
131
- }
132
-
133
135
  /**
134
- * Firecrawl `/v2/search` returns one array per requested source under `data`
135
- * (defaults to `web`). We request only `web`, so that is the array we read;
136
- * `news`/`images` are typed loosely since we never request them here.
136
+ * Firecrawl `/v2/search` and fastCRW `/v1/search` payloads. Result rows are
137
+ * read via {@link extractFirecrawlCompatSearchResults} (grouped `data.web`,
138
+ * self-hosted `data.results.web`, flat `data.results`, or array `data`).
137
139
  */
138
- interface FirecrawlSearchResponse {
140
+ interface FirecrawlCompatSearchResponse {
139
141
  success?: boolean;
140
- data?: {
141
- web?: FirecrawlSearchResult[];
142
- news?: FirecrawlSearchResult[];
143
- images?: unknown[];
144
- };
142
+ data?:
143
+ | FirecrawlCompatSearchResult[]
144
+ | {
145
+ web?: FirecrawlCompatSearchResult[];
146
+ results?: FirecrawlCompatSearchResult[];
147
+ news?: FirecrawlCompatSearchResult[];
148
+ images?: unknown[];
149
+ };
145
150
  warning?: string | null;
146
151
  }
147
152
 
@@ -278,11 +283,11 @@ function formatTavilyResults(
278
283
  return lines.join("\n");
279
284
  }
280
285
 
281
- function formatFirecrawlResults(
282
- data: FirecrawlSearchResponse,
286
+ function formatFirecrawlCompatResults(
287
+ data: FirecrawlCompatSearchResponse,
283
288
  query: string,
284
289
  ): string {
285
- const results = data.data?.web ?? [];
290
+ const results = extractFirecrawlCompatSearchResults(data);
286
291
 
287
292
  if (results.length === 0) {
288
293
  return `No results found for "${query}".`;
@@ -297,8 +302,9 @@ function formatFirecrawlResults(
297
302
  if (r.url) {
298
303
  lines.push(` URL: ${r.url}`);
299
304
  }
300
- if (r.description) {
301
- lines.push(` ${r.description}`);
305
+ const snippet = searchResultSnippet(r);
306
+ if (snippet) {
307
+ lines.push(` ${snippet}`);
302
308
  }
303
309
  lines.push("");
304
310
  }
@@ -442,12 +448,13 @@ function tavilyTimeRangeForFreshness(
442
448
  }
443
449
  }
444
450
 
445
- function buildFirecrawlMetadata(
446
- data: FirecrawlSearchResponse,
451
+ function buildFirecrawlCompatMetadata(
452
+ data: FirecrawlCompatSearchResponse,
447
453
  query: string,
448
454
  durationMs: number,
455
+ provider: "firecrawl" | "fastcrw",
449
456
  ): WebSearchMetadata {
450
- const results = data.data?.web ?? [];
457
+ const results = extractFirecrawlCompatSearchResults(data);
451
458
  const items: WebSearchResultItem[] = results.map((r, i) => {
452
459
  const url = r.url ?? "";
453
460
  const domain = extractDomain(url);
@@ -457,12 +464,12 @@ function buildFirecrawlMetadata(
457
464
  url,
458
465
  domain,
459
466
  faviconUrl: faviconUrlForDomain(domain),
460
- snippet: r.description,
467
+ snippet: searchResultSnippet(r),
461
468
  };
462
469
  });
463
470
  return {
464
471
  query,
465
- provider: "firecrawl",
472
+ provider,
466
473
  resultCount: items.length,
467
474
  durationMs,
468
475
  results: items,
@@ -1080,12 +1087,17 @@ async function executeTavilySearch(
1080
1087
  );
1081
1088
  }
1082
1089
 
1083
- async function executeFirecrawlSearch(
1090
+ async function executeFirecrawlCompatSearch(
1084
1091
  query: string,
1085
1092
  count: number,
1086
1093
  freshness: string | undefined,
1087
1094
  apiKey: string,
1088
- signal?: AbortSignal,
1095
+ options: {
1096
+ endpoint: string;
1097
+ provider: "firecrawl" | "fastcrw";
1098
+ displayName: string;
1099
+ signal?: AbortSignal;
1100
+ },
1089
1101
  ): Promise<ToolExecutionResult> {
1090
1102
  const tbs = firecrawlTbsForFreshness(freshness);
1091
1103
  const body: Record<string, unknown> = {
@@ -1098,36 +1110,51 @@ async function executeFirecrawlSearch(
1098
1110
  }
1099
1111
 
1100
1112
  const startedAt = Date.now();
1113
+ const headers: Record<string, string> = {
1114
+ "Content-Type": "application/json",
1115
+ "X-Client-Source": "vellum-assistant",
1116
+ };
1117
+ const trimmedKey = apiKey.trim();
1118
+ if (trimmedKey.length > 0) {
1119
+ headers.Authorization = `Bearer ${trimmedKey}`;
1120
+ }
1101
1121
 
1102
1122
  for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
1103
1123
  let response: Response;
1104
1124
  try {
1105
- response = await fetch(FIRECRAWL_API_URL, {
1125
+ response = await fetch(options.endpoint, {
1106
1126
  method: "POST",
1107
- headers: {
1108
- "Content-Type": "application/json",
1109
- Authorization: `Bearer ${apiKey}`,
1110
- "X-Client-Source": "vellum-assistant",
1111
- },
1127
+ headers,
1112
1128
  body: JSON.stringify(body),
1113
- signal,
1129
+ signal: options.signal,
1114
1130
  });
1115
1131
  } catch (err) {
1116
- return networkFailureResult(query, "firecrawl", startedAt, err, signal);
1132
+ return networkFailureResult(
1133
+ query,
1134
+ options.provider,
1135
+ startedAt,
1136
+ err,
1137
+ options.signal,
1138
+ );
1117
1139
  }
1118
1140
 
1119
1141
  if (response.ok) {
1120
- const data = (await response.json()) as FirecrawlSearchResponse;
1142
+ const data = (await response.json()) as FirecrawlCompatSearchResponse;
1121
1143
  const durationMs = Date.now() - startedAt;
1122
1144
  return {
1123
1145
  content:
1124
- wrapUntrustedContent(formatFirecrawlResults(data, query), {
1146
+ wrapUntrustedContent(formatFirecrawlCompatResults(data, query), {
1125
1147
  source: "search",
1126
- sourceDetail: "firecrawl",
1148
+ sourceDetail: options.provider,
1127
1149
  }) + CITATION_INSTRUCTION,
1128
1150
  isError: false,
1129
1151
  activityMetadata: {
1130
- webSearch: buildFirecrawlMetadata(data, query, durationMs),
1152
+ webSearch: buildFirecrawlCompatMetadata(
1153
+ data,
1154
+ query,
1155
+ durationMs,
1156
+ options.provider,
1157
+ ),
1131
1158
  },
1132
1159
  };
1133
1160
  }
@@ -1137,9 +1164,9 @@ async function executeFirecrawlSearch(
1137
1164
  if (response.status === 401 || response.status === 403) {
1138
1165
  return errorResult(
1139
1166
  query,
1140
- "firecrawl",
1167
+ options.provider,
1141
1168
  startedAt,
1142
- "Invalid or expired Firecrawl API key",
1169
+ `Invalid or expired ${options.displayName} API key`,
1143
1170
  );
1144
1171
  }
1145
1172
 
@@ -1150,34 +1177,73 @@ async function executeFirecrawlSearch(
1150
1177
  DEFAULT_BASE_DELAY_MS,
1151
1178
  );
1152
1179
  log.warn(
1153
- { attempt: attempt + 1, delayMs },
1154
- "Firecrawl Search rate limited, retrying",
1180
+ { attempt: attempt + 1, delayMs, provider: options.provider },
1181
+ `${options.displayName} Search rate limited, retrying`,
1155
1182
  );
1156
1183
  await sleep(delayMs);
1157
1184
  continue;
1158
1185
  }
1159
1186
 
1160
- log.warn({ status: response.status }, "Firecrawl Search API error");
1187
+ log.warn(
1188
+ { status: response.status, provider: options.provider },
1189
+ `${options.displayName} Search API error`,
1190
+ );
1161
1191
  return backendFailureResult(
1162
1192
  query,
1163
- "firecrawl",
1193
+ options.provider,
1164
1194
  startedAt,
1165
1195
  { statusCode: response.status, error: rawBodyDetail(bodyText) },
1166
1196
  response.status === 429
1167
- ? "Firecrawl Search rate limit exceeded after retries. Try again shortly."
1168
- : `Firecrawl Search API returned status ${response.status}`,
1197
+ ? `${options.displayName} Search rate limit exceeded after retries. Try again shortly.`
1198
+ : `${options.displayName} Search API returned status ${response.status}`,
1169
1199
  );
1170
1200
  }
1171
1201
 
1172
1202
  return backendFailureResult(
1173
1203
  query,
1174
- "firecrawl",
1204
+ options.provider,
1175
1205
  startedAt,
1176
1206
  { statusCode: 429 },
1177
- "Firecrawl Search rate limit exceeded after retries. Try again shortly.",
1207
+ `${options.displayName} Search rate limit exceeded after retries. Try again shortly.`,
1178
1208
  );
1179
1209
  }
1180
1210
 
1211
+ function executeFirecrawlSearch(
1212
+ query: string,
1213
+ count: number,
1214
+ freshness: string | undefined,
1215
+ apiKey: string,
1216
+ signal?: AbortSignal,
1217
+ ): Promise<ToolExecutionResult> {
1218
+ return executeFirecrawlCompatSearch(query, count, freshness, apiKey, {
1219
+ endpoint: FIRECRAWL_API_URL,
1220
+ provider: "firecrawl",
1221
+ displayName: "Firecrawl",
1222
+ signal,
1223
+ });
1224
+ }
1225
+
1226
+ function executeFastcrwSearch(
1227
+ query: string,
1228
+ count: number,
1229
+ freshness: string | undefined,
1230
+ apiKey: string,
1231
+ signal?: AbortSignal,
1232
+ ): Promise<ToolExecutionResult> {
1233
+ const apiBase = getConfig().services["web-search"]?.apiBase;
1234
+ const endpoint = resolveProviderApiUrl(
1235
+ apiBase,
1236
+ FASTCRW_SEARCH_PATH,
1237
+ FASTCRW_DEFAULT_API_BASE,
1238
+ );
1239
+ return executeFirecrawlCompatSearch(query, count, freshness, apiKey, {
1240
+ endpoint,
1241
+ provider: "fastcrw",
1242
+ displayName: "fastCRW",
1243
+ signal,
1244
+ });
1245
+ }
1246
+
1181
1247
  async function executeKeenableSearch(
1182
1248
  query: string,
1183
1249
  count: number,
@@ -1345,6 +1411,14 @@ const keenableSearchAdapter: WebSearchAdapter = {
1345
1411
  executeKeenableSearch(query, count, freshness, apiKey, signal),
1346
1412
  };
1347
1413
 
1414
+ const fastcrwSearchAdapter: WebSearchAdapter = {
1415
+ id: "fastcrw",
1416
+ providerKeyName: "fastcrw",
1417
+ fallbackOrder: 6,
1418
+ execute: ({ query, count, freshness, apiKey, signal }) =>
1419
+ executeFastcrwSearch(query, count, freshness, apiKey, signal),
1420
+ };
1421
+
1348
1422
  /**
1349
1423
  * All built-in web-search adapters keyed by provider id. The
1350
1424
  * `Record<WebSearchProvider, ...>` shape forces TypeScript to flag any
@@ -1356,6 +1430,7 @@ const WEB_SEARCH_ADAPTERS: Record<WebSearchProvider, WebSearchAdapter> = {
1356
1430
  tavily: tavilySearchAdapter,
1357
1431
  firecrawl: firecrawlSearchAdapter,
1358
1432
  keenable: keenableSearchAdapter,
1433
+ fastcrw: fastcrwSearchAdapter,
1359
1434
  };
1360
1435
 
1361
1436
  /**
@@ -1387,7 +1462,7 @@ export const webSearchTool = {
1387
1462
  count: {
1388
1463
  type: "number",
1389
1464
  description:
1390
- "Number of results to return (1-20, default 10). Used with Brave, Tavily, Firecrawl, and Keenable providers.",
1465
+ "Number of results to return (1-20, default 10). Used with Brave, Tavily, Firecrawl, Keenable, and fastCRW providers.",
1391
1466
  },
1392
1467
  offset: {
1393
1468
  type: "number",
@@ -1397,7 +1472,7 @@ export const webSearchTool = {
1397
1472
  freshness: {
1398
1473
  type: "string",
1399
1474
  description:
1400
- 'Filter by recency: "pd" (past day), "pw" (past week), "pm" (past month), "py" (past year). Used with Brave, Tavily, Firecrawl, and Keenable providers.',
1475
+ 'Filter by recency: "pd" (past day), "pw" (past week), "pm" (past month), "py" (past year). Used with Brave, Tavily, Firecrawl, Keenable, and fastCRW providers.',
1401
1476
  },
1402
1477
  },
1403
1478
  required: ["query"],
@@ -1462,13 +1537,24 @@ export const webSearchTool = {
1462
1537
  let provider = getWebSearchProvider();
1463
1538
  let apiKey = await getApiKey(provider);
1464
1539
 
1540
+ const webSearchApiBase = getConfig().services["web-search"]?.apiBase;
1541
+ const fastcrwAllowsKeylessSelfHost =
1542
+ provider === "fastcrw" &&
1543
+ typeof webSearchApiBase === "string" &&
1544
+ webSearchApiBase.trim().length > 0;
1545
+
1465
1546
  // A keyless provider (e.g. Keenable) runs without a stored key, so skip the
1466
1547
  // missing-key fallback/error entirely and execute it as configured.
1548
+ // fastCRW with a custom API Base may also run without a key (self-hosted).
1467
1549
  // Fallback: if a key-based provider has no key, try other BYOK search
1468
1550
  // providers in a stable order. This preserves existing installs that only
1469
1551
  // configured one search-provider key while still allowing new providers to
1470
1552
  // be selected explicitly.
1471
- if (!apiKey && !WEB_SEARCH_ADAPTERS[provider].keyless) {
1553
+ if (
1554
+ !apiKey &&
1555
+ !WEB_SEARCH_ADAPTERS[provider].keyless &&
1556
+ !fastcrwAllowsKeylessSelfHost
1557
+ ) {
1472
1558
  for (const fallback of fallbackProvidersFor(provider)) {
1473
1559
  const fallbackKey = await getApiKey(fallback);
1474
1560
  if (!fallbackKey) {
@@ -1502,7 +1588,7 @@ export const webSearchTool = {
1502
1588
  query,
1503
1589
  provider,
1504
1590
  startedAt,
1505
- "No web search API key configured. Set it via `keys set perplexity <key>`, `keys set brave <key>`, `keys set tavily <key>`, or `keys set firecrawl <key>`, or configure it from the Settings page under API Keys. Or switch the web-search provider to Keenable, which works without a key.",
1591
+ "No web search API key configured. Set it via `keys set perplexity <key>`, `keys set brave <key>`, `keys set tavily <key>`, `keys set firecrawl <key>`, or `keys set fastcrw <key>`, or configure it from the Settings page under API Keys. Or switch the web-search provider to Keenable, which works without a key.",
1506
1592
  );
1507
1593
  }
1508
1594
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ChannelConversationType,
2
3
  RiskAllowlistOption,
3
4
  RiskDirectoryScopeOption,
4
5
  } from "@vellumai/gateway-client";
@@ -377,7 +378,7 @@ export interface ToolContext {
377
378
  * resolution; undefined when the chat type is unknown or ambiguous.
378
379
  * @legacy
379
380
  */
380
- channelConversationType?: "dm" | "private" | "public";
381
+ channelConversationType?: ChannelConversationType;
381
382
  /**
382
383
  * External channel/conversation ID of the current chat (the binding's
383
384
  * external chat id — Slack channel, Telegram chat, …). Keys the channel
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ findContentTypeHeader,
5
+ isJsonContentType,
6
+ parseRequestBodyData,
7
+ } from "./oauth-request-body.js";
8
+
9
+ describe("isJsonContentType", () => {
10
+ test("recognizes application/json with and without parameters", () => {
11
+ expect(isJsonContentType("application/json")).toBe(true);
12
+ expect(isJsonContentType("application/json; charset=UTF-8")).toBe(true);
13
+ expect(isJsonContentType("Application/JSON")).toBe(true);
14
+ });
15
+
16
+ test("recognizes structured JSON suffixes", () => {
17
+ expect(isJsonContentType("application/vnd.api+json")).toBe(true);
18
+ });
19
+
20
+ test("rejects non-JSON media types and empty values", () => {
21
+ expect(isJsonContentType("multipart/related; boundary=b")).toBe(false);
22
+ expect(isJsonContentType("text/plain")).toBe(false);
23
+ expect(isJsonContentType(undefined)).toBe(false);
24
+ expect(isJsonContentType("")).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("findContentTypeHeader", () => {
29
+ test("matches the header in any casing", () => {
30
+ expect(findContentTypeHeader({ "content-type": "text/csv" })).toBe(
31
+ "text/csv",
32
+ );
33
+ expect(findContentTypeHeader({ "Content-Type": "text/csv" })).toBe(
34
+ "text/csv",
35
+ );
36
+ expect(findContentTypeHeader({ "CONTENT-TYPE": "text/csv" })).toBe(
37
+ "text/csv",
38
+ );
39
+ });
40
+
41
+ test("returns undefined when absent", () => {
42
+ expect(findContentTypeHeader(undefined)).toBeUndefined();
43
+ expect(
44
+ findContentTypeHeader({ Accept: "application/json" }),
45
+ ).toBeUndefined();
46
+ });
47
+ });
48
+
49
+ describe("parseRequestBodyData", () => {
50
+ test("keeps the raw string under a non-JSON Content-Type", () => {
51
+ expect(parseRequestBodyData('{"a":1}', "multipart/related")).toBe(
52
+ '{"a":1}',
53
+ );
54
+ });
55
+
56
+ test("parses under a JSON Content-Type", () => {
57
+ expect(parseRequestBodyData('{"a":1}', "application/json")).toEqual({
58
+ a: 1,
59
+ });
60
+ });
61
+
62
+ test("parses when no Content-Type is given", () => {
63
+ expect(parseRequestBodyData('{"a":1}', undefined)).toEqual({ a: 1 });
64
+ });
65
+
66
+ test("keeps unparseable text raw when no Content-Type is given", () => {
67
+ expect(parseRequestBodyData("a=1&b=2", undefined)).toBe("a=1&b=2");
68
+ });
69
+
70
+ test("keeps a JSON string scalar in its quoted wire form", () => {
71
+ expect(parseRequestBodyData('"hello"', "application/json")).toBe('"hello"');
72
+ expect(parseRequestBodyData('"hello"', undefined)).toBe('"hello"');
73
+ });
74
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared helpers for deciding how an outbound OAuth request body is encoded.
3
+ *
4
+ * A body is either structured (an object or array, serialized as JSON) or
5
+ * raw (a string forwarded to the provider byte-for-byte). The Content-Type
6
+ * the caller supplied decides which one a string body is: multipart, XML,
7
+ * form-encoded, and CSV payloads must survive intact, while JSON text is
8
+ * already in its wire form and must not be re-encoded.
9
+ */
10
+
11
+ /** True when a Content-Type header names a JSON media type. */
12
+ export function isJsonContentType(
13
+ contentType: string | undefined | null,
14
+ ): boolean {
15
+ if (!contentType) {
16
+ return false;
17
+ }
18
+ const mediaType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
19
+ return mediaType === "application/json" || mediaType.endsWith("+json");
20
+ }
21
+
22
+ /** Case-insensitive Content-Type lookup over a plain header map. */
23
+ export function findContentTypeHeader(
24
+ headers: Record<string, string> | undefined,
25
+ ): string | undefined {
26
+ if (!headers) {
27
+ return undefined;
28
+ }
29
+ for (const [key, value] of Object.entries(headers)) {
30
+ if (key.toLowerCase() === "content-type") {
31
+ return value;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Decide how a `-d` payload reaches the provider.
39
+ *
40
+ * With a non-JSON Content-Type the raw string is kept verbatim. Otherwise the
41
+ * string is JSON-parsed so structured bodies keep flowing through the pipeline
42
+ * as objects; text that does not parse stays a string.
43
+ */
44
+ export function parseRequestBodyData(
45
+ raw: string,
46
+ contentType: string | undefined,
47
+ ): unknown {
48
+ if (contentType !== undefined && !isJsonContentType(contentType)) {
49
+ return raw;
50
+ }
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return raw;
56
+ }
57
+ // A JSON string scalar ("hello") would be indistinguishable from a raw
58
+ // body once unquoted, so the original quoted text is kept as the wire form.
59
+ return typeof parsed === "string" ? raw : parsed;
60
+ }
@@ -253,6 +253,7 @@ export async function spawnWorkerProcess(args: {
253
253
  env: workerMemoryEnv(),
254
254
  stdio: ["ignore", "ignore", stderrFd],
255
255
  detached,
256
+ windowsHide: true,
256
257
  });
257
258
 
258
259
  // Close our copy of the log fd — the child has its own.