@vellumai/assistant 0.4.2 → 0.4.3

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 (84) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/docs/trusted-contact-access.md +20 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/access-request-decision.test.ts +0 -1
  5. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  6. package/src/__tests__/call-routes-http.test.ts +0 -25
  7. package/src/__tests__/channel-guardian.test.ts +6 -5
  8. package/src/__tests__/config-schema.test.ts +2 -0
  9. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  11. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  12. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  13. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  14. package/src/__tests__/non-member-access-request.test.ts +28 -1
  15. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  16. package/src/__tests__/relay-server.test.ts +644 -4
  17. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  18. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  19. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  20. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  21. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  22. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  23. package/src/__tests__/twilio-routes.test.ts +4 -3
  24. package/src/__tests__/update-bulletin.test.ts +0 -1
  25. package/src/approvals/guardian-decision-primitive.ts +2 -1
  26. package/src/approvals/guardian-request-resolvers.ts +42 -3
  27. package/src/calls/call-constants.ts +8 -0
  28. package/src/calls/call-controller.ts +2 -1
  29. package/src/calls/call-domain.ts +5 -4
  30. package/src/calls/relay-server.ts +513 -116
  31. package/src/calls/twilio-routes.ts +3 -5
  32. package/src/calls/types.ts +1 -1
  33. package/src/calls/voice-session-bridge.ts +4 -3
  34. package/src/cli/core-commands.ts +7 -4
  35. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  36. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  37. package/src/config/calls-schema.ts +12 -0
  38. package/src/config/feature-flag-registry.json +0 -8
  39. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  40. package/src/daemon/handlers/config-channels.ts +5 -7
  41. package/src/daemon/handlers/config-inbox.ts +2 -0
  42. package/src/daemon/handlers/index.ts +2 -1
  43. package/src/daemon/handlers/publish.ts +11 -46
  44. package/src/daemon/handlers/sessions.ts +11 -2
  45. package/src/daemon/ipc-contract/apps.ts +1 -0
  46. package/src/daemon/ipc-contract/inbox.ts +4 -0
  47. package/src/daemon/ipc-contract/integrations.ts +3 -1
  48. package/src/daemon/server.ts +2 -1
  49. package/src/daemon/session-agent-loop.ts +2 -1
  50. package/src/daemon/session-runtime-assembly.ts +3 -1
  51. package/src/daemon/session-surfaces.ts +29 -1
  52. package/src/memory/conversation-crud.ts +2 -1
  53. package/src/memory/conversation-title-service.ts +16 -2
  54. package/src/memory/db-init.ts +4 -0
  55. package/src/memory/delivery-crud.ts +2 -1
  56. package/src/memory/guardian-action-store.ts +2 -1
  57. package/src/memory/guardian-approvals.ts +3 -2
  58. package/src/memory/ingress-invite-store.ts +12 -2
  59. package/src/memory/ingress-member-store.ts +4 -3
  60. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  61. package/src/memory/migrations/index.ts +1 -0
  62. package/src/memory/schema.ts +10 -5
  63. package/src/notifications/copy-composer.ts +11 -1
  64. package/src/notifications/emit-signal.ts +2 -1
  65. package/src/runtime/access-request-helper.ts +11 -3
  66. package/src/runtime/actor-trust-resolver.ts +2 -2
  67. package/src/runtime/assistant-scope.ts +10 -0
  68. package/src/runtime/guardian-outbound-actions.ts +5 -4
  69. package/src/runtime/http-server.ts +11 -20
  70. package/src/runtime/ingress-service.ts +14 -0
  71. package/src/runtime/invite-redemption-service.ts +2 -1
  72. package/src/runtime/middleware/twilio-validation.ts +2 -4
  73. package/src/runtime/routes/call-routes.ts +2 -1
  74. package/src/runtime/routes/channel-route-shared.ts +3 -3
  75. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  76. package/src/runtime/routes/conversation-routes.ts +2 -1
  77. package/src/runtime/routes/events-routes.ts +2 -3
  78. package/src/runtime/routes/inbound-conversation.ts +4 -3
  79. package/src/runtime/routes/inbound-message-handler.ts +4 -3
  80. package/src/runtime/routes/ingress-routes.ts +2 -0
  81. package/src/tools/calls/call-start.ts +2 -1
  82. package/src/tools/terminal/parser.ts +12 -0
  83. package/src/tools/tool-approval-handler.ts +2 -1
  84. package/src/workspace/git-service.ts +19 -0
