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
@@ -0,0 +1,31 @@
1
+ import { getConfig } from '../config/loader.js';
2
+ import { getSecureKey } from '../security/secure-keys.js';
3
+
4
+ export interface ElevenLabsConfig {
5
+ apiKey: string;
6
+ apiBaseUrl: string;
7
+ agentId: string;
8
+ registerCallTimeoutMs: number;
9
+ }
10
+
11
+ export function getElevenLabsConfig(): ElevenLabsConfig {
12
+ const config = getConfig();
13
+ const voice = config.calls.voice;
14
+
15
+ const apiKey = getSecureKey('credential:elevenlabs:api_key') ?? '';
16
+ if (!apiKey) {
17
+ throw new Error('ElevenLabs API key is not configured. Set credential:elevenlabs:api_key in the secure key store.');
18
+ }
19
+
20
+ const agentId = voice.elevenlabs.agentId;
21
+ if (!agentId) {
22
+ throw new Error('ElevenLabs agent ID is not configured. Set calls.voice.elevenlabs.agentId in config.');
23
+ }
24
+
25
+ return {
26
+ apiKey,
27
+ apiBaseUrl: voice.elevenlabs.apiBaseUrl,
28
+ agentId,
29
+ registerCallTimeoutMs: voice.elevenlabs.registerCallTimeoutMs,
30
+ };
31
+ }
@@ -131,6 +131,97 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
131
131
  return data.status;
132
132
  }
133
133
 
134
+ // ── Caller ID eligibility ───────────────────────────────────────────
135
+
136
+ /**
137
+ * Check whether a phone number can be used as an outbound caller ID
138
+ * by the current Twilio account. A number is eligible if it appears as
139
+ * either an Incoming Phone Number (owned) or an Outgoing Caller ID
140
+ * (verified) on the account.
141
+ */
142
+ async checkCallerIdEligibility(
143
+ phoneNumber: string,
144
+ ): Promise<{ eligible: boolean; reason?: string }> {
145
+ const { accountSid, authToken } = this.getCredentials();
146
+ const encodedNumber = encodeURIComponent(phoneNumber);
147
+
148
+ let incomingOk = false;
149
+ let outgoingOk = false;
150
+
151
+ // Check incoming phone numbers (owned by this account)
152
+ const incomingRes = await fetch(
153
+ `${this.baseUrl(accountSid)}/IncomingPhoneNumbers.json?PhoneNumber=${encodedNumber}`,
154
+ {
155
+ method: 'GET',
156
+ headers: {
157
+ Authorization: this.authHeader(accountSid, authToken),
158
+ },
159
+ },
160
+ );
161
+
162
+ if (incomingRes.ok) {
163
+ incomingOk = true;
164
+ const incomingData = (await incomingRes.json()) as {
165
+ incoming_phone_numbers: unknown[];
166
+ };
167
+ if (incomingData.incoming_phone_numbers.length > 0) {
168
+ log.info({ phoneNumber }, 'Number found in IncomingPhoneNumbers — eligible as caller ID');
169
+ return { eligible: true };
170
+ }
171
+ } else {
172
+ log.warn(
173
+ { status: incomingRes.status, phoneNumber },
174
+ 'Failed to query IncomingPhoneNumbers — falling through to OutgoingCallerIds',
175
+ );
176
+ }
177
+
178
+ // Check outgoing caller IDs (verified with this account)
179
+ const outgoingRes = await fetch(
180
+ `${this.baseUrl(accountSid)}/OutgoingCallerIds.json?PhoneNumber=${encodedNumber}`,
181
+ {
182
+ method: 'GET',
183
+ headers: {
184
+ Authorization: this.authHeader(accountSid, authToken),
185
+ },
186
+ },
187
+ );
188
+
189
+ if (outgoingRes.ok) {
190
+ outgoingOk = true;
191
+ const outgoingData = (await outgoingRes.json()) as {
192
+ outgoing_caller_ids: unknown[];
193
+ };
194
+ if (outgoingData.outgoing_caller_ids.length > 0) {
195
+ log.info({ phoneNumber }, 'Number found in OutgoingCallerIds — eligible as caller ID');
196
+ return { eligible: true };
197
+ }
198
+ } else {
199
+ log.warn(
200
+ { status: outgoingRes.status, phoneNumber },
201
+ 'Failed to query OutgoingCallerIds',
202
+ );
203
+ }
204
+
205
+ // If any API call failed, the eligibility check is inconclusive —
206
+ // propagate as an error rather than returning a false negative.
207
+ if (!incomingOk || !outgoingOk) {
208
+ const failedEndpoints = [
209
+ ...(!incomingOk ? [`IncomingPhoneNumbers: ${incomingRes.status}`] : []),
210
+ ...(!outgoingOk ? [`OutgoingCallerIds: ${outgoingRes.status}`] : []),
211
+ ].join(', ');
212
+ throw new Error(
213
+ `Unable to verify caller ID eligibility for ${phoneNumber}: Twilio API error (${failedEndpoints}). The number may be eligible but could not be confirmed. Please check your Twilio credentials and try again.`,
214
+ );
215
+ }
216
+
217
+ log.info({ phoneNumber }, 'Number not found in either IncomingPhoneNumbers or OutgoingCallerIds');
218
+ return {
219
+ eligible: false,
220
+ reason:
221
+ 'Number is not owned by or verified with your Twilio account. To use this number as caller ID, either: (1) add it as an Incoming Phone Number, or (2) verify it as an Outgoing Caller ID in the Twilio Console.',
222
+ };
223
+ }
224
+
134
225
  // ── Webhook signature verification ──────────────────────────────────
