vellum 0.2.13 → 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 (207) 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 +113 -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 +137 -18
  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 +62 -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 +27 -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 +4 -4
  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 +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  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 +142 -34
  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 +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -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/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -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,9 +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 } from './voice-quality.js';
29
- import { getElevenLabsConfig } from './elevenlabs-config.js';
30
- import { ElevenLabsClient } from './elevenlabs-client.js';
28
+ import { resolveVoiceQualityProfile, isVoiceProfileValid } from './voice-quality.js';
31
29
 
32
30
  const log = getLogger('twilio-routes');
33
31
 
@@ -139,7 +137,7 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
139
137
  log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
140
138
  }
141
139
 
142
- const profile = resolveVoiceQualityProfile(loadConfig());
140
+ let profile = resolveVoiceQualityProfile(loadConfig());
143
141
 
144
142
  log.info({ callSessionId, mode: profile.mode, ttsProvider: profile.ttsProvider, voice: profile.voice }, 'Voice quality profile resolved');
145
143
 
@@ -147,6 +145,36 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
147
145
  log.warn({ callSessionId, errors: profile.validationErrors }, 'Voice quality profile has validation warnings');
148
146
  }
149
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
+
150
178
  const twilioConfig = getTwilioConfig();
151
179
  let relayUrl: string;
152
180
  try {
@@ -157,39 +185,6 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
157
185
  }
158
186
  const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
159
187
 
160
- if (profile.mode === 'elevenlabs_agent') {
161
- try {
162
- const elevenLabsConfig = getElevenLabsConfig();
163
- const client = new ElevenLabsClient({
164
- apiBaseUrl: elevenLabsConfig.apiBaseUrl,
165
- apiKey: elevenLabsConfig.apiKey,
166
- timeoutMs: elevenLabsConfig.registerCallTimeoutMs,
167
- });
168
-
169
- const result = await client.registerCall({
170
- agent_id: elevenLabsConfig.agentId,
171
- from_number: formBody.get('From') || session.fromNumber,
172
- to_number: formBody.get('To') || session.toNumber,
173
- direction: 'outbound',
174
- });
175
-
176
- log.info({ callSessionId }, 'ElevenLabs register-call succeeded');
177
- return new Response(result.twiml, {
178
- status: 200,
179
- headers: { 'Content-Type': 'text/xml' },
180
- });
181
- } catch (err) {
182
- log.error({ err, callSessionId }, 'ElevenLabs register-call failed');
183
- if (profile.fallbackToStandardOnError) {
184
- log.warn({ callSessionId }, 'Falling back to twilio_standard mode');
185
- const standardProfile = resolveVoiceQualityProfile({ ...loadConfig(), calls: { ...loadConfig().calls, voice: { ...loadConfig().calls.voice, mode: 'twilio_standard' } } });
186
- const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, standardProfile);
187
- return new Response(twiml, { status: 200, headers: { 'Content-Type': 'text/xml' } });
188
- }
189
- return new Response('ElevenLabs service unavailable', { status: 502 });
190
- }
191
- }
192
-
193
188
  const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, profile);
194
189
 
195
190
  log.info({ callSessionId }, 'Returning ConversationRelay TwiML');
@@ -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;
@@ -13,17 +13,34 @@ export interface VoiceQualityProfile {
13
13
 
14
14
  /**
15
15
  * Build a Twilio-compatible ElevenLabs voice string.
16
- * Format: voiceId or voiceId-modelId-stability_similarity_style
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
17
26
  */
18
27
  export function buildElevenLabsVoiceSpec(config: {
19
28
  voiceId: string;
20
- voiceModelId: string;
21
- stability: number;
22
- similarityBoost: number;
23
- style: number;
29
+ voiceModelId?: string;
30
+ speed?: number;
31
+ stability?: number;
32
+ similarityBoost?: number;
24
33
  }): string {
25
- if (!config.voiceId) return '';
26
- return `${config.voiceId}-${config.voiceModelId}-${config.stability}_${config.similarityBoost}_${config.style}`;
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}`;
27
44
  }
28
45
 
29
46
  /**
@@ -90,3 +107,8 @@ export function resolveVoiceQualityProfile(config?: ReturnType<typeof loadConfig
90
107
 
91
108
  return standardProfile;
92
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) {
@@ -1,9 +1,57 @@
1
1
  import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
2
- import { executeContactMerge } from '../../../../tools/contacts/contact-merge.js';
2
+ import { mergeContacts, getContact } from '../../../../contacts/contact-store.js';
3
3
 
4
- export async function run(
4
+ export async function executeContactMerge(
5
5
  input: Record<string, unknown>,
6
- context: ToolContext,
6
+ _context: ToolContext,
7
7
  ): Promise<ToolExecutionResult> {
8
- return executeContactMerge(input, context);
8
+ const keepId = input.keep_id as string | undefined;
9
+ const mergeId = input.merge_id as string | undefined;
10
+
11
+ if (!keepId || typeof keepId !== 'string') {
12
+ return { content: 'Error: keep_id is required', isError: true };
13
+ }
14
+ if (!mergeId || typeof mergeId !== 'string') {
15
+ return { content: 'Error: merge_id is required', isError: true };
16
+ }
17
+
18
+ // Show what will be merged for clarity
19
+ const keepContact = getContact(keepId);
20
+ const mergeContact = getContact(mergeId);
21
+
22
+ if (!keepContact) {
23
+ return { content: `Error: Contact "${keepId}" not found`, isError: true };
24
+ }
25
+ if (!mergeContact) {
26
+ return { content: `Error: Contact "${mergeId}" not found`, isError: true };
27
+ }
28
+
29
+ try {
30
+ const merged = mergeContacts(keepId, mergeId);
31
+
32
+ const channelList = merged.channels
33
+ .map((ch) => ` - ${ch.type}: ${ch.address}${ch.isPrimary ? ' (primary)' : ''}`)
34
+ .join('\n');
35
+
36
+ return {
37
+ content: [
38
+ `Merged "${mergeContact.displayName}" into "${keepContact.displayName}".`,
39
+ ``,
40
+ `Surviving contact (${merged.id}):`,
41
+ ` Name: ${merged.displayName}`,
42
+ ` Importance: ${merged.importance.toFixed(2)}`,
43
+ ` Interactions: ${merged.interactionCount}`,
44
+ merged.relationship ? ` Relationship: ${merged.relationship}` : null,
45
+ merged.channels.length > 0 ? ` Channels:\n${channelList}` : null,
46
+ ``,
47
+ `Deleted contact: ${mergeContact.displayName} (${mergeId})`,
48
+ ].filter(Boolean).join('\n'),
49
+ isError: false,
50
+ };
51
+ } catch (err) {
52
+ const msg = err instanceof Error ? err.message : String(err);
53
+ return { content: `Error: ${msg}`, isError: true };
54
+ }
9
55
  }
56
+
57
+ export { executeContactMerge as run };