vellum 0.2.12 → 0.2.14
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/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +171 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +402 -5
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +271 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +28 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +96 -8
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +97 -0
- package/src/calls/elevenlabs-config.ts +31 -0
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +50 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +114 -0
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +26 -2
- package/src/config/schema.ts +178 -9
- package/src/config/types.ts +3 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/defaults.ts +11 -0
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/conversation-routes.ts +12 -5
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
package/src/calls/call-bridge.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Call
|
|
3
|
-
* to
|
|
2
|
+
* Call message bridge: intercepts user messages in-thread and routes them
|
|
3
|
+
* to the live call orchestrator — either as answers to pending questions
|
|
4
|
+
* or as mid-call steering instructions.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Decision priority:
|
|
7
|
+
* 1. If a pending question exists → answer path (existing behavior).
|
|
8
|
+
* 2. If no pending question but an active call exists → instruction path.
|
|
9
|
+
*
|
|
10
|
+
* When the bridge consumes a message it returns `{ handled: true }` so
|
|
11
|
+
* the caller can skip agent processing.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { getLogger } from '../util/logger.js';
|
|
@@ -17,6 +20,7 @@ import {
|
|
|
17
20
|
getCallSession,
|
|
18
21
|
} from './call-store.js';
|
|
19
22
|
import { getCallOrchestrator } from './call-state.js';
|
|
23
|
+
import { relayInstruction } from './call-domain.js';
|
|
20
24
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
21
25
|
|
|
22
26
|
const log = getLogger('call-bridge');
|
|
@@ -24,18 +28,21 @@ const log = getLogger('call-bridge');
|
|
|
24
28
|
export interface CallBridgeResult {
|
|
25
29
|
handled: boolean;
|
|
26
30
|
reason?: string;
|
|
31
|
+
/** User-facing text persisted in-thread by the bridge (success ack or failure notice). */
|
|
32
|
+
userFacingText?: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/**
|
|
30
|
-
* Attempt to route a user message
|
|
36
|
+
* Attempt to route a user message to an active call — as an answer to
|
|
37
|
+
* a pending question (priority) or as a mid-call steering instruction.
|
|
31
38
|
*
|
|
32
39
|
* @param conversationId - The conversation the message belongs to.
|
|
33
40
|
* @param userText - The user's message text.
|
|
34
41
|
* @param _userMessageId - The persisted message ID (reserved for future use).
|
|
35
|
-
* @returns `{ handled: true }` if the
|
|
42
|
+
* @returns `{ handled: true }` if the message was consumed by the call system,
|
|
36
43
|
* `{ handled: false, reason }` otherwise.
|
|
37
44
|
*/
|
|
38
|
-
export async function
|
|
45
|
+
export async function tryRouteCallMessage(
|
|
39
46
|
conversationId: string,
|
|
40
47
|
userText: string,
|
|
41
48
|
_userMessageId?: string,
|
|
@@ -46,18 +53,38 @@ export async function tryHandlePendingCallAnswer(
|
|
|
46
53
|
return { handled: false, reason: 'no_active_call' };
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
// 2. Check for a pending question
|
|
56
|
+
// 2. Check for a pending question — answer path takes priority
|
|
50
57
|
const pendingQuestion = getPendingQuestion(callSession.id);
|
|
51
|
-
if (
|
|
52
|
-
return
|
|
58
|
+
if (pendingQuestion) {
|
|
59
|
+
return handleAnswer(conversationId, callSession.id, pendingQuestion, userText);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. No pending question — instruction path
|
|
63
|
+
return handleInstruction(conversationId, callSession.id, userText);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @deprecated Use `tryRouteCallMessage` instead. */
|
|
67
|
+
export const tryHandlePendingCallAnswer = tryRouteCallMessage;
|
|
68
|
+
|
|
69
|
+
// ── Answer path ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
async function handleAnswer(
|
|
72
|
+
conversationId: string,
|
|
73
|
+
callSessionId: string,
|
|
74
|
+
pendingQuestion: { id: string; questionText: string },
|
|
75
|
+
userText: string,
|
|
76
|
+
): Promise<CallBridgeResult> {
|
|
77
|
+
// Empty text (e.g. attachment-only messages) should not be consumed as
|
|
78
|
+
// an answer — fall through to normal processing so attachments are handled.
|
|
79
|
+
if (!userText.trim()) {
|
|
80
|
+
return { handled: false, reason: 'empty_answer_text' };
|
|
53
81
|
}
|
|
54
82
|
|
|
55
|
-
|
|
56
|
-
const orchestrator = getCallOrchestrator(callSession.id);
|
|
83
|
+
const orchestrator = getCallOrchestrator(callSessionId);
|
|
57
84
|
if (!orchestrator) {
|
|
58
85
|
// The call may have ended between the question being asked and the
|
|
59
86
|
// user replying. Persist a follow-up message so the user knows.
|
|
60
|
-
const freshSession = getCallSession(
|
|
87
|
+
const freshSession = getCallSession(callSessionId);
|
|
61
88
|
const ended = freshSession && (freshSession.status === 'completed' || freshSession.status === 'failed');
|
|
62
89
|
if (ended) {
|
|
63
90
|
conversationStore.addMessage(
|
|
@@ -76,20 +103,66 @@ export async function tryHandlePendingCallAnswer(
|
|
|
76
103
|
return { handled: false, reason: 'orchestrator_not_waiting' };
|
|
77
104
|
}
|
|
78
105
|
|
|
79
|
-
// 4. Route the answer through the orchestrator
|
|
80
106
|
const accepted = await orchestrator.handleUserAnswer(userText);
|
|
81
107
|
if (!accepted) {
|
|
82
108
|
return { handled: false, reason: 'orchestrator_rejected' };
|
|
83
109
|
}
|
|
84
110
|
|
|
85
|
-
// 5. Persist the answered state
|
|
86
111
|
answerPendingQuestion(pendingQuestion.id, userText);
|
|
87
|
-
recordCallEvent(
|
|
112
|
+
recordCallEvent(callSessionId, 'user_answered', { answer: userText });
|
|
88
113
|
|
|
89
114
|
log.info(
|
|
90
|
-
{ conversationId, callSessionId
|
|
115
|
+
{ conversationId, callSessionId, questionId: pendingQuestion.id },
|
|
91
116
|
'User reply routed as call answer via bridge',
|
|
92
117
|
);
|
|
93
118
|
|
|
94
119
|
return { handled: true };
|
|
95
120
|
}
|
|
121
|
+
|
|
122
|
+
// ── Instruction path ────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async function handleInstruction(
|
|
125
|
+
conversationId: string,
|
|
126
|
+
callSessionId: string,
|
|
127
|
+
userText: string,
|
|
128
|
+
): Promise<CallBridgeResult> {
|
|
129
|
+
// Empty text (e.g. attachment-only messages) should not be relayed —
|
|
130
|
+
// fall through to normal processing so attachments are handled.
|
|
131
|
+
if (!userText.trim()) {
|
|
132
|
+
return { handled: false, reason: 'empty_instruction_text' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await relayInstruction({ callSessionId, instructionText: userText });
|
|
136
|
+
|
|
137
|
+
if (!result.ok) {
|
|
138
|
+
log.warn(
|
|
139
|
+
{ conversationId, callSessionId, error: result.error },
|
|
140
|
+
'Instruction relay failed via bridge',
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const failureText = 'Failed to relay instruction to the active call.';
|
|
144
|
+
conversationStore.addMessage(
|
|
145
|
+
conversationId,
|
|
146
|
+
'assistant',
|
|
147
|
+
JSON.stringify([{ type: 'text', text: failureText }]),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Consumed: caller should NOT fall through to the agent loop
|
|
151
|
+
return { handled: true, reason: 'instruction_relay_failed', userFacingText: failureText };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Persist a concise acknowledgement so the user sees confirmation
|
|
155
|
+
const ackText = 'Instruction relayed to active call.';
|
|
156
|
+
conversationStore.addMessage(
|
|
157
|
+
conversationId,
|
|
158
|
+
'assistant',
|
|
159
|
+
JSON.stringify([{ type: 'text', text: ackText }]),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
log.info(
|
|
163
|
+
{ conversationId, callSessionId },
|
|
164
|
+
'User message routed as call instruction via bridge',
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return { handled: true, userFacingText: ackText };
|
|
168
|
+
}
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -22,7 +22,10 @@ import { TwilioConversationRelayProvider } from './twilio-provider.js';
|
|
|
22
22
|
import { getTwilioConfig } from './twilio-config.js';
|
|
23
23
|
import { getTwilioVoiceWebhookUrl, getTwilioStatusCallbackUrl } from '../inbound/public-ingress-urls.js';
|
|
24
24
|
import { loadConfig } from '../config/loader.js';
|
|
25
|
+
import { getSecureKey } from '../security/secure-keys.js';
|
|
25
26
|
import type { CallSession } from './types.js';
|
|
27
|
+
import { VALID_CALLER_IDENTITY_MODES } from '../config/schema.js';
|
|
28
|
+
import type { AssistantConfig } from '../config/types.js';
|
|
26
29
|
|
|
27
30
|
const log = getLogger('call-domain');
|
|
28
31
|
|
|
@@ -34,6 +37,7 @@ export interface StartCallResult {
|
|
|
34
37
|
ok: true;
|
|
35
38
|
session: CallSession;
|
|
36
39
|
callSid: string;
|
|
40
|
+
callerIdentityMode: 'assistant_number' | 'user_number';
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export interface CallError {
|
|
@@ -47,6 +51,7 @@ export type StartCallInput = {
|
|
|
47
51
|
task: string;
|
|
48
52
|
context?: string;
|
|
49
53
|
conversationId: string;
|
|
54
|
+
callerIdentityMode?: 'assistant_number' | 'user_number';
|
|
50
55
|
};
|
|
51
56
|
|
|
52
57
|
export type CancelCallInput = {
|
|
@@ -59,13 +64,118 @@ export type AnswerCallInput = {
|
|
|
59
64
|
answer: string;
|
|
60
65
|
};
|
|
61
66
|
|
|
67
|
+
export type RelayInstructionInput = {
|
|
68
|
+
callSessionId: string;
|
|
69
|
+
instructionText: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Caller identity resolution ───────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export type CallerIdentitySource = 'per_call_override' | 'implicit_default' | 'user_config' | 'secure_key' | 'env_var';
|
|
75
|
+
|
|
76
|
+
export type CallerIdentityResult =
|
|
77
|
+
| { ok: true; mode: 'assistant_number' | 'user_number'; fromNumber: string; source: CallerIdentitySource }
|
|
78
|
+
| { ok: false; error: string };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve which phone number to use as the caller ID for an outbound call.
|
|
82
|
+
*
|
|
83
|
+
* Policy: implicit calls (no explicit mode) always use `assistant_number`.
|
|
84
|
+
* `user_number` is only used when explicitly requested per call.
|
|
85
|
+
*
|
|
86
|
+
* - If `requestedMode` is provided and per-call overrides are allowed, use it.
|
|
87
|
+
* - If `requestedMode` is provided but overrides are disabled, return an error.
|
|
88
|
+
* - Otherwise, always use `assistant_number` (implicit default).
|
|
89
|
+
*
|
|
90
|
+
* For `assistant_number`: uses the Twilio phone number from `getTwilioConfig()`.
|
|
91
|
+
* No eligibility check is performed — this is a fast path.
|
|
92
|
+
* For `user_number`: uses `config.calls.callerIdentity.userNumber` or the
|
|
93
|
+
* secure key `credential:twilio:user_phone_number`, then validates that the
|
|
94
|
+
* number is usable as an outbound caller ID via the Twilio API.
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveCallerIdentity(
|
|
97
|
+
config: AssistantConfig,
|
|
98
|
+
requestedMode?: 'assistant_number' | 'user_number',
|
|
99
|
+
): Promise<CallerIdentityResult> {
|
|
100
|
+
const identityConfig = config.calls.callerIdentity;
|
|
101
|
+
let mode: 'assistant_number' | 'user_number';
|
|
102
|
+
let source: CallerIdentitySource;
|
|
103
|
+
|
|
104
|
+
if (requestedMode != null) {
|
|
105
|
+
if (!(VALID_CALLER_IDENTITY_MODES as readonly string[]).includes(requestedMode)) {
|
|
106
|
+
return { ok: false, error: `Invalid callerIdentityMode: "${requestedMode}". Must be one of: ${VALID_CALLER_IDENTITY_MODES.join(', ')}` };
|
|
107
|
+
}
|
|
108
|
+
if (!identityConfig.allowPerCallOverride) {
|
|
109
|
+
log.warn({ requestedMode }, 'Caller identity override rejected — per-call override is disabled in configuration');
|
|
110
|
+
return { ok: false, error: 'Per-call caller identity override is disabled in configuration' };
|
|
111
|
+
}
|
|
112
|
+
mode = requestedMode;
|
|
113
|
+
source = 'per_call_override';
|
|
114
|
+
} else {
|
|
115
|
+
// Implicit calls always use assistant_number regardless of config
|
|
116
|
+
mode = 'assistant_number';
|
|
117
|
+
source = 'implicit_default';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (mode === 'assistant_number') {
|
|
121
|
+
const twilioConfig = getTwilioConfig();
|
|
122
|
+
log.info({ mode, source, fromNumber: twilioConfig.phoneNumber }, 'Resolved caller identity');
|
|
123
|
+
return { ok: true, mode, fromNumber: twilioConfig.phoneNumber, source };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// user_number mode: resolve from config or secure key, tracking where the number came from
|
|
127
|
+
let userNumber = '';
|
|
128
|
+
let numberSource: CallerIdentitySource = source;
|
|
129
|
+
|
|
130
|
+
if (identityConfig.userNumber) {
|
|
131
|
+
userNumber = identityConfig.userNumber;
|
|
132
|
+
numberSource = 'user_config';
|
|
133
|
+
} else if (process.env.TWILIO_USER_PHONE_NUMBER) {
|
|
134
|
+
userNumber = process.env.TWILIO_USER_PHONE_NUMBER;
|
|
135
|
+
numberSource = 'env_var';
|
|
136
|
+
} else {
|
|
137
|
+
const secureKeyValue = getSecureKey('credential:twilio:user_phone_number');
|
|
138
|
+
if (secureKeyValue) {
|
|
139
|
+
userNumber = secureKeyValue;
|
|
140
|
+
numberSource = 'secure_key';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!userNumber) {
|
|
145
|
+
log.warn({ mode, source }, 'Caller identity resolution failed — no user phone number configured');
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: 'user_number mode requires a user phone number. Set calls.callerIdentity.userNumber in config or store credential:twilio:user_phone_number via the credential_store tool.',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!E164_REGEX.test(userNumber)) {
|
|
153
|
+
log.warn({ mode, source: numberSource, userNumber }, 'User phone number is not in E.164 format');
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `User phone number "${userNumber}" is not in E.164 format (must start with + followed by digits, e.g. +14155551234). Check calls.callerIdentity.userNumber in config or credential:twilio:user_phone_number.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Verify the user number is eligible as a caller ID with Twilio
|
|
161
|
+
const provider = new TwilioConversationRelayProvider();
|
|
162
|
+
const eligibility = await provider.checkCallerIdEligibility(userNumber);
|
|
163
|
+
if (!eligibility.eligible) {
|
|
164
|
+
log.warn({ mode, source: numberSource, userNumber, reason: eligibility.reason }, 'Caller ID eligibility check failed');
|
|
165
|
+
return { ok: false, error: eligibility.reason! };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
log.info({ mode, source: numberSource, fromNumber: userNumber }, 'Resolved caller identity');
|
|
169
|
+
return { ok: true, mode, fromNumber: userNumber, source: numberSource };
|
|
170
|
+
}
|
|
171
|
+
|
|
62
172
|
// ── Domain operations ────────────────────────────────────────────────
|
|
63
173
|
|
|
64
174
|
/**
|
|
65
175
|
* Initiate a new outbound call.
|
|
66
176
|
*/
|
|
67
177
|
export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
|
|
68
|
-
const { phoneNumber, task, context: callContext, conversationId } = input;
|
|
178
|
+
const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode } = input;
|
|
69
179
|
|
|
70
180
|
if (!phoneNumber || typeof phoneNumber !== 'string') {
|
|
71
181
|
return { ok: false, error: 'phone_number is required and must be a string', status: 400 };
|
|
@@ -90,23 +200,31 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
|
|
|
90
200
|
let sessionId: string | null = null;
|
|
91
201
|
|
|
92
202
|
try {
|
|
93
|
-
const twilioConfig = getTwilioConfig();
|
|
94
203
|
const ingressConfig = loadConfig();
|
|
95
204
|
const provider = new TwilioConversationRelayProvider();
|
|
96
205
|
|
|
206
|
+
// Resolve which phone number to use as caller ID
|
|
207
|
+
const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode);
|
|
208
|
+
if (!identityResult.ok) {
|
|
209
|
+
return { ok: false, error: identityResult.error, status: 400 };
|
|
210
|
+
}
|
|
211
|
+
const fromNumber = identityResult.fromNumber;
|
|
212
|
+
|
|
97
213
|
const session = createCallSession({
|
|
98
214
|
conversationId,
|
|
99
215
|
provider: 'twilio',
|
|
100
|
-
fromNumber
|
|
216
|
+
fromNumber,
|
|
101
217
|
toNumber: phoneNumber,
|
|
102
218
|
task: callContext ? `${task}\n\nContext: ${callContext}` : task,
|
|
219
|
+
callerIdentityMode: identityResult.mode,
|
|
220
|
+
callerIdentitySource: identityResult.source,
|
|
103
221
|
});
|
|
104
222
|
sessionId = session.id;
|
|
105
223
|
|
|
106
|
-
log.info({ callSessionId: session.id, to: phoneNumber, task }, 'Initiating outbound call');
|
|
224
|
+
log.info({ callSessionId: session.id, to: phoneNumber, from: fromNumber, task }, 'Initiating outbound call');
|
|
107
225
|
|
|
108
226
|
const { callSid } = await provider.initiateCall({
|
|
109
|
-
from:
|
|
227
|
+
from: fromNumber,
|
|
110
228
|
to: phoneNumber,
|
|
111
229
|
webhookUrl: getTwilioVoiceWebhookUrl(ingressConfig, session.id),
|
|
112
230
|
statusCallbackUrl: getTwilioStatusCallbackUrl(ingressConfig),
|
|
@@ -120,6 +238,7 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
|
|
|
120
238
|
ok: true,
|
|
121
239
|
session: { ...session, providerCallSid: callSid },
|
|
122
240
|
callSid,
|
|
241
|
+
callerIdentityMode: identityResult.mode,
|
|
123
242
|
};
|
|
124
243
|
} catch (err) {
|
|
125
244
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -276,3 +395,36 @@ export async function answerCall(input: AnswerCallInput): Promise<{ ok: true; qu
|
|
|
276
395
|
|
|
277
396
|
return { ok: true, questionId: question.id };
|
|
278
397
|
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Relay a user instruction to an active call's orchestrator.
|
|
401
|
+
* Validates that the call is active and the instruction is non-empty
|
|
402
|
+
* before injecting it into the orchestrator's conversation history.
|
|
403
|
+
*/
|
|
404
|
+
export async function relayInstruction(input: RelayInstructionInput): Promise<{ ok: true } | CallError> {
|
|
405
|
+
const { callSessionId, instructionText } = input;
|
|
406
|
+
|
|
407
|
+
if (!instructionText || typeof instructionText !== 'string' || instructionText.trim().length === 0) {
|
|
408
|
+
return { ok: false, error: 'instructionText is required and must be a non-empty string', status: 400 };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const session = getCallSession(callSessionId);
|
|
412
|
+
if (!session) {
|
|
413
|
+
return { ok: false, error: `No call session found with ID ${callSessionId}`, status: 404 };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (session.status === 'completed' || session.status === 'failed' || session.status === 'cancelled') {
|
|
417
|
+
return { ok: false, error: `Call session ${callSessionId} is not active (status: ${session.status})`, status: 409 };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const orchestrator = getCallOrchestrator(callSessionId);
|
|
421
|
+
if (!orchestrator) {
|
|
422
|
+
return { ok: false, error: 'No active orchestrator for this call', status: 409 };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
await orchestrator.handleUserInstruction(instructionText);
|
|
426
|
+
|
|
427
|
+
log.info({ callSessionId }, 'User instruction relayed to orchestrator');
|
|
428
|
+
|
|
429
|
+
return { ok: true };
|
|
430
|
+
}
|
|
@@ -41,6 +41,8 @@ export class CallOrchestrator {
|
|
|
41
41
|
private consultationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
42
42
|
private durationEndTimer: ReturnType<typeof setTimeout> | null = null;
|
|
43
43
|
private task: string | null;
|
|
44
|
+
/** Instructions queued while an LLM turn is in-flight or during waiting_on_user */
|
|
45
|
+
private pendingInstructions: string[] = [];
|
|
44
46
|
|
|
45
47
|
constructor(callSessionId: string, relay: RelayConnection, task: string | null) {
|
|
46
48
|
this.callSessionId = callSessionId;
|
|
@@ -101,8 +103,18 @@ export class CallOrchestrator {
|
|
|
101
103
|
this.state = 'processing';
|
|
102
104
|
updateCallSession(this.callSessionId, { status: 'in_progress' });
|
|
103
105
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
+
// Merge any instructions that were queued during the waiting_on_user
|
|
107
|
+
// state into a single user message alongside the answer to avoid
|
|
108
|
+
// consecutive user-role messages (which violate Anthropic API
|
|
109
|
+
// role-alternation requirements).
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
for (const instr of this.pendingInstructions) {
|
|
112
|
+
parts.push(`[USER_INSTRUCTION: ${instr}]`);
|
|
113
|
+
}
|
|
114
|
+
this.pendingInstructions = [];
|
|
115
|
+
parts.push(`[USER_ANSWERED: ${answerText}]`);
|
|
116
|
+
|
|
117
|
+
this.conversationHistory.push({ role: 'user', content: parts.join('\n') });
|
|
106
118
|
|
|
107
119
|
// Fire-and-forget: unblock the caller so the HTTP response and answer
|
|
108
120
|
// persistence happen immediately, before LLM streaming begins.
|
|
@@ -112,6 +124,46 @@ export class CallOrchestrator {
|
|
|
112
124
|
return true;
|
|
113
125
|
}
|
|
114
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Inject a user instruction into the orchestrator's conversation history.
|
|
129
|
+
* The instruction is formatted as a dedicated marker that the system prompt
|
|
130
|
+
* tells the model to treat as high-priority steering input.
|
|
131
|
+
*
|
|
132
|
+
* When the LLM is actively processing or speaking, or when the orchestrator
|
|
133
|
+
* is waiting on a user answer, the instruction is queued and spliced into
|
|
134
|
+
* the conversation at the correct chronological position once the current
|
|
135
|
+
* turn completes. This prevents:
|
|
136
|
+
* - History ordering corruption (instruction appearing before an in-flight
|
|
137
|
+
* assistant response).
|
|
138
|
+
* - Consecutive user-role messages (which violate Anthropic API
|
|
139
|
+
* role-alternation requirements).
|
|
140
|
+
*/
|
|
141
|
+
async handleUserInstruction(instructionText: string): Promise<void> {
|
|
142
|
+
recordCallEvent(this.callSessionId, 'user_instruction_relayed', { instruction: instructionText });
|
|
143
|
+
|
|
144
|
+
// Queue the instruction when it cannot be safely appended right now:
|
|
145
|
+
// - processing/speaking: an LLM turn is in-flight; appending would
|
|
146
|
+
// place the instruction before the assistant response in the array.
|
|
147
|
+
// - waiting_on_user: the last message is an assistant turn; the next
|
|
148
|
+
// message should be the user's answer. Queued instructions are merged
|
|
149
|
+
// into that answer message by handleUserAnswer().
|
|
150
|
+
if (this.state === 'processing' || this.state === 'speaking' || this.state === 'waiting_on_user') {
|
|
151
|
+
this.pendingInstructions.push(instructionText);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.conversationHistory.push({
|
|
156
|
+
role: 'user',
|
|
157
|
+
content: `[USER_INSTRUCTION: ${instructionText}]`,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Reset the silence timer so the instruction-triggered LLM turn
|
|
161
|
+
// doesn't race with a stale silence timeout.
|
|
162
|
+
this.resetSilenceTimer();
|
|
163
|
+
|
|
164
|
+
await this.runLlm();
|
|
165
|
+
}
|
|
166
|
+
|
|
115
167
|
/**
|
|
116
168
|
* Handle caller interrupting the assistant's speech.
|
|
117
169
|
*/
|
|
@@ -155,10 +207,11 @@ export class CallOrchestrator {
|
|
|
155
207
|
'2. Be concise — phone conversations should be brief and natural.',
|
|
156
208
|
'3. If the callee asks something you don\'t know, include [ASK_USER: your question here] in your response along with a hold message like "Let me check on that for you."',
|
|
157
209
|
'4. If the callee provides information preceded by [USER_ANSWERED: ...], use that answer naturally in the conversation.',
|
|
158
|
-
'5.
|
|
159
|
-
'6.
|
|
160
|
-
'7.
|
|
161
|
-
'8.
|
|
210
|
+
'5. If you see [USER_INSTRUCTION: ...], treat it as a high-priority steering directive from your user. Follow the instruction immediately, adjusting your approach or response accordingly.',
|
|
211
|
+
'6. When the call\'s purpose is fulfilled, include [END_CALL] in your response along with a polite goodbye.',
|
|
212
|
+
'7. Do not make up information — ask the user if unsure.',
|
|
213
|
+
'8. Keep responses short — 1-3 sentences is ideal for phone conversation.',
|
|
214
|
+
'9. When caller text includes [SPEAKER id="..." label="..."], treat each speaker as a distinct person and personalize responses using that speaker\'s prior context in this call.',
|
|
162
215
|
]
|
|
163
216
|
.filter(Boolean)
|
|
164
217
|
.join('\n');
|
|
@@ -190,9 +243,11 @@ export class CallOrchestrator {
|
|
|
190
243
|
try {
|
|
191
244
|
this.state = 'speaking';
|
|
192
245
|
|
|
246
|
+
const callModel = getConfig().calls.model?.trim() || 'claude-sonnet-4-20250514';
|
|
247
|
+
|
|
193
248
|
const stream = client.messages.stream(
|
|
194
249
|
{
|
|
195
|
-
model:
|
|
250
|
+
model: callModel,
|
|
196
251
|
max_tokens: 512,
|
|
197
252
|
system: this.buildSystemPrompt(),
|
|
198
253
|
messages: this.conversationHistory.map((m) => ({
|
|
@@ -324,6 +379,7 @@ export class CallOrchestrator {
|
|
|
324
379
|
this.state = 'idle';
|
|
325
380
|
updateCallSession(this.callSessionId, { status: 'in_progress' });
|
|
326
381
|
expirePendingQuestions(this.callSessionId);
|
|
382
|
+
this.flushPendingInstructions();
|
|
327
383
|
}
|
|
328
384
|
}, getUserConsultationTimeoutMs());
|
|
329
385
|
return;
|
|
@@ -349,8 +405,11 @@ export class CallOrchestrator {
|
|
|
349
405
|
return;
|
|
350
406
|
}
|
|
351
407
|
|
|
352
|
-
// Normal turn complete
|
|
408
|
+
// Normal turn complete — flush any instructions that arrived while
|
|
409
|
+
// the LLM was active. They are appended after the assistant response
|
|
410
|
+
// so chronological order is preserved, then a new LLM turn is started.
|
|
353
411
|
this.state = 'idle';
|
|
412
|
+
this.flushPendingInstructions();
|
|
354
413
|
} catch (err: unknown) {
|
|
355
414
|
// Aborted requests are expected (interruptions, rapid utterances)
|
|
356
415
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
@@ -360,9 +419,38 @@ export class CallOrchestrator {
|
|
|
360
419
|
log.error({ err, callSessionId: this.callSessionId }, 'LLM streaming error');
|
|
361
420
|
this.relay.sendTextToken('I\'m sorry, I encountered a technical issue. Could you repeat that?', true);
|
|
362
421
|
this.state = 'idle';
|
|
422
|
+
this.flushPendingInstructions();
|
|
363
423
|
}
|
|
364
424
|
}
|
|
365
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Drain any instructions that were queued while the LLM was active.
|
|
428
|
+
* Each instruction is appended as a user message (now correctly after
|
|
429
|
+
* the assistant response) and a new LLM turn is kicked off to handle
|
|
430
|
+
* them. Batches all pending instructions into a single user message to
|
|
431
|
+
* avoid triggering multiple sequential LLM turns.
|
|
432
|
+
*/
|
|
433
|
+
private flushPendingInstructions(): void {
|
|
434
|
+
if (this.pendingInstructions.length === 0) return;
|
|
435
|
+
|
|
436
|
+
const parts = this.pendingInstructions.map(
|
|
437
|
+
(instr) => `[USER_INSTRUCTION: ${instr}]`,
|
|
438
|
+
);
|
|
439
|
+
this.pendingInstructions = [];
|
|
440
|
+
|
|
441
|
+
this.conversationHistory.push({
|
|
442
|
+
role: 'user',
|
|
443
|
+
content: parts.join('\n'),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
this.resetSilenceTimer();
|
|
447
|
+
|
|
448
|
+
// Fire-and-forget so we don't block the current turn's cleanup.
|
|
449
|
+
this.runLlm().catch((err) =>
|
|
450
|
+
log.error({ err, callSessionId: this.callSessionId }, 'runLlm failed after flushing queued instructions'),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
366
454
|
private startDurationTimer(): void {
|
|
367
455
|
const maxDurationMs = getMaxCallDurationMs();
|
|
368
456
|
const warningMs = maxDurationMs - 2 * 60 * 1000; // 2 minutes before max
|
package/src/calls/call-store.ts
CHANGED
|
@@ -20,6 +20,8 @@ function parseCallSession(row: typeof callSessions.$inferSelect): CallSession {
|
|
|
20
20
|
toNumber: row.toNumber,
|
|
21
21
|
task: row.task,
|
|
22
22
|
status: row.status as CallSession['status'],
|
|
23
|
+
callerIdentityMode: row.callerIdentityMode,
|
|
24
|
+
callerIdentitySource: row.callerIdentitySource,
|
|
23
25
|
startedAt: row.startedAt,
|
|
24
26
|
endedAt: row.endedAt,
|
|
25
27
|
lastError: row.lastError,
|
|
@@ -58,6 +60,8 @@ export function createCallSession(opts: {
|
|
|
58
60
|
fromNumber: string;
|
|
59
61
|
toNumber: string;
|
|
60
62
|
task?: string;
|
|
63
|
+
callerIdentityMode?: string;
|
|
64
|
+
callerIdentitySource?: string;
|
|
61
65
|
}): CallSession {
|
|
62
66
|
const db = getDb();
|
|
63
67
|
const now = Date.now();
|
|
@@ -70,6 +74,8 @@ export function createCallSession(opts: {
|
|
|
70
74
|
toNumber: opts.toNumber,
|
|
71
75
|
task: opts.task ?? null,
|
|
72
76
|
status: 'initiated' as const,
|
|
77
|
+
callerIdentityMode: opts.callerIdentityMode ?? null,
|
|
78
|
+
callerIdentitySource: opts.callerIdentitySource ?? null,
|
|
73
79
|
startedAt: null,
|
|
74
80
|
endedAt: null,
|
|
75
81
|
lastError: null,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { getLogger } from '../util/logger.js';
|
|
2
|
+
|
|
3
|
+
const log = getLogger('elevenlabs-client');
|
|
4
|
+
|
|
5
|
+
export interface ElevenLabsRegisterCallRequest {
|
|
6
|
+
agent_id: string;
|
|
7
|
+
from_number: string;
|
|
8
|
+
to_number: string;
|
|
9
|
+
direction: 'outbound' | 'inbound';
|
|
10
|
+
conversation_initiation_client_data?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ElevenLabsRegisterCallResult {
|
|
14
|
+
twiml: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ElevenLabsClientOptions {
|
|
18
|
+
apiBaseUrl: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ElevenLabsErrorCode = 'ELEVENLABS_TIMEOUT' | 'ELEVENLABS_HTTP_ERROR' | 'ELEVENLABS_INVALID_RESPONSE';
|
|
24
|
+
|
|
25
|
+
export class ElevenLabsError extends Error {
|
|
26
|
+
code: ElevenLabsErrorCode;
|
|
27
|
+
statusCode?: number;
|
|
28
|
+
|
|
29
|
+
constructor(code: ElevenLabsErrorCode, message: string, statusCode?: number) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ElevenLabsError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.statusCode = statusCode;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ElevenLabsClient {
|
|
38
|
+
private options: ElevenLabsClientOptions;
|
|
39
|
+
|
|
40
|
+
constructor(options: ElevenLabsClientOptions) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async registerCall(request: ElevenLabsRegisterCallRequest): Promise<ElevenLabsRegisterCallResult> {
|
|
45
|
+
const url = `${this.options.apiBaseUrl}/v1/convai/twilio/register-call`;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
log.info({ agent_id: request.agent_id, direction: request.direction }, 'Registering call with ElevenLabs');
|
|
51
|
+
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'xi-api-key': this.options.apiKey,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(request),
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new ElevenLabsError(
|
|
64
|
+
'ELEVENLABS_HTTP_ERROR',
|
|
65
|
+
`ElevenLabs register-call returned ${response.status}`,
|
|
66
|
+
response.status,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const body = await response.text();
|
|
71
|
+
if (!body || body.trim().length === 0) {
|
|
72
|
+
throw new ElevenLabsError(
|
|
73
|
+
'ELEVENLABS_INVALID_RESPONSE',
|
|
74
|
+
'ElevenLabs register-call returned empty response',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lower = body.toLowerCase();
|
|
79
|
+
if (!lower.includes('<?xml') && !lower.includes('<response')) {
|
|
80
|
+
throw new ElevenLabsError(
|
|
81
|
+
'ELEVENLABS_INVALID_RESPONSE',
|
|
82
|
+
'Register-call response is not valid TwiML/XML',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { twiml: body };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err instanceof ElevenLabsError) throw err;
|
|
89
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
90
|
+
throw new ElevenLabsError('ELEVENLABS_TIMEOUT', `ElevenLabs register-call timed out after ${this.options.timeoutMs}ms`);
|
|
91
|
+
}
|
|
92
|
+
throw new ElevenLabsError('ELEVENLABS_HTTP_ERROR', `ElevenLabs register-call failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|