@vellumai/assistant 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/ARCHITECTURE.md +23 -6
  2. package/bun.lock +51 -0
  3. package/docs/trusted-contact-access.md +8 -0
  4. package/package.json +2 -1
  5. package/src/__tests__/actor-token-service.test.ts +4 -4
  6. package/src/__tests__/call-controller.test.ts +37 -0
  7. package/src/__tests__/channel-delivery-store.test.ts +2 -2
  8. package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
  9. package/src/__tests__/guardian-dispatch.test.ts +39 -1
  10. package/src/__tests__/guardian-routing-state.test.ts +8 -30
  11. package/src/__tests__/non-member-access-request.test.ts +7 -0
  12. package/src/__tests__/notification-decision-fallback.test.ts +232 -0
  13. package/src/__tests__/notification-decision-strategy.test.ts +304 -8
  14. package/src/__tests__/notification-guardian-path.test.ts +38 -1
  15. package/src/__tests__/relay-server.test.ts +65 -5
  16. package/src/__tests__/send-endpoint-busy.test.ts +29 -1
  17. package/src/__tests__/tool-grant-request-escalation.test.ts +1 -0
  18. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
  19. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -2
  20. package/src/__tests__/trusted-contact-multichannel.test.ts +1 -1
  21. package/src/calls/call-controller.ts +15 -0
  22. package/src/calls/relay-server.ts +45 -11
  23. package/src/calls/types.ts +1 -0
  24. package/src/daemon/providers-setup.ts +0 -8
  25. package/src/daemon/session-slash.ts +35 -2
  26. package/src/memory/db-init.ts +4 -0
  27. package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
  28. package/src/memory/migrations/index.ts +1 -0
  29. package/src/memory/migrations/registry.ts +1 -1
  30. package/src/memory/schema.ts +19 -0
  31. package/src/notifications/README.md +8 -1
  32. package/src/notifications/copy-composer.ts +160 -30
  33. package/src/notifications/decision-engine.ts +98 -1
  34. package/src/runtime/actor-refresh-token-service.ts +309 -0
  35. package/src/runtime/actor-refresh-token-store.ts +157 -0
  36. package/src/runtime/actor-token-service.ts +3 -3
  37. package/src/runtime/gateway-client.ts +239 -0
  38. package/src/runtime/http-server.ts +2 -0
  39. package/src/runtime/routes/guardian-bootstrap-routes.ts +10 -24
  40. package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
  41. package/src/runtime/routes/pairing-routes.ts +60 -50
  42. package/src/types/qrcode.d.ts +10 -0
@@ -37,24 +37,43 @@ mock.module('../util/logger.js', () => ({
37
37
  }));
38
38
 
39
39
  let mockTelegramBinding: unknown = null;
40
+ let mockVellumBinding: unknown = null;
40
41
 
41
42
  mock.module('../memory/channel-guardian-store.js', () => ({
42
43
  getActiveBinding: (_assistantId: string, channel: string) => {
43
44
  if (channel === 'telegram') return mockTelegramBinding;
45
+ if (channel === 'vellum') return mockVellumBinding;
44
46
  return null;
45
47
  },
48
+ createBinding: (params: Record<string, unknown>) => ({
49
+ id: `binding-${Date.now()}`,
50
+ ...params,
51
+ status: 'active',
52
+ verifiedAt: Date.now(),
53
+ verifiedVia: 'test',
54
+ metadataJson: null,
55
+ createdAt: Date.now(),
56
+ updatedAt: Date.now(),
57
+ }),
58
+ listActiveBindingsByAssistant: () => mockVellumBinding ? [mockVellumBinding] : [],
46
59
  }));
47
60
 
48
61
  mock.module('../config/loader.js', () => ({
49
62
  getConfig: () => ({
50
63
  ui: {},
51
-
64
+
52
65
  calls: {
53
66
  userConsultTimeoutSeconds: 120,
54
67
  },
55
68
  }),
56
69
  }));
57
70
 
71
+ // Mock guardian-vellum-migration to use a stable principal, avoiding UNIQUE
72
+ // constraint errors when ensureVellumGuardianBinding is called across tests.
73
+ mock.module('../runtime/guardian-vellum-migration.js', () => ({
74
+ ensureVellumGuardianBinding: () => 'test-principal-id',
75
+ }));
76
+
58
77
  const emitCalls: unknown[] = [];
