@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
@@ -2,7 +2,11 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
 
3
3
  import type { Command } from "commander";
4
4
 
5
- import { cliIpcCall, exitFromIpcResult } from "../../../ipc/cli-client.js";
5
+ import { exitFromIpcResult } from "../../../ipc/cli-client.js";
6
+ import {
7
+ findContentTypeHeader,
8
+ parseRequestBodyData,
9
+ } from "../../../util/oauth-request-body.js";
6
10
  import { readStdinSync } from "../../../util/read-stdin.js";
7
11
  import { subcommand } from "../../lib/cli-command-help.js";
8
12
  import { shouldOutputJson, writeOutput } from "../../output.js";
@@ -33,18 +37,6 @@ function parseHeader(raw: string): [string, string] {
33
37
  return [raw.slice(0, idx).trim(), raw.slice(idx + 1).trim()];
34
38
  }
35
39
 
36
- /**
37
- * Attempt to JSON-parse a string. Returns the parsed value on success,
38
- * or the original string on failure.
39
- */
40
- function tryJsonParse(raw: string): unknown {
41
- try {
42
- return JSON.parse(raw);
43
- } catch {
44
- return raw;
45
- }
46
- }
47
-
48
40
  /**
49
41
  * Read body data from the `-d` flag value. Supports:
50
42
  * - `@-` reads stdin
@@ -54,20 +46,28 @@ function tryJsonParse(raw: string): unknown {
54
46
  * File/stdin reading must happen on the CLI side (not the daemon)
55
47
  * since stdin is attached to the CLI process and file paths are
56
48
  * relative to the user's cwd.
49
+ *
50
+ * A non-JSON `Content-Type` keeps the payload as the exact string the caller
51
+ * gave, so multipart, XML, and form-encoded bodies reach the provider
52
+ * unchanged. Files are read as UTF-8 text, so a body that is not valid UTF-8
53
+ * (a raw image, an archive) cannot travel through this flag.
57
54
  */
