@vellumai/assistant 0.9.0 → 0.9.1-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 (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -24,7 +24,7 @@ import * as appCreate from "./bundled-skills/app-builder/tools/app-create.js";
24
24
  import * as appDelete from "./bundled-skills/app-builder/tools/app-delete.js";
25
25
  import * as appGenerateIcon from "./bundled-skills/app-builder/tools/app-generate-icon.js";
26
26
  import * as appList from "./bundled-skills/app-builder/tools/app-list.js";
27
- import * as appBuilder_appRefresh from "./bundled-skills/app-builder/tools/app-refresh.js";
27
+ import * as appRefresh from "./bundled-skills/app-builder/tools/app-refresh.js";
28
28
  import * as appUpdate from "./bundled-skills/app-builder/tools/app-update.js";
29
29
  // ── app-control ────────────────────────────────────────────────────────────────
30
30
  import * as appControlClick from "./bundled-skills/app-control/tools/app-control-click.js";
@@ -88,8 +88,6 @@ import * as messagingRead from "./bundled-skills/messaging/tools/messaging-read.
88
88
  import * as messagingSearch from "./bundled-skills/messaging/tools/messaging-search.js";
89
89
  import * as messagingSend from "./bundled-skills/messaging/tools/messaging-send.js";
90
90
  import * as messagingSenderDigest from "./bundled-skills/messaging/tools/messaging-sender-digest.js";
91
- // ── personal-page ──────────────────────────────────────────────────────────────
92
- import * as personalPage_appRefresh from "./bundled-skills/personal-page/tools/app-refresh.js";
93
91
  // ── phone-calls ────────────────────────────────────────────────────────────────
94
92
  import * as callEnd from "./bundled-skills/phone-calls/tools/call-end.js";
95
93
  import * as callStart from "./bundled-skills/phone-calls/tools/call-start.js";
@@ -148,7 +146,7 @@ export const bundledToolRegistry = new Map<string, SkillToolScript>([
148
146
  ["app-builder:tools/app-create.ts", appCreate],
149
147
  ["app-builder:tools/app-update.ts", appUpdate],
150
148
  ["app-builder:tools/app-delete.ts", appDelete],
151
- ["app-builder:tools/app-refresh.ts", appBuilder_appRefresh],
149
+ ["app-builder:tools/app-refresh.ts", appRefresh],
152
150
  ["app-builder:tools/app-generate-icon.ts", appGenerateIcon],
153
151
  ["app-builder:tools/app-list.ts", appList],
154
152
 
@@ -228,9 +226,6 @@ export const bundledToolRegistry = new Map<string, SkillToolScript>([
228
226
  ["messaging:tools/messaging-sender-digest.ts", messagingSenderDigest],
229
227
  ["messaging:tools/messaging-archive-by-sender.ts", messagingArchiveBySender],
230
228
 
231
- // personal-page
232
- ["personal-page:tools/app-refresh.ts", personalPage_appRefresh],
233
-
234
229
  // phone-calls
235
230
  ["phone-calls:tools/call-start.ts", callStart],
236
231
  ["phone-calls:tools/call-status.ts", callStatus],
@@ -1,7 +1,13 @@
1
1
  import { type LLMCallSite } from "./schemas/llm.js";
2
2
 
3
3
  type CallSiteDefaultConfig = {
4
- profile: string;
4
+ /**
5
+ * Named profile the call site resolves to. Omit to inherit the workspace
6
+ * default config (`llm.default`, with the active profile applied) — used for
7
+ * call sites that must resolve to a credentialed provider on every install
8
+ * rather than pinning a profile that may be unavailable.
9
+ */
10
+ profile?: string;
5
11
  maxTokens?: number;
6
12
  effort?: "none" | "low" | "medium" | "high" | "xhigh" | "max";
7
13
  temperature?: number | null;
@@ -125,8 +131,12 @@ export const CALL_SITE_DEFAULTS: Record<LLMCallSite, CallSiteDefaultConfig> = {
125
131
  thinking: { enabled: false },
126
132
  disableCache: true,
127
133
  },
134
+ // Anonymous and schema leaves inherit the workspace default config (no pinned
135
+ // profile) so they always resolve to a credentialed provider. Pinning a
136
+ // managed profile like `cost-optimized` breaks BYOK installs where the managed
137
+ // profiles are uncredentialed. A per-leaf `profile` option or a `workflowLeaf`
138
+ // call-site override still takes precedence for cost control.
128
139
  workflowLeaf: {
129
- profile: "cost-optimized",
130
140
  effort: "low",
131
141
  thinking: { enabled: false },
132
142
  },
@@ -9,14 +9,6 @@
9
9
  "description": "Automatically trigger conversation analysis on the same cadence as memory extraction (batch threshold, idle debounce, end-of-conversation). The analysis agent has full tool access and writes back to memory and skills without user approval.",
10
10
  "defaultEnabled": false
11
11
  },
12
- {
13
- "id": "memory-retrospective-fork",
14
- "scope": "assistant",
15
- "key": "memory-retrospective-fork",
16
- "label": "Fork-based memory retrospective",
17
- "description": "Fork the source conversation through its latest message for memory retrospectives, instead of rendering the slice into a transcript and waking an empty background conversation. The retrospective reads the conversation natively, including any inherited compaction summary and tail messages. Provider prompt-cache reuse additionally requires memory.retrospective.matchConversationProfile.",
18
- "defaultEnabled": false
19
- },
20
12
  {
21
13
  "id": "user-hosted-enabled",
22
14
  "scope": "client",
@@ -39,7 +31,7 @@
39
31
  "scope": "both",
40
32
  "key": "experiment-activation-flow-2026-06-03",
41
33
  "label": "Activation Flow Experiment 2026-06-03",
42
- "description": "Multivariate activation-flow experiment. control = standard flow; variant-a = activation rail; personal-page = preseeded personal-page app (read by the daemon, hence scope \"both\"). Targeted via LaunchDarkly.",
34
+ "description": "Multivariate activation-flow experiment. control = standard flow; variant-a = activation rail; personal-page = new sign-up-page variant (front-end only). Targeted via LaunchDarkly.",
43
35
  "defaultEnabled": "control",
44
36
  "values": ["control", "variant-a", "personal-page"]
45
37
  },
@@ -410,14 +402,6 @@
410
402
  "label": "Self-intro first message",
411
403
  "description": "On the first conversation, send a natural self-introduction (e.g. \"Hi Vela, I'm alex. Nice to meet you.\") on the user's behalf and route it through real LLM inference, instead of serving the canned first greeting. Names come from the onboarding context; falls back to the canned greeting when no name is known. Exposed to clients so pre-hatch onboarding can compute the source-of-truth initial message, and to the assistant so older clients can still be gated server-side. See assistant/src/daemon/first-greeting.ts (buildSelfIntroMessage).",
412
404
  "defaultEnabled": false
413
- },
414
- {
415
- "id": "workflows",
416
- "scope": "assistant",
417
- "key": "workflows",
418
- "label": "Assistant Workflows",
419
- "description": "Enable the sandboxed workflow orchestration engine (script-driven parallel leaf agents).",
420
- "defaultEnabled": false
421
405
  }
422
406
  ]
423
407
  }
@@ -0,0 +1,26 @@
1
+ import { getConfigReadOnly } from "./loader.js";
2
+
3
+ /**
4
+ * Validate an inference-profile key against the configured `llm.profiles`
5
+ * catalog. Returns a user-facing error message when the key is empty or
6
+ * unknown, or `null` when valid.
7
+ */
8
+ export function validateInferenceProfileKey(profile: string): string | null {
9
+ if (!profile.trim()) {
10
+ return "inferenceProfile must be a non-empty string";
11
+ }
12
+ const profiles = getConfigReadOnly().llm?.profiles ?? {};
13
+ const entry = profiles[profile];
14
+ if (entry === undefined) {
15
+ const available = Object.keys(profiles).sort();
16
+ const hint =
17
+ available.length > 0
18
+ ? ` Available profiles: ${available.join(", ")}.`
19
+ : " No profiles defined in llm.profiles.";
20
+ return `Inference profile "${profile}" is not defined in llm.profiles.${hint}`;
21
+ }
22
+ if (entry.status === "disabled") {
23
+ return `Inference profile "${profile}" is disabled.`;
24
+ }
25
+ return null;
26
+ }
@@ -362,6 +362,10 @@ const DEPRECATED_FIELDS: Record<string, string> = {
362
362
  "per-lane concurrency caps (slowLlmConcurrency, fastConcurrency, " +
363
363
  "embedConcurrency) instead of a single batch size. " +
364
364
  "The field will be removed from your config file.",
365
+ "daemon.reapOrphanedSubprocesses":
366
+ "daemon.reapOrphanedSubprocesses has been removed. The daemon now reaps " +
367
+ "orphaned subprocesses automatically whenever it runs as PID 1 on Linux. " +
368
+ "The field will be removed from your config file.",
365
369
  };
366
370
 
367
371
  /**
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Presentation ordering for inference profiles. `llm.profileOrder` lists the
3
+ * keys a workspace wants surfaced first; the resolver ignores it, so it exists
4
+ * purely to keep profile pickers consistent across surfaces.
5
+ */
6
+
7
+ /**
8
+ * Order profile keys for presentation: keys named in `profileOrder` first
9
+ * (deduped, and only those that resolve to a real profile), then the remaining
10
+ * keys alphabetically.
11
+ */
12
+ export function orderProfileKeys(
13
+ profiles: Record<string, unknown>,
14
+ profileOrder: readonly string[] | undefined,
15
+ ): string[] {
16
+ const seen = new Set<string>();
17
+ const ordered: string[] = [];
18
+ for (const name of profileOrder ?? []) {
19
+ if (profiles[name] != null && !seen.has(name)) {
20
+ ordered.push(name);
21
+ seen.add(name);
22
+ }
23
+ }
24
+ const tail = Object.keys(profiles)
25
+ .filter((n) => !seen.has(n))
26
+ .sort();
27
+ return [...ordered, ...tail];
28
+ }
@@ -1,6 +1,5 @@
1
1
  // Default ElevenLabs voice — "Amelia" (expressive, enthusiastic, British English).
2
2
  // Used by both in-app TTS and phone calls (via Twilio ConversationRelay).
3
- // Mirrored in: clients/macos/.../OpenAIVoiceService.swift (defaultVoiceId)
4
3
  export const DEFAULT_ELEVENLABS_VOICE_ID = "ZF6FPAbjXT4488VcRRnw";
5
4
 
6
5
  /** Valid conversation timeout values (seconds). Shared with voice-config-update tool. */
@@ -87,14 +87,6 @@ export const DaemonConfigSchema = z
87
87
  .describe(
88
88
  "Whether the daemon records conversations even when no client is connected",
89
89
  ),
90
- reapOrphanedSubprocesses: z
91
- .boolean({
92
- error: "daemon.reapOrphanedSubprocesses must be a boolean",
93
- })
94
- .default(false)
95
- .describe(
96
- "Whether the daemon, when running as PID 1 in a container, periodically reaps orphaned subprocesses that reparented to it. Off by default while the behavior is being validated.",
97
- ),
98
90
  })