59
78
  let mockThreadCreated: ThreadCreatedInfo | null = null;
60
79
  let mockEmitResult: {
@@ -114,12 +133,30 @@ function resetTables(): void {
114
133
  db.run('DELETE FROM canonical_guardian_requests');
115
134
  db.run('DELETE FROM guardian_action_deliveries');
116
135
  db.run('DELETE FROM guardian_action_requests');
136
+ db.run('DELETE FROM channel_guardian_bindings');
117
137
  db.run('DELETE FROM call_pending_questions');
118
138
  db.run('DELETE FROM call_events');
119
139
  db.run('DELETE FROM call_sessions');
120
140
  db.run('DELETE FROM conversations');
141
+
121
142
  emitCalls.length = 0;
122
143
  mockTelegramBinding = null;
144
+ // Pre-seed vellum binding so the self-healing path in dispatchGuardianQuestion
145
+ // never triggers (avoids UNIQUE constraint violations on repeated dispatches).
146
+ mockVellumBinding = {
147
+ id: 'binding-vellum-test',
148
+ assistantId: 'self',
149
+ channel: 'vellum',
150
+ guardianExternalUserId: 'vellum-guardian',
151
+ guardianDeliveryChatId: 'local',
152
+ guardianPrincipalId: 'test-principal-id',
153
+ status: 'active',
154
+ verifiedAt: Date.now(),
155
+ verifiedVia: 'test',
156
+ metadataJson: null,
157
+ createdAt: Date.now(),
158
+ updatedAt: Date.now(),
159
+ };
123
160
  mockThreadCreated = null;
124
161
  mockEmitResult = {
125
162
  signalId: 'sig-1',
@@ -44,6 +44,13 @@ mock.module('../util/logger.js', () => ({
44
44
  }),
45
45
  }));
46
46
 
47
+ // ── Identity helpers mock ─────────────────────────────────────────────
48
+
49
+ let mockAssistantName: string | null = 'Vellum';
50
+ mock.module('../daemon/identity-helpers.js', () => ({
51
+ getAssistantName: () => mockAssistantName,
52
+ }));
53
+
47
54
  // ── Config mock ─────────────────────────────────────────────────────
48
55
 
49
56
  const mockConfig = {
@@ -1915,10 +1922,11 @@ describe('relay-server', () => {
1915
1922
  // Should be in the name capture state (not denied)
1916
1923
  expect(relay.getConnectionState()).toBe('awaiting_name');
1917
1924
 
1918
- // Should have sent the name capture prompt
1925
+ // Should have sent the name capture prompt with assistant name + guardian label
1919
1926
  const textMessages = ws.sentMessages
1920
1927
  .map((raw) => JSON.parse(raw) as { type: string; token?: string })
1921
1928
  .filter((m) => m.type === 'text');
1929
+ expect(textMessages.some((m) => (m.token ?? '').includes('Hi, this is Vellum,'))).toBe(true);
1922
1930
  expect(textMessages.some((m) => (m.token ?? '').includes("don't recognize this number"))).toBe(true);
1923
1931
  expect(textMessages.some((m) => (m.token ?? '').includes('Can I get your name'))).toBe(true);
1924
1932
 
@@ -1929,6 +1937,46 @@ describe('relay-server', () => {
1929
1937
  relay.destroy();
1930
1938
  });
1931
1939
 
1940
+ test('inbound voice: unknown caller name capture uses fallback when assistant name is unavailable', async () => {
1941
+ const prevName = mockAssistantName;
1942
+ mockAssistantName = null;
1943
+ try {
1944
+ ensureConversation('conv-invite-no-name');
1945
+ const session = createCallSession({
1946
+ conversationId: 'conv-invite-no-name',
1947
+ provider: 'twilio',
1948
+ fromNumber: '+15558885556',
1949
+ toNumber: '+15551111111',
1950
+ assistantId: 'self',
1951
+ });
1952
+
1953
+ const { ws, relay } = createMockWs(session.id);
1954
+
1955
+ await relay.handleMessage(JSON.stringify({
1956
+ type: 'setup',
1957
+ callSid: 'CA_invite_no_name',
1958
+ from: '+15558885556',
1959
+ to: '+15551111111',
1960
+ }));
1961
+
1962
+ expect(relay.getConnectionState()).toBe('awaiting_name');
1963
+
1964
+ // Fallback prompt should NOT include assistant name but should include guardian label
1965
+ const textMessages = ws.sentMessages
1966
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
1967
+ .filter((m) => m.type === 'text');
1968
+ const promptText = textMessages.map((m) => m.token ?? '').join('');
1969
+ expect(promptText).toContain("Hi, this is my guardian's assistant.");
1970
+ expect(promptText).not.toContain('Vellum');
1971
+ expect(promptText).toContain("don't recognize this number");
1972
+ expect(promptText).toContain('Can I get your name');
1973
+
1974
+ relay.destroy();
1975
+ } finally {
1976
+ mockAssistantName = prevName;
1977
+ }
1978
+ });
1979
+
1932
1980
  // ── Friend-initiated in-call guardian approval flow ────────────────────
1933
1981
 
1934
1982
  test('name capture flow: caller provides name and enters guardian decision wait', async () => {
@@ -2156,7 +2204,7 @@ describe('relay-server', () => {
2156
2204
  relay.destroy();
2157
2205
  });
2158
2206
 
2159
- test('name capture flow: approved access request activates caller and continues call', async () => {
2207
+ test('name capture flow: approved access request activates caller with deterministic handoff copy', async () => {
2160
2208
  ensureConversation('conv-access-approved');
2161
2209
  const session = createCallSession({
2162
2210
  conversationId: 'conv-access-approved',
@@ -2166,9 +2214,10 @@ describe('relay-server', () => {
2166
2214
  assistantId: 'self',
2167
2215
  });
2168
2216
 
2169
- mockSendMessage.mockImplementation(createMockProviderResponse(['I can help you with that.']));
2217
+ // Track provider calls to verify no LLM turn is triggered on approval
2218
+ const providerCallCountBefore = mockSendMessage.mock.calls.length;
2170
2219
 
2171
- const { relay } = createMockWs(session.id);
2220
+ const { ws, relay } = createMockWs(session.id);
2172
2221
 
2173
2222
  await relay.handleMessage(JSON.stringify({
2174
2223
  type: 'setup',
@@ -2209,9 +2258,20 @@ describe('relay-server', () => {
2209
2258
  // Should have transitioned to connected state
2210
2259
  expect(relay.getConnectionState()).toBe('connected');
2211
2260
 
2212
- // Verify events
2261
+ // Verify deterministic handoff copy was sent (not an LLM-generated response)
2262
+ const textMessages = ws.sentMessages
2263
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2264
+ .filter((m) => m.type === 'text');
2265
+ expect(textMessages.some((m) => (m.token ?? '').includes('said I can speak with you. How can I help?'))).toBe(true);
2266
+
2267
+ // Verify no provider (LLM) call was made as part of the approval handoff
2268
+ expect(mockSendMessage.mock.calls.length).toBe(providerCallCountBefore);
2269
+
2270
+ // Verify events — including assistant_spoke for transcript parity
2213
2271
  const events = getCallEvents(session.id);
2214
2272
  expect(events.some((e) => e.eventType === 'inbound_acl_access_approved')).toBe(true);
2273
+ expect(events.some((e) => e.eventType === 'assistant_spoke')).toBe(true);
2274
+ expect(events.some((e) => e.eventType === 'inbound_acl_post_approval_handoff_spoken')).toBe(true);
2215
2275
 
2216
2276
  // Session should be in_progress
2217
2277
  const updated = getCallSession(session.id);
@@ -15,6 +15,7 @@ import { afterAll, beforeEach, describe, expect, mock,test } from 'bun:test';
15
15
  import type { ServerMessage } from '../daemon/ipc-protocol.js';
16
16
  import type { Session } from '../daemon/session.js';
17
17
  import { createCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
18
+ import { createBinding } from '../memory/channel-guardian-store.js';
18
19
  import { getOrCreateConversation } from '../memory/conversation-key-store.js';
19
20
 
20
21
  const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'send-endpoint-busy-test-')));
@@ -41,7 +42,7 @@ mock.module('../util/logger.js', () => ({
41
42
  mock.module('../config/loader.js', () => ({
42
43
  getConfig: () => ({
43
44
  ui: {},
44
-
45
+
45
46
  model: 'test',
46
47
  provider: 'test',
47
48
  apiKeys: {},
@@ -51,6 +52,23 @@ mock.module('../config/loader.js', () => ({
51
52
  }),
52
53
  }));
53
54
 
55
+ // Mock guardian-vellum-migration to use a stable principal matching the one
56
+ // in createCanonicalGuardianRequest calls below ('test-principal-id').
57
+ mock.module('../runtime/guardian-vellum-migration.js', () => ({
58
+ ensureVellumGuardianBinding: () => 'test-principal-id',
59
+ }));
60
+
61
+ // Mock local-actor-identity to return a stable guardian context that uses
62
+ // the same principal as the canonical requests created in tests.
63
+ mock.module('../runtime/local-actor-identity.js', () => ({
64
+ resolveLocalIpcGuardianContext: () => ({
65
+ sourceChannel: 'vellum',
66
+ trustClass: 'guardian',
67
+ guardianPrincipalId: 'test-principal-id',
68
+ guardianExternalUserId: 'test-principal-id',
69
+ }),
70
+ }));
71
+
54
72
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
55
73
  import type { AssistantEvent } from '../runtime/assistant-event.js';
56
74
  import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
@@ -217,7 +235,17 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
217
235
  db.run('DELETE FROM conversation_keys');
218
236
  db.run('DELETE FROM canonical_guardian_deliveries');
219
237
  db.run('DELETE FROM canonical_guardian_requests');
238
+ db.run('DELETE FROM channel_guardian_bindings');
220
239
  pendingInteractions.clear();
240
+
241
+ createBinding({
242
+ assistantId: 'self',
243
+ channel: 'vellum',
244
+ guardianExternalUserId: 'guardian-vellum',
245
+ guardianDeliveryChatId: 'vellum',
246
+ guardianPrincipalId: 'test-principal-id',
247
+ });
248
+
221
249
  eventHub = new AssistantEventHub();
222
250
  });
223
251
 
@@ -84,6 +84,7 @@ mock.module('../runtime/channel-guardian-service.js', () => ({
84
84
  channel: 'telegram',
85
85
  guardianExternalUserId: 'guardian-1',
86
86
  guardianDeliveryChatId: 'guardian-chat-1',
87
+ guardianPrincipalId: 'test-principal-id',
87
88
  status: 'active',
88
89
  };
89
90
  }
@@ -103,6 +103,7 @@ let mockGuardianBinding: Record<string, unknown> | null = {
103
103
  channel: 'telegram',
104
104
  guardianExternalUserId: 'guardian-1',
105
105
  guardianDeliveryChatId: 'guardian-chat-1',
106
+ guardianPrincipalId: 'test-principal-id',
106
107
  status: 'active',
107
108
  };
108
109
 
@@ -259,6 +260,7 @@ describe('(a) target flow: trusted-contact inline guardian approval end-to-end',
259
260
  channel: 'telegram',
260
261
  guardianExternalUserId: 'guardian-1',
261
262
  guardianDeliveryChatId: 'guardian-chat-1',
263
+ guardianPrincipalId: 'test-principal-id',
262
264
  status: 'active',
263
265
  };
264
266
  });
@@ -368,6 +370,7 @@ describe('(b) prompt-path flow: confirmation_request bridges to guardian', () =>
368
370
  channel: 'telegram',
369
371
  guardianExternalUserId: 'guardian-1',
370
372
  guardianDeliveryChatId: 'guardian-chat-1',
373
+ guardianPrincipalId: 'test-principal-id',
371
374
  status: 'active',
372
375
  };
373
376
  });
@@ -550,6 +553,7 @@ describe('(d) unknown actor flow: fail-closed with no interactive approval', ()
550
553
  channel: 'telegram',
551
554
  guardianExternalUserId: 'guardian-1',
552
555
  guardianDeliveryChatId: 'guardian-chat-1',
556
+ guardianPrincipalId: 'test-principal-id',
553
557
  status: 'active',
554
558
  };
555
559
  });
@@ -734,6 +738,7 @@ describe('(f) timeout/stale flow: stale guardian decision after inline wait time
734
738
  channel: 'telegram',
735
739
  guardianExternalUserId: 'guardian-1',
736
740
  guardianDeliveryChatId: 'guardian-chat-1',
741
+ guardianPrincipalId: 'test-principal-id',
737
742
  status: 'active',
738
743
  };
739
744
  });
@@ -948,6 +953,7 @@ describe('cross-milestone integration checks', () => {
948
953
  channel: 'telegram',
949
954
  guardianExternalUserId: 'guardian-1',
950
955
  guardianDeliveryChatId: 'guardian-chat-1',
956
+ guardianPrincipalId: 'test-principal-id',
951
957
  status: 'active',
952
958
  };
953
959
  });
@@ -405,8 +405,8 @@ describe('trusted contact activated notification signal', () => {
405
405
  // Verify payload
406
406
  const payload = activatedSignals[0].contextPayload as Record<string, unknown>;
407
407
  expect(payload.sourceChannel).toBe('telegram');
408
- expect(payload.externalUserId).toBe('requester-user-456');
409
- expect(payload.externalChatId).toBe('chat-123');
408
+ expect(payload.actorExternalId).toBe('requester-user-456');
409
+ expect(payload.conversationExternalId).toBe('chat-123');
410
410
 
411
411
  // Verify deduplication key includes the user identity
412
412
  const dedupeKey = activatedSignals[0].dedupeKey as string;
@@ -221,7 +221,7 @@ for (const config of CHANNEL_CONFIGS) {
221
221
  // Guardian notification helper was called for the correct channel
222
222
  expect(notifyGuardianCalls.length).toBe(1);
223
223
  expect(notifyGuardianCalls[0].sourceChannel).toBe(config.channel);
224
- expect(notifyGuardianCalls[0].senderExternalUserId).toBe(config.senderExternalUserId);
224
+ expect(notifyGuardianCalls[0].actorExternalId).toBe(config.senderExternalUserId);
225
225
  });
226
226
 
227
227
  test('verification creates active member for channel', () => {
@@ -281,6 +281,21 @@ export class CallController {
281
281
  this.guardianContext = ctx;
282
282
  }
283
283
 
284
+ /**
285
+ * Mark the next caller utterance as an opening acknowledgment so it
286
+ * receives the [CALL_OPENING_ACK] marker. Used after deterministic
287
+ * transitions (e.g. post-approval handoff) to ensure the next LLM
288
+ * turn continues naturally without reintroduction.
289
+ *
290
+ * Also resets the silence timer so the "Are you still there?" nudge
291
+ * fires at the correct interval after the deterministic handoff copy.
292
+ */
293
+ markNextCallerTurnAsOpeningAck(): void {
294
+ this.awaitingOpeningAck = true;
295
+ this.lastSentWasOpener = false;
296
+ this.resetSilenceTimer();
297
+ }
298
+
284
299
  /**
285
300
  * Kick off the first outbound call utterance from the assistant.
286
301
  */
@@ -11,6 +11,7 @@ import { randomInt } from 'node:crypto';
11
11
  import type { ServerWebSocket } from 'bun';
12
12
 
13
13
  import { getConfig } from '../config/loader.js';
14
+ import { getAssistantName } from '../daemon/identity-helpers.js';
14
15
  import { getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
15
16
  import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
16
17
  import * as conversationStore from '../memory/conversation-store.js';
@@ -1104,10 +1105,14 @@ export class RelayConnection {
1104
1105
  this.accessRequestFromNumber = fromNumber;
1105
1106
  this.connectionState = 'awaiting_name';
1106
1107
 
1107
- this.sendTextToken(
1108
- "Sorry, I don't recognize this number. I'll let my guardian know you called and see if I have permission to speak with you. Can I get your name?",
1109
- true,
1110
- );
1108
+ const guardianLabel = this.resolveGuardianLabel();
1109
+ const assistantName = this.resolveAssistantLabel();
1110
+
1111
+ const greeting = assistantName
1112
+ ? `Hi, this is ${assistantName}, ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`
1113
+ : `Hi, this is ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`;
1114
+
1115
+ this.sendTextToken(greeting, true);
1111
1116
 
1112
1117
  // Start a timeout so silent callers don't keep the call open indefinitely.
1113
1118
  // Uses a 30-second window — enough time to speak a name but short enough
@@ -1325,15 +1330,31 @@ export class RelayConnection {
1325
1330
  'Access request approved — caller activated and continuing call',
1326
1331
  );
1327
1332
 
1328
- // Use handleUserInstruction to deliver the approval-aware greeting
1329
- // through the normal session pipeline.
1333
+ // Deliver deterministic transition copy directly via TTS instead of
1334
+ // routing through handleUserInstruction, which would start a fresh
1335
+ // model turn and risk reintroduction/disclosure reset.
1330
1336
  const guardianLabel = this.resolveGuardianLabel();
1337
+ const handoffText = `Great! ${guardianLabel} said I can speak with you. How can I help?`;
1338
+ this.sendTextToken(handoffText, true);
1339
+
1340
+ // Record the deterministic handoff as an assistant_spoke event and
1341
+ // fire the transcript notifier so it appears in conversation history
1342
+ // and real-time transcript subscribers — matching the parity of text
1343
+ // spoken through the normal runTurn() pipeline.
1344
+ recordCallEvent(this.callSessionId, 'assistant_spoke', { text: handoffText });
1345
+ const session = getCallSession(this.callSessionId);
1346
+ if (session) {
1347
+ fireCallTranscriptNotifier(session.conversationId, this.callSessionId, 'assistant', handoffText);
1348
+ }
1349
+
1350
+ recordCallEvent(this.callSessionId, 'inbound_acl_post_approval_handoff_spoken', {
1351
+ from: fromNumber,
1352
+ });
1353
+
1354
+ // Mark the next caller utterance as an opening acknowledgment so the
1355
+ // LLM continues naturally without emitting a fresh introduction.
1331
1356
  if (this.controller) {
1332
- this.controller.handleUserInstruction(
1333
- `Great, ${guardianLabel} approved! Now how can I help you?`,
1334
- ).catch((err) => {
1335
- log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting');
1336
- });
1357
+ this.controller.markNextCallerTurnAsOpeningAck();
1337
1358
  }
1338
1359
  }
1339
1360
 
@@ -1672,6 +1693,19 @@ export class RelayConnection {
1672
1693
  return 'my guardian';
1673
1694
  }
1674
1695
 
1696
+ /**
1697
+ * Resolve the assistant's display name from identity configuration.
1698
+ * Returns the trimmed name or null if unavailable.
1699
+ */
1700
+ private resolveAssistantLabel(): string | null {
1701
+ try {
1702
+ const name = getAssistantName();
1703
+ return name?.trim() || null;
1704
+ } catch {
1705
+ return null;
1706
+ }
1707
+ }
1708
+
1675
1709
  /**
1676
1710
  * Generate a non-repetitive heartbeat message for the caller based
1677
1711
  * on the current sequence counter and guardian label.
@@ -1,5 +1,6 @@
1
1
  export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
2
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
+ | 'inbound_acl_post_approval_handoff_spoken'
3
4
  | 'callback_handoff_notified'
4
5
  | 'callback_handoff_failed';
5
6
  export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
@@ -21,32 +21,24 @@ import { slackProvider as slackWatcherProvider } from '../watcher/providers/slac
21
21
  const log = getLogger('lifecycle');
22
22
 
23
23
  export async function initializeProvidersAndTools(config: AssistantConfig): Promise<void> {
24
- console.log('[Daemon] Initializing providers and tools...');
25
24
  log.info('Daemon startup: initializing providers and tools');
26
25
  initializeProviders(config);
27
- console.log('[Daemon] Providers initialized');
28
26
  await initializeTools();
29
- console.log('[Daemon] Tools initialized');
30
27
 
31
28
  // Start MCP servers and register their tools
32
29
  if (config.mcp?.servers && Object.keys(config.mcp.servers).length > 0) {
33
- console.log('[MCP] Initializing MCP servers:', Object.keys(config.mcp.servers).join(', '));
34
30
  const manager = getMcpServerManager();
35
31
  try {
36
32
  const serverToolInfos = await manager.start(config.mcp);
37
33
  for (const { serverId, serverConfig, tools } of serverToolInfos) {
38
- console.log(`[MCP] Server "${serverId}" connected — discovered ${tools.length} tools:`, tools.map(t => t.name).join(', '));
39
34
  const mcpTools = createMcpToolsFromServer(tools, serverId, serverConfig, manager);
40
35
  registerMcpTools(mcpTools);
41
- console.log(`[MCP] Registered ${mcpTools.length} tools from "${serverId}":`, mcpTools.map(t => t.name).join(', '));
42
36
  }
43
37
  } catch (err) {
44
- console.error('[MCP] Server initialization failed:', err);
45
38
  log.error({ err }, 'MCP server initialization failed — continuing without MCP tools');
46
39
  }
47
40
  }
48
41
 
49
- console.log('[Daemon] Providers and tools initialization complete');
50
42
  log.info('Daemon startup: providers and tools initialized');
51
43
  }
52
44
 
@@ -1,4 +1,8 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import QRCode from 'qrcode';
2
6
 
3
7
  import { getGatewayPort, getIngressPublicBaseUrl } from '../config/env.js';
4
8
  import { getConfig, loadRawConfig, saveRawConfig } from '../config/loader.js';
@@ -11,13 +15,14 @@ import {
11
15
  rewriteKnownSlashCommandPrompt,
12
16
  } from '../skills/slash-commands.js';
13
17
  import { getLocalIPv4 } from '../util/network-info.js';
18
+ import { getWorkspaceDir } from '../util/platform.js';
14
19
  import { getAssistantName } from './identity-helpers.js';
15
20
  import type { PairingStore } from './pairing-store.js';
16
21
 
17
22
  export type SlashResolution =
18
23
  | { kind: 'passthrough'; content: string }
19
24
  | { kind: 'rewritten'; content: string; skillId: string }
20
- | { kind: 'unknown'; message: string };
25
+ | { kind: 'unknown'; message: string; qrFilename?: string };
21
26
 
22
27
  // ── /pair command — module-level pairing context ────────────────────
23
28
 
@@ -349,6 +354,27 @@ export function resolveSlash(content: string, context?: SlashContext): SlashReso
349
354
 
350
355
  // ── /pair command ────────────────────────────────────────────────────
351
356
 
357
+ function buildPairingQRCodeFilename(): string {
358
+ const now = new Date();
359
+ const ts = [
360
+ now.getFullYear(),
361
+ String(now.getMonth() + 1).padStart(2, '0'),
362
+ String(now.getDate()).padStart(2, '0'),
363
+ String(now.getHours()).padStart(2, '0'),
364
+ String(now.getMinutes()).padStart(2, '0'),
365
+ String(now.getSeconds()).padStart(2, '0'),
366
+ ].join('');
367
+ return `code${ts}.png`;
368
+ }
369
+
370
+ async function savePairingQRCodePng(payloadJson: string, filename: string): Promise<void> {
371
+ const qrDir = join(getWorkspaceDir(), 'pairing-qr');
372
+ mkdirSync(qrDir, { recursive: true });
373
+ const qrPngPath = join(qrDir, filename);
374
+ const pngBuffer = await QRCode.toBuffer(payloadJson, { type: 'png', width: 512 });
375
+ writeFileSync(qrPngPath, pngBuffer);
376
+ }
377
+
352
378
  function resolvePairCommand(content: string): SlashResolution | null {
353
379
  if (content.trim() !== '/pair') return null;
354
380
 
@@ -402,6 +428,12 @@ function resolvePairCommand(content: string): SlashResolution | null {
402
428
  payload.localLanUrl = localLanUrl;
403
429
  }
404
430
 
431
+ // Save QR code as PNG to the workspace pairing-qr folder (fire-and-forget
432
+ // so the synchronous slash resolution is not blocked).
433
+ const payloadJson = JSON.stringify(payload);
434
+ const qrFilename = buildPairingQRCodeFilename();
435
+ savePairingQRCodePng(payloadJson, qrFilename).catch(() => {});
436
+
405
437
  const lines = [
406
438
  'Pairing Ready\n',
407
439
  'Scan the QR code below with the Vellum iOS app, or use the pairing payload to connect manually.\n',
@@ -414,10 +446,11 @@ function resolvePairCommand(content: string): SlashResolution | null {
414
446
  lines.push(`LAN URL: ${localLanUrl}`);
415
447
  }
416
448
  lines.push(
449
+ `\nQR code saved to pairing-qr/${qrFilename}`,
417
450
  '\nThis pairing request expires in 5 minutes. Run `/pair` again to generate a new one.',
418
451
  );
419
452
 
420
- return { kind: 'unknown', message: lines.join('\n') };
453
+ return { kind: 'unknown', message: lines.join('\n'), qrFilename };
421
454
  }
422
455
 
423
456
  // ── Provider Ordering Error Detection ────────────────────────────────
@@ -1,6 +1,7 @@
1
1
  import { getDb } from './db-connection.js';
2
2
  import {
3
3
  addCoreColumns,
4
+ createActorRefreshTokenRecordsTable,
4
5
  createActorTokenRecordsTable,
5
6
  createAssistantInboxTables,
6
7
  createCallSessionsTables,
@@ -175,6 +176,9 @@ export function initializeDb(): void {
175
176
  // 28. Actor token records (hash-only actor token persistence)
176
177
  createActorTokenRecordsTable(database);
177
178
 
179
+ // 28b. Actor refresh token records (rotating refresh tokens with family tracking)
180
+ createActorRefreshTokenRecordsTable(database);
181
+
178
182
  // 29. Guardian principal ID columns on channel_guardian_bindings and canonical_guardian_requests
179
183
  migrateGuardianPrincipalIdColumns(database);
180
184
 
@@ -0,0 +1,51 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Create the actor_refresh_token_records table for hash-only refresh token persistence.
5
+ *
6
+ * Stores the SHA-256 hash of each refresh token with family tracking,
7
+ * device binding, and dual expiry (absolute + inactivity).
8
+ * The raw token plaintext is never stored.
9
+ */
10
+ export function createActorRefreshTokenRecordsTable(database: DrizzleDb): void {
11
+ database.run(/*sql*/ `
12
+ CREATE TABLE IF NOT EXISTS actor_refresh_token_records (
13
+ id TEXT PRIMARY KEY,
14
+ token_hash TEXT NOT NULL,
15
+ family_id TEXT NOT NULL,
16
+ assistant_id TEXT NOT NULL,
17
+ guardian_principal_id TEXT NOT NULL,
18
+ hashed_device_id TEXT NOT NULL,
19
+ platform TEXT NOT NULL,
20
+ status TEXT NOT NULL DEFAULT 'active',
21
+ issued_at INTEGER NOT NULL,
22
+ absolute_expires_at INTEGER NOT NULL,
23
+ inactivity_expires_at INTEGER NOT NULL,
24
+ last_used_at INTEGER,
25
+ created_at INTEGER NOT NULL,
26
+ updated_at INTEGER NOT NULL
27
+ )
28
+ `);
29
+
30
+ // Token hash lookup (any status — needed for replay detection)
31
+ database.run(
32
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
33
+ ON actor_refresh_token_records(token_hash)`,
34
+ );
35
+
36
+ // Unique active refresh token per device binding.
37
+ // DROP first so that databases that already created the older non-unique
38
+ // index with the same name get upgraded to UNIQUE.
39
+ database.run(/*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`);
40
+ database.run(
41
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
42
+ ON actor_refresh_token_records(assistant_id, guardian_principal_id, hashed_device_id)
43
+ WHERE status = 'active'`,
44
+ );
45
+
46
+ // Family lookup for replay detection (revoke entire family)
47
+ database.run(
48
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family
49
+ ON actor_refresh_token_records(family_id)`,
50
+ );
51
+ }
@@ -40,6 +40,7 @@ export { migrateGuardianActionSupersession } from './035-guardian-action-superse
40
40
  export { migrateNormalizePhoneIdentities } from './036-normalize-phone-identities.js';
41
41
  export { migrateVoiceInviteColumns } from './037-voice-invite-columns.js';
42
42
  export { createActorTokenRecordsTable } from './038-actor-token-records.js';
43
+ export { createActorRefreshTokenRecordsTable } from './039-actor-refresh-token-records.js';
43
44
  export { createCoreTables } from './100-core-tables.js';
44
45
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
45
46
  export { addCoreColumns } from './102-alter-table-columns.js';
@@ -96,7 +96,7 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
96
96
  description: 'Normalize phone-like identity fields to E.164 format across guardian bindings, verification challenges, canonical requests, ingress members, and rate limits',
97
97
  },
98
98
  {
99
- key: 'migration_backfill_guardian_principal_id_v2',
99
+ key: 'migration_backfill_guardian_principal_id_v3',
100
100
  version: 15,
101
101
  description: 'Backfill guardianPrincipalId for existing channel_guardian_bindings and canonical_guardian_requests rows, expire unresolvable pending requests',
102
102
  },