@@ -103,7 +103,6 @@ mock.module('../util/platform.js', () => ({
103
103
  getTCPPort: () => 8765,
104
104
  isIOSPairingEnabled: () => false,
105
105
  isTCPEnabled: () => false,
106
- normalizeAssistantId: (id: string) => id,
107
106
  readHttpToken: () => null,
108
107
  readLockfile: () => null,
109
108
  readPlatformToken: () => null,
@@ -615,7 +615,10 @@ describe('injectInboundActorContext', () => {
615
615
 
616
616
  const result = injectInboundActorContext(baseUserMessage, ctx);
617
617
  const text = (result.content[0] as { type: 'text'; text: string }).text;
618
- expect(text).toContain('non-guardian account');
618
+ expect(text).toContain('trusted contact (non-guardian)');
619
+ expect(text).toContain('attempt to fulfill it normally');
620
+ expect(text).toContain('tool execution layer will automatically deny it and escalate');
621
+ expect(text).toContain('Do not self-approve');
619
622
  expect(text).toContain('Do not explain the verification system');
620
623
  expect(text).toContain('member_status: active');
621
624
  expect(text).toContain('member_policy: default');
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
 
3
3
  import type {
4
4
  CardSurfaceData,
5
+ DynamicPageSurfaceData,
5
6
  ServerMessage,
6
7
  SurfaceData,
7
8
  SurfaceType,
@@ -96,6 +97,48 @@ describe('task_progress surface compatibility', () => {
96
97
  expect((card.templateData as Record<string, unknown>).status).toBe('in_progress');
97
98
  });
98
99
 
100
+ test('ui_show normalizes top-level dynamic_page fields into data', async () => {
101
+ const sent: ServerMessage[] = [];
102
+ const ctx = makeContext(sent);
103
+
104
+ const result = await surfaceProxyResolver(ctx, 'ui_show', {
105
+ surface_type: 'dynamic_page',
106
+ title: 'My Slides',
107
+ html: '<h1>Hello</h1>',
108
+ preview: { title: 'Slides', subtitle: '3 slides about Apple' },
109
+ });
110
+
111
+ expect(result.isError).toBe(false);
112
+
113
+ const showMessage = sent.find((msg): msg is UiSurfaceShow => msg.type === 'ui_surface_show');
114
+ expect(showMessage).toBeDefined();
115
+ if (!showMessage || showMessage.surfaceType !== 'dynamic_page') return;
116
+
117
+ const page = showMessage.data as DynamicPageSurfaceData;
118
+ expect(page.html).toBe('<h1>Hello</h1>');
119
+ expect(page.preview).toEqual({ title: 'Slides', subtitle: '3 slides about Apple' });
120
+ });
121
+
122
+ test('ui_show dynamic_page uses data.html when properly nested', async () => {
123
+ const sent: ServerMessage[] = [];
124
+ const ctx = makeContext(sent);
125
+
126
+ const result = await surfaceProxyResolver(ctx, 'ui_show', {
127
+ surface_type: 'dynamic_page',
128
+ title: 'My Slides',
129
+ data: { html: '<h1>Nested</h1>' },
130
+ });
131
+
132
+ expect(result.isError).toBe(false);
133
+
134
+ const showMessage = sent.find((msg): msg is UiSurfaceShow => msg.type === 'ui_surface_show');
135
+ expect(showMessage).toBeDefined();
136
+ if (!showMessage || showMessage.surfaceType !== 'dynamic_page') return;
137
+
138
+ const page = showMessage.data as DynamicPageSurfaceData;
139
+ expect(page.html).toBe('<h1>Nested</h1>');
140
+ });
141
+
99
142
  test('ui_update normalizes top-level task_progress fields into templateData', async () => {
100
143
  const sent: ServerMessage[] = [];
101
144
  const ctx = makeContext(sent);
@@ -34,7 +34,6 @@ mock.module('../util/platform.js', () => ({
34
34
  getDbPath: () => join(testDir, 'test.db'),
35
35
  getLogPath: () => join(testDir, 'test.log'),
36
36
  ensureDataDir: () => {},
37
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
38
37
  readHttpToken: () => 'test-bearer-token',
39
38
  }));
40
39
 
@@ -82,6 +81,7 @@ mock.module('../runtime/approval-message-composer.js', () => ({
82
81
  composeApprovalMessageGenerative: async () => 'mock generative message',
83
82
  }));
84
83
 
84
+ import { getResolver } from '../approvals/guardian-request-resolvers.js';
85
85
  import {
86
86
  createApprovalRequest,
87
87
  createBinding,
@@ -489,6 +489,16 @@ describe('trusted contact activated notification signal', () => {
489
489
  expect(activatedSignals.length).toBe(0);
490
490
  });
491
491
 
492
+ test('voice access_request resolver has registered handler for access_request kind', () => {
493
+ // The access_request resolver is registered during module load. When the
494
+ // source channel is 'voice', it should directly activate the member via
495
+ // upsertMember (no verification session). This test validates the resolver
496
+ // is registered and accessible.
497
+ const resolver = getResolver('access_request');
498
+ expect(resolver).toBeDefined();
499
+ expect(resolver!.kind).toBe('access_request');
500
+ });
501
+
492
502
  test('member is persisted BEFORE activated signal is emitted', async () => {
493
503
  // Set up a guardian binding
494
504
  createBinding({
@@ -29,7 +29,6 @@ mock.module('../util/platform.js', () => ({
29
29
  getDbPath: () => join(testDir, 'test.db'),
30
30
  getLogPath: () => join(testDir, 'test.log'),
31
31
  ensureDataDir: () => {},
32
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
33
32
  readHttpToken: () => 'test-bearer-token',
34
33
  }));
35
34
 
@@ -32,7 +32,6 @@ mock.module('../util/platform.js', () => ({
32
32
  getDbPath: () => join(testDir, 'test.db'),
33
33
  getLogPath: () => join(testDir, 'test.log'),
34
34
  ensureDataDir: () => {},
35
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
36
35
  readHttpToken: () => 'test-bearer-token',
37
36
  }));
38
37
 
@@ -704,19 +704,20 @@ describe('twilio webhook routes', () => {
704
704
  expect(res.status).toBe(400);
705
705
  });
706
706
 
707
- test('inbound webhook with forwarded assistantId creates session with correct assistantId', async () => {
707
+ test('inbound webhook creates session with internal scope assistantId', async () => {
708
708
  const req = makeInboundVoiceRequest({
709
709
  CallSid: 'CA_inbound_assist_1',
710
710
  From: '+14155551234',
711
711
  To: '+15550001111',
712
712
  });
713
713
 
714
- const res = await handleVoiceWebhook(req, 'my-assistant-id');
714
+ const res = await handleVoiceWebhook(req);
715
715
 
716
716
  expect(res.status).toBe(200);
717
717
  const session = getCallSessionByCallSid('CA_inbound_assist_1');
718
718
  expect(session).not.toBeNull();
719
- expect(session!.assistantId).toBe('my-assistant-id');
719
+ // Daemon always uses internal scope — external assistant IDs are not leaked into session state.
720
+ expect(session!.assistantId).toBe('self');
720
721
  });
721
722
 
722
723
  test('outbound call flow remains non-regressed with callSessionId present', async () => {
@@ -53,7 +53,6 @@ mock.module('../util/platform.js', () => ({
53
53
  getInterfacesDir: () => '',
54
54
  getClipboardCommand: () => null,
55
55
  readLockfile: () => null,
56
- normalizeAssistantId: (id: string) => id,
57
56
  writeLockfile: () => {},
58
57
  readPlatformToken: () => null,
59
58
  readSessionToken: () => null,
@@ -35,6 +35,7 @@ import {
35
35
  type GuardianApprovalRequest,
36
36
  updateApprovalDecision,
37
37
  } from '../memory/channel-guardian-store.js';
38
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
38
39
  import type {
39
40
  ApprovalAction,
40
41
  ApprovalDecisionResult,
@@ -233,7 +234,7 @@ export function mintCanonicalRequestGrant(params: {
233
234
  }
234
235
 
235
236
  const result = mintGrantFromDecision({
236
- assistantId: 'self',
237
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
237
238
  scopeMode: 'tool_signature',
238
239
  toolName: request.toolName,
239
240
  inputDigest: request.inputDigest,
@@ -13,8 +13,10 @@
13
13
 
14
14
  import { answerCall } from '../calls/call-domain.js';
15
15
  import type { CanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
16
+ import { upsertMember } from '../memory/ingress-member-store.js';
16
17
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
17
18
  import { addRule } from '../permissions/trust-store.js';
19
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
18
20
  import type { ApprovalAction } from '../runtime/channel-approval-types.js';
19
21
  import { createOutboundSession } from '../runtime/channel-guardian-service.js';
20
22
  import { deliverChannelReply } from '../runtime/gateway-client.js';
@@ -24,6 +26,10 @@ import { getLogger } from '../util/logger.js';
24
26
 
25
27
  const log = getLogger('guardian-request-resolvers');
26
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
27
33
  // ---------------------------------------------------------------------------
28
34
  // Types
29
35
  // ---------------------------------------------------------------------------
@@ -278,7 +284,7 @@ const accessRequestResolver: GuardianRequestResolver = {
278
284
  const requesterExternalUserId = request.requesterExternalUserId ?? '';
279
285
  const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
280
286
  const decidedByExternalUserId = ctx.actor.externalUserId ?? '';
281
- const assistantId = channelDeliveryContext?.assistantId ?? 'self';
287
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
282
288
 
283
289
  if (decision.action === 'reject') {
284
290
  log.info(
@@ -340,7 +346,40 @@ const accessRequestResolver: GuardianRequestResolver = {
340
346
  return { ok: true, applied: true };
341
347
  }
342
348
 
343
- // On approve: mint an identity-bound verification session so the
349
+ // Voice approvals: directly activate the trusted contact without minting
350
+ // a verification session. The caller is already on the line and the
351
+ // relay server's in-call wait loop will detect the approved status.
352
+ if (channel === 'voice') {
353
+ try {
354
+ upsertMember({
355
+ assistantId,
356
+ sourceChannel: 'voice',
357
+ externalUserId: requesterExternalUserId,
358
+ externalChatId: requesterChatId,
359
+ status: 'active',
360
+ policy: 'allow',
361
+ });
362
+ } catch (err) {
363
+ log.error(
364
+ { err, requesterExternalUserId },
365
+ 'Access request resolver: failed to activate voice caller as trusted contact',
366
+ );
367
+ }
368
+
369
+ log.info(
370
+ {
371
+ event: 'resolver_access_request_voice_approved',
372
+ requestId: request.id,
373
+ channel,
374
+ requesterExternalUserId,
375
+ },
376
+ 'Access request resolver: voice approval — direct trusted-contact activation (no verification session)',
377
+ );
378
+
379
+ return { ok: true, applied: true };
380
+ }
381
+
382
+ // Non-voice approvals: mint an identity-bound verification session so the
344
383
  // requester can verify their identity.
345
384
  const session = createOutboundSession({
346
385
  assistantId,
@@ -461,7 +500,7 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
461
500
  async resolve(ctx: ResolverContext): Promise<ResolverResult> {
462
501
  const { request, decision, channelDeliveryContext } = ctx;
463
502
  const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
464
- const assistantId = channelDeliveryContext?.assistantId ?? 'self';
503
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
465
504
 
466
505
  if (decision.action === 'reject') {
467
506
  log.info(
@@ -41,6 +41,14 @@ export function getUserConsultationTimeoutMs(): number {
41
41
  return getConfig().calls.userConsultTimeoutSeconds * 1000;
42
42
  }
43
43
 
44
+ export function getTtsPlaybackDelayMs(): number {
45
+ return getConfig().calls.ttsPlaybackDelayMs;
46
+ }
47
+
48
+ export function getAccessRequestPollIntervalMs(): number {
49
+ return getConfig().calls.accessRequestPollIntervalMs;
50
+ }
51
+
44
52
  export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds
45
53
 
46
54
  // Legacy named exports for backward compatibility (use functions above for config-backed values)
@@ -18,6 +18,7 @@ import {
18
18
  listCanonicalGuardianDeliveries,
19
19
  } from '../memory/canonical-guardian-store.js';
20
20
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
21
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
21
22
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
22
23
  import { getLogger } from '../util/logger.js';
23
24
  import { readHttpToken } from '../util/platform.js';
@@ -245,7 +246,7 @@ export class CallController {
245
246
  this.task = task;
246
247
  this.isInbound = !task;
247
248
  this.broadcast = opts?.broadcast;
248
- this.assistantId = opts?.assistantId ?? 'self';
249
+ this.assistantId = opts?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
249
250
  this.guardianContext = opts?.guardianContext ?? null;
250
251
 
251
252
  // Resolve the conversation ID from the call session
@@ -14,6 +14,7 @@ import { getOrCreateConversation } from '../memory/conversation-key-store.js';
14
14
  import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
15
15
  import { upsertBinding } from '../memory/external-conversation-store.js';
16
16
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
17
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
17
18
  import { isGuardian } from '../runtime/channel-guardian-service.js';
18
19
  import { getSecureKey } from '../security/secure-keys.js';
19
20
  import { getLogger } from '../util/logger.js';
@@ -208,7 +209,7 @@ export type CreateInboundVoiceSessionResult = {
208
209
  export function createInboundVoiceSession(
209
210
  input: CreateInboundVoiceSessionInput,
210
211
  ): CreateInboundVoiceSessionResult {
211
- const { callSid, fromNumber, toNumber, assistantId = 'self' } = input;
212
+ const { callSid, fromNumber, toNumber, assistantId = DAEMON_INTERNAL_ASSISTANT_ID } = input;
212
213
 
213
214
  // Check if a session already exists for this CallSid (replay protection)
214
215
  const existing = getCallSessionByCallSid(callSid);
@@ -219,7 +220,7 @@ export function createInboundVoiceSession(
219
220
 
220
221
  // Create a dedicated voice conversation keyed by CallSid so inbound calls
221
222
  // get their own conversation thread.
222
- const voiceConvKey = assistantId && assistantId !== 'self'
223
+ const voiceConvKey = assistantId && assistantId !== DAEMON_INTERNAL_ASSISTANT_ID
223
224
  ? `asst:${assistantId}:voice:inbound:${callSid}`
224
225
  : `voice:inbound:${callSid}`;
225
226
  const { conversationId: voiceConversationId } = getOrCreateConversation(voiceConvKey);
@@ -272,7 +273,7 @@ export function createInboundVoiceSession(
272
273
  * Initiate a new outbound call.
273
274
  */
274
275
  export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
275
- const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = 'self' } = input;
276
+ const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = DAEMON_INTERNAL_ASSISTANT_ID } = input;
276
277
 
277
278
  if (!phoneNumber || typeof phoneNumber !== 'string') {
278
279
  return { ok: false, error: 'phone_number is required and must be a string', status: 400 };
@@ -644,7 +645,7 @@ export type StartGuardianVerificationCallResult =
644
645
  export async function startGuardianVerificationCall(
645
646
  input: StartGuardianVerificationCallInput,
646
647
  ): Promise<StartGuardianVerificationCallResult> {
647
- const { phoneNumber, guardianVerificationSessionId, assistantId = 'self', originConversationId } = input;
648
+ const { phoneNumber, guardianVerificationSessionId, assistantId = DAEMON_INTERNAL_ASSISTANT_ID, originConversationId } = input;
648
649
 
649
650
  if (!phoneNumber || !E164_REGEX.test(phoneNumber)) {
650
651
  return { ok: false, error: 'phone_number must be in E.164 format', status: 400 };