@vellumai/assistant 0.3.28 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -12,13 +12,11 @@ import { getGatewayInternalBaseUrl } from '../config/env.js';
12
12
  import type { ServerMessage } from '../daemon/ipc-contract.js';
13
13
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
14
14
  import {
15
- backfillSupersessionMetadata,
16
- expireGuardianActionRequest,
17
- getByPendingQuestionId,
18
- getDeliveriesByRequestId,
19
- getPendingRequestByCallSessionId,
20
- markTimedOutWithReason,
21
- } from '../memory/guardian-action-store.js';
15
+ expireCanonicalGuardianRequest,
16
+ getCanonicalRequestByPendingQuestionId,
17
+ getPendingCanonicalRequestByCallSessionId,
18
+ listCanonicalGuardianDeliveries,
19
+ } from '../memory/canonical-guardian-store.js';
22
20
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
23
21
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
24
22
  import { getLogger } from '../util/logger.js';
@@ -709,8 +707,7 @@ export class CallController {
709
707
  effectiveToolMeta && this.pendingConsultation.toolApprovalMeta
710
708
  ? effectiveToolMeta.toolName === this.pendingConsultation.toolApprovalMeta.toolName
711
709
  && effectiveToolMeta.inputDigest === this.pendingConsultation.toolApprovalMeta.inputDigest
712
- : !effectiveToolMeta && !this.pendingConsultation.toolApprovalMeta
713
- && questionText === this.pendingConsultation.questionText;
710
+ : !effectiveToolMeta && !this.pendingConsultation.toolApprovalMeta;
714
711
 
715
712
  if (isSameToolAction) {
716
713
  // Same tool/action — coalesce. Keep the existing consultation
@@ -728,11 +725,11 @@ export class CallController {
728
725
  // Expire the previous consultation's storage records so stale
729
726
  // guardian answers cannot match the old request.
730
727
  expirePendingQuestions(this.callSessionId);
731
- const previousRequest = getPendingRequestByCallSessionId(this.callSessionId);
728
+ const previousRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
732
729
  if (previousRequest) {
733
730
  // Immediately expire with 'superseded' reason to prevent
734
731
  // stale answers from resolving the old request.
735
- expireGuardianActionRequest(previousRequest.id, 'superseded');
732
+ expireCanonicalGuardianRequest(previousRequest.id);
736
733
  log.info(
737
734
  { callSessionId: this.callSessionId, requestId: previousRequest.id },
738
735
  'Superseded guardian action request (materially different intent)',
@@ -769,9 +766,9 @@ export class CallController {
769
766
  // a completed call with a dangling pendingQuestion, and guardian
770
767
  // replies are cleanly rejected instead of hitting answerCall failures.
771
768
  expirePendingQuestions(this.callSessionId);
772
- const previousRequest = getPendingRequestByCallSessionId(this.callSessionId);
769
+ const previousRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
773
770
  if (previousRequest) {
774
- expireGuardianActionRequest(previousRequest.id, 'cancelled');
771
+ expireCanonicalGuardianRequest(previousRequest.id);
775
772
  }
776
773
 
777
774
  this.pendingConsultation = null;
@@ -857,7 +854,7 @@ export class CallController {
857
854
  }
858
855
 
859
856
  private isCallerGuardian(): boolean {
860
- return this.guardianContext?.actorRole === 'guardian';
857
+ return this.guardianContext?.trustClass === 'guardian';
861
858
  }
862
859
 
863
860
  /**
@@ -896,11 +893,16 @@ export class CallController {
896
893
  inputDigest: effectiveToolMeta?.inputDigest,
897
894
  }).then(() => {
898
895
  // Backfill supersession chain: now that the new request exists in
899
- // the store, update the old request's superseded_by_request_id.
896
+ // the store, link the old request to the new one.
900
897
  if (supersededRequestId) {
901
- const newRequest = getByPendingQuestionId(stablePendingQuestionId);
898
+ const newRequest = getCanonicalRequestByPendingQuestionId(stablePendingQuestionId);
902
899
  if (newRequest) {
903
- backfillSupersessionMetadata(supersededRequestId, newRequest.id);
900
+ // Canonical store does not track supersession metadata;
901
+ // the old request was already expired above.
902
+ log.info(
903
+ { callSessionId: this.callSessionId, oldRequestId: supersededRequestId, newRequestId: newRequest.id },
904
+ 'Supersession chain: new canonical request created',
905
+ );
904
906
  }
905
907
  }
906
908
  });
@@ -918,17 +920,18 @@ export class CallController {
918
920
  // send expiry notices to guardian destinations. Deliveries
919
921
  // must be captured before markTimedOutWithReason changes
920
922
  // their status.
921
- const pendingActionRequest = getPendingRequestByCallSessionId(this.callSessionId);
923
+ const pendingActionRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
922
924
  if (pendingActionRequest) {
923
- const deliveries = getDeliveriesByRequestId(pendingActionRequest.id);
924
- markTimedOutWithReason(pendingActionRequest.id, 'call_timeout');
925
+ const canonicalDeliveries = listCanonicalGuardianDeliveries(pendingActionRequest.id);
926
+ // Expire the canonical request and its deliveries
927
+ expireCanonicalGuardianRequest(pendingActionRequest.id);
925
928
  log.info(
926
929
  { callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
927
- 'Marked guardian action request as timed out',
930
+ 'Marked canonical guardian request as timed out',
928
931
  );
929
932
  void sendGuardianExpiryNotices(
930
- deliveries,
931
- pendingActionRequest.assistantId,
933
+ canonicalDeliveries,
934
+ this.assistantId,
932
935
  getGatewayInternalBaseUrl(),
933
936
  readHttpToken() ?? undefined,
934
937
  ).catch((err) => {
@@ -10,7 +10,6 @@
10
10
  */
11
11
 
12
12
  import { addMessage } from '../memory/conversation-store.js';
13
- import type { GuardianActionDelivery } from '../memory/guardian-action-store.js';
14
13
  import {
15
14
  expireGuardianActionRequest,
16
15
  getDeliveriesByRequestId,
@@ -37,8 +36,17 @@ let sweepInProgress = false;
37
36
  * Deliveries must be captured *before* their status is changed to 'expired'
38
37
  * so the sent/pending filter still matches.
39
38
  */
39
+ /** Minimal delivery shape used by the expiry notice sender. */
40
+ export interface ExpiryDeliveryInfo {
41
+ id: string;
42
+ status: string;
43
+ destinationChannel: string;
44
+ destinationConversationId: string | null;
45
+ destinationChatId: string | null;
46
+ }
47
+
40
48
  export async function sendGuardianExpiryNotices(
41
- deliveries: GuardianActionDelivery[],
49
+ deliveries: ExpiryDeliveryInfo[],
42
50
  assistantId: string,
43
51
  gatewayBaseUrl: string,
44
52
  bearerToken?: string,
@@ -10,23 +10,25 @@ import { randomInt } from 'node:crypto';
10
10
 
11
11
  import type { ServerWebSocket } from 'bun';
12
12
 
13
+ import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
13
14
  import { getConfig } from '../config/loader.js';
14
15
  import * as conversationStore from '../memory/conversation-store.js';
16
+ import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
15
17
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
16
18
  import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
17
- import { resolveActorTrust, toGuardianContextCompat } from '../runtime/actor-trust-resolver.js';
19
+ import {
20
+ resolveActorTrust,
21
+ toGuardianRuntimeContextFromTrust,
22
+ } from '../runtime/actor-trust-resolver.js';
18
23
  import {
19
24
  getPendingChallenge,
20
25
  validateAndConsumeChallenge,
21
26
  } from '../runtime/channel-guardian-service.js';
22
- import {
23
- resolveGuardianContext,
24
- toGuardianRuntimeContext,
25
- } from '../runtime/guardian-context-resolver.js';
26
27
  import {
27
28
  composeVerificationVoice,
28
29
  GUARDIAN_VERIFY_TEMPLATE_KEYS,
29
30
  } from '../runtime/guardian-verification-templates.js';
31
+ import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
30
32
  import { parseJsonSafe } from '../util/json.js';
31
33
  import { getLogger } from '../util/logger.js';
32
34
  import { normalizeAssistantId } from '../util/platform.js';
@@ -173,6 +175,12 @@ export class RelayConnection {
173
175
  // Outbound guardian verification state (system calls the guardian)
174
176
  private outboundGuardianVerificationSessionId: string | null = null;
175
177
 
178
+ // Inbound voice invite redemption state
179
+ private inviteRedemptionActive = false;
180
+ private inviteRedemptionAssistantId: string | null = null;
181
+ private inviteRedemptionFromNumber: string | null = null;
182
+ private inviteRedemptionCodeLength = 6;
183
+
176
184
  constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
177
185
  this.ws = ws;
178
186
  this.callSessionId = callSessionId;
@@ -428,15 +436,13 @@ export class RelayConnection {
428
436
  // calls msg.from is the caller; for outbound calls msg.to is the
429
437
  // recipient (msg.from is the assistant's Twilio number).
430
438
  const otherPartyNumber = isInbound ? msg.from : msg.to;
431
- const initialGuardianContext = toGuardianRuntimeContext(
432
- 'voice',
433
- resolveGuardianContext({
434
- assistantId,
435
- sourceChannel: 'voice',
436
- externalChatId: otherPartyNumber,
437
- senderExternalUserId: otherPartyNumber || undefined,
438
- }),
439
- );
439
+ const initialActorTrust = resolveActorTrust({
440
+ assistantId,
441
+ sourceChannel: 'voice',
442
+ externalChatId: otherPartyNumber,
443
+ senderExternalUserId: otherPartyNumber || undefined,
444
+ });
445
+ const initialGuardianContext = toGuardianRuntimeContextFromTrust(initialActorTrust, otherPartyNumber);
440
446
 
441
447
  const controller = new CallController(this.callSessionId, this, session?.task ?? null, {
442
448
  broadcast: globalBroadcast,
@@ -494,6 +500,41 @@ export class RelayConnection {
494
500
  const pendingChallenge = getPendingChallenge(assistantId, 'voice');
495
501
 
496
502
  if (actorTrust.trustClass === 'unknown' && !pendingChallenge) {
503
+ // Before denying, check if there is an active voice invite bound
504
+ // to the caller's phone number. If so, enter the invite redemption
505
+ // subflow instead of denying the call outright.
506
+ // Gated behind the voice-invite-redemption feature flag (defaults OFF).
507
+ const voiceInviteEnabled = isAssistantFeatureFlagEnabled(
508
+ 'feature_flags.voice-invite-redemption.enabled',
509
+ config,
510
+ );
511
+
512
+ if (voiceInviteEnabled) {
513
+ let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
514
+ try {
515
+ voiceInvites = findActiveVoiceInvites({
516
+ assistantId,
517
+ expectedExternalUserId: msg.from,
518
+ });
519
+ } catch (err) {
520
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
521
+ }
522
+
523
+ // Exclude invites that are past their expiresAt even if the DB
524
+ // status hasn't been lazily flipped to 'expired' yet.
525
+ const now = Date.now();
526
+ const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
527
+
528
+ if (nonExpiredInvites.length > 0) {
529
+ log.info(
530
+ { callSessionId: this.callSessionId, from: msg.from },
531
+ 'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
532
+ );
533
+ this.startInviteRedemption(assistantId, msg.from);
534
+ return;
535
+ }
536
+ }
537
+
497
538
  log.info(
498
539
  { callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
499
540
  'Inbound voice ACL: unknown caller denied',
@@ -613,10 +654,7 @@ export class RelayConnection {
613
654
  // Update the controller's guardian context with the trust-resolved
614
655
  // context so downstream policy gates have accurate actor metadata.
615
656
  if (this.controller && actorTrust.trustClass !== 'unknown') {
616
- const resolvedGuardianContext = toGuardianRuntimeContext(
617
- 'voice',
618
- toGuardianContextCompat(actorTrust, msg.from),
619
- );
657
+ const resolvedGuardianContext = toGuardianRuntimeContextFromTrust(actorTrust, msg.from);
620
658
  this.controller.setGuardianContext(resolvedGuardianContext);
621
659
  }
622
660
 
@@ -876,16 +914,14 @@ export class RelayConnection {
876
914
  } else {
877
915
  // Inbound: proceed to normal call flow
878
916
  if (this.controller) {
917
+ const verifiedActorTrust = resolveActorTrust({
918
+ assistantId: this.guardianChallengeAssistantId,
919
+ sourceChannel: 'voice',
920
+ externalChatId: this.guardianVerificationFromNumber,
921
+ senderExternalUserId: this.guardianVerificationFromNumber,
922
+ });
879
923
  this.controller.setGuardianContext(
880
- toGuardianRuntimeContext(
881
- 'voice',
882
- resolveGuardianContext({
883
- assistantId: this.guardianChallengeAssistantId,
884
- sourceChannel: 'voice',
885
- externalChatId: this.guardianVerificationFromNumber,
886
- senderExternalUserId: this.guardianVerificationFromNumber,
887
- }),
888
- ),
924
+ toGuardianRuntimeContextFromTrust(verifiedActorTrust, this.guardianVerificationFromNumber),
889
925
  );
890
926
  this.startNormalCallFlow(this.controller, true);
891
927
  }
@@ -960,6 +996,126 @@ export class RelayConnection {
960
996
  }
961
997
  }
962
998
 
999
+ /**
1000
+ * Enter the invite redemption subflow for an inbound unknown caller
1001
+ * who has an active voice invite. Prompts the caller to enter their
1002
+ * invite code via DTMF or speech.
1003
+ */
1004
+ private startInviteRedemption(assistantId: string, fromNumber: string): void {
1005
+ this.inviteRedemptionActive = true;
1006
+ this.inviteRedemptionAssistantId = assistantId;
1007
+ this.inviteRedemptionFromNumber = fromNumber;
1008
+ this.connectionState = 'verification_pending';
1009
+ this.verificationAttempts = 0;
1010
+ this.verificationMaxAttempts = 3;
1011
+ this.inviteRedemptionCodeLength = 6;
1012
+ this.dtmfBuffer = '';
1013
+
1014
+ recordCallEvent(this.callSessionId, 'invite_redemption_started', {
1015
+ assistantId,
1016
+ codeLength: 6,
1017
+ maxAttempts: this.verificationMaxAttempts,
1018
+ });
1019
+
1020
+ this.sendTextToken(
1021
+ 'Please enter your 6-digit invite code using your keypad, or speak the digits now.',
1022
+ true,
1023
+ );
1024
+
1025
+ log.info(
1026
+ { callSessionId: this.callSessionId, assistantId },
1027
+ 'Inbound voice invite redemption started',
1028
+ );
1029
+ }
1030
+
1031
+ /**
1032
+ * Validate an entered invite code against active voice invites for the
1033
+ * caller. On success, create/activate the ingress member and transition
1034
+ * to the normal call flow. On failure, allow retries up to max attempts.
1035
+ */
1036
+ private attemptInviteCodeRedemption(enteredCode: string): void {
1037
+ if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
1038
+ return;
1039
+ }
1040
+
1041
+ const result = redeemVoiceInviteCode({
1042
+ assistantId: this.inviteRedemptionAssistantId,
1043
+ callerExternalUserId: this.inviteRedemptionFromNumber,
1044
+ sourceChannel: 'voice',
1045
+ code: enteredCode,
1046
+ });
1047
+
1048
+ if (result.ok) {
1049
+ this.connectionState = 'connected';
1050
+ this.inviteRedemptionActive = false;
1051
+ this.verificationAttempts = 0;
1052
+ this.dtmfBuffer = '';
1053
+
1054
+ recordCallEvent(this.callSessionId, 'invite_redemption_succeeded', {
1055
+ memberId: result.memberId,
1056
+ ...(result.type === 'redeemed' ? { inviteId: result.inviteId } : {}),
1057
+ });
1058
+ log.info(
1059
+ { callSessionId: this.callSessionId, memberId: result.memberId, type: result.type },
1060
+ 'Voice invite redemption succeeded',
1061
+ );
1062
+
1063
+ if (this.controller) {
1064
+ const redeemedActorTrust = resolveActorTrust({
1065
+ assistantId: this.inviteRedemptionAssistantId,
1066
+ sourceChannel: 'voice',
1067
+ externalChatId: this.inviteRedemptionFromNumber,
1068
+ senderExternalUserId: this.inviteRedemptionFromNumber,
1069
+ });
1070
+ this.controller.setGuardianContext(
1071
+ toGuardianRuntimeContextFromTrust(redeemedActorTrust, this.inviteRedemptionFromNumber),
1072
+ );
1073
+ this.startNormalCallFlow(this.controller, true);
1074
+ }
1075
+ } else {
1076
+ this.verificationAttempts++;
1077
+
1078
+ if (this.verificationAttempts >= this.verificationMaxAttempts) {
1079
+ this.inviteRedemptionActive = false;
1080
+
1081
+ recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
1082
+ attempts: this.verificationAttempts,
1083
+ });
1084
+ log.warn(
1085
+ { callSessionId: this.callSessionId, attempts: this.verificationAttempts },
1086
+ 'Voice invite redemption failed — max attempts reached',
1087
+ );
1088
+
1089
+ this.sendTextToken('Too many invalid attempts. Goodbye.', true);
1090
+
1091
+ updateCallSession(this.callSessionId, {
1092
+ status: 'failed',
1093
+ endedAt: Date.now(),
1094
+ lastError: 'Voice invite redemption failed — max attempts exceeded',
1095
+ });
1096
+
1097
+ const failSession = getCallSession(this.callSessionId);
1098
+ if (failSession) {
1099
+ expirePendingQuestions(this.callSessionId);
1100
+ persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
1101
+ log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
1102
+ });
1103
+ fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
1104
+ }
1105
+
1106
+ setTimeout(() => {
1107
+ this.endSession('Invite redemption failed');
1108
+ }, 2000);
1109
+ } else {
1110
+ log.info(
1111
+ { callSessionId: this.callSessionId, attempt: this.verificationAttempts, maxAttempts: this.verificationMaxAttempts },
1112
+ 'Voice invite redemption attempt failed — retrying',
1113
+ );
1114
+ this.sendTextToken('Invalid code. Please try again.', true);
1115
+ }
1116
+ }
1117
+ }
1118
+
963
1119
  private async handlePrompt(msg: RelayPromptMessage): Promise<void> {
964
1120
  if (this.connectionState === 'disconnecting') {
965
1121
  return;
@@ -990,6 +1146,26 @@ export class RelayConnection {
990
1146
  return;
991
1147
  }
992
1148
 
1149
+ // During invite redemption, attempt to parse spoken digits from the
1150
+ // transcript and validate against the caller's active voice invite.
1151
+ if (this.connectionState === 'verification_pending' && this.inviteRedemptionActive) {
1152
+ const spokenDigits = RelayConnection.parseDigitsFromSpeech(msg.voicePrompt);
1153
+ log.info(
1154
+ { callSessionId: this.callSessionId, transcript: msg.voicePrompt, spokenDigits },
1155
+ 'Speech received during invite redemption',
1156
+ );
1157
+ if (spokenDigits.length >= this.inviteRedemptionCodeLength) {
1158
+ const enteredCode = spokenDigits.slice(0, this.inviteRedemptionCodeLength);
1159
+ this.attemptInviteCodeRedemption(enteredCode);
1160
+ } else if (spokenDigits.length > 0) {
1161
+ this.sendTextToken(
1162
+ `I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
1163
+ true,
1164
+ );
1165
+ }
1166
+ return;
1167
+ }
1168
+
993
1169
  // During outbound callee verification, ignore voice prompts — the callee
994
1170
  // should be entering DTMF digits, not speaking.
995
1171
  if (this.connectionState === 'verification_pending') {
@@ -1102,6 +1278,19 @@ export class RelayConnection {
1102
1278
  return;
1103
1279
  }
1104
1280
 
1281
+ // If invite redemption is pending, accumulate digits and validate
1282
+ // the code against the caller's active voice invite.
1283
+ if (this.connectionState === 'verification_pending' && this.inviteRedemptionActive) {
1284
+ this.dtmfBuffer += msg.digit;
1285
+
1286
+ if (this.dtmfBuffer.length >= this.inviteRedemptionCodeLength) {
1287
+ const enteredCode = this.dtmfBuffer.slice(0, this.inviteRedemptionCodeLength);
1288
+ this.dtmfBuffer = '';
1289
+ this.attemptInviteCodeRedemption(enteredCode);
1290
+ }
1291
+ return;
1292
+ }
1293
+
1105
1294
  // If outbound callee verification is pending, accumulate digits and check the code
1106
1295
  if (this.connectionState === 'verification_pending' && this.verificationCode) {
1107
1296
  this.dtmfBuffer += msg.digit;
@@ -1,5 +1,5 @@
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';
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' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed';
3
3
  export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
4
4
 
5
5
  /**
@@ -250,8 +250,8 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
250
250
  // - guardian: permission prompts auto-allow (parity with guardian chat)
251
251
  // - everyone else (including unknown): fail-closed strict side-effects
252
252
  // with auto-deny confirmations.
253
- const actorRole = opts.guardianContext?.actorRole;
254
- const isGuardian = actorRole === 'guardian';
253
+ const trustClass = opts.guardianContext?.trustClass;
254
+ const isGuardian = trustClass === 'guardian';
255
255
  const forceStrictSideEffects = isGuardian ? undefined : true;
256
256
 
257
257
  // Replace the [CALL_OPENING] marker with a neutral instruction before
@@ -264,7 +264,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
264
264
 
265
265
  // Build the call-control protocol prompt so the model knows how to emit
266
266
  // control markers (ASK_GUARDIAN, END_CALL, etc.) and recognize opener turns.
267
- const isCallerGuardian = opts.guardianContext?.actorRole === 'guardian';
267
+ const isCallerGuardian = opts.guardianContext?.trustClass === 'guardian';
268
268
 
269
269
  const voiceCallControlPrompt = buildVoiceCallControlPrompt({
270
270
  isInbound: opts.isInbound,
package/src/cli.ts CHANGED
@@ -492,6 +492,18 @@ export async function startCli(): Promise<void> {
492
492
  break;
493
493
  }
494
494
 
495
+ case 'message_request_complete': {
496
+ // Request-level terminal for inline approval consumption.
497
+ // When no agent turn remains active, clear busy state and re-prompt.
498
+ if (msg.runStillActive !== true) {
499
+ spinner.stop();
500
+ generating = false;
501
+ process.stdout.write('\n\n');
502
+ prompt();
503
+ }
504
+ break;
505
+ }
506
+
495
507
  case 'generation_handoff': {
496
508
  // The current request's generation is done; show usage and re-prompt.
497
509
  // Always clear `generating` — this CLI client's generation is finished
@@ -131,7 +131,7 @@ export const WorkspaceGitConfigSchema = z.object({
131
131
  commitMessageLLM: z.object({
132
132
  enabled: z.boolean({ error: 'workspaceGit.commitMessageLLM.enabled must be a boolean' }).default(false),
133
133
  useConfiguredProvider: z.boolean({ error: 'workspaceGit.commitMessageLLM.useConfiguredProvider must be a boolean' }).default(true),
134
- providerFastModelOverrides: z.record(z.string(), z.string()).default({} as any),
134
+ providerFastModelOverrides: z.record(z.string(), z.string()).default({} as Record<string, string>),
135
135
  timeoutMs: z.number({ error: 'workspaceGit.commitMessageLLM.timeoutMs must be a number' })
136
136
  .int('workspaceGit.commitMessageLLM.timeoutMs must be an integer')
137
137
  .positive('workspaceGit.commitMessageLLM.timeoutMs must be a positive integer')
@@ -163,8 +163,19 @@ export const WorkspaceGitConfigSchema = z.object({
163
163
  .int().positive().default(2000),
164
164
  backoffMaxMs: z.number({ error: 'workspaceGit.commitMessageLLM.breaker.backoffMaxMs must be a number' })
165
165
  .int().positive().default(60000),
166
- }).default({} as any),
167
- }).default({} as any),
166
+ }).default({ openAfterFailures: 3, backoffBaseMs: 2000, backoffMaxMs: 60000 }),
167
+ }).default({
168
+ enabled: false,
169
+ useConfiguredProvider: true,
170
+ providerFastModelOverrides: {},
171
+ timeoutMs: 600,
172
+ maxTokens: 120,
173
+ temperature: 0.2,
174
+ maxFilesInPrompt: 30,
175
+ maxDiffBytes: 12000,
176
+ minRemainingTurnBudgetMs: 1000,
177
+ breaker: { openAfterFailures: 3, backoffBaseMs: 2000, backoffMaxMs: 60000 },
178
+ }),
168
179
  });
169
180
 
170
181
  export type HeartbeatConfig = z.infer<typeof HeartbeatConfigSchema>;
@@ -76,7 +76,7 @@ export const CallsVoiceConfigSchema = z.object({
76
76
  fallbackToStandardOnError: z
77
77
  .boolean({ error: 'calls.voice.fallbackToStandardOnError must be a boolean' })
78
78
  .default(true),
79
- elevenlabs: CallsElevenLabsConfigSchema.default({} as any),
79
+ elevenlabs: CallsElevenLabsConfigSchema.default(CallsElevenLabsConfigSchema.parse({})),
80
80
  });
81
81
 
82
82
  export const CallerIdentityConfigSchema = z.object({
@@ -125,14 +125,14 @@ export const CallsConfigSchema = z.object({
125
125
  .positive('calls.userConsultTimeoutSeconds must be a positive integer')
126
126
  .max(2_147_483, 'calls.userConsultTimeoutSeconds must be at most 2147483 (setTimeout-safe limit)')
127
127
  .default(120),
128
- disclosure: CallsDisclosureConfigSchema.default({} as any),
129
- safety: CallsSafetyConfigSchema.default({} as any),
130
- voice: CallsVoiceConfigSchema.default({} as any),
128
+ disclosure: CallsDisclosureConfigSchema.default(CallsDisclosureConfigSchema.parse({})),
129
+ safety: CallsSafetyConfigSchema.default(CallsSafetyConfigSchema.parse({})),
130
+ voice: CallsVoiceConfigSchema.default(CallsVoiceConfigSchema.parse({})),
131
131
  model: z
132
132
  .string({ error: 'calls.model must be a string' })
133
133
  .optional(),
134
- callerIdentity: CallerIdentityConfigSchema.default({} as any),
135
- verification: CallsVerificationConfigSchema.default({} as any),
134
+ callerIdentity: CallerIdentityConfigSchema.default(CallerIdentityConfigSchema.parse({})),
135
+ verification: CallsVerificationConfigSchema.default(CallsVerificationConfigSchema.parse({})),
136
136
  });
137
137
 
138
138
  export type CallsConfig = z.infer<typeof CallsConfigSchema>;
@@ -231,8 +231,8 @@ const IngressBaseSchema = z.object({
231
231
  'ingress.publicBaseUrl must be an absolute URL starting with http:// or https://',
232
232
  )
233
233
  .default(''),
234
- webhook: IngressWebhookConfigSchema.default({} as any),
235
- rateLimit: IngressRateLimitConfigSchema.default({} as any),
234
+ webhook: IngressWebhookConfigSchema.default(IngressWebhookConfigSchema.parse({})),
235
+ rateLimit: IngressRateLimitConfigSchema.default(IngressRateLimitConfigSchema.parse({})),
236
236
  shutdownDrainMs: z
237
237
  .number({ error: 'ingress.shutdownDrainMs must be a number' })
238
238
  .int('ingress.shutdownDrainMs must be an integer')
@@ -241,7 +241,7 @@ const IngressBaseSchema = z.object({
241
241
  });
242
242
 
243
243
  export const IngressConfigSchema = IngressBaseSchema
244
- .default({} as any)
244
+ .default(IngressBaseSchema.parse({}))
245
245
  .transform((val) => ({
246
246
  ...val,
247
247
  // Backward compatibility: if `enabled` was never explicitly set (undefined),
@@ -49,6 +49,14 @@
49
49
  "description": "Send crash reports and error diagnostics to help improve the app",
50
50
  "defaultEnabled": true
51
51
  },
52
+ {
53
+ "id": "voice-invite-redemption",
54
+ "scope": "assistant",
55
+ "key": "feature_flags.voice-invite-redemption.enabled",
56
+ "label": "Voice Invite Redemption",
57
+ "description": "Enable voice invite code redemption for inbound callers with active voice invites",
58
+ "defaultEnabled": false
59
+ },
52
60
  {
53
61
  "id": "user-hosted-enabled",
54
62
  "scope": "macos",
@@ -37,7 +37,7 @@ export const McpServerConfigSchema = z.object({
37
37
  });
38
38
 
39
39
  export const McpConfigSchema = z.object({
40
- servers: z.record(z.string(), McpServerConfigSchema).default({} as any),
40
+ servers: z.record(z.string(), McpServerConfigSchema).default({} as Record<string, never>),
41
41
  globalMaxTools: z.number({ error: 'mcp globalMaxTools must be a number' }).int().positive().default(50),
42
42
  });
43
43