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.
Files changed (32) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -3
  3. package/src/__tests__/config-schema.test.ts +6 -0
  4. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  5. package/src/__tests__/ipc-snapshot.test.ts +9 -0
  6. package/src/__tests__/memory-regressions.test.ts +100 -2
  7. package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
  8. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  9. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  10. package/src/calls/call-domain.ts +3 -3
  11. package/src/calls/twilio-config.ts +8 -8
  12. package/src/calls/twilio-provider.ts +4 -4
  13. package/src/calls/twilio-webhook-urls.ts +50 -0
  14. package/src/cli/map.ts +30 -6
  15. package/src/config/defaults.ts +1 -0
  16. package/src/config/schema.ts +4 -0
  17. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  18. package/src/daemon/handlers/config.ts +44 -2
  19. package/src/daemon/ipc-contract-inventory.json +4 -0
  20. package/src/daemon/ipc-contract.ts +23 -0
  21. package/src/daemon/ride-shotgun-handler.ts +2 -1
  22. package/src/daemon/session-agent-loop.ts +37 -2
  23. package/src/daemon/session-conflict-gate.ts +18 -109
  24. package/src/memory/conflict-intent.ts +114 -0
  25. package/src/memory/job-handlers/conflict.ts +23 -1
  26. package/src/runtime/gateway-client.ts +36 -0
  27. package/src/runtime/http-server.ts +58 -2
  28. package/src/runtime/routes/channel-routes.ts +121 -79
  29. package/src/tools/browser/api-map.ts +123 -50
  30. package/src/tools/claude-code/claude-code.ts +130 -0
  31. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  32. 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: () => new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
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(1);
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 asks once and continues with normal answer flow', async () => {
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(['conflict-irrelevant']);
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(1);
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(2);
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(1);
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(2);
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(1);
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 again for this unrelated question.
477
- expect(resolverCallCount).toBe(1);
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(1);
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(1);
522
+ expect(resolverCallCount).toBe(0);
520
523
  expect(runCalls).toHaveLength(1);
521
524
  });
522
525
 
523
- test('cooldown prevents repeated asks on subsequent turns', async () => {
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(['conflict-cooldown']);
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
+ });
@@ -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: `${baseUrl}/webhooks/twilio/voice?callSessionId=${session.id}`,
110
- statusCallbackUrl: `${baseUrl}/webhooks/twilio/status`,
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('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 || '';
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 twilio_account_sid and twilio_auth_token via the credential_store tool.');
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('twilio_account_sid');
21
- const authToken = getSecureKey('twilio_auth_token');
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 twilio_account_sid and twilio_auth_token via the credential_store tool.',
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('twilio_auth_token') ?? null;
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(domain: string, durationSeconds: number): Promise<LearnResult> {
99
- await ensureChromeWithCDP(domain);
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: domain,
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
- // 1. Start learn session (launches Chrome + auto-navigates)
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
- console.log(`Starting API map session for ${domain} (${duration}s)...`);
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(domain, duration);
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');
@@ -217,6 +217,7 @@ export const DEFAULT_CONFIG: AssistantConfig = {
217
217
  calls: {
218
218
  enabled: true,
219
219
  provider: 'twilio' as const,
220
+ webhookBaseUrl: '',
220
221
  maxDurationSeconds: 3600,
221
222
  userConsultTimeoutSeconds: 120,
222
223
  disclosure: {
@@ -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
- Remind the user that the gateway needs these environment variables set to match:
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 already-persisted client ID to avoid inconsistent OAuth state
522
- deleteSecureKey('credential:integration:twitter:oauth_client_id');
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",