@vellumai/assistant 0.4.3 → 0.4.4

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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -12,10 +12,12 @@ import type { ServerWebSocket } from 'bun';
12
12
 
13
13
  import { getConfig } from '../config/loader.js';
14
14
  import { getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
15
+ import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
15
16
  import * as conversationStore from '../memory/conversation-store.js';
16
17
  import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
17
- import { upsertMember } from '../memory/ingress-member-store.js';
18
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
18
19
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
20
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
19
21
  import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
20
22
  import {
21
23
  resolveActorTrust,
@@ -23,6 +25,7 @@ import {
23
25
  } from '../runtime/actor-trust-resolver.js';
24
26
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
25
27
  import {
28
+ getGuardianBinding,
26
29
  getPendingChallenge,
27
30
  validateAndConsumeChallenge,
28
31
  } from '../runtime/channel-guardian-service.js';
@@ -33,7 +36,15 @@ import {
33
36
  import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
34
37
  import { parseJsonSafe } from '../util/json.js';
35
38
  import { getLogger } from '../util/logger.js';
36
- import { getAccessRequestPollIntervalMs, getTtsPlaybackDelayMs, getUserConsultationTimeoutMs } from './call-constants.js';
39
+ import {
40
+ getAccessRequestPollIntervalMs,
41
+ getGuardianWaitUpdateInitialIntervalMs,
42
+ getGuardianWaitUpdateInitialWindowMs,
43
+ getGuardianWaitUpdateSteadyMaxIntervalMs,
44
+ getGuardianWaitUpdateSteadyMinIntervalMs,
45
+ getTtsPlaybackDelayMs,
46
+ getUserConsultationTimeoutMs,
47
+ } from './call-constants.js';
37
48
  import { CallController } from './call-controller.js';
38
49
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
39
50
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
@@ -197,6 +208,20 @@ export class RelayConnection {
197
208
  // Name capture timeout (unknown inbound callers)
198
209
  private nameCaptureTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
199
210
 
211
+ // Guardian wait heartbeat state
212
+ private accessRequestHeartbeatTimer: ReturnType<typeof setTimeout> | null = null;
213
+ private accessRequestWaitStartedAt: number = 0;
214
+ private heartbeatSequence = 0;
215
+
216
+ // In-wait prompt handling state
217
+ private lastInWaitReplyAt = 0;
218
+ private static readonly IN_WAIT_REPLY_COOLDOWN_MS = 3000;
219
+
220
+ // Callback offer state (in-memory per-call)
221
+ private callbackOfferMade = false;
222
+ private callbackOptIn = false;
223
+ private callbackHandoffNotified = false;
224
+
200
225
  constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
201
226
  this.ws = ws;
202
227
  this.callSessionId = callSessionId;
@@ -328,6 +353,10 @@ export class RelayConnection {
328
353
  clearTimeout(this.accessRequestTimeoutTimer);
329
354
  this.accessRequestTimeoutTimer = null;
330
355
  }
356
+ if (this.accessRequestHeartbeatTimer) {
357
+ clearTimeout(this.accessRequestHeartbeatTimer);
358
+ this.accessRequestHeartbeatTimer = null;
359
+ }
331
360
  if (this.nameCaptureTimeoutTimer) {
332
361
  clearTimeout(this.nameCaptureTimeoutTimer);
333
362
  this.nameCaptureTimeoutTimer = null;
@@ -344,6 +373,12 @@ export class RelayConnection {
344
373
  * we still finalize the call lifecycle from the relay close signal.
345
374
  */
346
375
  handleTransportClosed(code?: number, reason?: string): void {
376
+ // If the call was still in guardian-wait with callback opt-in, emit the
377
+ // handoff notification before cleaning up wait state.
378
+ if (this.accessRequestWaitActive && this.callbackOptIn) {
379
+ this.emitAccessRequestCallbackHandoff('transport_closed');
380
+ }
381
+
347
382
  // Clean up access request wait state on disconnect to stop polling
348
383
  this.clearAccessRequestWait();
349
384
  if (this.nameCaptureTimeoutTimer) {
@@ -1166,13 +1201,19 @@ export class RelayConnection {
1166
1201
  const timeoutMs = getUserConsultationTimeoutMs();
1167
1202
  const pollIntervalMs = getAccessRequestPollIntervalMs();
1168
1203
 
1204
+ const guardianLabel = this.resolveGuardianLabel();
1169
1205
  this.sendTextToken(
1170
- "Thank you. I've let my guardian know. Please hold while I check if I have permission to speak with you.",
1206
+ `Thank you. I've let ${guardianLabel} know. Please hold while I check if I have permission to speak with you.`,
1171
1207
  true,
1172
1208
  );
1173
1209
 
1174
1210
  updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
1175
1211
 
1212
+ // Start the heartbeat timer for periodic progress updates
1213
+ this.accessRequestWaitStartedAt = Date.now();
1214
+ this.heartbeatSequence = 0;
1215
+ this.scheduleNextHeartbeat();
1216
+
1176
1217
  // Poll the canonical request status
1177
1218
  this.accessRequestPollTimer = setInterval(() => {
1178
1219
  if (!this.accessRequestWaitActive || !this.accessRequestId) {
@@ -1224,6 +1265,10 @@ export class RelayConnection {
1224
1265
  clearTimeout(this.accessRequestTimeoutTimer);
1225
1266
  this.accessRequestTimeoutTimer = null;
1226
1267
  }
1268
+ if (this.accessRequestHeartbeatTimer) {
1269
+ clearTimeout(this.accessRequestHeartbeatTimer);
1270
+ this.accessRequestHeartbeatTimer = null;
1271
+ }
1227
1272
  }
1228
1273
 
1229
1274
  /**
@@ -1282,10 +1327,10 @@ export class RelayConnection {
1282
1327
 
1283
1328
  // Use handleUserInstruction to deliver the approval-aware greeting
1284
1329
  // through the normal session pipeline.
1285
- const guardianName = 'my guardian';
1330
+ const guardianLabel = this.resolveGuardianLabel();
1286
1331
  if (this.controller) {
1287
1332
  this.controller.handleUserInstruction(
1288
- `Great, ${guardianName} approved! Now how can I help you?`,
1333
+ `Great, ${guardianLabel} approved! Now how can I help you?`,
1289
1334
  ).catch((err) => {
1290
1335
  log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting');
1291
1336
  });
@@ -1298,13 +1343,15 @@ export class RelayConnection {
1298
1343
  private handleAccessRequestDenied(): void {
1299
1344
  this.clearAccessRequestWait();
1300
1345
 
1346
+ const guardianLabel = this.resolveGuardianLabel();
1347
+
1301
1348
  recordCallEvent(this.callSessionId, 'inbound_acl_access_denied', {
1302
1349
  from: this.accessRequestFromNumber,
1303
1350
  requestId: this.accessRequestId,
1304
1351
  });
1305
1352
 
1306
1353
  this.sendTextToken(
1307
- "Sorry, my guardian says I'm not allowed to speak with you. Goodbye.",
1354
+ `Sorry, ${guardianLabel} says I'm not allowed to speak with you. Goodbye.`,
1308
1355
  true,
1309
1356
  );
1310
1357
 
@@ -1330,15 +1377,24 @@ export class RelayConnection {
1330
1377
  * Handle an access request timeout: deliver deterministic copy and hang up.
1331
1378
  */
1332
1379
  private handleAccessRequestTimeout(): void {
1380
+ // Emit callback handoff notification before clearing wait state
1381
+ this.emitAccessRequestCallbackHandoff('timeout');
1382
+
1333
1383
  this.clearAccessRequestWait();
1334
1384
 
1385
+ const guardianLabel = this.resolveGuardianLabel();
1386
+
1335
1387
  recordCallEvent(this.callSessionId, 'inbound_acl_access_timeout', {
1336
1388
  from: this.accessRequestFromNumber,
1337
1389
  requestId: this.accessRequestId,
1390
+ callbackOptIn: this.callbackOptIn,
1338
1391
  });
1339
1392
 
1393
+ const callbackNote = this.callbackOptIn
1394
+ ? ` I've noted that you'd like a callback — I'll pass that along to ${guardianLabel}.`
1395
+ : '';
1340
1396
  this.sendTextToken(
1341
- "Sorry, I can't get ahold of my guardian right now. I'll let them know you called.",
1397
+ `Sorry, I can't get ahold of ${guardianLabel} right now. I'll let them know you called.${callbackNote}`,
1342
1398
  true,
1343
1399
  );
1344
1400
 
@@ -1360,6 +1416,100 @@ export class RelayConnection {
1360
1416
  }, getTtsPlaybackDelayMs());
1361
1417
  }
1362
1418
 
1419
+ /**
1420
+ * Emit a callback handoff notification to the guardian when the caller
1421
+ * opted into a callback during guardian wait but the wait ended without
1422
+ * resolution (timeout or transport close).
1423
+ *
1424
+ * Idempotent: uses callbackHandoffNotified guard + deterministic dedupeKey
1425
+ * to ensure at most one notification per call/request.
1426
+ */
1427
+ private emitAccessRequestCallbackHandoff(reason: 'timeout' | 'transport_closed'): void {
1428
+ if (!this.callbackOptIn) return;
1429
+ if (!this.accessRequestId) return;
1430
+ if (this.callbackHandoffNotified) return;
1431
+
1432
+ this.callbackHandoffNotified = true;
1433
+
1434
+ const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
1435
+ const fromNumber = this.accessRequestFromNumber ?? null;
1436
+
1437
+ // Resolve canonical request for requestCode and conversationId
1438
+ const canonicalRequest = this.accessRequestId
1439
+ ? getCanonicalGuardianRequest(this.accessRequestId)
1440
+ : null;
1441
+
1442
+ // Resolve trusted-contact member reference when possible
1443
+ let requesterMemberId: string | null = null;
1444
+ if (fromNumber) {
1445
+ try {
1446
+ const member = findMember({
1447
+ assistantId,
1448
+ sourceChannel: 'voice',
1449
+ externalUserId: fromNumber,
1450
+ externalChatId: fromNumber,
1451
+ });
1452
+ if (member && member.status === 'active' && member.policy === 'allow') {
1453
+ requesterMemberId = member.id;
1454
+ }
1455
+ } catch (err) {
1456
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to resolve member for callback handoff');
1457
+ }
1458
+ }
1459
+
1460
+ const dedupeKey = `access-request-callback-handoff:${this.accessRequestId}`;
1461
+ const sourceSessionId = canonicalRequest?.conversationId
1462
+ ?? `access-req-callback-${this.accessRequestId}`;
1463
+
1464
+ void emitNotificationSignal({
1465
+ sourceEventName: 'ingress.access_request.callback_handoff',
1466
+ sourceChannel: 'voice',
1467
+ sourceSessionId,
1468
+ assistantId,
1469
+ attentionHints: {
1470
+ requiresAction: false,
1471
+ urgency: 'medium',
1472
+ isAsyncBackground: true,
1473
+ visibleInSourceNow: false,
1474
+ },
1475
+ contextPayload: {
1476
+ requestId: this.accessRequestId,
1477
+ requestCode: canonicalRequest?.requestCode ?? null,
1478
+ callSessionId: this.callSessionId,
1479
+ sourceChannel: 'voice',
1480
+ reason,
1481
+ callbackOptIn: true,
1482
+ callerPhoneNumber: fromNumber,
1483
+ callerName: this.accessRequestCallerName ?? null,
1484
+ requesterExternalUserId: fromNumber,
1485
+ requesterChatId: fromNumber,
1486
+ requesterMemberId,
1487
+ requesterMemberSourceChannel: requesterMemberId ? 'voice' : null,
1488
+ },
1489
+ dedupeKey,
1490
+ }).then(() => {
1491
+ recordCallEvent(this.callSessionId, 'callback_handoff_notified', {
1492
+ requestId: this.accessRequestId,
1493
+ reason,
1494
+ requesterMemberId,
1495
+ });
1496
+ log.info(
1497
+ { callSessionId: this.callSessionId, requestId: this.accessRequestId, reason },
1498
+ 'Callback handoff notification emitted',
1499
+ );
1500
+ }).catch((err) => {
1501
+ recordCallEvent(this.callSessionId, 'callback_handoff_failed', {
1502
+ requestId: this.accessRequestId,
1503
+ reason,
1504
+ error: err instanceof Error ? err.message : String(err),
1505
+ });
1506
+ log.error(
1507
+ { err, callSessionId: this.callSessionId, requestId: this.accessRequestId },
1508
+ 'Failed to emit callback handoff notification',
1509
+ );
1510
+ });
1511
+ }
1512
+
1363
1513
  /**
1364
1514
  * Handle a name capture timeout: the caller never provided their name
1365
1515
  * within the allotted window. Deliver deterministic copy and hang up.
@@ -1482,6 +1632,270 @@ export class RelayConnection {
1482
1632
  }
1483
1633
  }
1484
1634
 
1635
+ // ── Guardian wait UX layer ─────────────────────────────────────
1636
+
1637
+ /**
1638
+ * Resolve a human-readable guardian label for voice wait copy.
1639
+ * Prefers displayName from the guardian binding metadata, falls back
1640
+ * to @username, then "my guardian".
1641
+ */
1642
+ private resolveGuardianLabel(): string {
1643
+ const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
1644
+
1645
+ // Try the voice-channel binding first, then fall back to any active
1646
+ // binding for the assistant (mirrors the cross-channel fallback pattern
1647
+ // in access-request-helper.ts).
1648
+ let metadataJson: string | null = null;
1649
+ const voiceBinding = getGuardianBinding(assistantId, 'voice');
1650
+ if (voiceBinding?.metadataJson) {
1651
+ metadataJson = voiceBinding.metadataJson;
1652
+ } else {
1653
+ const allBindings = listActiveBindingsByAssistant(assistantId);
1654
+ if (allBindings.length > 0 && allBindings[0].metadataJson) {
1655
+ metadataJson = allBindings[0].metadataJson;
1656
+ }
1657
+ }
1658
+
1659
+ if (metadataJson) {
1660
+ try {
1661
+ const parsed = JSON.parse(metadataJson) as Record<string, unknown>;
1662
+ if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
1663
+ return parsed.displayName.trim();
1664
+ }
1665
+ if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
1666
+ return `@${parsed.username.trim()}`;
1667
+ }
1668
+ } catch {
1669
+ // ignore malformed metadata
1670
+ }
1671
+ }
1672
+ return 'my guardian';
1673
+ }
1674
+
1675
+ /**
1676
+ * Generate a non-repetitive heartbeat message for the caller based
1677
+ * on the current sequence counter and guardian label.
1678
+ */
1679
+ private getHeartbeatMessage(): string {
1680
+ const guardianLabel = this.resolveGuardianLabel();
1681
+ const seq = this.heartbeatSequence++;
1682
+ const messages = [
1683
+ `Still waiting to hear back from ${guardianLabel}. Thank you for your patience.`,
1684
+ `I'm still trying to reach ${guardianLabel}. One moment please.`,
1685
+ `Hang tight, still waiting on ${guardianLabel}.`,
1686
+ `Still checking with ${guardianLabel}. I appreciate you waiting.`,
1687
+ `I haven't heard back from ${guardianLabel} yet. Thanks for holding.`,
1688
+ ];
1689
+ return messages[seq % messages.length];
1690
+ }
1691
+
1692
+ /**
1693
+ * Schedule the next heartbeat update. Uses the initial fixed interval
1694
+ * during the initial window, then jitters between steady min/max.
1695
+ */
1696
+ private scheduleNextHeartbeat(): void {
1697
+ if (!this.accessRequestWaitActive) return;
1698
+
1699
+ const elapsed = Date.now() - this.accessRequestWaitStartedAt;
1700
+ const initialWindow = getGuardianWaitUpdateInitialWindowMs();
1701
+ const intervalMs = elapsed < initialWindow
1702
+ ? getGuardianWaitUpdateInitialIntervalMs()
1703
+ : getGuardianWaitUpdateSteadyMinIntervalMs() +
1704
+ Math.floor(Math.random() * Math.max(0, getGuardianWaitUpdateSteadyMaxIntervalMs() - getGuardianWaitUpdateSteadyMinIntervalMs()));
1705
+
1706
+ this.accessRequestHeartbeatTimer = setTimeout(() => {
1707
+ if (!this.accessRequestWaitActive) return;
1708
+
1709
+ const message = this.getHeartbeatMessage();
1710
+ this.sendTextToken(message, true);
1711
+
1712
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_heartbeat_sent', {
1713
+ sequence: this.heartbeatSequence - 1,
1714
+ message,
1715
+ });
1716
+
1717
+ log.debug(
1718
+ { callSessionId: this.callSessionId, sequence: this.heartbeatSequence - 1 },
1719
+ 'Guardian wait heartbeat sent',
1720
+ );
1721
+
1722
+ // Schedule the next heartbeat
1723
+ this.scheduleNextHeartbeat();
1724
+ }, intervalMs);
1725
+ }
1726
+
1727
+ /**
1728
+ * Classify a caller utterance during guardian wait into one of:
1729
+ * - 'empty': whitespace or noise
1730
+ * - 'patience_check': asking for status or checking in
1731
+ * - 'impatient': expressing frustration or wanting to end
1732
+ * - 'callback_opt_in': explicitly agreeing to a callback
1733
+ * - 'callback_decline': explicitly declining a callback
1734
+ * - 'neutral': anything else
1735
+ */
1736
+ private classifyWaitUtterance(text: string): 'empty' | 'patience_check' | 'impatient' | 'callback_opt_in' | 'callback_decline' | 'neutral' {
1737
+ const lower = text.toLowerCase().trim();
1738
+ if (lower.length === 0) return 'empty';
1739
+
1740
+ // Callback opt-in patterns (check before impatience to catch "yes call me back")
1741
+ if (this.callbackOfferMade) {
1742
+ if (/\b(yes|yeah|yep|sure|okay|ok|please)\b.*\b(call\s*(me\s*)?back|callback)\b/.test(lower)
1743
+ || /\b(call\s*(me\s*)?back|callback)\b.*\b(yes|yeah|please|sure)\b/.test(lower)
1744
+ || /^(yes|yeah|yep|sure|okay|ok|please)\s*[.,!]?\s*$/.test(lower)
1745
+ || /\bcall\s*(me\s*)?back\b/.test(lower)
1746
+ || /\bplease\s+do\b/.test(lower)) {
1747
+ return 'callback_opt_in';
1748
+ }
1749
+ if (/\b(no|nah|nope)\b/.test(lower)
1750
+ || /\bi('?ll| will)\s+hold\b/.test(lower)
1751
+ || /\bi('?ll| will)\s+wait\b/.test(lower)) {
1752
+ return 'callback_decline';
1753
+ }
1754
+ }
1755
+
1756
+ // Impatience patterns
1757
+ if (/\bhurry\s*(up)?\b/.test(lower)
1758
+ || /\btaking\s+(too\s+|so\s+)?long\b/.test(lower)
1759
+ || /\bforget\s+it\b/.test(lower)
1760
+ || /\bnever\s*mind\b/.test(lower)
1761
+ || /\bdon'?t\s+have\s+time\b/.test(lower)
1762
+ || /\bhow\s+much\s+longer\b/.test(lower)
1763
+ || /\bi('?m| am)\s+(getting\s+)?impatient\b/.test(lower)
1764
+ || /\bthis\s+is\s+(ridiculous|absurd|crazy)\b/.test(lower)
1765
+ || /\bcome\s+on\b/.test(lower)
1766
+ || /\bi\s+(gotta|have\s+to|need\s+to)\s+go\b/.test(lower)) {
1767
+ return 'impatient';
1768
+ }
1769
+
1770
+ // Patience check / status inquiry patterns
1771
+ if (/\bhello\??\s*$/.test(lower)
1772
+ || /\bstill\s+there\b/.test(lower)
1773
+ || /\bany\s+(update|news)\b/.test(lower)
1774
+ || /\bwhat('?s| is)\s+(happening|going\s+on)\b/.test(lower)
1775
+ || /\bare\s+you\s+still\b/.test(lower)
1776
+ || /\bhow\s+(long|much\s+longer)\b/.test(lower)
1777
+ || /\banyone\s+there\b/.test(lower)) {
1778
+ return 'patience_check';
1779
+ }
1780
+
1781
+ return 'neutral';
1782
+ }
1783
+
1784
+ /**
1785
+ * Handle a caller utterance during the guardian decision wait state.
1786
+ * Provides reassurance, impatience detection, and callback offer.
1787
+ */
1788
+ private handleWaitStatePrompt(text: string): void {
1789
+ const now = Date.now();
1790
+ const classification = this.classifyWaitUtterance(text);
1791
+
1792
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_prompt_classified', {
1793
+ classification,
1794
+ transcript: text,
1795
+ });
1796
+
1797
+ if (classification === 'empty') return;
1798
+
1799
+ const guardianLabel = this.resolveGuardianLabel();
1800
+
1801
+ // Callback decisions must always be processed regardless of cooldown —
1802
+ // the caller is answering a direct question and dropping their response
1803
+ // would silently discard their decision.
1804
+ switch (classification) {
1805
+ case 'callback_opt_in': {
1806
+ this.callbackOptIn = true;
1807
+ this.lastInWaitReplyAt = now;
1808
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_set', {});
1809
+ if (this.accessRequestHeartbeatTimer) {
1810
+ clearTimeout(this.accessRequestHeartbeatTimer);
1811
+ this.accessRequestHeartbeatTimer = null;
1812
+ }
1813
+ this.sendTextToken(
1814
+ `Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`,
1815
+ true,
1816
+ );
1817
+ this.scheduleNextHeartbeat();
1818
+ return;
1819
+ }
1820
+ case 'callback_decline': {
1821
+ this.callbackOptIn = false;
1822
+ this.lastInWaitReplyAt = now;
1823
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_declined', {});
1824
+ if (this.accessRequestHeartbeatTimer) {
1825
+ clearTimeout(this.accessRequestHeartbeatTimer);
1826
+ this.accessRequestHeartbeatTimer = null;
1827
+ }
1828
+ this.sendTextToken(
1829
+ `No problem, I'll keep holding. Still waiting on ${guardianLabel}.`,
1830
+ true,
1831
+ );
1832
+ this.scheduleNextHeartbeat();
1833
+ return;
1834
+ }
1835
+ default:
1836
+ break;
1837
+ }
1838
+
1839
+ // Enforce cooldown on non-callback utterances to prevent spam
1840
+ if (now - this.lastInWaitReplyAt < RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS) {
1841
+ log.debug({ callSessionId: this.callSessionId }, 'In-wait reply suppressed by cooldown');
1842
+ return;
1843
+ }
1844
+ this.lastInWaitReplyAt = now;
1845
+
1846
+ switch (classification) {
1847
+ case 'impatient': {
1848
+ if (this.accessRequestHeartbeatTimer) {
1849
+ clearTimeout(this.accessRequestHeartbeatTimer);
1850
+ this.accessRequestHeartbeatTimer = null;
1851
+ }
1852
+ if (!this.callbackOfferMade) {
1853
+ this.callbackOfferMade = true;
1854
+ recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_offer_sent', {});
1855
+ this.sendTextToken(
1856
+ `I understand this is taking a while. I can have ${guardianLabel} call you back once I hear from them. Would you like that, or would you prefer to keep holding?`,
1857
+ true,
1858
+ );
1859
+ } else {
1860
+ // Already offered callback — just reassure
1861
+ this.sendTextToken(
1862
+ `I hear you, I'm sorry for the wait. Still trying to reach ${guardianLabel}.`,
1863
+ true,
1864
+ );
1865
+ }
1866
+ this.scheduleNextHeartbeat();
1867
+ break;
1868
+ }
1869
+ case 'patience_check': {
1870
+ // Immediate reassurance — reset the heartbeat timer so we
1871
+ // don't double up with a scheduled heartbeat
1872
+ if (this.accessRequestHeartbeatTimer) {
1873
+ clearTimeout(this.accessRequestHeartbeatTimer);
1874
+ this.accessRequestHeartbeatTimer = null;
1875
+ }
1876
+ this.sendTextToken(
1877
+ `Yes, I'm still here. Still waiting to hear back from ${guardianLabel}.`,
1878
+ true,
1879
+ );
1880
+ this.scheduleNextHeartbeat();
1881
+ break;
1882
+ }
1883
+ case 'neutral':
1884
+ default: {
1885
+ if (this.accessRequestHeartbeatTimer) {
1886
+ clearTimeout(this.accessRequestHeartbeatTimer);
1887
+ this.accessRequestHeartbeatTimer = null;
1888
+ }
1889
+ this.sendTextToken(
1890
+ `Thanks for that. I'm still waiting on ${guardianLabel}. I'll let you know as soon as I hear back.`,
1891
+ true,
1892
+ );
1893
+ this.scheduleNextHeartbeat();
1894
+ break;
1895
+ }
1896
+ }
1897
+ }
1898
+
1485
1899
  private async handlePrompt(msg: RelayPromptMessage): Promise<void> {
1486
1900
  if (this.connectionState === 'disconnecting') {
1487
1901
  return;
@@ -1509,12 +1923,10 @@ export class RelayConnection {
1509
1923
  return;
1510
1924
  }
1511
1925
 
1512
- // During guardian decision wait, ignore caller speech — they are on hold.
1926
+ // During guardian decision wait, classify caller speech for
1927
+ // reassurance, impatience detection, and callback offer.
1513
1928
  if (this.connectionState === 'awaiting_guardian_decision') {
1514
- log.debug(
1515
- { callSessionId: this.callSessionId },
1516
- 'Ignoring voice prompt during guardian decision wait',
1517
- );
1929
+ this.handleWaitStatePrompt(msg.voicePrompt);
1518
1930
  return;
1519
1931
  }
1520
1932
 
@@ -15,18 +15,11 @@ export interface TwilioConfig {
15
15
  wssBaseUrl: string;
16
16
  }
17
17
 
18
- function resolveTwilioPhoneNumber(assistantId: string | undefined, config: ReturnType<typeof loadConfig>): string {
18
+ function resolveTwilioPhoneNumber(config: ReturnType<typeof loadConfig>, assistantId?: string): string {
19
19
  if (assistantId) {
20
- const assistantPhone = config.sms?.assistantPhoneNumbers?.[assistantId];
21
- if (assistantPhone) {
22
- return assistantPhone;
23
- }
20
+ const scoped = (config.sms?.assistantPhoneNumbers as Record<string, string> | undefined)?.[assistantId];
21
+ if (scoped) return scoped;
24
22
  }
25
-
26
- // Global fallback order:
27
- // 1. TWILIO_PHONE_NUMBER env var (explicit override)
28
- // 2. config file sms.phoneNumber (primary storage)
29
- // 3. credential:twilio:phone_number secure key (backward-compat fallback)
30
23
  return getTwilioPhoneNumberEnv() || config.sms?.phoneNumber || getSecureKey('credential:twilio:phone_number') || '';
31
24
  }
32
25
 
@@ -34,7 +27,7 @@ export function getTwilioConfig(assistantId?: string): TwilioConfig {
34
27
  const accountSid = getSecureKey('credential:twilio:account_sid');
35
28
  const authToken = getSecureKey('credential:twilio:auth_token');
36
29
  const config = loadConfig();
37
- const phoneNumber = resolveTwilioPhoneNumber(assistantId, config);
30
+ const phoneNumber = resolveTwilioPhoneNumber(config, assistantId);
38
31
  const webhookBaseUrl = getPublicBaseUrl(config);
39
32
 
40
33
  // Always use the centralized relay URL derived from the public ingress base URL.
@@ -252,7 +252,7 @@ function buildVoiceWebhookTwiml(
252
252
  });
253
253
  }
254
254
 
255
- const twilioConfig = getTwilioConfig(assistantId);
255
+ const twilioConfig = getTwilioConfig();
256
256
  let relayUrl: string;
257
257
  try {
258
258
  relayUrl = getTwilioRelayUrl(loadConfig());
@@ -1,5 +1,7 @@
1
1
  export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
2
- export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced' | 'inbound_acl_denied' | 'inbound_acl_name_capture_started' | 'inbound_acl_name_captured' | 'inbound_acl_name_capture_timeout' | 'inbound_acl_access_approved' | 'inbound_acl_access_denied' | 'inbound_acl_access_timeout' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed';
2
+ export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced' | 'inbound_acl_denied' | 'inbound_acl_name_capture_started' | 'inbound_acl_name_captured' | 'inbound_acl_name_capture_timeout' | 'inbound_acl_access_approved' | 'inbound_acl_access_denied' | 'inbound_acl_access_timeout' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed' | 'voice_guardian_wait_heartbeat_sent' | 'voice_guardian_wait_prompt_classified' | 'voice_guardian_wait_callback_offer_sent' | 'voice_guardian_wait_callback_opt_in_set' | 'voice_guardian_wait_callback_opt_in_declined'
3
+ | 'callback_handoff_notified'
4
+ | 'callback_handoff_failed';
3
5
  export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
4
6
 
5
7
  /**
package/src/cli.ts CHANGED
@@ -233,7 +233,7 @@ export async function startCli(): Promise<void> {
233
233
  process.stdout.write(`\u2502\n`);
234
234
  process.stdout.write(`\u2502 [a] Allow once\n`);
235
235
  process.stdout.write(`\u2502 [d] Deny once\n`);
236
- if (req.allowlistOptions.length > 0) {
236
+ if (req.allowlistOptions.length > 0 && req.scopeOptions.length > 0) {
237
237
  process.stdout.write(`\u2502 [A] Allowlist...\n`);
238
238
  process.stdout.write(`\u2502 [H] Allowlist (high-risk)...\n`);
239
239
  process.stdout.write(`\u2502 [D] Denylist...\n`);
@@ -246,21 +246,22 @@ export async function startCli(): Promise<void> {
246
246
  const choice = trimmed.toLowerCase();
247
247
 
248
248
  // Uppercase 'A' → allowlist pattern selection (check before lowercase 'a')
249
- if (trimmed === 'A' || choice === 'allowlist') {
249
+ // Only process when scope options exist, matching the display guard above
250
+ if ((trimmed === 'A' || choice === 'allowlist') && req.allowlistOptions.length > 0 && req.scopeOptions.length > 0) {
250
251
  // pendingConfirmation stays true through sub-prompts
251
252
  renderPatternSelection(req, 'always_allow');
252
253
  return;
253
254
  }
254
255
 
255
256
  // Uppercase 'H' → high-risk allowlist pattern selection
256
- if (trimmed === 'H') {
257
+ if (trimmed === 'H' && req.allowlistOptions.length > 0 && req.scopeOptions.length > 0) {
257
258
  // pendingConfirmation stays true through sub-prompts
258
259
  renderPatternSelection(req, 'always_allow_high_risk');
259
260
  return;
260
261
  }
261
262
 
262
263
  // Uppercase 'D' → denylist pattern selection (check before lowercase 'd')
263
- if (trimmed === 'D' || choice === 'denylist') {
264
+ if ((trimmed === 'D' || choice === 'denylist') && req.allowlistOptions.length > 0 && req.scopeOptions.length > 0) {
264
265
  // pendingConfirmation stays true through sub-prompts
265
266
  renderPatternSelection(req, 'always_deny');
266
267
  return;
@@ -14,6 +14,10 @@ Example: `host_bash("vellum email status --json")`
14
14
 
15
15
  Never use browser/computer-use unless user explicitly approves fallback.
16
16
 
17
+ ## When to Use This Skill
18
+
19
+ This skill manages the **assistant's own** AgentMail address (`@agentmail.to`) — not the user's personal email. Only use this skill when the user explicitly asks the assistant to send email **from the assistant's own address**, manage the assistant's inbox, or perform operations on the assistant's AgentMail account. Generic email requests ("send an email", "check my email", "draft a reply") are about the **user's Gmail** and should be handled by the Messaging skill instead.
20
+
17
21
  ## Rules
18
22
 
19
23
  - Always run `vellum email` commands via `host_bash` and parse JSON output.