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.
Files changed (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -1,11 +1,14 @@
1
1
  /**
2
- * Call-answer bridge: auto-consumes user replies in-thread as answers
3
- * to pending call questions, routing them to the live call orchestrator.
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
- * When a call has a pending question and the user sends a normal message
6
- * in the conversation thread, this bridge intercepts the message before
7
- * the agent loop, forwards the answer to the orchestrator, and returns
8
- * `{ handled: true }` so the caller can skip agent processing.
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 as an answer to a pending call question.
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 answer was consumed by the call system,
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 tryHandlePendingCallAnswer(
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 (!pendingQuestion) {
52
- return { handled: false, reason: 'no_pending_question' };
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
- // 3. Check that the orchestrator is alive and waiting
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(callSession.id);
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(callSession.id, 'user_answered', { answer: userText });
112
+ recordCallEvent(callSessionId, 'user_answered', { answer: userText });
88
113
 
89
114
  log.info(
90
- { conversationId, callSessionId: callSession.id, questionId: pendingQuestion.id },
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
+ }
@@ -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: twilioConfig.phoneNumber,
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: twilioConfig.phoneNumber,
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
- // Append the user's answer as a special message the model recognizes
105
- this.conversationHistory.push({ role: 'user', content: `[USER_ANSWERED: ${answerText}]` });
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. When the call\'s purpose is fulfilled, include [END_CALL] in your response along with a polite goodbye.',
159
- '6. Do not make up information ask the user if unsure.',
160
- '7. Keep responses short 1-3 sentences is ideal for phone conversation.',
161
- '8. 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.',
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: 'claude-sonnet-4-20250514',
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
@@ -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
+ }