vellum 0.2.0 → 0.2.1

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -0,0 +1,298 @@
1
+ /**
2
+ * WebSocket handler for Twilio ConversationRelay protocol.
3
+ *
4
+ * Manages real-time voice conversations over WebSocket. Each active call
5
+ * has a single RelayConnection instance that processes inbound messages
6
+ * from Twilio and can send text tokens back for TTS.
7
+ */
8
+
9
+ import type { ServerWebSocket } from 'bun';
10
+ import { getLogger } from '../util/logger.js';
11
+ import {
12
+ getCallSession,
13
+ updateCallSession,
14
+ recordCallEvent,
15
+ } from './call-store.js';
16
+ import { CallOrchestrator } from './call-orchestrator.js';
17
+
18
+ const log = getLogger('relay-server');
19
+
20
+ // ── ConversationRelay message types ──────────────────────────────────
21
+
22
+ // Messages FROM Twilio
23
+ export interface RelaySetupMessage {
24
+ type: 'setup';
25
+ callSid: string;
26
+ from: string;
27
+ to: string;
28
+ customParameters?: Record<string, string>;
29
+ }
30
+
31
+ export interface RelayPromptMessage {
32
+ type: 'prompt';
33
+ voicePrompt: string;
34
+ lang: string;
35
+ last: boolean;
36
+ }
37
+
38
+ export interface RelayInterruptMessage {
39
+ type: 'interrupt';
40
+ utteranceUntilInterrupt: string;
41
+ }
42
+
43
+ export interface RelayDtmfMessage {
44
+ type: 'dtmf';
45
+ digit: string;
46
+ }
47
+
48
+ export interface RelayErrorMessage {
49
+ type: 'error';
50
+ description: string;
51
+ }
52
+
53
+ export type RelayInboundMessage =
54
+ | RelaySetupMessage
55
+ | RelayPromptMessage
56
+ | RelayInterruptMessage
57
+ | RelayDtmfMessage
58
+ | RelayErrorMessage;
59
+
60
+ // Messages TO Twilio
61
+ export interface RelayTextMessage {
62
+ type: 'text';
63
+ token: string;
64
+ last: boolean;
65
+ }
66
+
67
+ export interface RelayEndMessage {
68
+ type: 'end';
69
+ handoffData?: string;
70
+ }
71
+
72
+ // ── WebSocket data type ──────────────────────────────────────────────
73
+
74
+ export interface RelayWebSocketData {
75
+ callSessionId: string;
76
+ }
77
+
78
+ // ── Module-level state ───────────────────────────────────────────────
79
+
80
+ /** Active relay connections keyed by callSessionId. */
81
+ export const activeRelayConnections = new Map<string, RelayConnection>();
82
+
83
+ // ── RelayConnection ──────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Manages a single WebSocket connection for one call.
87
+ */
88
+ export class RelayConnection {
89
+ private ws: ServerWebSocket<RelayWebSocketData>;
90
+ private callSessionId: string;
91
+ private conversationHistory: Array<{ role: 'caller' | 'assistant'; text: string; timestamp: number }>;
92
+ private abortController: AbortController;
93
+ private orchestrator: CallOrchestrator | null = null;
94
+
95
+ constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
96
+ this.ws = ws;
97
+ this.callSessionId = callSessionId;
98
+ this.conversationHistory = [];
99
+ this.abortController = new AbortController();
100
+ }
101
+
102
+ /**
103
+ * Handle an inbound message from Twilio via the ConversationRelay WebSocket.
104
+ */
105
+ async handleMessage(data: string): Promise<void> {
106
+ let parsed: RelayInboundMessage;
107
+ try {
108
+ parsed = JSON.parse(data) as RelayInboundMessage;
109
+ } catch {
110
+ log.warn({ callSessionId: this.callSessionId, data }, 'Failed to parse relay message');
111
+ return;
112
+ }
113
+
114
+ switch (parsed.type) {
115
+ case 'setup':
116
+ await this.handleSetup(parsed);
117
+ break;
118
+ case 'prompt':
119
+ await this.handlePrompt(parsed);
120
+ break;
121
+ case 'interrupt':
122
+ this.handleInterrupt(parsed);
123
+ break;
124
+ case 'dtmf':
125
+ this.handleDtmf(parsed);
126
+ break;
127
+ case 'error':
128
+ this.handleError(parsed);
129
+ break;
130
+ default:
131
+ log.warn({ callSessionId: this.callSessionId, type: (parsed as Record<string, unknown>).type }, 'Unknown relay message type');
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Send a text token to the caller for TTS playback.
137
+ */
138
+ sendTextToken(token: string, last: boolean): void {
139
+ const message: RelayTextMessage = { type: 'text', token, last };
140
+ try {
141
+ this.ws.send(JSON.stringify(message));
142
+ } catch (err) {
143
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to send text token');
144
+ }
145
+ }
146
+
147
+ /**
148
+ * End the ConversationRelay session.
149
+ */
150
+ endSession(reason?: string): void {
151
+ const message: RelayEndMessage = { type: 'end' };
152
+ if (reason) {
153
+ message.handoffData = JSON.stringify({ reason });
154
+ }
155
+ try {
156
+ this.ws.send(JSON.stringify(message));
157
+ } catch (err) {
158
+ log.error({ err, callSessionId: this.callSessionId }, 'Failed to send end message');
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get the conversation history for context.
164
+ */
165
+ getConversationHistory(): Array<{ role: string; text: string }> {
166
+ return this.conversationHistory.map(({ role, text }) => ({ role, text }));
167
+ }
168
+
169
+ /**
170
+ * Get the call session ID for this connection.
171
+ */
172
+ getCallSessionId(): string {
173
+ return this.callSessionId;
174
+ }
175
+
176
+ /**
177
+ * Set the orchestrator for this connection.
178
+ */
179
+ setOrchestrator(orchestrator: CallOrchestrator): void {
180
+ this.orchestrator = orchestrator;
181
+ }
182
+
183
+ /**
184
+ * Get the orchestrator for this connection.
185
+ */
186
+ getOrchestrator(): CallOrchestrator | null {
187
+ return this.orchestrator;
188
+ }
189
+
190
+ /**
191
+ * Clean up resources on disconnect.
192
+ */
193
+ destroy(): void {
194
+ if (this.orchestrator) {
195
+ this.orchestrator.destroy();
196
+ this.orchestrator = null;
197
+ }
198
+ this.abortController.abort();
199
+ log.info({ callSessionId: this.callSessionId }, 'RelayConnection destroyed');
200
+ }
201
+
202
+ // ── Private handlers ─────────────────────────────────────────────
203
+
204
+ private async handleSetup(msg: RelaySetupMessage): Promise<void> {
205
+ log.info(
206
+ { callSessionId: this.callSessionId, callSid: msg.callSid, from: msg.from, to: msg.to },
207
+ 'ConversationRelay setup received',
208
+ );
209
+
210
+ // Store the callSid association on the call session
211
+ const session = getCallSession(this.callSessionId);
212
+ if (session) {
213
+ updateCallSession(this.callSessionId, { providerCallSid: msg.callSid });
214
+ }
215
+
216
+ recordCallEvent(this.callSessionId, 'call_connected', {
217
+ callSid: msg.callSid,
218
+ from: msg.from,
219
+ to: msg.to,
220
+ customParameters: msg.customParameters,
221
+ });
222
+
223
+ // Create and attach the LLM-driven orchestrator
224
+ const orchestrator = new CallOrchestrator(this.callSessionId, this, session?.task ?? null);
225
+ this.setOrchestrator(orchestrator);
226
+ }
227
+
228
+ private async handlePrompt(msg: RelayPromptMessage): Promise<void> {
229
+ if (!msg.last) {
230
+ // Partial transcript, wait for final
231
+ return;
232
+ }
233
+
234
+ log.info(
235
+ { callSessionId: this.callSessionId, transcript: msg.voicePrompt, lang: msg.lang },
236
+ 'Caller transcript received (final)',
237
+ );
238
+
239
+ // Record in conversation history
240
+ this.conversationHistory.push({
241
+ role: 'caller',
242
+ text: msg.voicePrompt,
243
+ timestamp: Date.now(),
244
+ });
245
+
246
+ // Record event
247
+ recordCallEvent(this.callSessionId, 'caller_spoke', {
248
+ transcript: msg.voicePrompt,
249
+ lang: msg.lang,
250
+ });
251
+
252
+ // Route to orchestrator for LLM-driven response
253
+ if (this.orchestrator) {
254
+ await this.orchestrator.handleCallerUtterance(msg.voicePrompt);
255
+ } else {
256
+ // Fallback if orchestrator not yet initialized
257
+ this.sendTextToken('I\'m still setting up. Please hold.', true);
258
+ }
259
+ }
260
+
261
+ private handleInterrupt(msg: RelayInterruptMessage): void {
262
+ log.info(
263
+ { callSessionId: this.callSessionId, utteranceUntilInterrupt: msg.utteranceUntilInterrupt },
264
+ 'Caller interrupted assistant',
265
+ );
266
+
267
+ // Abort any in-flight processing
268
+ this.abortController.abort();
269
+ this.abortController = new AbortController();
270
+
271
+ // Notify the orchestrator of the interruption
272
+ if (this.orchestrator) {
273
+ this.orchestrator.handleInterrupt();
274
+ }
275
+ }
276
+
277
+ private handleDtmf(msg: RelayDtmfMessage): void {
278
+ log.info(
279
+ { callSessionId: this.callSessionId, digit: msg.digit },
280
+ 'DTMF digit received',
281
+ );
282
+
283
+ recordCallEvent(this.callSessionId, 'caller_spoke', {
284
+ dtmfDigit: msg.digit,
285
+ });
286
+ }
287
+
288
+ private handleError(msg: RelayErrorMessage): void {
289
+ log.error(
290
+ { callSessionId: this.callSessionId, description: msg.description },
291
+ 'ConversationRelay error',
292
+ );
293
+
294
+ recordCallEvent(this.callSessionId, 'call_failed', {
295
+ error: msg.description,
296
+ });
297
+ }
298
+ }
@@ -0,0 +1,34 @@
1
+ import { getSecureKey } from '../security/secure-keys.js';
2
+ import { getLogger } from '../util/logger.js';
3
+
4
+ const log = getLogger('twilio-config');
5
+
6
+ export interface TwilioConfig {
7
+ accountSid: string;
8
+ authToken: string;
9
+ phoneNumber: string;
10
+ webhookBaseUrl: string;
11
+ wssBaseUrl: string;
12
+ }
13
+
14
+ export function getTwilioConfig(): TwilioConfig {
15
+ const accountSid = getSecureKey('twilio_account_sid');
16
+ const authToken = getSecureKey('twilio_auth_token');
17
+ const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('twilio_phone_number') || '';
18
+ const webhookBaseUrl = process.env.TWILIO_WEBHOOK_BASE_URL || '';
19
+ const wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
20
+
21
+ if (!accountSid || !authToken) {
22
+ throw new Error('Twilio credentials not configured. Set twilio_account_sid and twilio_auth_token via the credential_store tool.');
23
+ }
24
+ if (!phoneNumber) {
25
+ throw new Error('TWILIO_PHONE_NUMBER not configured.');
26
+ }
27
+ if (!webhookBaseUrl) {
28
+ throw new Error('TWILIO_WEBHOOK_BASE_URL not configured.');
29
+ }
30
+
31
+ log.debug('Twilio config loaded successfully');
32
+
33
+ return { accountSid, authToken, phoneNumber, webhookBaseUrl, wssBaseUrl };
34
+ }
@@ -0,0 +1,169 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { getLogger } from '../util/logger.js';
3
+ import { getSecureKey } from '../security/secure-keys.js';
4
+ import type { VoiceProvider, InitiateCallOptions } from './voice-provider.js';
5
+
6
+ const log = getLogger('twilio-provider');
7
+
8
+ /**
9
+ * Twilio ConversationRelay voice provider.
10
+ *
11
+ * Uses the Twilio REST API directly via fetch() — no twilio npm package.
12
+ * Credentials are resolved lazily from the secure key store on each call.
13
+ */
14
+ export class TwilioConversationRelayProvider implements VoiceProvider {
15
+ readonly name = 'twilio';
16
+
17
+ // ── Credential helpers ──────────────────────────────────────────────
18
+
19
+ private getCredentials(): { accountSid: string; authToken: string } {
20
+ const accountSid = getSecureKey('twilio_account_sid');
21
+ const authToken = getSecureKey('twilio_auth_token');
22
+ if (!accountSid || !authToken) {
23
+ throw new Error(
24
+ 'Twilio credentials not configured. Set twilio_account_sid and twilio_auth_token via the credential_store tool.',
25
+ );
26
+ }
27
+ return { accountSid, authToken };
28
+ }
29
+
30
+ private authHeader(accountSid: string, authToken: string): string {
31
+ return 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64');
32
+ }
33
+
34
+ private baseUrl(accountSid: string): string {
35
+ return `https://api.twilio.com/2010-04-01/Accounts/${accountSid}`;
36
+ }
37
+
38
+ // ── VoiceProvider interface ─────────────────────────────────────────
39
+
40
+ async initiateCall(opts: InitiateCallOptions): Promise<{ callSid: string }> {
41
+ const { accountSid, authToken } = this.getCredentials();
42
+
43
+ const body = new URLSearchParams({
44
+ From: opts.from,
45
+ To: opts.to,
46
+ Url: opts.webhookUrl,
47
+ StatusCallback: opts.statusCallbackUrl,
48
+ StatusCallbackEvent: 'initiated ringing answered completed',
49
+ });
50
+
51
+ const reservedKeys = new Set(['From', 'To', 'Url', 'StatusCallback', 'StatusCallbackEvent']);
52
+ if (opts.customParams) {
53
+ for (const [key, value] of Object.entries(opts.customParams)) {
54
+ if (reservedKeys.has(key)) {
55
+ log.warn({ key }, 'Ignoring reserved Twilio parameter in customParams');
56
+ continue;
57
+ }
58
+ body.set(key, value);
59
+ }
60
+ }
61
+
62
+ log.info({ from: opts.from, to: opts.to }, 'Initiating Twilio call');
63
+
64
+ const res = await fetch(`${this.baseUrl(accountSid)}/Calls.json`, {
65
+ method: 'POST',
66
+ headers: {
67
+ Authorization: this.authHeader(accountSid, authToken),
68
+ 'Content-Type': 'application/x-www-form-urlencoded',
69
+ },
70
+ body: body.toString(),
71
+ });
72
+
73
+ if (!res.ok) {
74
+ const text = await res.text();
75
+ log.error({ status: res.status, body: text }, 'Twilio initiateCall failed');
76
+ throw new Error(`Twilio API error ${res.status}: ${text}`);
77
+ }
78
+
79
+ const data = (await res.json()) as { sid: string };
80
+ log.info({ callSid: data.sid }, 'Twilio call initiated');
81
+ return { callSid: data.sid };
82
+ }
83
+
84
+ async endCall(callSid: string): Promise<void> {
85
+ const { accountSid, authToken } = this.getCredentials();
86
+
87
+ log.info({ callSid }, 'Ending Twilio call');
88
+
89
+ const body = new URLSearchParams({ Status: 'completed' });
90
+
91
+ const res = await fetch(`${this.baseUrl(accountSid)}/Calls/${callSid}.json`, {
92
+ method: 'POST',
93
+ headers: {
94
+ Authorization: this.authHeader(accountSid, authToken),
95
+ 'Content-Type': 'application/x-www-form-urlencoded',
96
+ },
97
+ body: body.toString(),
98
+ });
99
+
100
+ if (!res.ok) {
101
+ const text = await res.text();
102
+ log.error({ status: res.status, body: text, callSid }, 'Twilio endCall failed');
103
+ throw new Error(`Twilio API error ${res.status}: ${text}`);
104
+ }
105
+
106
+ log.info({ callSid }, 'Twilio call ended');
107
+ }
108
+
109
+ async getCallStatus(callSid: string): Promise<string> {
110
+ const { accountSid, authToken } = this.getCredentials();
111
+
112
+ const res = await fetch(`${this.baseUrl(accountSid)}/Calls/${callSid}.json`, {
113
+ method: 'GET',
114
+ headers: {
115
+ Authorization: this.authHeader(accountSid, authToken),
116
+ },
117
+ });
118
+
119
+ if (!res.ok) {
120
+ const text = await res.text();
121
+ log.error({ status: res.status, body: text, callSid }, 'Twilio getCallStatus failed');
122
+ throw new Error(`Twilio API error ${res.status}: ${text}`);
123
+ }
124
+
125
+ const data = (await res.json()) as { status: string };
126
+ return data.status;
127
+ }
128
+
129
+ // ── Webhook signature verification ──────────────────────────────────
130
+
131
+ /**
132
+ * Validates an X-Twilio-Signature header using HMAC-SHA1.
133
+ *
134
+ * Algorithm (from Twilio docs):
135
+ * 1. Take the full URL of the request.
136
+ * 2. Sort the POST parameters alphabetically by key.
137
+ * 3. Concatenate the URL with each key-value pair (key + value, no delimiters).
138
+ * 4. HMAC-SHA1 the result using the auth token as the key.
139
+ * 5. Base64-encode the hash.
140
+ * 6. Compare to the X-Twilio-Signature header value.
141
+ */
142
+ static verifyWebhookSignature(
143
+ url: string,
144
+ params: Record<string, string>,
145
+ signature: string,
146
+ ): boolean {
147
+ const authToken = getSecureKey('twilio_auth_token');
148
+ if (!authToken) {
149
+ log.error('Cannot verify Twilio webhook signature: auth token not configured');
150
+ return false;
151
+ }
152
+
153
+ const sortedKeys = Object.keys(params).sort();
154
+ let data = url;
155
+ for (const key of sortedKeys) {
156
+ data += key + params[key];
157
+ }
158
+
159
+ const computed = createHmac('sha1', authToken)
160
+ .update(data)
161
+ .digest('base64');
162
+
163
+ // Constant-time comparison to prevent timing attacks
164
+ const a = Buffer.from(computed);
165
+ const b = Buffer.from(signature);
166
+ if (a.length !== b.length) return false;
167
+ return timingSafeEqual(a, b);
168
+ }
169
+ }