vellum 0.2.11 → 0.2.13

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.
@@ -0,0 +1,29 @@
1
+ import { getConfig } from '../config/loader.js';
2
+ import { getSecureKey } from '../security/secure-keys.js';
3
+ import { getLogger } from '../util/logger.js';
4
+
5
+ const log = getLogger('elevenlabs-config');
6
+
7
+ export interface ElevenLabsConfig {
8
+ apiKey: string;
9
+ apiBaseUrl: string;
10
+ agentId: string;
11
+ registerCallTimeoutMs: number;
12
+ }
13
+
14
+ export function getElevenLabsConfig(): ElevenLabsConfig {
15
+ const config = getConfig();
16
+ const voice = config.calls.voice;
17
+
18
+ const apiKey = getSecureKey('credential:elevenlabs:api_key') ?? '';
19
+ if (!apiKey) {
20
+ log.warn('No ElevenLabs API key found in secure key store (credential:elevenlabs:api_key)');
21
+ }
22
+
23
+ return {
24
+ apiKey,
25
+ apiBaseUrl: voice.elevenlabs.apiBaseUrl,
26
+ agentId: voice.elevenlabs.agentId,
27
+ registerCallTimeoutMs: voice.elevenlabs.registerCallTimeoutMs,
28
+ };
29
+ }
@@ -25,6 +25,9 @@ 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
31
 
29
32
  const log = getLogger('twilio-routes');
30
33
 
@@ -39,17 +42,22 @@ function escapeXml(str: string): string {
39
42
  .replace(/'/g, ''');
40
43
  }
41
44
 