99
91
  .describe("Background daemon process configuration");
100
92
 
@@ -167,10 +167,13 @@ export type SeedInferenceProfilesOptions = {
167
167
  *
168
168
  * Runs on every daemon startup. Two responsibilities:
169
169
  *
170
- * 1. **Managed profiles** (`balanced`, `quality-optimized`, `cost-optimized`):
171
- * overwritten on every boot so Vellum can push model/config updates to
172
- * customers. Each carries `provider_connection: "anthropic-managed"`.
173
- * Platform overlays (`preserveProfileNames`) take precedence.
170
+ * 1. **Managed profiles** (`balanced`, `quality-optimized`, `cost-optimized`,
171
+ * `balanced-economy`): reconciled from the code templates on every boot —
172
+ * on-platform and off-platform alike — so Vellum can push model/config
173
+ * updates to customers in a release without a workspace migration. The
174
+ * templates own all profile content; only `label` and `status` are
175
+ * user-overridable and survive reseeds. Platform overlays
176
+ * (`preserveProfileNames`) take precedence for the boot they are supplied.
174
177
  *
175
178
  * 2. **User profiles** (`custom-balanced`, `custom-quality-optimized`,
176
179
  * `custom-cost-optimized`): materialized once at hatch time for
@@ -208,22 +211,25 @@ export function seedInferenceProfiles(
208
211
  // subsequent boot.
209
212
  const isByokMode = !isPlatform;
210
213
 
211
- // 1. Managed profiles. Off-platform: overwrite on every boot so Vellum can
212
- // push model/config updates in new releases. On-platform: insert only if
213
- // absent — the platform controls profiles through overlays, and the
214
- // overlay fragment is authoritative even when it omits fields the local
215
- // template carries (e.g. an overlay supplying only provider/model/label
216
- // must not get its maxTokens/thinking polluted from the template). The
217
- // legacy migration-052 backfill that seeds label-less Anthropic
218
- // defaults is healed by workspace migration 082
219
- // (`backfill-managed-profile-labels`) rather than the seeder, so
220
- // this skip path stays simple.
214
+ // 1. Managed profiles. Reconciled from the code templates on every boot —
215
+ // on-platform and off-platform alike — so Vellum can push model/config
216
+ // updates in new releases just by editing `MANAGED_PROFILE_TEMPLATES` /
217
+ // `model-intents.ts` and shipping a release, with no workspace migration.
218
+ // The templates are the single source of truth for profile *content*
219
+ // (model, maxTokens, effort, thinking, description, provider/connection).
221
220
  //
222
- // Two user-editable fields survive the overwrite: `label` (display
223
- // rename) and `status` (active/disabled toggle). The PUT route
224
- // `/v1/config/llm/profiles/:name` lets users patch these on managed
225
- // profiles without duplicating; we have to honor those edits across
226
- // reseeds or they'd silently revert on every boot. Carry by
221
+ // Platform overlays (`preserveProfileNames`) still take precedence for the
222
+ // boot they are supplied: a profile named in the overlay is skipped here so
223
+ // the overlay fragment lands verbatim and is never polluted by template
224
+ // fields it omits. The overlay is a one-time hatch input (archived after
225
+ // its first merge), so on subsequent boots the templates reconcile content
226
+ // as usual.
227
+ //
228
+ // Two user-editable fields survive the reconcile: `label` (display
229
+ // rename) and `status` (active/disabled toggle) — the only fields a user
230
+ // may override. The PUT route `/v1/config/llm/profiles/:name` lets users
231
+ // patch these on managed profiles without duplicating; we honor those
232
+ // edits across reseeds or they'd silently revert on every boot. Carry by
227
233
  // key-presence rather than truthiness so an explicit `null` (user
228
234
  // cleared the label) survives too.
229
235
  //
@@ -249,7 +255,6 @@ export function seedInferenceProfiles(
249
255
 
250
256
  for (const [name, template] of Object.entries(MANAGED_PROFILE_TEMPLATES)) {
251
257
  if (preservedProfileNames.has(name)) continue;
252
- if (isPlatform && readObject(profiles[name]) !== null) continue;
253
258
 
254
259
  const previous = readObject(profiles[name]);
255
260
  const effectiveTemplate: ManagedProfileTemplate = isByokMode
@@ -1,12 +1,14 @@
1
1
  import { and, asc, desc, eq, isNotNull, like, sql } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
+ import type { ChannelId } from "../channels/types.js";
4
5
  import { getDb } from "../memory/db-connection.js";
5
6
  import {
6
7
  assistantContactMetadata,
7
8
  contactChannels,
8
9
  contacts,
9
10
  } from "../memory/schema.js";
11
+ import { canonicalizeInboundIdentity } from "../util/canonicalize-identity.js";
10
12
  import { emitContactChange } from "./contact-events.js";
11
13
  import type {
12
14
  AssistantContactMetadata,
@@ -27,6 +29,27 @@ function escapeLike(value: string): string {
27
29
  return value.replace(/%/g, "").replace(/_/g, "");
28
30
  }
29
31
 
32
+ /**
33
+ * Find the first contact_channels row whose (type, address) matches.
34
+ * Uses COLLATE NOCASE to find legacy lowercased rows (pre-migration 290).
35
+ */
36
+ function findConflictingChannel(
37
+ db: ReturnType<typeof getDb>,
38
+ type: string,
39
+ address: string,
40
+ ) {
41
+ return db
42
+ .select()
43
+ .from(contactChannels)
44
+ .where(
45
+ and(
46
+ eq(contactChannels.type, type),
47
+ sql`${contactChannels.address} = ${address} COLLATE NOCASE`,
48
+ ),
49
+ )
50
+ .get();
51
+ }
52
+
30
53
  /**
31
54
  * Pure slug transform applied to a display name. No DB lookup, no collision
32
55
  * handling — callers that need a collision-free filename should use
@@ -152,15 +175,7 @@ interface SyncChannelData {
152
175
 
153
176
  // ── CRUD ─────────────────────────────────────────────────────────────
154
177
 
155
- /** Retrieve a contact by ID.
156
- * Used by functions that have already resolved identity through channel lookups. */
157
- export function getContactInternal(id: string): ContactWithChannels | null {
158
- const db = getDb();
159
- const row = db.select().from(contacts).where(eq(contacts.id, id)).get();
160
- if (!row) return null;
161
- return withChannels(parseContact(row));
162
- }
163
-
178
+ /** Retrieve a contact by ID. */
164
179
  export function getContact(id: string): ContactWithChannels | null {
165
180
  const db = getDb();
166
181
  const row = db.select().from(contacts).where(eq(contacts.id, id)).get();
@@ -168,6 +183,9 @@ export function getContact(id: string): ContactWithChannels | null {
168
183
  return withChannels(parseContact(row));
169
184
  }
170
185
 
186
+ /** @deprecated Use {@link getContact} directly. */
187
+ export const getContactInternal = getContact;
188
+
171
189
  /**
172
190
  * Look up a single contact channel by its primary key.
173
191
  * Returns the parsed channel row, or null if it does not exist.
@@ -199,6 +217,15 @@ export function upsertContact(params: {
199
217
  const db = getDb();
200
218
  const now = Date.now();
201
219
 
220
+ // Canonicalize all channel addresses up front so every downstream path
221
+ // (lookups, inserts, conflict checks) uses the canonical form.
222
+ const canonicalChannels = params.channels?.map((ch) => ({
223
+ ...ch,
224
+ address:
225
+ canonicalizeInboundIdentity(ch.type as ChannelId, ch.address) ??
226
+ ch.address,
227
+ }));
228
+
202
229
  let contactId = params.id;
203
230
 
204
231
  // If an ID is provided, check if the contact exists for update
@@ -226,10 +253,10 @@ export function upsertContact(params: {
226
253
  .where(eq(contacts.id, contactId))
227
254
  .run();
228
255
 
229
- if (params.channels) {
256
+ if (canonicalChannels) {
230
257
  syncChannels(
231
258
  contactId,
232
- params.channels,
259
+ canonicalChannels,
233
260
  now,
234
261
  params.reassignConflictingChannels,
235
262
  );
@@ -240,20 +267,10 @@ export function upsertContact(params: {
240
267
  }
241
268
  }
242
269
 
243
- // Try to find by channel address to avoid duplicates
244
- if (!contactId && params.channels && params.channels.length > 0) {
245
- for (const ch of params.channels) {
246
- // Primary lookup: match by (type, address)
247
- const existingChannel = db
248
- .select()
249
- .from(contactChannels)
250
- .where(
251
- and(
252
- eq(contactChannels.type, ch.type),
253
- eq(contactChannels.address, ch.address.toLowerCase()),
254
- ),
255
- )
256
- .get();
270
+ // Try to find by channel canonical identity to avoid duplicates
271
+ if (!contactId && canonicalChannels && canonicalChannels.length > 0) {
272
+ for (const ch of canonicalChannels) {
273
+ const existingChannel = findConflictingChannel(db, ch.type, ch.address);
257
274
 
258
275
  if (existingChannel) {
259
276
  contactId = existingChannel.contactId;
@@ -274,7 +291,7 @@ export function upsertContact(params: {
274
291
  .where(eq(contacts.id, contactId))
275
292
  .run();
276
293
 
277
- syncChannels(contactId, params.channels, now);
294
+ syncChannels(contactId, canonicalChannels, now);
278
295
  emitContactChange();
279
296
  return { ...getContactInternal(contactId)!, created: false };
280
297
  }
@@ -318,10 +335,10 @@ export function upsertContact(params: {
318
335
  })
319
336
  .run();
320
337
 
321
- if (params.channels) {
338
+ if (canonicalChannels) {
322
339
  syncChannels(
323
340
  contactId,
324
- params.channels,
341
+ canonicalChannels,
325
342
  now,
326
343
  params.reassignConflictingChannels,
327
344
  );
@@ -345,9 +362,8 @@ function syncChannels(
345
362
  const db = getDb();
346
363
 
347
364
  for (const ch of channels) {
348
- const normalizedAddress = ch.address.toLowerCase();
349
-
350
- // Check if this channel already exists for this contact
365
+ // Match by (type, address) — the canonical identity for all channel types.
366
+ // COLLATE NOCASE catches legacy rows that were lowercased by old write paths.
351
367
  const existing = db
352
368
  .select()
353
369
  .from(contactChannels)
@@ -355,7 +371,7 @@ function syncChannels(
355
371
  and(
356
372
  eq(contactChannels.contactId, contactId),
357
373
  eq(contactChannels.type, ch.type),
358
- eq(contactChannels.address, normalizedAddress),
374
+ sql`${contactChannels.address} = ${ch.address} COLLATE NOCASE`,
359
375
  ),
360
376
  )
361
377
  .get();
@@ -367,6 +383,8 @@ function syncChannels(
367
383
  const isBlocked = existing.status === "blocked";
368
384
 
369
385
  const updateSet: Record<string, unknown> = {};
386
+ // Self-heal legacy lowercased addresses to canonical form.
387
+ if (existing.address !== ch.address) updateSet.address = ch.address;
370
388
  if (ch.isPrimary !== undefined) updateSet.isPrimary = ch.isPrimary;
371
389
  if (ch.externalUserId !== undefined)
372
390
  updateSet.externalUserId = ch.externalUserId;
@@ -394,17 +412,8 @@ function syncChannels(
394
412
  continue;
395
413
  }
396
414
 
397
- // Check if this channel exists for a different contact (unique constraint)
398
- const conflicting = db
399
- .select()
400
- .from(contactChannels)
401
- .where(
402
- and(
403
- eq(contactChannels.type, ch.type),
404
- eq(contactChannels.address, normalizedAddress),
405
- ),
406
- )
407
- .get();
415
+ // Check if this channel's canonical identity conflicts with another contact.
416
+ const conflicting = findConflictingChannel(db, ch.type, ch.address);
408
417
 
409
418
  if (conflicting) {
410
419
  if (reassignConflicting) {
@@ -451,7 +460,7 @@ function syncChannels(
451
460
  id: uuid(),
452
461
  contactId,
453
462
  type: ch.type,
454
- address: normalizedAddress,
463
+ address: ch.address,
455
464
  isPrimary: ch.isPrimary ?? false,
456
465
  externalUserId: ch.externalUserId ?? null,
457
466
  externalChatId: ch.externalChatId ?? null,
@@ -480,8 +489,8 @@ export function searchContacts(params: {
480
489
 
481
490
  // Search by channel address first (exact or partial match)
482
491
  if (params.channelAddress) {
483
- const normalizedAddress = escapeLike(params.channelAddress.toLowerCase());
484
- if (!normalizedAddress) return [];
492
+ const escapedAddress = escapeLike(params.channelAddress);
493
+ if (!escapedAddress) return [];
485
494
  const channelRows = db
486
495
  .select({ contactId: contactChannels.contactId })
487
496
  .from(contactChannels)
@@ -490,9 +499,9 @@ export function searchContacts(params: {
490
499
  params.channelType
491
500
  ? and(
492
501
  eq(contactChannels.type, params.channelType),
493
- like(contactChannels.address, `%${normalizedAddress}%`),
502
+ like(contactChannels.address, `%${escapedAddress}%`),
494
503
  )
495
- : and(like(contactChannels.address, `%${normalizedAddress}%`)),
504
+ : and(like(contactChannels.address, `%${escapedAddress}%`)),
496
505
  )
497
506
  .all();
498
507
 
@@ -675,6 +684,8 @@ export function mergeContacts(
675
684
  .all();
676
685
 
677
686
  for (const ch of donorChannels) {
687
+ // COLLATE NOCASE catches legacy lowercased rows so we don't try to
688
+ // move a donor channel that collides with an existing survivor channel.
678
689
  const exists = tx
679
690
  .select()
680
691
  .from(contactChannels)
@@ -682,7 +693,7 @@ export function mergeContacts(
682
693
  and(
683
694
  eq(contactChannels.contactId, keepId),
684
695
  eq(contactChannels.type, ch.type),
685
- eq(contactChannels.address, ch.address),
696
+ sql`${contactChannels.address} = ${ch.address} COLLATE NOCASE`,
686
697
  ),
687
698
  )
688
699
  .get();
@@ -705,11 +716,15 @@ export function mergeContacts(
705
716
 
706
717
  /**
707
718
  * Find a contact by a specific channel address. Returns null if not found.
719
+ * Canonicalizes the address before querying. Uses COLLATE NOCASE to match
720
+ * legacy lowercased rows that migration 290 couldn't restore.
708
721
  */
709
722
  export function findContactByAddress(
710
723
  type: string,
711
724
  address: string,
712
725
  ): ContactWithChannels | null {
726
+ const canonical =
727
+ canonicalizeInboundIdentity(type as ChannelId, address) ?? address;
713
728
  const db = getDb();
714
729
  const channel = db
715
730
  .select()
@@ -717,31 +732,7 @@ export function findContactByAddress(
717
732
  .where(
718
733
  and(
719
734
  eq(contactChannels.type, type),
720
- eq(contactChannels.address, address.toLowerCase()),
721
- ),
722
- )
723
- .get();
724
-
725
- if (!channel) return null;
726
- return getContactInternal(channel.contactId);
727
- }
728
-
729
- /**
730
- * Find a contact by channel external user ID. This is the key lookup for trust
731
- * resolution — maps a channel-native sender identity to its parent Contact.
732
- */
733
- export function findContactByChannelExternalId(
734
- channelType: string,
735
- externalUserId: string,
736
- ): ContactWithChannels | null {
737
- const db = getDb();
738
- const channel = db
739
- .select()
740
- .from(contactChannels)
741
- .where(
742
- and(
743
- eq(contactChannels.type, channelType),
744
- eq(contactChannels.externalUserId, externalUserId),
735
+ sql`${contactChannels.address} = ${canonical} COLLATE NOCASE`,
745
736
  ),
746
737
  )
747
738
  .get();
@@ -753,6 +744,7 @@ export function findContactByChannelExternalId(
753
744
  /**
754
745
  * Find a contact by channel external chat ID. This is the fallback lookup path
755
746
  * when externalUserId is not available — matches by (type, externalChatId).
747
+ * No unique constraint exists on externalChatId, so ORDER BY is needed.
756
748
  */
757
749
  function findContactByChannelExternalChatId(
758
750
  channelType: string,
@@ -768,30 +760,40 @@ function findContactByChannelExternalChatId(
768
760
  eq(contactChannels.externalChatId, externalChatId),
769
761
  ),
770
762
  )
763
+ .orderBy(
764
+ sql`CASE ${contactChannels.status}
765
+ WHEN 'active' THEN 0
766
+ WHEN 'unverified' THEN 1
767
+ ELSE 2
768
+ END`,
769
+ desc(contactChannels.updatedAt),
770
+ )
771
771
  .get();
772
772
  if (!channel) return null;
773
773
  return getContactInternal(channel.contactId);
774
774
  }
775
775
 
776
776
  /**
777
- * Find a contact and matching channel by trying externalUserId first, then
777
+ * Find a contact and matching channel by trying address first, then
778
778
  * falling back to externalChatId. Mirrors the findMember lookup strategy.
779
779
  */
780
780
  export function findContactChannel(params: {
781
781
  channelType: string;
782
- externalUserId?: string;
782
+ address?: string;
783
783
  externalChatId?: string;
784
784
  }): { contact: ContactWithChannels; channel: ContactChannel } | null {
785
- if (params.externalUserId) {
786
- const contact = findContactByChannelExternalId(
787
- params.channelType,
788
- params.externalUserId,
789
- );
785
+ if (params.address) {
786
+ const canonical =
787
+ canonicalizeInboundIdentity(
788
+ params.channelType as ChannelId,
789
+ params.address,
790
+ ) ?? params.address;
791
+ const contact = findContactByAddress(params.channelType, canonical);
790
792
  if (contact) {
791
793
  const ch = contact.channels.find(
792
794
  (c) =>
793
795
  c.type === params.channelType &&
794
- c.externalUserId === params.externalUserId,
796
+ c.address.toLowerCase() === canonical.toLowerCase(),
795
797
  );
796
798
  if (ch) return { contact, channel: ch };
797
799
  }
@@ -952,8 +954,6 @@ export function updateContactPrincipalAndChannel(
952
954
  ): boolean {
953
955
  const db = getDb();
954
956
  const now = Date.now();
955
- const normalizedAddress = newPrincipalId.toLowerCase();
956
-
957
957
  // Look up the channel we're about to update so we know its type.
958
958
  const channel = db
959
959
  .select()
@@ -962,17 +962,8 @@ export function updateContactPrincipalAndChannel(
962
962
  .get();
963
963
  if (!channel) return false;
964
964
 
965
- // Guard: check if another channel row already holds this (type, address).
966
- const conflicting = db
967
- .select()
968
- .from(contactChannels)
969
- .where(
970
- and(
971
- eq(contactChannels.type, channel.type),
972
- eq(contactChannels.address, normalizedAddress),
973
- ),
974
- )
975
- .get();
965
+ // Guard: check if another channel row holds this canonical identity.
966
+ const conflicting = findConflictingChannel(db, channel.type, newPrincipalId);
976
967
 
977
968
  if (conflicting && conflicting.id !== channelId) {
978
969
  return false;
@@ -986,8 +977,8 @@ export function updateContactPrincipalAndChannel(
986
977
 
987
978
  db.update(contactChannels)
988
979
  .set({
980
+ address: newPrincipalId,
989
981
  externalUserId: newPrincipalId,
990
- address: normalizedAddress,
991
982
  updatedAt: now,
992
983
  })
993
984
  .where(eq(contactChannels.id, channelId))