135
226
 
136
227
  /**
@@ -25,6 +25,7 @@ import { getTwilioConfig } from './twilio-config.js';
25
25
  import { loadConfig } from '../config/loader.js';
26
26
  import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
27
27
  import { fireCallCompletionNotifier } from './call-state.js';
28
+ import { resolveVoiceQualityProfile, isVoiceProfileValid } from './voice-quality.js';
28
29
 
29
30
  const log = getLogger('twilio-routes');
30
31
 
@@ -39,17 +40,22 @@ function escapeXml(str: string): string {
39
40
  .replace(/'/g, '&apos;');
40
41
  }
41
42
 
42
- function generateTwiML(callSessionId: string, relayUrl: string, welcomeGreeting: string): string {
43
+ export function generateTwiML(
44
+ callSessionId: string,
45
+ relayUrl: string,
46
+ welcomeGreeting: string,
47
+ profile: { language: string; transcriptionProvider: string; ttsProvider: string; voice: string },
48
+ ): string {
43
49
  return `<?xml version="1.0" encoding="UTF-8"?>
44
50
  <Response>
45
51
  <Connect>
46
52
  <ConversationRelay
47
53
  url="${escapeXml(relayUrl)}?callSessionId=${escapeXml(callSessionId)}"
48
54
  welcomeGreeting="${escapeXml(welcomeGreeting)}"
49
- voice="Google.en-US-Journey-O"
50
- language="en-US"
51
- transcriptionProvider="Deepgram"
52
- ttsProvider="Google"
55
+ voice="${escapeXml(profile.voice)}"
56
+ language="${escapeXml(profile.language)}"
57
+ transcriptionProvider="${escapeXml(profile.transcriptionProvider)}"
58
+ ttsProvider="${escapeXml(profile.ttsProvider)}"
53
59
  interruptible="true"
54
60
  dtmfDetection="true"
55
61
  />
@@ -131,6 +137,44 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
131
137
  log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
132
138
  }
133
139
 
140
+ let profile = resolveVoiceQualityProfile(loadConfig());
141
+
142
+ log.info({ callSessionId, mode: profile.mode, ttsProvider: profile.ttsProvider, voice: profile.voice }, 'Voice quality profile resolved');
143
+
144
+ if (profile.validationErrors.length > 0) {
145
+ log.warn({ callSessionId, errors: profile.validationErrors }, 'Voice quality profile has validation warnings');
146
+ }
147
+
148
+ // WS-A: Enforce strict fallback semantics — reject invalid profiles when fallback is disabled
149
+ if (!isVoiceProfileValid(profile)) {
150
+ if (!profile.fallbackToStandardOnError) {
151
+ const errorMsg = `Voice quality configuration error: ${profile.validationErrors.join('; ')}`;
152
+ log.error({ callSessionId, errors: profile.validationErrors }, errorMsg);
153
+ return new Response(errorMsg, { status: 500 });
154
+ }
155
+ // Fallback is enabled — profile already resolved to standard; log explicitly
156
+ log.info({ callSessionId }, 'Profile invalid with fallback enabled; proceeding with standard mode');
157
+ }
158
+
159
+ // WS-B: Guard elevenlabs_agent until consultation bridge exists.
160
+ // This fires BEFORE any ElevenLabs API calls, blocking the entire mode.
161
+ if (profile.mode === 'elevenlabs_agent') {
162
+ if (!profile.fallbackToStandardOnError) {
163
+ const msg = 'elevenlabs_agent mode is restricted: consultation bridging (waiting_on_user) is not yet supported. Set calls.voice.fallbackToStandardOnError=true to fall back to standard mode.';
164
+ log.error({ callSessionId }, msg);
165
+ return new Response(msg, { status: 501 });
166
+ }
167
+ log.warn({ callSessionId }, 'elevenlabs_agent mode is restricted/experimental — consultation bridging is not yet supported; falling back to standard ConversationRelay TwiML');
168
+ const standardConfig = loadConfig();
169
+ profile = resolveVoiceQualityProfile({
170
+ ...standardConfig,
171
+ calls: {
172
+ ...standardConfig.calls,
173
+ voice: { ...standardConfig.calls.voice, mode: 'twilio_standard' },
174
+ },
175
+ });
176
+ }
177
+
134
178
  const twilioConfig = getTwilioConfig();
135
179
  let relayUrl: string;
136
180
  try {
@@ -141,7 +185,7 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
141
185
  }
142
186
  const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
143
187
 
144
- const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting);
188
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, profile);
145
189
 
146
190
  log.info({ callSessionId }, 'Returning ConversationRelay TwiML');
147
191
 
@@ -1,5 +1,5 @@
1
1
  export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
2
- export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'call_ended' | 'call_failed';
2
+ export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed';
3
3
  export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
4
4
 
5
5
  export interface CallSession {
@@ -11,6 +11,8 @@ export interface CallSession {
11
11
  toNumber: string;
12
12
  task: string | null;
13
13
  status: CallStatus;
14
+ callerIdentityMode: string | null;
15
+ callerIdentitySource: string | null;
14
16
  startedAt: number | null;
15
17
  endedAt: number | null;
16
18
  lastError: string | null;
@@ -0,0 +1,114 @@
1
+ import { loadConfig } from '../config/loader.js';
2
+
3
+ export interface VoiceQualityProfile {
4
+ mode: 'twilio_standard' | 'twilio_elevenlabs_tts' | 'elevenlabs_agent';
5
+ language: string;
6
+ transcriptionProvider: string;
7
+ ttsProvider: string;
8
+ voice: string;
9
+ agentId?: string;
10
+ fallbackToStandardOnError: boolean;
11
+ validationErrors: string[];
12
+ }
13
+
14
+ /**
15
+ * Build a Twilio-compatible ElevenLabs voice string.
16
+ *
17
+ * Twilio ConversationRelay accepts:
18
+ * - bare voiceId
19
+ * - voiceId-model-speed_stability_similarity
20
+ *
21
+ * We default to bare voiceId unless a model is explicitly configured.
22
+ * This avoids forcing model/tuning suffixes that may be rejected for some
23
+ * voice + model combinations.
24
+ *
25
+ * See: https://www.twilio.com/docs/voice/conversationrelay/voice-configuration
26
+ */
27
+ export function buildElevenLabsVoiceSpec(config: {
28
+ voiceId: string;
29
+ voiceModelId?: string;
30
+ speed?: number;
31
+ stability?: number;
32
+ similarityBoost?: number;
33
+ }): string {
34
+ const voiceId = config.voiceId?.trim();
35
+ if (!voiceId) return '';
36
+
37
+ const voiceModelId = config.voiceModelId?.trim();
38
+ if (!voiceModelId) return voiceId;
39
+
40
+ const speed = config.speed ?? 1.0;
41
+ const stability = config.stability ?? 0.5;
42
+ const similarityBoost = config.similarityBoost ?? 0.75;
43
+ return `${voiceId}-${voiceModelId}-${speed}_${stability}_${similarityBoost}`;
44
+ }
45
+
46
+ /**
47
+ * Resolve the effective voice quality profile from config.
48
+ * Returns a profile with all resolved values ready for use by TwiML generation
49
+ * and call orchestration.
50
+ */
51
+ export function resolveVoiceQualityProfile(config?: ReturnType<typeof loadConfig>): VoiceQualityProfile {
52
+ const cfg = config ?? loadConfig();
53
+ const voice = cfg.calls.voice;
54
+ const errors: string[] = [];
55
+
56
+ // Default/standard profile
57
+ const standardProfile: VoiceQualityProfile = {
58
+ mode: 'twilio_standard',
59
+ language: voice.language,
60
+ transcriptionProvider: voice.transcriptionProvider,
61
+ ttsProvider: 'Google',
62
+ voice: 'Google.en-US-Journey-O',
63
+ fallbackToStandardOnError: voice.fallbackToStandardOnError,
64
+ validationErrors: [],
65
+ };
66
+
67
+ if (voice.mode === 'twilio_standard') {
68
+ return standardProfile;
69
+ }
70
+
71
+ if (voice.mode === 'twilio_elevenlabs_tts') {
72
+ if (!voice.elevenlabs.voiceId && !voice.fallbackToStandardOnError) {
73
+ errors.push('calls.voice.elevenlabs.voiceId is required for twilio_elevenlabs_tts mode when fallback is disabled');
74
+ }
75
+ if (!voice.elevenlabs.voiceId && voice.fallbackToStandardOnError) {
76
+ return { ...standardProfile, validationErrors: ['calls.voice.elevenlabs.voiceId is empty; falling back to twilio_standard'] };
77
+ }
78
+ return {
79
+ mode: 'twilio_elevenlabs_tts',
80
+ language: voice.language,
81
+ transcriptionProvider: voice.transcriptionProvider,
82
+ ttsProvider: 'ElevenLabs',
83
+ voice: buildElevenLabsVoiceSpec(voice.elevenlabs),
84
+ fallbackToStandardOnError: voice.fallbackToStandardOnError,
85
+ validationErrors: errors,
86
+ };
87
+ }
88
+
89
+ if (voice.mode === 'elevenlabs_agent') {
90
+ if (!voice.elevenlabs.agentId && !voice.fallbackToStandardOnError) {
91
+ errors.push('calls.voice.elevenlabs.agentId is required for elevenlabs_agent mode when fallback is disabled');
92
+ }
93
+ if (!voice.elevenlabs.agentId && voice.fallbackToStandardOnError) {
94
+ return { ...standardProfile, validationErrors: ['calls.voice.elevenlabs.agentId is empty; falling back to twilio_standard'] };
95
+ }
96
+ return {
97
+ mode: 'elevenlabs_agent',
98
+ language: voice.language,
99
+ transcriptionProvider: voice.transcriptionProvider,
100
+ ttsProvider: 'ElevenLabs',
101
+ voice: buildElevenLabsVoiceSpec(voice.elevenlabs),
102
+ agentId: voice.elevenlabs.agentId,
103
+ fallbackToStandardOnError: voice.fallbackToStandardOnError,
104
+ validationErrors: errors,
105
+ };
106
+ }
107
+
108
+ return standardProfile;
109
+ }
110
+
111
+ /** Returns false when the profile has any validation errors. */
112
+ export function isVoiceProfileValid(profile: VoiceQualityProfile): boolean {
113
+ return profile.validationErrors.length === 0;
114
+ }
@@ -13,7 +13,6 @@ import {
13
13
  clearSession,
14
14
  } from '../twitter/session.js';