42
- function generateTwiML(callSessionId: string, relayUrl: string, welcomeGreeting: string): string {
45
+ export function generateTwiML(
46
+ callSessionId: string,
47
+ relayUrl: string,
48
+ welcomeGreeting: string,
49
+ profile: { language: string; transcriptionProvider: string; ttsProvider: string; voice: string },
50
+ ): string {
43
51
  return `<?xml version="1.0" encoding="UTF-8"?>
44
52
  <Response>
45
53
  <Connect>
46
54
  <ConversationRelay
47
55
  url="${escapeXml(relayUrl)}?callSessionId=${escapeXml(callSessionId)}"
48
56
  welcomeGreeting="${escapeXml(welcomeGreeting)}"
49
- voice="Google.en-US-Journey-O"
50
- language="en-US"
51
- transcriptionProvider="Deepgram"
52
- ttsProvider="Google"
57
+ voice="${escapeXml(profile.voice)}"
58
+ language="${escapeXml(profile.language)}"
59
+ transcriptionProvider="${escapeXml(profile.transcriptionProvider)}"
60
+ ttsProvider="${escapeXml(profile.ttsProvider)}"
53
61
  interruptible="true"
54
62
  dtmfDetection="true"
55
63
  />
@@ -131,6 +139,14 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
131
139
  log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
132
140
  }
133
141
 
142
+ const profile = resolveVoiceQualityProfile(loadConfig());
143
+
144
+ log.info({ callSessionId, mode: profile.mode, ttsProvider: profile.ttsProvider, voice: profile.voice }, 'Voice quality profile resolved');
145
+
146
+ if (profile.validationErrors.length > 0) {
147
+ log.warn({ callSessionId, errors: profile.validationErrors }, 'Voice quality profile has validation warnings');
148
+ }
149
+
134
150
  const twilioConfig = getTwilioConfig();
135
151
  let relayUrl: string;
136
152
  try {
@@ -141,7 +157,40 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
141
157
  }
142
158
  const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
143
159
 
144
- const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting);
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
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, profile);
145
194
 
146
195
  log.info({ callSessionId }, 'Returning ConversationRelay TwiML');
147
196
 
@@ -0,0 +1,92 @@
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
+ * Format: voiceId or voiceId-modelId-stability_similarity_style
17
+ */
18
+ export function buildElevenLabsVoiceSpec(config: {
19
+ voiceId: string;
20
+ voiceModelId: string;
21
+ stability: number;
22
+ similarityBoost: number;
23
+ style: number;
24
+ }): string {
25
+ if (!config.voiceId) return '';
26
+ return `${config.voiceId}-${config.voiceModelId}-${config.stability}_${config.similarityBoost}_${config.style}`;
27
+ }
28
+
29
+ /**
30
+ * Resolve the effective voice quality profile from config.
31
+ * Returns a profile with all resolved values ready for use by TwiML generation
32
+ * and call orchestration.
33
+ */
34
+ export function resolveVoiceQualityProfile(config?: ReturnType<typeof loadConfig>): VoiceQualityProfile {
35
+ const cfg = config ?? loadConfig();
36
+ const voice = cfg.calls.voice;
37
+ const errors: string[] = [];
38
+
39
+ // Default/standard profile
40
+ const standardProfile: VoiceQualityProfile = {
41
+ mode: 'twilio_standard',
42
+ language: voice.language,
43
+ transcriptionProvider: voice.transcriptionProvider,
44
+ ttsProvider: 'Google',
45
+ voice: 'Google.en-US-Journey-O',
46
+ fallbackToStandardOnError: voice.fallbackToStandardOnError,
47
+ validationErrors: [],
48
+ };
49
+
50
+ if (voice.mode === 'twilio_standard') {
51
+ return standardProfile;
52
+ }
53
+
54
+ if (voice.mode === 'twilio_elevenlabs_tts') {
55
+ if (!voice.elevenlabs.voiceId && !voice.fallbackToStandardOnError) {
56
+ errors.push('calls.voice.elevenlabs.voiceId is required for twilio_elevenlabs_tts mode when fallback is disabled');
57
+ }
58
+ if (!voice.elevenlabs.voiceId && voice.fallbackToStandardOnError) {
59
+ return { ...standardProfile, validationErrors: ['calls.voice.elevenlabs.voiceId is empty; falling back to twilio_standard'] };
60
+ }
61
+ return {
62
+ mode: 'twilio_elevenlabs_tts',
63
+ language: voice.language,
64
+ transcriptionProvider: voice.transcriptionProvider,
65
+ ttsProvider: 'ElevenLabs',
66
+ voice: buildElevenLabsVoiceSpec(voice.elevenlabs),
67
+ fallbackToStandardOnError: voice.fallbackToStandardOnError,
68
+ validationErrors: errors,
69
+ };
70
+ }
71
+
72
+ if (voice.mode === 'elevenlabs_agent') {
73
+ if (!voice.elevenlabs.agentId && !voice.fallbackToStandardOnError) {
74
+ errors.push('calls.voice.elevenlabs.agentId is required for elevenlabs_agent mode when fallback is disabled');
75
+ }
76
+ if (!voice.elevenlabs.agentId && voice.fallbackToStandardOnError) {
77
+ return { ...standardProfile, validationErrors: ['calls.voice.elevenlabs.agentId is empty; falling back to twilio_standard'] };
78
+ }
79
+ return {
80
+ mode: 'elevenlabs_agent',
81
+ language: voice.language,
82
+ transcriptionProvider: voice.transcriptionProvider,
83
+ ttsProvider: 'ElevenLabs',
84
+ voice: buildElevenLabsVoiceSpec(voice.elevenlabs),
85
+ agentId: voice.elevenlabs.agentId,
86
+ fallbackToStandardOnError: voice.fallbackToStandardOnError,
87
+ validationErrors: errors,
88
+ };
89
+ }
90
+
91
+ return standardProfile;
92
+ }
@@ -1,19 +1,9 @@
1
- import { type ReactElement } from "react";
2
- import { basename } from "node:path";
3
- import { Box, render as inkRender, Text } from "ink";
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
4
3
  import { getSocketPath, getWorkspaceDir } from "../util/platform.js";
5
- import { APP_VERSION } from "../version.js";
6
4
 
7
5
  const LEFT_PANEL_WIDTH = 36;
8
-
9
- const VELLY_ART = [
10
- " ,___,",
11
- " ( O O )",
12
- " /)V(\\",
13
- " // \\\\",
14
- ' /" "\\',
15
- " ^ ^",
16
- ];
6
+ const RIGHT_LINE_COUNT = 11;
17
7
 
18
8
  export interface MainScreenLayout {
19
9
  height: number;
@@ -21,116 +11,24 @@ export interface MainScreenLayout {
21
11
  statusCol: number;
22
12
  }
23
13
 
24
- function DefaultMainScreen(): ReactElement {
14
+ export function renderMainScreen(): MainScreenLayout {
25
15
  const socketPath = getSocketPath();
26
16
  const workspace = getWorkspaceDir();
27
- const dirName = basename(workspace);
28
-
29
- const tips = [
30
- "Send a message to start chatting",
31
- "Use /help to see available commands",
32
- ];
33
-
34
- const leftLines = [
35
- " ",
36
- " Meet your Assistant!",
37
- " ",
38
- ...VELLY_ART.map((l) => ` ${l}`),
39
- " ",
40
- ` ${socketPath}`,
41
- ` ~/${dirName}`,
42
- ];
43
-
44
- const rightLines = [
45
- " ",
46
- "Tips for getting started",
47
- ...tips,
48
- " ",
49
- "Daemon",
50
- "connecting...",
51
- "Version",
52
- APP_VERSION,
53
- "Status",
54
- "checking...",
55
- ];
17
+ const assistantId = workspace.split("/").pop() ?? "vellum";
56
18
 
57
- const maxLines = Math.max(leftLines.length, rightLines.length);
58
-
59
- return (
60
- <Box flexDirection="column" width={72}>
61
- <Text dimColor>{"── Vellum " + "─".repeat(62)}</Text>
62
- <Box flexDirection="row">
63
- <Box flexDirection="column" width={LEFT_PANEL_WIDTH}>
64
- {Array.from({ length: maxLines }, (_, i) => {
65
- const line = leftLines[i] ?? " ";
66
- if (i === 1) {
67
- return (
68
- <Text key={i} bold>
69
- {line}
70
- </Text>
71
- );
72
- }
73
- if (i > 2 && i <= 2 + VELLY_ART.length) {
74
- return (
75
- <Text key={i} color="magenta">
76
- {line}
77
- </Text>
78
- );
79
- }
80
- if (i > 2 + VELLY_ART.length) {
81
- return (
82
- <Text key={i} dimColor>
83
- {line}
84
- </Text>
85
- );
86
- }
87
- return <Text key={i}>{line}</Text>;
88
- })}
89
- </Box>
90
- <Box flexDirection="column">
91
- {Array.from({ length: maxLines }, (_, i) => {
92
- const line = rightLines[i] ?? " ";
93
- const isHeading = i === 1 || i === 6;
94
- const isDim = i === 5 || i === 7 || i === 9;
95
- if (isHeading) {
96
- return (
97
- <Text key={i} color="magenta">
98
- {line}
99
- </Text>
100
- );
101
- }
102
- if (isDim) {
103
- return (
104
- <Text key={i} dimColor>
105
- {line}
106
- </Text>
107
- );
108
- }
109
- return <Text key={i}>{line}</Text>;
110
- })}
111
- </Box>
112
- </Box>
113
- <Text dimColor>{"─".repeat(72)}</Text>
114
- <Text> </Text>
115
- <Text dimColor> ? for shortcuts</Text>
116
- <Text> </Text>
117
- </Box>
118
- );
119
- }
120
-
121
- export function renderMainScreen(): MainScreenLayout {
122
- const leftLineCount = 3 + VELLY_ART.length + 3;
123
- const rightLineCount = 11;
124
- const maxLines = Math.max(leftLineCount, rightLineCount);
19
+ const require = createRequire(import.meta.url);
20
+ const cliPkgPath = require.resolve("@vellumai/cli/package.json");
21
+ const cliRoot = dirname(cliPkgPath);
22
+ // Dynamic require to bypass NodeNext strict module resolution for the
23
+ // CLI package which ships raw TypeScript with bundler-style imports.
24
+ const { render } = require(join(cliRoot, "src", "components", "DefaultMainScreen.tsx")) as {
25
+ render: (runtimeUrl: string, assistantId: string, species: string) => number;
26
+ };
125
27
 
126
- const { unmount } = inkRender(<DefaultMainScreen />, {
127
- exitOnCtrlC: false,
128
- });
129
- unmount();
28
+ const height = render(socketPath, assistantId, "vellum");
130
29
 
131
- const statusCanvasLine = rightLineCount + 1;
30
+ const statusCanvasLine = RIGHT_LINE_COUNT + 1;
132
31
  const statusCol = LEFT_PANEL_WIDTH + 1;
133
- const height = 1 + maxLines + 4;
134
32
 
135
33
  return { height, statusLine: statusCanvasLine, statusCol };
136
34
  }
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: "macOS Automation"
3
+ description: "Automate native macOS apps and system interactions via osascript (AppleScript)"
4
+ user-invocable: false
5
+ disable-model-invocation: false
6
+ metadata: {"vellum": {"emoji": "🍎", "os": ["darwin"]}}
7
+ ---
8
+
9
+ Use this skill to interact with native macOS apps and system-level features via `osascript` (AppleScript) through `host_bash`. Always prefer osascript over browser automation or computer-use for anything involving a native macOS app.
10
+
11
+ ## Supported Apps
12
+
13
+ **Communication:** Messages, Mail, Microsoft Outlook, FaceTime
14
+ **Contacts & Calendar:** Contacts, Calendar, Reminders
15
+ **Notes & Writing:** Notes, TextEdit, Pages, BBEdit, CotEditor
16
+ **Files:** Finder, Path Finder
17
+ **Browsers:** Safari, Google Chrome
18
+ **Music & Media:** Music (iTunes), Spotify, VLC, Podcasts, TV
19
+ **Productivity:** OmniFocus, Things 3, OmniOutliner, OmniPlan, OmniGraffle
20
+ **Office:** Microsoft Word, Microsoft Excel, Numbers, Keynote
21
+ **Developer tools:** Xcode, Terminal, iTerm2, Script Editor
22
+ **System:** System Events (UI scripting for any app), System Settings
23
+ **Automation:** Keyboard Maestro, Alfred, Automator
24
+ **Creative:** Adobe Photoshop, Final Cut Pro
25
+
26
+ For any unlisted app, check scriptability first:
27
+ ```bash
28
+ osascript -e 'tell application "AppName" to get name'
29
+ ```
30
+
31
+ ## Examples
32
+
33
+ ```bash
34
+ # Send an iMessage
35
+ osascript -e 'tell application "Messages" to send "Hello!" to buddy "user@example.com"'
36
+
37
+ # Look up a contact
38
+ osascript -e 'tell application "Contacts" to get {name, phones} of every person whose name contains "Marina"'
39
+
40
+ # Read upcoming calendar events
41
+ osascript -e 'tell application "Calendar" to get summary of every event of calendar "Home" whose start date > (current date)'
42
+
43
+ # Create a reminder
44
+ osascript -e 'tell application "Reminders" to make new reminder with properties {name:"Buy milk", due date:((current date) + 1 * hours)}'
45
+
46
+ # Send an email
47
+ osascript -e 'tell application "Mail" to send (make new outgoing message with properties {subject:"Hi", content:"Hello", visible:true})'
48
+
49
+ # Create a note
50
+ osascript -e 'tell application "Notes" to make new note at folder "Notes" with properties {body:"My note"}'
51
+
52
+ # Open a URL in Safari
53
+ osascript -e 'tell application "Safari" to open location "https://example.com"'
54
+
55
+ # Play/pause Music
56
+ osascript -e 'tell application "Music" to playpause'
57
+
58
+ # Display a system notification
59
+ osascript -e 'display notification "Done!" with title "Vellum"'
60
+ ```
61
+
62
+ ## Tips
63
+
64
+ - For multi-line scripts, write them to a `.applescript` file and run with `osascript path/to/script.applescript`
65
+ - Use `System Events` for UI scripting apps that don't have their own AppleScript dictionary
66
+ - AppleScript permissions are gated by macOS TCC — if a command fails with a permission error, use `request_system_permission` to prompt the user