vellum 0.2.2 → 0.2.7
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.
- package/bun.lock +68 -100
- package/package.json +3 -3
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/handlers-twilio-config.test.ts +221 -0
- package/src/__tests__/ipc-snapshot.test.ts +9 -0
- package/src/__tests__/memory-regressions.test.ts +100 -2
- package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
- package/src/__tests__/session-conflict-gate.test.ts +28 -25
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
- package/src/calls/call-domain.ts +3 -3
- package/src/calls/twilio-config.ts +8 -8
- package/src/calls/twilio-provider.ts +4 -4
- package/src/calls/twilio-webhook-urls.ts +50 -0
- package/src/cli/map.ts +30 -6
- package/src/config/defaults.ts +1 -0
- package/src/config/schema.ts +4 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
- package/src/daemon/handlers/config.ts +44 -2
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +23 -0
- package/src/daemon/ride-shotgun-handler.ts +2 -1
- package/src/daemon/session-agent-loop.ts +37 -2
- package/src/daemon/session-conflict-gate.ts +18 -109
- package/src/memory/conflict-intent.ts +114 -0
- package/src/memory/job-handlers/conflict.ts +23 -1
- package/src/runtime/gateway-client.ts +36 -0
- package/src/runtime/http-server.ts +58 -2
- package/src/runtime/routes/channel-routes.ts +121 -79
- package/src/tools/browser/api-map.ts +123 -50
- package/src/tools/claude-code/claude-code.ts +130 -0
- package/src/workspace/commit-message-enrichment-service.ts +3 -3
- package/src/workspace/provider-commit-message-generator.ts +28 -1
|
@@ -39,8 +39,18 @@ let resolverResult: {
|
|
|
39
39
|
|
|
40
40
|
const persistedMessages: Array<{ id: string; role: string; content: string; createdAt: number }> = [];
|
|
41
41
|
|
|
42
|
+
function makeMockLogger(): Record<string, unknown> {
|
|
43
|
+
const logger: Record<string, unknown> = {};
|
|
44
|
+
logger.child = () => logger;
|
|
45
|
+
logger.debug = () => {};
|
|
46
|
+
logger.info = () => {};
|
|
47
|
+
logger.warn = () => {};
|
|
48
|
+
logger.error = () => {};
|
|
49
|
+
return logger;
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
mock.module('../util/logger.js', () => ({
|
|
43
|
-
getLogger: () =>
|
|
53
|
+
getLogger: () => makeMockLogger(),
|
|
44
54
|
}));
|
|
45
55
|
|
|
46
56
|
mock.module('../util/platform.js', () => ({
|
|
@@ -305,7 +315,7 @@ describe('Session conflict soft gate', () => {
|
|
|
305
315
|
await session.processMessage('Should I use React or Vue here?', [], (event) => events.push(event));
|
|
306
316
|
|
|
307
317
|
expect(runCalls).toHaveLength(0);
|
|
308
|
-
expect(resolverCallCount).toBe(
|
|
318
|
+
expect(resolverCallCount).toBe(0);
|
|
309
319
|
expect(markAskedCalls).toEqual(['conflict-relevant']);
|
|
310
320
|
const clarificationEvent = events.find((event) => event.type === 'assistant_text_delta');
|
|
311
321
|
expect(clarificationEvent).toBeDefined();
|
|
@@ -315,7 +325,7 @@ describe('Session conflict soft gate', () => {
|
|
|
315
325
|
expect(events.some((event) => event.type === 'message_complete')).toBe(true);
|
|
316
326
|
});
|
|
317
327
|
|
|
318
|
-
test('irrelevant unresolved conflict
|
|
328
|
+
test('irrelevant unresolved conflict does not inject side-question into normal answer flow', async () => {
|
|
319
329
|
pendingConflicts = [{
|
|
320
330
|
id: 'conflict-irrelevant',
|
|
321
331
|
scopeId: 'default',
|
|
@@ -332,13 +342,6 @@ describe('Session conflict soft gate', () => {
|
|
|
332
342
|
existingStatement: 'Use Postgres as the default database.',
|
|
333
343
|
candidateStatement: 'Use MySQL as the default database.',
|
|
334
344
|
}];
|
|
335
|
-
resolverResult = {
|
|
336
|
-
resolution: 'keep_existing',
|
|
337
|
-
strategy: 'heuristic',
|
|
338
|
-
resolvedStatement: null,
|
|
339
|
-
explanation: 'Resolved by accident.',
|
|
340
|
-
};
|
|
341
|
-
|
|
342
345
|
const session = makeSession();
|
|
343
346
|
await session.loadFromDb();
|
|
344
347
|
|
|
@@ -349,10 +352,10 @@ describe('Session conflict soft gate', () => {
|
|
|
349
352
|
const injectedUser = runCalls[0][runCalls[0].length - 1];
|
|
350
353
|
expect(injectedUser.role).toBe('user');
|
|
351
354
|
const injectedText = extractText(injectedUser);
|
|
352
|
-
expect(injectedText).toContain('Memory clarification request');
|
|
353
|
-
expect(injectedText).toContain('Should I assume Postgres or MySQL?');
|
|
355
|
+
expect(injectedText).not.toContain('Memory clarification request');
|
|
356
|
+
expect(injectedText).not.toContain('Should I assume Postgres or MySQL?');
|
|
354
357
|
expect(resolverCallCount).toBe(0);
|
|
355
|
-
expect(markAskedCalls).toEqual([
|
|
358
|
+
expect(markAskedCalls).toEqual([]);
|
|
356
359
|
expect(events.some((event) => event.type === 'message_complete')).toBe(true);
|
|
357
360
|
});
|
|
358
361
|
|
|
@@ -379,7 +382,7 @@ describe('Session conflict soft gate', () => {
|
|
|
379
382
|
|
|
380
383
|
// First turn asks the clarification and records it as asked.
|
|
381
384
|
await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
|
|
382
|
-
expect(resolverCallCount).toBe(
|
|
385
|
+
expect(resolverCallCount).toBe(0);
|
|
383
386
|
expect(markAskedCalls).toEqual(['conflict-followup']);
|
|
384
387
|
|
|
385
388
|
resolverResult = {
|
|
@@ -392,7 +395,7 @@ describe('Session conflict soft gate', () => {
|
|
|
392
395
|
// Follow-up reply does not overlap statement tokens but should still resolve.
|
|
393
396
|
await session.processMessage('Keep the new one.', [], () => {});
|
|
394
397
|
|
|
395
|
-
expect(resolverCallCount).toBe(
|
|
398
|
+
expect(resolverCallCount).toBe(1);
|
|
396
399
|
expect(markAskedCalls).toEqual(['conflict-followup']);
|
|
397
400
|
expect(runCalls).toHaveLength(1);
|
|
398
401
|
});
|
|
@@ -420,7 +423,7 @@ describe('Session conflict soft gate', () => {
|
|
|
420
423
|
|
|
421
424
|
// First turn asks the clarification.
|
|
422
425
|
await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
|
|
423
|
-
expect(resolverCallCount).toBe(
|
|
426
|
+
expect(resolverCallCount).toBe(0);
|
|
424
427
|
expect(markAskedCalls).toEqual(['conflict-concise']);
|
|
425
428
|
|
|
426
429
|
resolverResult = {
|
|
@@ -433,7 +436,7 @@ describe('Session conflict soft gate', () => {
|
|
|
433
436
|
// Short directional reply with no action verb should still resolve.
|
|
434
437
|
await session.processMessage('both', [], () => {});
|
|
435
438
|
|
|
436
|
-
expect(resolverCallCount).toBe(
|
|
439
|
+
expect(resolverCallCount).toBe(1);
|
|
437
440
|
expect(runCalls).toHaveLength(1);
|
|
438
441
|
});
|
|
439
442
|
|
|
@@ -460,7 +463,7 @@ describe('Session conflict soft gate', () => {
|
|
|
460
463
|
|
|
461
464
|
// First turn: relevant question triggers clarification ask.
|
|
462
465
|
await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
|
|
463
|
-
expect(resolverCallCount).toBe(
|
|
466
|
+
expect(resolverCallCount).toBe(0);
|
|
464
467
|
expect(markAskedCalls).toEqual(['conflict-unrelated']);
|
|
465
468
|
|
|
466
469
|
// Second turn: unrelated question containing the cue word "new" should NOT
|
|
@@ -473,8 +476,8 @@ describe('Session conflict soft gate', () => {
|
|
|
473
476
|
};
|
|
474
477
|
await session.processMessage("What's new in Bun?", [], () => {});
|
|
475
478
|
|
|
476
|
-
// The resolver should NOT have been called
|
|
477
|
-
expect(resolverCallCount).toBe(
|
|
479
|
+
// The resolver should NOT have been called for this unrelated question.
|
|
480
|
+
expect(resolverCallCount).toBe(0);
|
|
478
481
|
// Normal agent loop should still run.
|
|
479
482
|
expect(runCalls).toHaveLength(1);
|
|
480
483
|
});
|
|
@@ -502,7 +505,7 @@ describe('Session conflict soft gate', () => {
|
|
|
502
505
|
|
|
503
506
|
// First turn: triggers clarification ask.
|
|
504
507
|
await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
|
|
505
|
-
expect(resolverCallCount).toBe(
|
|
508
|
+
expect(resolverCallCount).toBe(0);
|
|
506
509
|
expect(markAskedCalls).toEqual(['conflict-unrelated-no-qmark']);
|
|
507
510
|
|
|
508
511
|
resolverResult = {
|
|
@@ -516,11 +519,11 @@ describe('Session conflict soft gate', () => {
|
|
|
516
519
|
// Should NOT resolve the conflict.
|
|
517
520
|
await session.processMessage('I started a new project today', [], () => {});
|
|
518
521
|
|
|
519
|
-
expect(resolverCallCount).toBe(
|
|
522
|
+
expect(resolverCallCount).toBe(0);
|
|
520
523
|
expect(runCalls).toHaveLength(1);
|
|
521
524
|
});
|
|
522
525
|
|
|
523
|
-
test('
|
|
526
|
+
test('irrelevant conflicts remain silent across subsequent turns', async () => {
|
|
524
527
|
pendingConflicts = [{
|
|
525
528
|
id: 'conflict-cooldown',
|
|
526
529
|
scopeId: 'default',
|
|
@@ -547,9 +550,9 @@ describe('Session conflict soft gate', () => {
|
|
|
547
550
|
expect(runCalls).toHaveLength(2);
|
|
548
551
|
const firstUserText = extractText(runCalls[0][runCalls[0].length - 1]);
|
|
549
552
|
const secondUserText = extractText(runCalls[1][runCalls[1].length - 1]);
|
|
550
|
-
expect(firstUserText).toContain('Memory clarification request');
|
|
553
|
+
expect(firstUserText).not.toContain('Memory clarification request');
|
|
551
554
|
expect(secondUserText).not.toContain('Memory clarification request');
|
|
552
|
-
expect(markAskedCalls).toEqual([
|
|
555
|
+
expect(markAskedCalls).toEqual([]);
|
|
553
556
|
});
|
|
554
557
|
|
|
555
558
|
test('passes session scopeId through to conflict store queries', async () => {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks — silence logger output during tests
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
function makeLoggerStub(): Record<string, unknown> {
|
|
8
|
+
const stub: Record<string, unknown> = {};
|
|
9
|
+
for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
|
|
10
|
+
stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
|
|
11
|
+
}
|
|
12
|
+
return stub;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mock.module('../../util/logger.js', () => ({
|
|
16
|
+
getLogger: () => makeLoggerStub(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
normalizeBaseUrl,
|
|
21
|
+
buildTwilioVoiceWebhookUrl,
|
|
22
|
+
buildTwilioStatusCallbackUrl,
|
|
23
|
+
getWebhookBaseUrl,
|
|
24
|
+
} from '../twilio-webhook-urls.js';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// normalizeBaseUrl
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('normalizeBaseUrl', () => {
|
|
31
|
+
test('returns already-clean URL unchanged', () => {
|
|
32
|
+
expect(normalizeBaseUrl('https://example.com')).toBe('https://example.com');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('strips trailing slash', () => {
|
|
36
|
+
expect(normalizeBaseUrl('https://example.com/')).toBe('https://example.com');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('strips multiple trailing slashes', () => {
|
|
40
|
+
expect(normalizeBaseUrl('https://example.com///')).toBe('https://example.com');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('trims leading and trailing whitespace', () => {
|
|
44
|
+
expect(normalizeBaseUrl(' https://example.com ')).toBe('https://example.com');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('trims whitespace and strips trailing slash together', () => {
|
|
48
|
+
expect(normalizeBaseUrl(' https://example.com/ ')).toBe('https://example.com');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// buildTwilioVoiceWebhookUrl
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
describe('buildTwilioVoiceWebhookUrl', () => {
|
|
57
|
+
test('returns correct URL with callSessionId', () => {
|
|
58
|
+
const url = buildTwilioVoiceWebhookUrl('https://example.com', 'session-123');
|
|
59
|
+
expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=session-123');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('normalizes base URL before composing', () => {
|
|
63
|
+
const url = buildTwilioVoiceWebhookUrl('https://example.com/', 'abc');
|
|
64
|
+
expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=abc');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// buildTwilioStatusCallbackUrl
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
describe('buildTwilioStatusCallbackUrl', () => {
|
|
73
|
+
test('returns correct URL', () => {
|
|
74
|
+
const url = buildTwilioStatusCallbackUrl('https://example.com');
|
|
75
|
+
expect(url).toBe('https://example.com/webhooks/twilio/status');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('normalizes base URL before composing', () => {
|
|
79
|
+
const url = buildTwilioStatusCallbackUrl('https://example.com/');
|
|
80
|
+
expect(url).toBe('https://example.com/webhooks/twilio/status');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// getWebhookBaseUrl
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe('getWebhookBaseUrl', () => {
|
|
89
|
+
let savedEnv: string | undefined;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
savedEnv = process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
93
|
+
delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
if (savedEnv !== undefined) {
|
|
98
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = savedEnv;
|
|
99
|
+
} else {
|
|
100
|
+
delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('uses config value when set', () => {
|
|
105
|
+
const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: 'https://config.example.com/' } });
|
|
106
|
+
expect(result).toBe('https://config.example.com');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('falls back to env var when config value is empty', () => {
|
|
110
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com/';
|
|
111
|
+
const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: '' } });
|
|
112
|
+
expect(result).toBe('https://env.example.com');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('falls back to env var when config value is undefined', () => {
|
|
116
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
|
|
117
|
+
const result = getWebhookBaseUrl({ calls: {} });
|
|
118
|
+
expect(result).toBe('https://env.example.com');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('throws when neither config nor env var is set', () => {
|
|
122
|
+
expect(() => getWebhookBaseUrl({ calls: { webhookBaseUrl: '' } })).toThrow(
|
|
123
|
+
/No webhook base URL configured/,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('throws when config is undefined and env var is unset', () => {
|
|
128
|
+
expect(() => getWebhookBaseUrl({ calls: {} })).toThrow(
|
|
129
|
+
/No webhook base URL configured/,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('normalizes the returned URL', () => {
|
|
134
|
+
const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: ' https://example.com/ ' } });
|
|
135
|
+
expect(result).toBe('https://example.com');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('falls through when config value is whitespace-only', () => {
|
|
139
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
|
|
140
|
+
const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: ' ' } });
|
|
141
|
+
expect(result).toBe('https://env.example.com');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('falls through when config value is slash-only', () => {
|
|
145
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
|
|
146
|
+
const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: '///' } });
|
|
147
|
+
expect(result).toBe('https://env.example.com');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('throws when config is whitespace-only and env var is unset', () => {
|
|
151
|
+
expect(() => getWebhookBaseUrl({ calls: { webhookBaseUrl: ' ' } })).toThrow(
|
|
152
|
+
/No webhook base URL configured/,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('throws when env var is whitespace-only and config is empty', () => {
|
|
157
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = ' ';
|
|
158
|
+
expect(() => getWebhookBaseUrl({ calls: {} })).toThrow(
|
|
159
|
+
/No webhook base URL configured/,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { getCallOrchestrator, unregisterCallOrchestrator } from './call-state.js
|
|
|
20
20
|
import { activeRelayConnections } from './relay-server.js';
|
|
21
21
|
import { TwilioConversationRelayProvider } from './twilio-provider.js';
|
|
22
22
|
import { getTwilioConfig } from './twilio-config.js';
|
|
23
|
+
import { buildTwilioVoiceWebhookUrl, buildTwilioStatusCallbackUrl } from './twilio-webhook-urls.js';
|
|
23
24
|
import type { CallSession } from './types.js';
|
|
24
25
|
|
|
25
26
|
const log = getLogger('call-domain');
|
|
@@ -102,12 +103,11 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
|
|
|
102
103
|
|
|
103
104
|
log.info({ callSessionId: session.id, to: phoneNumber, task }, 'Initiating outbound call');
|
|
104
105
|
|
|
105
|
-
const baseUrl = config.webhookBaseUrl.replace(/\/$/, '');
|
|
106
106
|
const { callSid } = await provider.initiateCall({
|
|
107
107
|
from: config.phoneNumber,
|
|
108
108
|
to: phoneNumber,
|
|
109
|
-
webhookUrl:
|
|
110
|
-
statusCallbackUrl:
|
|
109
|
+
webhookUrl: buildTwilioVoiceWebhookUrl(config.webhookBaseUrl, session.id),
|
|
110
|
+
statusCallbackUrl: buildTwilioStatusCallbackUrl(config.webhookBaseUrl),
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
updateCallSession(session.id, { providerCallSid: callSid });
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getSecureKey } from '../security/secure-keys.js';
|
|
2
2
|
import { getLogger } from '../util/logger.js';
|
|
3
|
+
import { loadConfig } from '../config/loader.js';
|
|
4
|
+
import { getWebhookBaseUrl } from './twilio-webhook-urls.js';
|
|
3
5
|
|
|
4
6
|
const log = getLogger('twilio-config');
|
|
5
7
|
|
|
@@ -12,21 +14,19 @@ export interface TwilioConfig {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export function getTwilioConfig(): TwilioConfig {
|
|
15
|
-
const accountSid = getSecureKey('
|
|
16
|
-
const authToken = getSecureKey('
|
|
17
|
-
const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('
|
|
18
|
-
const
|
|
17
|
+
const accountSid = getSecureKey('credential:twilio:account_sid');
|
|
18
|
+
const authToken = getSecureKey('credential:twilio:auth_token');
|
|
19
|
+
const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const webhookBaseUrl = getWebhookBaseUrl(config);
|
|
19
22
|
const wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
|
|
20
23
|
|
|
21
24
|
if (!accountSid || !authToken) {
|
|
22
|
-
throw new Error('Twilio credentials not configured. Set
|
|
25
|
+
throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
|
|
23
26
|
}
|
|
24
27
|
if (!phoneNumber) {
|
|
25
28
|
throw new Error('TWILIO_PHONE_NUMBER not configured.');
|
|
26
29
|
}
|
|
27
|
-
if (!webhookBaseUrl) {
|
|
28
|
-
throw new Error('TWILIO_WEBHOOK_BASE_URL not configured.');
|
|
29
|
-
}
|
|
30
30
|
|
|
31
31
|
log.debug('Twilio config loaded successfully');
|
|
32
32
|
|
|
@@ -17,11 +17,11 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
|
|
|
17
17
|
// ── Credential helpers ──────────────────────────────────────────────
|
|
18
18
|
|
|
19
19
|
private getCredentials(): { accountSid: string; authToken: string } {
|
|
20
|
-
const accountSid = getSecureKey('
|
|
21
|
-
const authToken = getSecureKey('
|
|
20
|
+
const accountSid = getSecureKey('credential:twilio:account_sid');
|
|
21
|
+
const authToken = getSecureKey('credential:twilio:auth_token');
|
|
22
22
|
if (!accountSid || !authToken) {
|
|
23
23
|
throw new Error(
|
|
24
|
-
'Twilio credentials not configured. Set
|
|
24
|
+
'Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.',
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
return { accountSid, authToken };
|
|
@@ -134,7 +134,7 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
|
|
|
134
134
|
* HTTP server webhook middleware) can check availability independently.
|
|
135
135
|
*/
|
|
136
136
|
static getAuthToken(): string | null {
|
|
137
|
-
return getSecureKey('
|
|
137
|
+
return getSecureKey('credential:twilio:auth_token') ?? null;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getLogger } from '../util/logger.js';
|
|
2
|
+
|
|
3
|
+
const log = getLogger('twilio-webhook-urls');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the webhook base URL from config, falling back to the
|
|
7
|
+
* TWILIO_WEBHOOK_BASE_URL environment variable with a deprecation warning.
|
|
8
|
+
* Throws if neither source provides a value.
|
|
9
|
+
*/
|
|
10
|
+
export function getWebhookBaseUrl(config: { calls: { webhookBaseUrl?: string } }): string {
|
|
11
|
+
const configValue = config.calls.webhookBaseUrl;
|
|
12
|
+
if (configValue) {
|
|
13
|
+
const normalized = normalizeBaseUrl(configValue);
|
|
14
|
+
if (normalized) return normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const envValue = process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
18
|
+
if (envValue) {
|
|
19
|
+
log.warn(
|
|
20
|
+
'TWILIO_WEBHOOK_BASE_URL env var is deprecated — set calls.webhookBaseUrl in config instead.',
|
|
21
|
+
);
|
|
22
|
+
const normalized = normalizeBaseUrl(envValue);
|
|
23
|
+
if (normalized) return normalized;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error(
|
|
27
|
+
'No webhook base URL configured. Set calls.webhookBaseUrl in config or TWILIO_WEBHOOK_BASE_URL env var.',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Trim whitespace and strip trailing slash from a URL string.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeBaseUrl(url: string): string {
|
|
35
|
+
return url.trim().replace(/\/+$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the Twilio voice webhook URL for a given call session.
|
|
40
|
+
*/
|
|
41
|
+
export function buildTwilioVoiceWebhookUrl(baseUrl: string, callSessionId: string): string {
|
|
42
|
+
return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the Twilio status callback URL.
|
|
47
|
+
*/
|
|
48
|
+
export function buildTwilioStatusCallbackUrl(baseUrl: string): string {
|
|
49
|
+
return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/status`;
|
|
50
|
+
}
|
package/src/cli/map.ts
CHANGED
|
@@ -15,9 +15,20 @@ import {
|
|
|
15
15
|
serialize,
|
|
16
16
|
createMessageParser,
|
|
17
17
|
} from '../daemon/ipc-protocol.js';
|
|
18
|
+
import { parse as parseTld } from 'tldts';
|
|
18
19
|
import { loadRecording } from '../tools/browser/recording-store.js';
|
|
19
20
|
import { analyzeApiMap, saveApiMap, printApiMapTable } from '../tools/browser/api-map.js';
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Extract the registrable base domain from a hostname.
|
|
24
|
+
* e.g. "open.spotify.com" → "spotify.com", "connect.garmin.com" → "garmin.com"
|
|
25
|
+
* Falls back to the input if tldts can't parse it.
|
|
26
|
+
*/
|
|
27
|
+
function getBaseDomain(domain: string): string {
|
|
28
|
+
const result = parseTld(domain);
|
|
29
|
+
return result.domain ?? domain;
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
// ---------------------------------------------------------------------------
|
|
22
33
|
// Helpers
|
|
23
34
|
// ---------------------------------------------------------------------------
|
|
@@ -95,8 +106,12 @@ interface LearnResult {
|
|
|
95
106
|
recordingPath?: string;
|
|
96
107
|
}
|
|
97
108
|
|
|
98
|
-
async function startLearnSession(
|
|
99
|
-
|
|
109
|
+
async function startLearnSession(
|
|
110
|
+
navigateDomain: string,
|
|
111
|
+
recordDomain: string,
|
|
112
|
+
durationSeconds: number,
|
|
113
|
+
): Promise<LearnResult> {
|
|
114
|
+
await ensureChromeWithCDP(navigateDomain);
|
|
100
115
|
|
|
101
116
|
return new Promise((resolve, reject) => {
|
|
102
117
|
const socketPath = getSocketPath();
|
|
@@ -123,7 +138,8 @@ async function startLearnSession(domain: string, durationSeconds: number): Promi
|
|
|
123
138
|
durationSeconds,
|
|
124
139
|
intervalSeconds: 5,
|
|
125
140
|
mode: 'learn',
|
|
126
|
-
targetDomain:
|
|
141
|
+
targetDomain: recordDomain,
|
|
142
|
+
navigateDomain,
|
|
127
143
|
autoNavigate: true,
|
|
128
144
|
} as unknown as import('../daemon/ipc-protocol.js').ClientMessage),
|
|
129
145
|
);
|
|
@@ -196,11 +212,19 @@ export function registerMapCommand(program: Command): void {
|
|
|
196
212
|
const duration = parseInt(opts.duration, 10);
|
|
197
213
|
|
|
198
214
|
try {
|
|
199
|
-
//
|
|
215
|
+
// Split into navigation domain (what Chrome browses) and recording domain (network filter).
|
|
216
|
+
// e.g. "open.spotify.com" → navigate open.spotify.com, record *.spotify.com
|
|
217
|
+
const navigateDomain = domain;
|
|
218
|
+
const recordDomain = getBaseDomain(domain);
|
|
219
|
+
|
|
200
220
|
if (!json) {
|
|
201
|
-
|
|
221
|
+
if (navigateDomain !== recordDomain) {
|
|
222
|
+
console.log(`Starting API map session: navigating ${navigateDomain}, recording *.${recordDomain} (${duration}s)...`);
|
|
223
|
+
} else {
|
|
224
|
+
console.log(`Starting API map session for ${domain} (${duration}s)...`);
|
|
225
|
+
}
|
|
202
226
|
}
|
|
203
|
-
const result = await startLearnSession(
|
|
227
|
+
const result = await startLearnSession(navigateDomain, recordDomain, duration);
|
|
204
228
|
|
|
205
229
|
if (!result.recordingId) {
|
|
206
230
|
outputError('Recording completed but no recording ID returned');
|
package/src/config/defaults.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -883,6 +883,9 @@ export const CallsConfigSchema = z.object({
|
|
|
883
883
|
error: `calls.provider must be one of: ${VALID_CALL_PROVIDERS.join(', ')}`,
|
|
884
884
|
})
|
|
885
885
|
.default('twilio'),
|
|
886
|
+
webhookBaseUrl: z
|
|
887
|
+
.string({ error: 'calls.webhookBaseUrl must be a string' })
|
|
888
|
+
.default(''),
|
|
886
889
|
maxDurationSeconds: z
|
|
887
890
|
.number({ error: 'calls.maxDurationSeconds must be a number' })
|
|
888
891
|
.int('calls.maxDurationSeconds must be an integer')
|
|
@@ -1149,6 +1152,7 @@ export const AssistantConfigSchema = z.object({
|
|
|
1149
1152
|
calls: CallsConfigSchema.default({
|
|
1150
1153
|
enabled: true,
|
|
1151
1154
|
provider: 'twilio',
|
|
1155
|
+
webhookBaseUrl: '',
|
|
1152
1156
|
maxDurationSeconds: 3600,
|
|
1153
1157
|
userConsultTimeoutSeconds: 120,
|
|
1154
1158
|
disclosure: {
|
|
@@ -98,8 +98,4 @@ Summarize what was done:
|
|
|
98
98
|
- Bot commands registered: /new
|
|
99
99
|
- Credentials stored securely in the vault
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
- `TELEGRAM_BOT_TOKEN` — the bot token
|
|
103
|
-
- `TELEGRAM_WEBHOOK_SECRET` — the generated secret
|
|
104
|
-
|
|
105
|
-
The values are stored in the credential vault and can be retrieved for gateway configuration.
|
|
101
|
+
The gateway automatically detects credentials from the vault and will begin accepting Telegram webhooks shortly. No manual environment variable configuration is needed.
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
ReminderCancel,
|
|
20
20
|
ShareToSlackRequest,
|
|
21
21
|
SlackWebhookConfigRequest,
|
|
22
|
+
TwilioWebhookConfigRequest,
|
|
22
23
|
VercelApiConfigRequest,
|
|
23
24
|
TwitterIntegrationConfigRequest,
|
|
24
25
|
} from '../ipc-protocol.js';
|
|
@@ -396,6 +397,41 @@ export function handleSlackWebhookConfig(
|
|
|
396
397
|
}
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
export function handleTwilioWebhookConfig(
|
|
401
|
+
msg: TwilioWebhookConfigRequest,
|
|
402
|
+
socket: net.Socket,
|
|
403
|
+
ctx: HandlerContext,
|
|
404
|
+
): void {
|
|
405
|
+
try {
|
|
406
|
+
if (msg.action === 'get') {
|
|
407
|
+
const raw = loadRawConfig();
|
|
408
|
+
const webhookBaseUrl = (raw?.calls as Record<string, unknown>)?.webhookBaseUrl as string ?? '';
|
|
409
|
+
ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl, success: true });
|
|
410
|
+
} else if (msg.action === 'set') {
|
|
411
|
+
const value = (msg.webhookBaseUrl ?? '').trim().replace(/\/+$/, '');
|
|
412
|
+
const raw = loadRawConfig();
|
|
413
|
+
const calls = (raw?.calls ?? {}) as Record<string, unknown>;
|
|
414
|
+
calls.webhookBaseUrl = value || undefined;
|
|
415
|
+
const wasSuppressed = ctx.suppressConfigReload;
|
|
416
|
+
ctx.setSuppressConfigReload(true);
|
|
417
|
+
try {
|
|
418
|
+
saveRawConfig({ ...raw, calls });
|
|
419
|
+
} catch (err) {
|
|
420
|
+
ctx.setSuppressConfigReload(wasSuppressed);
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
|
|
424
|
+
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
|
|
425
|
+
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
|
|
426
|
+
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
|
|
427
|
+
ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: value, success: true });
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
431
|
+
ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: '', success: false, error: message });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
399
435
|
export function handleVercelApiConfig(
|
|
400
436
|
msg: VercelApiConfigRequest,
|
|
401
437
|
socket: net.Socket,
|
|
@@ -503,6 +539,7 @@ export function handleTwitterIntegrationConfig(
|
|
|
503
539
|
});
|
|
504
540
|
return;
|
|
505
541
|
}
|
|
542
|
+
const previousClientId = getSecureKey('credential:integration:twitter:oauth_client_id');
|
|
506
543
|
const storedId = setSecureKey('credential:integration:twitter:oauth_client_id', msg.clientId);
|
|
507
544
|
if (!storedId) {
|
|
508
545
|
ctx.send(socket, {
|
|
@@ -518,8 +555,12 @@ export function handleTwitterIntegrationConfig(
|
|
|
518
555
|
if (msg.clientSecret) {
|
|
519
556
|
const storedSecret = setSecureKey('credential:integration:twitter:oauth_client_secret', msg.clientSecret);
|
|
520
557
|
if (!storedSecret) {
|
|
521
|
-
// Roll back the
|
|
522
|
-
|
|
558
|
+
// Roll back the client ID to its previous value to avoid inconsistent OAuth state
|
|
559
|
+
if (previousClientId) {
|
|
560
|
+
setSecureKey('credential:integration:twitter:oauth_client_id', previousClientId);
|
|
561
|
+
} else {
|
|
562
|
+
deleteSecureKey('credential:integration:twitter:oauth_client_id');
|
|
563
|
+
}
|
|
523
564
|
ctx.send(socket, {
|
|
524
565
|
type: 'twitter_integration_config_response',
|
|
525
566
|
success: false,
|
|
@@ -616,6 +657,7 @@ export const configHandlers = defineHandlers({
|
|
|
616
657
|
reminder_cancel: handleReminderCancel,
|
|
617
658
|
share_to_slack: handleShareToSlack,
|
|
618
659
|
slack_webhook_config: handleSlackWebhookConfig,
|
|
660
|
+
twilio_webhook_config: handleTwilioWebhookConfig,
|
|
619
661
|
vercel_api_config: handleVercelApiConfig,
|
|
620
662
|
twitter_integration_config: handleTwitterIntegrationConfig,
|
|
621
663
|
env_vars_request: (_msg, socket, ctx) => handleEnvVarsRequest(socket, ctx),
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"SuggestionRequest",
|
|
81
81
|
"TaskSubmit",
|
|
82
82
|
"TrustRulesList",
|
|
83
|
+
"TwilioWebhookConfigRequest",
|
|
83
84
|
"TwitterAuthStartRequest",
|
|
84
85
|
"TwitterAuthStatusRequest",
|
|
85
86
|
"TwitterIntegrationConfigRequest",
|
|
@@ -191,6 +192,7 @@
|
|
|
191
192
|
"ToolUseStart",
|
|
192
193
|
"TraceEvent",
|
|
193
194
|
"TrustRulesListResponse",
|
|
195
|
+
"TwilioWebhookConfigResponse",
|
|
194
196
|
"TwitterAuthResult",
|
|
195
197
|
"TwitterAuthStatusResponse",
|
|
196
198
|
"TwitterIntegrationConfigResponse",
|
|
@@ -301,6 +303,7 @@
|
|
|
301
303
|
"suggestion_request",
|
|
302
304
|
"task_submit",
|
|
303
305
|
"trust_rules_list",
|
|
306
|
+
"twilio_webhook_config",
|
|
304
307
|
"twitter_auth_start",
|
|
305
308
|
"twitter_auth_status",
|
|
306
309
|
"twitter_integration_config",
|
|
@@ -412,6 +415,7 @@
|
|
|
412
415
|
"tool_use_start",
|
|
413
416
|
"trace_event",
|
|
414
417
|
"trust_rules_list_response",
|
|
418
|
+
"twilio_webhook_config_response",
|
|
415
419
|
"twitter_auth_result",
|
|
416
420
|
"twitter_auth_status_response",
|
|
417
421
|
"twitter_integration_config_response",
|