15
15
  import {
16
- postTweet,
17
16
  getUserByScreenName,
18
17
  getUserTweets,
19
18
  getTweetDetail,
@@ -27,6 +26,7 @@ import {
27
26
  getUserMedia,
28
27
  SessionExpiredError,
29
28
  } from '../twitter/client.js';
29
+ import { routedPostTweet } from '../twitter/router.js';
30
30
  import { getSocketPath, readSessionToken } from '../util/platform.js';
31
31
  import {
32
32
  serialize,
@@ -69,11 +69,32 @@ async function run(cmd: Command, fn: () => Promise<unknown>): Promise<void> {
69
69
  getJson(cmd),
70
70
  );
71
71
  } catch (err) {
72
+ const meta = err as Record<string, unknown>;
72
73
  if (err instanceof SessionExpiredError) {
73
- output(
74
- { ok: false, error: 'session_expired', message: SESSION_EXPIRED_MSG },
75
- getJson(cmd),
76
- );
74
+ // Preserve backward-compatible error code while surfacing router metadata
75
+ const payload: Record<string, unknown> = {
76
+ ok: false,
77
+ error: 'session_expired',
78
+ message: SESSION_EXPIRED_MSG,
79
+ };
80
+ if (meta.pathUsed !== undefined) payload.pathUsed = meta.pathUsed;
81
+ if (meta.suggestAlternative !== undefined) payload.suggestAlternative = meta.suggestAlternative;
82
+ if (meta.oauthError !== undefined) payload.oauthError = meta.oauthError;
83
+ output(payload, getJson(cmd));
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ // For routed errors with any router metadata, emit structured JSON
88
+ // so callers can see dual-path diagnostics (pathUsed, oauthError, etc.)
89
+ if (err instanceof Error && (meta.pathUsed !== undefined || meta.suggestAlternative !== undefined || meta.oauthError !== undefined)) {
90
+ const payload: Record<string, unknown> = {
91
+ ok: false,
92
+ error: err.message,
93
+ };
94
+ if (meta.pathUsed !== undefined) payload.pathUsed = meta.pathUsed;
95
+ if (meta.suggestAlternative !== undefined) payload.suggestAlternative = meta.suggestAlternative;
96
+ if (meta.oauthError !== undefined) payload.oauthError = meta.oauthError;
97
+ output(payload, getJson(cmd));
77
98
  process.exitCode = 1;
78
99
  return;
79
100
  }
@@ -90,7 +111,7 @@ export function registerTwitterCommand(program: Command): void {
90
111
  .command('x')
91
112
  .alias('twitter')
92
113
  .description(
93
- 'Post on X and manage sessions. Requires a session imported from a Ride Shotgun recording.',
114
+ 'Post on X and manage connections. Supports OAuth (official API) and browser session paths.',
94
115
  )
95
116
  .option('--json', 'Machine-readable JSON output');
96
117
 
@@ -172,25 +193,95 @@ export function registerTwitterCommand(program: Command): void {
172
193
  });
173
194
 
174
195
  // =========================================================================
175
- // status — check session status
196
+ // status — check session status + OAuth and strategy info
176
197
  // =========================================================================
177
198
  tw.command('status')
178
- .description('Check if a Twitter session is active')
179
- .action((_opts: unknown, cmd: Command) => {
199
+ .description('Check Twitter session, OAuth, and strategy status')
200
+ .action(async (_opts: unknown, cmd: Command) => {
180
201
  const session = loadSession();
181
- if (session) {
182
- output(
183
- {
184
- ok: true,
185
- loggedIn: true,
202
+ const browserInfo: Record<string, unknown> = session
203
+ ? {
204
+ browserSessionActive: true,
186
205
  cookieCount: session.cookies.length,
187
206
  importedAt: session.importedAt,
188
207
  recordingId: session.recordingId,
189
- },
190
- getJson(cmd),
191
- );
192
- } else {
193
- output({ ok: true, loggedIn: false }, getJson(cmd));
208
+ }
209
+ : { browserSessionActive: false };
210
+
211
+ // Query daemon for OAuth / strategy config
212
+ let oauthInfo: Record<string, unknown> = {};
213
+ try {
214
+ const daemonResponse = await sendDaemonMessage({
215
+ type: 'twitter_integration_config',
216
+ action: 'get',
217
+ } as import('../daemon/ipc-protocol.js').ClientMessage, 'twitter_integration_config_response');
218
+ const r = daemonResponse as Record<string, unknown>;
219
+ oauthInfo = {
220
+ oauthConnected: r.connected ?? false,
221
+ oauthAccount: r.accountInfo ?? undefined,
222
+ preferredStrategy: r.strategy ?? 'auto',
223
+ strategyConfigured: r.strategyConfigured ?? false,
224
+ };
225
+ } catch {
226
+ // Daemon may not be running; report what we can from the local session
227
+ oauthInfo = {
228
+ oauthConnected: undefined,
229
+ oauthAccount: undefined,
230
+ preferredStrategy: undefined,
231
+ strategyConfigured: undefined,
232
+ };
233
+ }
234
+
235
+ output(
236
+ {
237
+ ok: true,
238
+ loggedIn: !!session,
239
+ ...browserInfo,
240
+ ...oauthInfo,
241
+ },
242
+ getJson(cmd),
243
+ );
244
+ });
245
+
246
+ // =========================================================================
247
+ // strategy — get or set the Twitter operation strategy
248
+ // =========================================================================
249
+ const strategyCli = tw.command('strategy')
250
+ .description('Get or set the Twitter operation strategy (oauth, browser, auto)')
251
+ .action(async (_opts: unknown, cmd: Command) => {
252
+ const json = getJson(cmd);
253
+ try {
254
+ const daemonResponse = await sendDaemonMessage({
255
+ type: 'twitter_integration_config',
256
+ action: 'get_strategy',
257
+ } as import('../daemon/ipc-protocol.js').ClientMessage, 'twitter_integration_config_response');
258
+ const r = daemonResponse as Record<string, unknown>;
259
+ output({ ok: true, strategy: r.strategy ?? 'auto' }, json);
260
+ } catch (err) {
261
+ outputError(err instanceof Error ? err.message : String(err));
262
+ }
263
+ });
264
+
265
+ strategyCli.command('set')
266
+ .description('Set the Twitter operation strategy')
267
+ .argument('<value>', 'Strategy value: oauth, browser, or auto')
268
+ .action(async (value: string, _opts: unknown, cmd: Command) => {
269
+ const json = getJson(cmd);
270
+ try {
271
+ const daemonResponse = await sendDaemonMessage({
272
+ type: 'twitter_integration_config',
273
+ action: 'set_strategy',
274
+ strategy: value,
275
+ } as import('../daemon/ipc-protocol.js').ClientMessage, 'twitter_integration_config_response');
276
+ const r = daemonResponse as Record<string, unknown>;
277
+ if (r.success) {
278
+ output({ ok: true, strategy: r.strategy }, json);
279
+ } else {
280
+ output({ ok: false, error: r.error ?? 'Failed to set strategy' }, json);
281
+ process.exitCode = 1;
282
+ }
283
+ } catch (err) {
284
+ outputError(err instanceof Error ? err.message : String(err));
194
285
  }
195
286
  });
196
287
 
@@ -202,11 +293,12 @@ export function registerTwitterCommand(program: Command): void {
202
293
  .argument('<text>', 'Tweet text')
203
294
  .action(async (text: string, _opts: unknown, cmd: Command) => {
204
295
  await run(cmd, async () => {
205
- const result = await postTweet(text);
296
+ const { result, pathUsed } = await routedPostTweet(text);
206
297
  return {
207
298
  tweetId: result.tweetId,
208
299
  text: result.text,
209
300
  url: result.url,
301
+ pathUsed,
210
302
  };
211
303
  });
212
304
  });
@@ -226,12 +318,13 @@ export function registerTwitterCommand(program: Command): void {
226
318
  throw new Error(`Could not extract tweet ID from: ${tweetUrl}`);
227
319
  }
228
320
  const inReplyToTweetId = idMatch[1];
229
- const result = await postTweet(text, { inReplyToTweetId });
321
+ const { result, pathUsed } = await routedPostTweet(text, { inReplyToTweetId });
230
322
  return {
231
323
  tweetId: result.tweetId,
232
324
  text: result.text,
233
325
  url: result.url,
234
326
  inReplyToTweetId,
327
+ pathUsed,
235
328
  };
236
329
  });
237
330
  });
@@ -382,6 +475,92 @@ export function registerTwitterCommand(program: Command): void {
382
475
  });
383
476
  }
384
477
 
478
+ // ---------------------------------------------------------------------------
479
+ // Daemon IPC helper — send a message and wait for the first response
480
+ // ---------------------------------------------------------------------------
481
+
482
+ function sendDaemonMessage(
483
+ message: import('../daemon/ipc-protocol.js').ClientMessage,
484
+ expectedResponseType: string,
485
+ ): Promise<Record<string, unknown>> {
486
+ return new Promise((resolve, reject) => {
487
+ const socketPath = getSocketPath();
488
+ const sessionToken = readSessionToken();
489
+ const socket = net.createConnection(socketPath);
490
+ const parser = createMessageParser();
491
+
492
+ const timeoutHandle = setTimeout(() => {
493
+ socket.destroy();
494
+ reject(new Error('Daemon request timed out after 10s'));
495
+ }, 10_000);
496
+ timeoutHandle.unref();
497
+
498
+ let authenticated = !sessionToken;
499
+ let messageSent = false;
500
+
501
+ const sendPayload = () => {
502
+ if (messageSent) return;
503
+ messageSent = true;
504
+ socket.write(serialize(message));
505
+ };
506
+
507
+ socket.on('error', (err) => {
508
+ clearTimeout(timeoutHandle);
509
+ reject(new Error(`Cannot connect to daemon: ${err.message}. Is the daemon running?`));
510
+ });
511
+
512
+ socket.on('data', (chunk) => {
513
+ const messages = parser.feed(chunk.toString('utf-8'));
514
+ for (const msg of messages) {
515
+ const m = msg as unknown as Record<string, unknown>;
516
+
517
+ if (!authenticated && m.type === 'auth_result') {
518
+ if ((m as { success: boolean }).success) {
519
+ authenticated = true;
520
+ sendPayload();
521
+ } else {
522
+ clearTimeout(timeoutHandle);
523
+ socket.destroy();
524
+ reject(new Error('Daemon authentication failed'));
525
+ }
526
+ continue;
527
+ }
528
+
529
+ // Reject immediately on daemon error frames so the CLI surfaces the
530
+ // real failure reason instead of hanging until the timeout fires.
531
+ if (m.type === 'error') {
532
+ clearTimeout(timeoutHandle);
533
+ socket.destroy();
534
+ reject(new Error((m as { message?: string }).message ?? 'Daemon returned an error'));
535
+ return;
536
+ }
537
+
538
+ // Only resolve on the expected response type; skip everything else
539
+ if (m.type === expectedResponseType) {
540
+ clearTimeout(timeoutHandle);
541
+ socket.destroy();
542
+ resolve(m);
543
+ return;
544
+ }
545
+ // Skip all other message types (auth_result, daemon_status, pong, session_info, tasks_changed, etc.)
546
+ }
547
+ });
548
+
549
+ socket.on('connect', () => {
550
+ if (sessionToken) {
551
+ socket.write(
552
+ serialize({
553
+ type: 'auth',
554
+ token: sessionToken,
555
+ } as unknown as import('../daemon/ipc-protocol.js').ClientMessage),
556
+ );
557
+ } else {
558
+ sendPayload();
559
+ }
560
+ });
561
+ });
562
+ }
563
+
385
564
  // ---------------------------------------------------------------------------
386
565
  // Chrome CDP restart helper
387
566
  // ---------------------------------------------------------------------------
package/src/cli.ts CHANGED
@@ -20,7 +20,6 @@ import { ensureDaemonRunning } from './daemon/lifecycle.js';
20
20
  import { shouldAutoStartDaemon } from './daemon/connection-policy.js';
21
21
  import { renderMainScreen, updateStatusText, updateDaemonText, type MainScreenLayout } from './cli/main-screen.jsx';
22
22
 
23
- const SHORT_HASH_LENGTH = 8;
24
23
  const HEARTBEAT_INTERVAL_MS = 30_000;
25
24
  const HEARTBEAT_TIMEOUT_MS = 10_000;
26
25
  const RECONNECT_BASE_DELAY_MS = 1_000;
@@ -43,21 +42,6 @@ export function sanitizeUrlForDisplay(rawUrl: unknown): string {
43
42
  }
44
43
  }