58
- function readBodyData(data: string): unknown {
55
+ export function readBodyData(
56
+ data: string,
57
+ headers: Record<string, string>,
58
+ ): unknown {
59
+ const contentType = findContentTypeHeader(headers);
60
+
59
61
  if (data === "@-") {
60
- const raw = readStdinSync();
61
- return tryJsonParse(raw);
62
+ return parseRequestBodyData(readStdinSync(), contentType);
62
63
  }
63
64
 
64
65
  if (data.startsWith("@")) {
65
66
  const filePath = data.slice(1);
66
- const raw = readFileSync(filePath, "utf-8");
67
- return tryJsonParse(raw);
67
+ return parseRequestBodyData(readFileSync(filePath, "utf-8"), contentType);
68
68
  }
69
69
 
70
- return tryJsonParse(data);
70
+ return parseRequestBodyData(data, contentType);
71
71
  }
72
72
 
73
73
  // ---------------------------------------------------------------------------
@@ -90,7 +90,7 @@ export function registerRequestCommand(oauth: Command): void {
90
90
  )
91
91
  .option(
92
92
  "-d, --data <data>",
93
- "Request body: inline JSON, @filename, or @- for stdin",
93
+ "Request body: inline JSON, @filename, or @- for stdin. Sent raw when Content-Type is not JSON",
94
94
  )
95
95
  .option("-G, --get", "Force GET; body data becomes query params")
96
96
  .option("-I, --head", "Send a HEAD request")
@@ -172,10 +172,9 @@ export function registerRequestCommand(oauth: Command): void {
172
172
  // Read body data on the CLI side (file/stdin reading must happen here)
173
173
  let parsedData: unknown;
174
174
  if (opts.data !== undefined) {
175
- parsedData = readBodyData(opts.data);
175
+ parsedData = readBodyData(opts.data, parsedHeaders);
176
176
  }
177
177
 
178
- // Build IPC request body
179
178
  const body: Record<string, unknown> = {
180
179
  provider: opts.provider,
181
180
  url,
@@ -202,22 +201,36 @@ export function registerRequestCommand(oauth: Command): void {
202
201
  body.client_id = opts.clientId;
203
202
  }
204
203
 
205
- const r = await cliIpcCall<{
204
+ // Run the route handler in this process so Gmail-sized fetch and
205
+ // JSON parse stay off the assistant event loop.
206
+ const { handleRequest } =
207
+ await import("../../../runtime/routes/oauth-commands-routes.js");
208
+ const { RouteError } =
209
+ await import("../../../runtime/routes/errors.js");
210
+
211
+ let result: {
206
212
  ok: boolean;
207
213
  status: number;
208
214
  headers: Record<string, string>;
209
215
  body: unknown;
216
+ bodyEncoding?: "base64";
210
217
  hint?: string;
211
218
  account?: string | null;
212
219
  accountWarning?: string;
213
- }>("oauth_request", { body });
214
-
215
- if (!r.ok) {
216
- return exitFromIpcResult(r);
220
+ };
221
+ try {
222
+ result = (await handleRequest({ body })) as typeof result;
223
+ } catch (err) {
224
+ if (err instanceof RouteError) {
225
+ return exitFromIpcResult({
226
+ ok: false,
227
+ error: err.message,
228
+ statusCode: err.statusCode,
229
+ });
230
+ }
231
+ throw err;
217
232
  }
218
233
 
219
- const result = r.result!;
220
-
221
234
  // Non-2xx exit code
222
235
  if (result.status < 200 || result.status >= 300) {
223
236
  process.exitCode = 1;
@@ -251,20 +264,23 @@ export function registerRequestCommand(oauth: Command): void {
251
264
  writeInfo(`<`);
252
265
  }
253
266
 
254
- // Body output (skip for null bodies — HEAD requests, 204, etc.)
255
- if (result.body != null) {
256
- const bodyStr =
257
- typeof result.body === "string"
258
- ? result.body
259
- : JSON.stringify(result.body, null, 2);
260
-
261
- if (opts.output) {
262
- writeFileSync(opts.output, bodyStr, "utf-8");
263
- } else {
264
- process.stdout.write(bodyStr + "\n");
267
+ // Body output (skip for null bodies: HEAD requests, 204, etc.)
268
+ if (result.body != null || result.bodyEncoding === "base64") {
269
+ const { materializeOAuthRequestOutput } =
270
+ await import("../../../oauth/connection.js");
271
+ const output = materializeOAuthRequestOutput(result);
272
+ if (output) {
273
+ if (opts.output) {
274
+ writeFileSync(opts.output, output.bytes);
275
+ } else {
276
+ process.stdout.write(output.bytes);
277
+ if (!output.isBinary) {
278
+ process.stdout.write("\n");
279
+ }
280
+ }
265
281
  }
266
282
  } else if (opts.output) {
267
- writeFileSync(opts.output, "", "utf-8");
283
+ writeFileSync(opts.output, Buffer.alloc(0));
268
284
  }
269
285
  } catch (err) {
270
286
  const message = err instanceof Error ? err.message : String(err);
@@ -214,6 +214,19 @@
214
214
  "license": "MIT",
215
215
  "icon": "📣"
216
216
  },
217
+ {
218
+ "name": "imessage",
219
+ "source": {
220
+ "source": "github",
221
+ "repo": "vellum-ai/imessage",
222
+ "ref": "069a8375e2a6ed069550866114a653218864bc91"
223
+ },
224
+ "description": "iMessage and SMS channel for the Vellum assistant, backed by a Photon line.",
225
+ "category": "productivity",
226
+ "homepage": "https://github.com/vellum-ai/imessage",
227
+ "license": "MIT",
228
+ "icon": "📲"
229
+ },
217
230
  {
218
231
  "name": "kitchen-companion",
219
232
  "source": {
@@ -297,7 +310,7 @@
297
310
  "source": {
298
311
  "source": "github",
299
312
  "repo": "vellum-ai/natural",
300
- "ref": "3cfc8c97c14df311fc64992ab8b172c1b2d64eb0"
313
+ "ref": "e71880d144337d1b8b6d3e65ab98006b7d015ef6"
301
314
  },
302
315
  "description": "Hosted MCP access to Natural payment operations for Vellum assistants.",
303
316
  "category": "finance",
@@ -0,0 +1,67 @@
1
+ import type { ChildProcess, spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { describe, expect, test } from "bun:test";
7
+
8
+ import { openInHostBrowser } from "./open-browser.js";
9
+
10
+ describe("openInHostBrowser", () => {
11
+ test("launches the default browser directly on macOS", () => {
12
+ const calls: Array<{
13
+ command: string;
14
+ args: readonly string[];
15
+ options: unknown;
16
+ }> = [];
17
+ let unrefCalled = false;
18
+ const child = new EventEmitter() as ChildProcess;
19
+ child.unref = () => {
20
+ unrefCalled = true;
21
+ return child;
22
+ };
23
+ const spawnImpl = ((
24
+ command: string,
25
+ args: readonly string[],
26
+ options: unknown,
27
+ ) => {
28
+ calls.push({ command, args, options });
29
+ return child;
30
+ }) as typeof spawn;
31
+
32
+ openInHostBrowser("https://example.com/auth", {
33
+ platform: "darwin",
34
+ spawnImpl,
35
+ });
36
+
37
+ expect(calls).toEqual([
38
+ {
39
+ command: "open",
40
+ args: ["https://example.com/auth"],
41
+ options: { detached: true, stdio: "ignore" },
42
+ },
43
+ ]);
44
+ expect(unrefCalled).toBe(true);
45
+ });
46
+
47
+ test("emits a host-client signal on Windows", () => {
48
+ const workspaceDir = mkdtempSync(join(tmpdir(), "open-browser-test-"));
49
+ try {
50
+ openInHostBrowser("https://example.com/auth", {
51
+ platform: "win32",
52
+ workspaceDir,
53
+ });
54
+
55
+ expect(
56
+ JSON.parse(
57
+ readFileSync(join(workspaceDir, "signals", "emit-event"), "utf8"),
58
+ ),
59
+ ).toEqual({
60
+ type: "open_url",
61
+ url: "https://example.com/auth",
62
+ });
63
+ } finally {
64
+ rmSync(workspaceDir, { recursive: true, force: true });
65
+ }
66
+ });
67
+ });
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * CLI-side helper that opens a URL on the user's host machine.
3
3
  *
4
- * Writes an `open_url` event to the `signals/emit-event` file so that the
5
- * assistant's ConfigWatcher picks it up and publishes it to connected
6
- * clients (e.g. the Swift macOS app) via the assistant event hub.
4
+ * On macOS, launches the default browser directly. Other platforms write an
5
+ * `open_url` event so a connected host client can open the URL.
7
6
  *
8
7
  * CLI-initiated emit — no conversation context available, so the inner
9
8
  * message has no `conversationId`. That's fine: `OpenUrlEventSchema`
@@ -13,6 +12,7 @@
13
12
  * Uses only `node:` imports so it's safe for `ipc`-tagged CLI commands.
14
13
  */
15
14
 
15
+ import { spawn } from "node:child_process";
16
16
  import { mkdirSync, writeFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
 
@@ -27,9 +27,28 @@ function getWorkspaceDir(): string {
27
27
  );
28
28
  }
29
29
 
30
- export function openInHostBrowser(url: string): void {
30
+ export function openInHostBrowser(
31
+ url: string,
32
+ deps: {
33
+ platform?: NodeJS.Platform;
34
+ spawnImpl?: typeof spawn;
35
+ workspaceDir?: string;
36
+ } = {},
37
+ ): void {
38
+ if ((deps.platform ?? process.platform) === "darwin") {
39
+ const child = (deps.spawnImpl ?? spawn)("open", [url], {
40
+ detached: true,
41
+ stdio: "ignore",
42
+ });
43
+ child.on("error", () => {
44
+ // The caller prints a fallback URL.
45
+ });
46
+ child.unref();
47
+ return;
48
+ }
49
+
31
50
  try {
32
- const signalsDir = join(getWorkspaceDir(), "signals");
51
+ const signalsDir = join(deps.workspaceDir ?? getWorkspaceDir(), "signals");
33
52
  mkdirSync(signalsDir, { recursive: true });
34
53
  writeFileSync(
35
54
  join(signalsDir, "emit-event"),
@@ -4,6 +4,7 @@ import { describe, expect, mock, test } from "bun:test";
4
4
  // kind, so the tests control the row store directly.
5
5
  const connectionRows = new Map<string, { name: string; provider: string }>([
6
6
  ["anthropic-personal", { name: "anthropic-personal", provider: "anthropic" }],
7
+ ["local-ollama", { name: "local-ollama", provider: "ollama" }],
7
8
  ]);
8
9
  mock.module("../../persistence/db-connection.js", () => ({
9
10
  getDb: () => ({}),
@@ -36,6 +37,31 @@ const fullDefault = LLMConfigBase.parse({
36
37
  });
37
38
 
38
39
  describe("completeCustomProfile", () => {
40
+ test("clamps a filled maxTokens to the model's catalog output cap", () => {
41
+ const completed = completeCustomProfile(fullDefault, {
42
+ provider: "ollama",
43
+ model: "llama3.2",
44
+ });
45
+ expect(completed.maxTokens).toBe(4096);
46
+ });
47
+
48
+ test("clamps a filled maxTokens for an entry-name provider via its row's kind", () => {
49
+ const completed = completeCustomProfile(fullDefault, {
50
+ provider: "local-ollama",
51
+ model: "llama3.2",
52
+ });
53
+ expect(completed.maxTokens).toBe(4096);
54
+ });
55
+
56
+ test("never clamps an explicit maxTokens", () => {
57
+ const completed = completeCustomProfile(fullDefault, {
58
+ provider: "ollama",
59
+ model: "llama3.2",
60
+ maxTokens: 64000,
61
+ });
62
+ expect(completed.maxTokens).toBe(64000);
63
+ });
64
+
39
65
  test("inherits omitted scalar fields from the default", () => {
40
66
  const completed = completeCustomProfile(fullDefault, {
41
67
  model: "claude-fable-5",
@@ -80,6 +80,12 @@ The system has a 30-second silence timeout. If nobody speaks for 30 seconds duri
80
80
  - Verify `services.tts.providers.elevenlabs.voiceId` is set to a valid ElevenLabs voice ID
81
81
  - Ask for the desired voice style again and try a different voice selection
82
82
 
83
+ ## Inbound call asks for a name or says "I don't recognize this number"
84
+
85
+ This is the Phone channel trust floor, not a Twilio or webhook failure. The call reached the assistant. Phone defaults to **Verified contacts**, so unknown callers hear that greeting and you get an access request instead of a normal conversation.
86
+
87
+ Load `twilio-setup` and follow its Channel Trust Floors section. Change **Who can message** on **Channels → Phone**, or verify the caller, rather than retuning ingress.
88
+
83
89
  ## Twilio says "application error" right after answer
84
90
 
85
91
  - This often means the voice webhook failed or returned invalid TwiML, or the media-stream WebSocket could not connect
@@ -158,7 +158,7 @@ Optionally pass `routing_hints` (a JSON object) to influence routing decisions (
158
158
  2. **`interface` fallback** — if `source_channel` is absent (common for guardian/direct users), map the `interface` value to a channel name:
159
159
  | `interface` value | Channel name |
160
160
  | --- | --- |
161
- | `macos`, `ios` | `vellum` |
161
+ | `macos`, `windows`, `ios` | `vellum` |
162
162
  | `telegram` | `telegram` |
163
163
  | `slack` | `slack` |
164
164
  | `cli` | _(omit — no routable channel)_ |
@@ -92,6 +92,14 @@
92
92
  "description": "Enable the Quick Input popover on right-click of the menu bar icon",
93
93
  "defaultEnabled": false
94
94
  },
95
+ {
96
+ "id": "teach",
97
+ "scope": "client",
98
+ "key": "teach",
99
+ "label": "Teach",
100
+ "description": "Offer Teach, the session that reads the user's screen while they narrate what they are doing. Gates the companion surface's Teach control and the toggleWatch command that starts a session; evaluated in the app's window and read again in the Electron main process, which pushes it to the floating surface",
101
+ "defaultEnabled": false
102
+ },
95
103
  {
96
104
  "id": "expand-completed-steps",
97
105
  "scope": "client",
@@ -100,6 +108,14 @@
100
108
  "description": "Auto-expand completed tool call step groups instead of showing them collapsed",
101
109
  "defaultEnabled": false
102
110
  },
111
+ {
112
+ "id": "inline-assistant-intermediates",
113
+ "scope": "client",
114
+ "key": "inline-assistant-intermediates",
115
+ "label": "Inline Assistant Intermediates",
116
+ "description": "Render a completed response's intermediate activity inline at full size instead of collapsing it behind the Earlier activity disclosure",
117
+ "defaultEnabled": false
118
+ },
103
119
  {
104
120
  "id": "backward-releases",
105
121
  "scope": "assistant",
@@ -145,7 +161,7 @@
145
161
  "scope": "client",
146
162
  "key": "internal-thread-actions",
147
163
  "label": "Internal Thread Actions",
148
- "description": "Show the internal thread affordances: 'Fork from here' on messages, 'Fork Conversation', 'Analyze Conversation', and 'Copy conversation ID' in the thread menu, plus the LLM inspector surfaces. The flag is the sole gate: any session that enables it gets the affordances, including local sessions with no platform identity.",
164
+ "description": "Show the internal thread affordances: 'Fork from here' and 'Summarize up to here' on messages, 'Fork Conversation', 'Analyze Conversation', and 'Copy conversation ID' in the thread menu, plus the LLM inspector surfaces. The flag is the sole gate: any session that enables it gets the affordances, including local sessions with no platform identity.",
149
165
  "defaultEnabled": false
150
166
  },
151
167
  {
@@ -1,7 +1,9 @@
1
1
  import { getDb } from "../persistence/db-connection.js";
2
+ import { catalogProviderForProfile } from "../providers/connection-resolution.js";
2
3
  import { ROUTING_IDENTITY_PROVIDERS } from "../providers/inference/auth.js";
3
4
  import { getConnection } from "../providers/inference/connections.js";
4
5
  import {
6
+ catalogMaxOutputTokens,
5
7
  getCatalogProviderForModel,
6
8
  isModelInCatalog,
7
9
  } from "../providers/model-catalog.js";
@@ -100,6 +102,33 @@ export function completeCustomProfile(
100
102
  }
101
103
  }
102
104
 
105
+ // A FILLED maxTokens expresses no user preference, so it is clamped to the
106
+ // model's catalog output cap — an over-cap fill (e.g. 64000 onto a
107
+ // 4096-cap model) would make every request fail upstream. Runs after the
108
+ // provider implication above so the cap is read from the provider the
109
+ // profile actually dispatches with. An explicit profile value is never
110
+ // touched, and models the catalog does not know keep the default (the
111
+ // save-time probe covers those). Mirrors `clampMaxTokensToModelCap` for
112
+ // the code-owned default profiles.
113
+ if (
114
+ profile.maxTokens === undefined &&
115
+ completed.maxTokens !== undefined &&
116
+ completed.provider !== undefined &&
117
+ completed.model !== undefined
118
+ ) {
119
+ const catalogProvider = catalogProviderForProfile(
120
+ completed.provider,
121
+ completed.model,
122
+ );
123
+ const cap =
124
+ catalogProvider !== null
125
+ ? catalogMaxOutputTokens(catalogProvider, completed.model)
126
+ : undefined;
127
+ if (cap !== undefined && completed.maxTokens > cap) {
128
+ completed.maxTokens = cap;
129
+ }
130
+ }
131
+
103
132
  // Completion never stamps a `provider_connection`; an inherited binding
104
133
  // would only re-introduce the collapsed field on disk. The default's
105
134
  // explicit binding is instead inherited IN the provider value, the
@@ -1,3 +1,9 @@
1
+ import {
2
+ INGRESS_ASSISTANT_ID_KEY,
3
+ INGRESS_LAST_TUNNEL_KEY,
4
+ INGRESS_PAIRING_TUNNEL_KEY,
5
+ } from "@vellumai/service-contracts/ingress";
6
+
1
7
  /**
2
8
  * Strips environment-specific fields from config JSON before transferring
3
9
  * between local and platform environments (teleport/restore).
@@ -6,6 +12,9 @@
6
12
  * - `ingress.publicBaseUrl` → set to `""`
7
13
  * - `ingress.enabled` → deleted
8
14
  * - `ingress.publicBaseUrlManagedBy` → deleted
15
+ * - `ingress.lastTunnel` → deleted
16
+ * - `ingress.pairingTunnel` → deleted
17
+ * - `ingress.assistantId` → deleted
9
18
  * - `telegram.registeredWebhookUrl` → deleted
10
19
  * - `daemon` → deleted entirely
11
20
  * - `skills.load.extraDirs` → set to `[]`
@@ -44,6 +53,13 @@ export function sanitizeConfigForTransfer(configJson: string): string {
44
53
  ingress.publicBaseUrl = "";
45
54
  delete ingress.enabled;
46
55
  delete ingress.publicBaseUrlManagedBy;
56
+ // The remembered tunnels and the assistant they fronted belong to the
57
+ // source host; carrying them over makes tunnel-status UIs name the old
58
+ // deployment, and the pairing record would have the destination advertise
59
+ // an address on the source's tailnet.
60
+ delete ingress[INGRESS_LAST_TUNNEL_KEY];
61
+ delete ingress[INGRESS_PAIRING_TUNNEL_KEY];
62
+ delete ingress[INGRESS_ASSISTANT_ID_KEY];
47
63
  }
48
64
 
49
65
  // Strip the recorded Telegram webhook URL. It records where *this*
@@ -604,6 +604,13 @@ export const ProfileEntry = LLMConfigFragment.extend({
604
604
  * not yet backfilled by the boot-time migration.
605
605
  */
606
606
  provider_connection: z.string().min(1).optional(),
607
+ /**
608
+ * The profile was deliberately created for a model the catalog does not
609
+ * list (the write routes' allowUnlisted escape hatch). Stamped at write
610
+ * time so listings do not flag the row as misconfigured on every read;
611
+ * a model the checks can vouch for never needs it.
612
+ */
613
+ allowUnlisted: z.boolean().optional(),
607
614
  /**
608
615
  * Absent means active. `.nullable()` matches `label` so the PUT route's
609
616
  * "send `null` to clear" sentinel works for status too — a managed
@@ -76,6 +76,9 @@ const WebSearchServiceSchema = z.object({
76
76
  provider: z
77
77
  .enum(VALID_WEB_SEARCH_PROVIDERS)
78
78
  .default("inference-provider-native"),
79
+ // Origin for providers that support a custom API base (e.g. fastCRW).
80
+ // Empty / omitted uses the provider's cloud default.
81
+ apiBase: z.string().optional(),
79
82
  });
80
83
 
81
84
  /**
@@ -88,6 +91,9 @@ const WebFetchServiceSchema = z.object({
88
91
  // `firecrawl`) scrape via their hosted API and reuse the same stored key as
89
92
  // their web-search counterpart.
90
93
  provider: z.enum(VALID_WEB_FETCH_PROVIDERS).default("default"),
94
+ // Origin for providers that support a custom API base (e.g. fastCRW).
95
+ // Empty / omitted uses the provider's cloud default.
96
+ apiBase: z.string().optional(),
91
97
  });
92
98
 
93
99
  const GoogleOAuthServiceSchema = BaseServiceSchema.extend({
@@ -26,6 +26,12 @@ const AX_TREE_PLACEHOLDER = "<ax_tree_omitted />";
26
26
  * prematurely when the user happens to be viewing XML/HTML source that
27
27
  * contains the closing tag. The escaped content does not need to be
28
28
  * unescaped because compaction replaces the entire block with a placeholder.
29
+ *
30
+ * Deliberately narrower than `escapeTagBoundaries` in
31
+ * `security/untrusted-content.ts`, which is the general defense for fencing
32
+ * untrusted text: this one leaves the opening tag intact because
33
+ * {@link AX_TREE_PATTERN} needs it to find whole blocks. Do not fold the two
34
+ * together.
29
35
  */
30
36
  export function escapeAxTreeContent(content: string): string {
31
37
  return content.replace(/<\/ax-tree>/gi, "&lt;/ax-tree&gt;");
@@ -0,0 +1,98 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, mock, test } from "bun:test";
4
+
5
+ /**
6
+ * The watch-timeline sweep's startup contract.
7
+ *
8
+ * `drainOrphanedWatchTimelineEntries` is the recovery path for a purge that
9
+ * did not happen, and the rows it reclaims hold narration, AX trees, and
10
+ * screenshots of the user's screen. Database maintenance runs it on a periodic
11
+ * cadence from the memory plugin's jobs worker, so an install with that plugin
12
+ * disabled or `memory.enabled: false` reaches it never. Daemon startup runs it
13
+ * on every install and closes that gap.
14
+ *
15
+ * Two properties carry the contract, and each is checked the way it can be:
16
+ *
17
+ * 1. lifecycle calls the sweep on its DB-ready startup path, after migrations
18
+ * have settled. Mounting the full lifecycle import graph to observe a real
19
+ * boot is disproportionately heavy, so this is a source guard over the call
20
+ * site.
21
+ * 2. a throwing sweep does not abort startup. The wiring below mirrors the
22
+ * lifecycle call site verbatim.
23
+ */
24
+
25
+ const LIFECYCLE_PATH = join(import.meta.dir, "..", "lifecycle.ts");
26
+
27
+ describe("lifecycle watch-timeline sweep wiring", () => {
28
+ test("lifecycle sweeps orphaned entries on the DB-ready startup path", () => {
29
+ const source = readFileSync(LIFECYCLE_PATH, "utf8");
30
+
31
+ const sweepIndex = source.indexOf("drainOrphanedWatchTimelineEntries()");
32
+ expect(sweepIndex).toBeGreaterThan(-1);
33
+
34
+ // The drain yields between pages, so the call has to be awaited. Without
35
+ // the await the count is a pending promise, which compares false against
36
+ // zero, and the pass reports nothing swept however much it removed.
37
+ expect(source).toContain("await drainOrphanedWatchTimelineEntries()");
38
+
39
+ // `startRuntimeHttpServerBackgroundSweeps()` is the point where lifecycle
40
+ // has established that migrations settled, so a call after it runs against
41
+ // a database whose tables exist.
42
+ const migrationsSettledIndex = source.indexOf(
43
+ "startRuntimeHttpServerBackgroundSweeps();",
44
+ );
45
+ expect(migrationsSettledIndex).toBeGreaterThan(-1);
46
+ expect(sweepIndex).toBeGreaterThan(migrationsSettledIndex);
47
+
48
+ // The call sits under a `dbReady` guard, so a boot whose DB never opened
49
+ // does not reach it.
50
+ const guardIndex = source.lastIndexOf("if (dbReady) {", sweepIndex);
51
+ expect(guardIndex).toBeGreaterThan(migrationsSettledIndex);
52
+ });
53
+
54
+ test("invokes the sweep once and logs only when it reclaimed rows", () => {
55
+ const sweep = mock(() => 0);
56
+
57
+ const quiet = runStartupSweepWiring(sweep);
58
+ expect(sweep).toHaveBeenCalledTimes(1);
59
+ expect(quiet.logged).toBe(false);
60
+ expect(quiet.reachedNextStep).toBe(true);
61
+
62
+ const noisy = runStartupSweepWiring(mock(() => 3));
63
+ expect(noisy.logged).toBe(true);
64
+ expect(noisy.reachedNextStep).toBe(true);
65
+ });
66
+
67
+ test("a throwing sweep does NOT abort startup", () => {
68
+ const sweep = mock(() => {
69
+ throw new Error("sweep exploded");
70
+ });
71
+
72
+ const { reachedNextStep, logged } = runStartupSweepWiring(sweep);
73
+
74
+ expect(sweep).toHaveBeenCalledTimes(1);
75
+ expect(logged).toBe(false);
76
+ expect(reachedNextStep).toBe(true);
77
+ });
78
+ });
79
+
80
+ /**
81
+ * Replicates the lifecycle startup wiring: sweep, log only a non-zero count,
82
+ * swallow any throw, then continue to the next startup step.
83
+ */
84
+ function runStartupSweepWiring(sweep: () => number): {
85
+ reachedNextStep: boolean;
86
+ logged: boolean;
87
+ } {
88
+ let logged = false;
89
+ try {
90
+ const sweptWatchEntries = sweep();
91
+ if (sweptWatchEntries > 0) {
92
+ logged = true;
93
+ }
94
+ } catch {
95
+ // Startup must never block on a subsystem failure.
96
+ }
97
+ return { reachedNextStep: true, logged };
98
+ }