@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.
- package/ARCHITECTURE.md +18 -34
- package/bun.lock +7 -8
- package/docs/activation-funnel-telemetry.md +4 -4
- package/docs/architecture/security.md +29 -28
- package/docs/stt-provider-onboarding.md +3 -5
- package/docs/workflows-testing.md +13 -44
- package/docs/workflows.md +3 -5
- package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
- package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
- package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
- package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
- package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
- package/openapi.yaml +135 -59
- package/package.json +2 -1
- package/scripts/sync-llm-catalog.ts +6 -15
- package/scripts/sync-web-search-catalog.ts +3 -11
- package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
- package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
- package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
- package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
- package/src/__tests__/app-dir-path-guard.test.ts +0 -1
- package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
- package/src/__tests__/avatar-identity-sync.test.ts +2 -27
- package/src/__tests__/btw-routes.test.ts +6 -8
- package/src/__tests__/checker.test.ts +0 -3
- package/src/__tests__/config-loader-backfill.test.ts +103 -6
- package/src/__tests__/config-watcher.test.ts +0 -18
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
- package/src/__tests__/credential-broker.test.ts +449 -1
- package/src/__tests__/credential-execution-tools.test.ts +0 -1
- package/src/__tests__/credential-prompt-route.test.ts +3 -4
- package/src/__tests__/credential-routes.test.ts +360 -0
- package/src/__tests__/credential-security-invariants.test.ts +2 -13
- package/src/__tests__/dynamic-page-surface.test.ts +101 -1
- package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
- package/src/__tests__/gateway-only-guard.test.ts +3 -7
- package/src/__tests__/identity-routes.test.ts +0 -189
- package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
- package/src/__tests__/invite-redemption-service.test.ts +4 -4
- package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
- package/src/__tests__/llm-catalog-parity.test.ts +0 -22
- package/src/__tests__/llm-resolver.test.ts +49 -24
- package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
- package/src/__tests__/onboarding-persona-write.test.ts +1 -1
- package/src/__tests__/persona-resolver.test.ts +11 -14
- package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
- package/src/__tests__/registry.test.ts +2 -7
- package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
- package/src/__tests__/schedule-routes.test.ts +0 -30
- package/src/__tests__/schedule-tools.test.ts +2 -18
- package/src/__tests__/skill-execute-input.test.ts +46 -1
- package/src/__tests__/skill-runtime-path.test.ts +2 -3
- package/src/__tests__/subagent-tools.test.ts +116 -0
- package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
- package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
- package/src/__tests__/token-manager.test.ts +519 -0
- package/src/__tests__/tool-executor.test.ts +0 -79
- package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
- package/src/__tests__/trusted-contact-verification.test.ts +6 -6
- package/src/__tests__/voice-invite-redemption.test.ts +2 -2
- package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
- package/src/__tests__/workspace-greetings.test.ts +152 -0
- package/src/agent/loop.ts +25 -5
- package/src/api/README.md +6 -6
- package/src/api/responses/conversation-message.ts +2 -4
- package/src/api/responses/home.ts +0 -4
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/relay-access-wait.ts +1 -1
- package/src/calls/voice-session-bridge.ts +2 -2
- package/src/cli/commands/plugins.ts +143 -2
- package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
- package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
- package/src/cli/lib/diff-plugin.ts +346 -0
- package/src/cli/lib/install-from-github.ts +105 -17
- package/src/cli/lib/merge-plugin-tree.ts +228 -0
- package/src/cli/lib/plugin-fingerprint.ts +14 -0
- package/src/cli/lib/upgrade-plugin.ts +270 -10
- package/src/cli/program.ts +0 -2
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/bundled-skills/workflows/SKILL.md +0 -1
- package/src/config/bundled-tool-registry.ts +2 -7
- package/src/config/call-site-defaults.ts +12 -2
- package/src/config/feature-flag-registry.json +1 -17
- package/src/config/inference-profile-validation.ts +26 -0
- package/src/config/loader.ts +4 -0
- package/src/config/profile-order.ts +28 -0
- package/src/config/schemas/elevenlabs.ts +0 -1
- package/src/config/schemas/platform.ts +0 -8
- package/src/config/seed-inference-profiles.ts +25 -20
- package/src/contacts/contact-store.ts +87 -96
- package/src/contacts/contacts-write.ts +5 -21
- package/src/context/compactor.ts +2 -2
- package/src/credential-execution/process-manager.ts +55 -14
- package/src/credential-execution/prompted-credential.ts +2 -3
- package/src/daemon/config-watcher.ts +0 -4
- package/src/daemon/conversation-agent-loop.ts +15 -4
- package/src/daemon/conversation-slash.ts +2 -23
- package/src/daemon/conversation-tool-setup.ts +11 -3
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/handlers/config-channels.ts +20 -16
- package/src/daemon/handlers/config-slack-channel.ts +2 -3
- package/src/daemon/lifecycle.ts +0 -7
- package/src/daemon/message-types/conversations.ts +3 -3
- package/src/daemon/message-types/sync.ts +0 -1
- package/src/daemon/orphan-reaper.test.ts +0 -19
- package/src/daemon/orphan-reaper.ts +2 -24
- package/src/daemon/server.ts +0 -10
- package/src/home/relationship-state.ts +2 -4
- package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
- package/src/memory/bookmark-crud.ts +1 -2
- package/src/memory/db-init.ts +7 -17
- package/src/memory/embedding-backend.ts +23 -0
- package/src/memory/embedding-billing-breaker.ts +96 -0
- package/src/memory/jobs-store.ts +25 -13
- package/src/memory/jobs-worker.ts +52 -0
- package/src/memory/memory-retrospective-constants.ts +4 -4
- package/src/memory/memory-retrospective-job.ts +19 -227
- package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
- package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
- package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/run-migrations.ts +41 -0
- package/src/memory/migrations/validate-migration-state.ts +1 -1
- package/src/memory/schema/contacts.ts +0 -4
- package/src/messaging/providers/slack/adapter.ts +1 -1
- package/src/notifications/adapters/shared.ts +29 -0
- package/src/notifications/adapters/slack.ts +5 -32
- package/src/notifications/adapters/telegram.ts +2 -20
- package/src/notifications/broadcaster.ts +10 -1
- package/src/notifications/home-feed-side-effect.ts +4 -3
- package/src/notifications/notification-utils.ts +17 -19
- package/src/notifications/types.ts +7 -0
- package/src/oauth/AGENTS.md +5 -24
- package/src/plugin-api/constants.ts +1 -1
- package/src/plugin-api/index.ts +6 -1
- package/src/plugin-api/model-profiles.ts +33 -0
- package/src/plugin-api/types.ts +50 -2
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
- package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
- package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
- package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
- package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
- package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
- package/src/prompts/persona-resolver.ts +2 -2
- package/src/runtime/AGENTS.md +0 -1
- package/src/runtime/actor-trust-resolver.ts +12 -44
- package/src/runtime/btw-sidechain.ts +3 -6
- package/src/runtime/channel-approval-types.ts +18 -45
- package/src/runtime/channel-invite-transports/telegram.ts +4 -4
- package/src/runtime/channel-verification-service.ts +4 -3
- package/src/runtime/invite-redemption-service.ts +3 -3
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
- package/src/runtime/routes/app-routes.ts +1 -1
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
- package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
- package/src/runtime/routes/btw-routes.ts +1 -27
- package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +2 -2
- package/src/runtime/routes/credential-routes.ts +40 -16
- package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
- package/src/runtime/routes/identity-routes.ts +1 -296
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
- package/src/runtime/routes/plugins-routes.ts +171 -5
- package/src/runtime/routes/schedule-routes.ts +0 -22
- package/src/runtime/routes/workflow-routes.test.ts +4 -43
- package/src/runtime/routes/workflow-routes.ts +0 -28
- package/src/runtime/routes/workspace-greetings.ts +55 -0
- package/src/runtime/sync/resource-sync-events.ts +1 -11
- package/src/schedule/inference-profile.ts +2 -14
- package/src/subagent/manager.ts +6 -0
- package/src/subagent/types.ts +6 -0
- package/src/tools/AGENTS.md +3 -3
- package/src/tools/browser/browser-execution.ts +1 -1
- package/src/tools/network/web-search-error.ts +1 -1
- package/src/tools/permission-checker.ts +1 -1
- package/src/tools/schedule/create.ts +3 -9
- package/src/tools/schedule/update.ts +2 -10
- package/src/tools/side-effects.ts +2 -17
- package/src/tools/skills/execute.ts +34 -0
- package/src/tools/subagent/spawn.ts +34 -9
- package/src/tools/tool-approval-handler.ts +1 -3
- package/src/tools/tool-manifest.ts +0 -2
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/workflows/run-workflow.test.ts +8 -18
- package/src/util/platform.ts +2 -2
- package/src/workflows/capabilities.ts +2 -3
- package/src/workflows/run-manager.test.ts +0 -25
- package/src/workflows/run-manager.ts +2 -24
- package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
- package/src/__tests__/credential-security-e2e.test.ts +0 -362
- package/src/__tests__/credential-vault-unit.test.ts +0 -1528
- package/src/__tests__/credential-vault.test.ts +0 -1706
- package/src/__tests__/identity-intro-cache.test.ts +0 -315
- package/src/__tests__/secret-onetime-send.test.ts +0 -182
- package/src/cli/commands/__tests__/task.test.ts +0 -914
- package/src/cli/commands/task.ts +0 -771
- package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
- package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
- package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
- package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
- package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
- package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
- package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
- package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
- package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
- package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
- package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
- package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
- package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
- package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
- package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
- package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
- package/src/memory/preloaded-apps.ts +0 -116
- package/src/runtime/routes/identity-intro-cache.ts +0 -172
- 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
|
|
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",
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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.
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
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
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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 (
|
|
256
|
+
if (canonicalChannels) {
|
|
230
257
|
syncChannels(
|
|
231
258
|
contactId,
|
|
232
|
-
|
|
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
|
|
244
|
-
if (!contactId &&
|
|
245
|
-
for (const ch of
|
|
246
|
-
|
|
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,
|
|
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 (
|
|
338
|
+
if (canonicalChannels) {
|
|
322
339
|
syncChannels(
|
|
323
340
|
contactId,
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
484
|
-
if (!
|
|
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, `%${
|
|
502
|
+
like(contactChannels.address, `%${escapedAddress}%`),
|
|
494
503
|
)
|
|
495
|
-
: and(like(contactChannels.address, `%${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
782
|
+
address?: string;
|
|
783
783
|
externalChatId?: string;
|
|
784
784
|
}): { contact: ContactWithChannels; channel: ContactChannel } | null {
|
|
785
|
-
if (params.
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
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.
|
|
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
|
|
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))
|