45
44
 
46
- /**
47
- * Format a human-readable principal tag for display in permission prompts.
48
- * Core principals are omitted (empty string) since they're the default.
49
- * Skill principals show the skill name, shortened version hash, and target.
50
- */
51
- export function formatPrincipalTag(req: Pick<ConfirmationRequest, 'principalKind' | 'principalId' | 'principalVersion' | 'executionTarget'>): string {
52
- if (!req.principalKind || req.principalKind === 'core') return '';
53
- const name = req.principalId ?? req.principalKind;
54
- // Show a shortened version hash when available (first 8 hex chars after any scheme prefix)
55
- const versionSuffix = req.principalVersion
56
- ? `@${req.principalVersion.replace(/^[^:]+:/, '').slice(0, SHORT_HASH_LENGTH)}`
57
- : '';
58
- const target = req.executionTarget ? ` \u2192 ${req.executionTarget}` : '';
59
- return `[${req.principalKind}: ${name}${versionSuffix}${target}]`;
60
- }
61
45
 
62
46
  export async function startCli(): Promise<void> {
63
47
  const socketPath = getSocketPath();
@@ -186,13 +170,10 @@ export async function startCli(): Promise<void> {
186
170
 
187
171
  function renderConfirmationPrompt(req: ConfirmationRequest): void {
188
172
  const preview = formatCommandPreview(req);
189
- const principalTag = formatPrincipalTag(req);
190
173
  process.stdout.write('\n');
191
174
  process.stdout.write(`\u250C ${req.toolName}: ${preview}\n`);
192
175
  process.stdout.write(`\u2502 Risk: ${req.riskLevel}${req.sandboxed ? ' [sandboxed]' : ''}\n`);
193
- if (principalTag) {
194
- process.stdout.write(`\u2502 Principal: ${principalTag}\n`);
195
- } else if (req.executionTarget) {
176
+ if (req.executionTarget) {
196
177
  process.stdout.write(`\u2502 Target: ${req.executionTarget}\n`);
197
178
  }
198
179
  if (req.diff) {