@vellumai/assistant 0.4.50 → 0.4.51

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 (153) hide show
  1. package/docs/architecture/integrations.md +2 -2
  2. package/docs/architecture/keychain-broker.md +6 -6
  3. package/knip.json +32 -0
  4. package/package.json +3 -2
  5. package/src/__tests__/btw-routes.test.ts +61 -5
  6. package/src/__tests__/config-watcher.test.ts +8 -0
  7. package/src/__tests__/credential-security-invariants.test.ts +8 -7
  8. package/src/__tests__/credential-vault-unit.test.ts +19 -18
  9. package/src/__tests__/credential-vault.test.ts +17 -17
  10. package/src/__tests__/credentials-cli.test.ts +257 -82
  11. package/src/__tests__/inbound-invite-redemption.test.ts +36 -7
  12. package/src/__tests__/integration-status.test.ts +31 -30
  13. package/src/__tests__/invite-redemption-service.test.ts +121 -32
  14. package/src/__tests__/invite-routes-http.test.ts +166 -5
  15. package/src/__tests__/list-messages-attachments.test.ts +193 -0
  16. package/src/__tests__/oauth-cli.test.ts +286 -60
  17. package/src/__tests__/oauth-provider-profiles.test.ts +1 -1
  18. package/src/__tests__/oauth-store.test.ts +243 -11
  19. package/src/__tests__/relay-server.test.ts +9 -0
  20. package/src/__tests__/secret-routes-managed-proxy.test.ts +183 -0
  21. package/src/__tests__/secure-keys.test.ts +71 -16
  22. package/src/__tests__/server-history-render.test.ts +2 -2
  23. package/src/__tests__/skills.test.ts +2 -2
  24. package/src/__tests__/slack-channel-config.test.ts +10 -8
  25. package/src/__tests__/twilio-config.test.ts +11 -10
  26. package/src/__tests__/twilio-provider.test.ts +9 -4
  27. package/src/__tests__/voice-invite-redemption.test.ts +58 -9
  28. package/src/calls/call-domain.ts +3 -4
  29. package/src/calls/relay-server.ts +1 -1
  30. package/src/calls/twilio-config.ts +4 -3
  31. package/src/calls/twilio-provider.ts +14 -9
  32. package/src/calls/twilio-rest.ts +10 -7
  33. package/src/cli/commands/config.ts +14 -9
  34. package/src/cli/commands/contacts.ts +3 -0
  35. package/src/cli/commands/credentials.ts +170 -174
  36. package/src/cli/commands/doctor.ts +7 -5
  37. package/src/cli/commands/keys.ts +9 -9
  38. package/src/cli/commands/oauth/apps.ts +40 -11
  39. package/src/cli/commands/oauth/connections.ts +66 -30
  40. package/src/cli/commands/oauth/index.ts +3 -3
  41. package/src/cli/commands/oauth/providers.ts +3 -3
  42. package/src/cli.ts +16 -12
  43. package/src/config/__tests__/feature-flag-registry-bundled.test.ts +39 -0
  44. package/src/config/bundled-skills/contacts/SKILL.md +35 -11
  45. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
  46. package/src/config/bundled-skills/gmail/SKILL.md +1 -1
  47. package/src/config/bundled-skills/gmail/TOOLS.json +52 -0
  48. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +13 -3
  49. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +9 -2
  50. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +5 -1
  51. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +5 -1
  52. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +5 -1
  53. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +5 -1
  54. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +9 -2
  55. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +5 -1
  56. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +5 -1
  57. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +5 -1
  58. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +5 -1
  59. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +5 -1
  60. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +5 -1
  61. package/src/config/bundled-skills/google-calendar/TOOLS.json +20 -0
  62. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +2 -1
  63. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +2 -1
  64. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +2 -1
  65. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +2 -1
  66. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +2 -1
  67. package/src/config/bundled-skills/google-calendar/tools/shared.ts +8 -2
  68. package/src/config/bundled-skills/messaging/SKILL.md +1 -1
  69. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  70. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
  71. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +2 -2
  72. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +2 -2
  73. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +2 -2
  74. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +2 -2
  75. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +2 -2
  76. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +2 -2
  77. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +2 -2
  78. package/src/config/bundled-skills/messaging/tools/shared.ts +7 -5
  79. package/src/config/bundled-skills/slack/tools/shared.ts +1 -1
  80. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  81. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  82. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  83. package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +1 -1
  84. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  85. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +1 -1
  86. package/src/config/loader.ts +6 -42
  87. package/src/contacts/contact-store.ts +39 -2
  88. package/src/contacts/contacts-write.ts +9 -0
  89. package/src/daemon/config-watcher.ts +8 -13
  90. package/src/daemon/handlers/config-ingress.ts +2 -2
  91. package/src/daemon/handlers/config-slack-channel.ts +59 -39
  92. package/src/daemon/handlers/config-telegram.ts +23 -14
  93. package/src/daemon/handlers/session-history.ts +1 -358
  94. package/src/daemon/handlers/shared.ts +3 -17
  95. package/src/daemon/lifecycle.ts +8 -1
  96. package/src/daemon/message-types/sessions.ts +0 -42
  97. package/src/daemon/server.ts +0 -6
  98. package/src/daemon/session-slash.ts +3 -5
  99. package/src/email/providers/index.ts +2 -2
  100. package/src/media/avatar-router.ts +1 -1
  101. package/src/memory/conversation-queries.ts +3 -80
  102. package/src/memory/db-init.ts +4 -0
  103. package/src/memory/invite-store.ts +19 -0
  104. package/src/memory/migrations/149-oauth-tables.ts +1 -1
  105. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +1 -1
  106. package/src/memory/migrations/157-invite-contact-id.ts +104 -0
  107. package/src/memory/migrations/index.ts +1 -0
  108. package/src/memory/migrations/registry.ts +6 -0
  109. package/src/memory/schema/contacts.ts +1 -0
  110. package/src/messaging/provider.ts +1 -1
  111. package/src/messaging/providers/gmail/adapter.ts +1 -1
  112. package/src/messaging/providers/telegram-bot/adapter.ts +17 -8
  113. package/src/messaging/providers/whatsapp/adapter.ts +13 -9
  114. package/src/messaging/registry.ts +9 -5
  115. package/src/oauth/byo-connection.test.ts +32 -24
  116. package/src/oauth/connect-orchestrator.ts +4 -10
  117. package/src/oauth/connection-resolver.ts +20 -6
  118. package/src/oauth/manual-token-connection.ts +5 -5
  119. package/src/oauth/oauth-store.ts +83 -17
  120. package/src/oauth/platform-connection.test.ts +1 -1
  121. package/src/oauth/provider-behaviors.ts +503 -4
  122. package/src/oauth/seed-providers.ts +208 -8
  123. package/src/oauth/token-persistence.ts +20 -13
  124. package/src/runtime/channel-readiness-service.ts +48 -40
  125. package/src/runtime/http-types.ts +2 -0
  126. package/src/runtime/invite-redemption-service.ts +71 -29
  127. package/src/runtime/invite-service.ts +40 -22
  128. package/src/runtime/middleware/twilio-validation.ts +1 -1
  129. package/src/runtime/routes/btw-routes.ts +10 -5
  130. package/src/runtime/routes/conversation-routes.ts +47 -10
  131. package/src/runtime/routes/integrations/slack/channel.ts +2 -2
  132. package/src/runtime/routes/integrations/telegram.ts +2 -2
  133. package/src/runtime/routes/integrations/twilio.ts +17 -17
  134. package/src/runtime/routes/invite-routes.ts +29 -4
  135. package/src/runtime/routes/secret-routes.ts +17 -0
  136. package/src/runtime/routes/settings-routes.ts +3 -3
  137. package/src/runtime/routes/workspace-routes.ts +7 -3
  138. package/src/runtime/routes/workspace-utils.ts +8 -2
  139. package/src/schedule/integration-status.ts +26 -19
  140. package/src/security/oauth2.ts +6 -7
  141. package/src/security/secure-keys.ts +19 -16
  142. package/src/security/token-manager.ts +13 -6
  143. package/src/services/vercel-deploy.ts +0 -24
  144. package/src/signals/confirm.ts +78 -0
  145. package/src/signals/mcp-reload.ts +18 -0
  146. package/src/tools/credentials/vault.ts +22 -5
  147. package/src/tools/network/script-proxy/session-manager.ts +8 -8
  148. package/src/tools/schedule/create.ts +2 -2
  149. package/src/watcher/provider-types.ts +1 -1
  150. package/src/watcher/providers/github.ts +1 -1
  151. package/src/watcher/providers/gmail.ts +3 -3
  152. package/src/watcher/providers/google-calendar.ts +3 -3
  153. package/src/watcher/providers/linear.ts +1 -1
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ChannelId } from "../channels/types.js";
11
- import { findContactChannel } from "../contacts/contact-store.js";
11
+ import { findContactChannel, getContact } from "../contacts/contact-store.js";
12
12
  import { upsertContactChannel } from "../contacts/contacts-write.js";
13
13
  import { getSqlite } from "../memory/db.js";
14
14
  import {
@@ -135,7 +135,17 @@ export function redeemInvite(params: {
135
135
  const existingChannel = contactResult?.channel ?? null;
136
136
  const existingContact = contactResult?.contact ?? null;
137
137
 
138
- if (existingChannel && existingChannel.status === "active") {
138
+ // If the invite targets a specific contact and the sender's existing channel
139
+ // belongs to a different contact, ignore the existing match — the invite
140
+ // should bind the sender's identity to the target contact, not the existing one.
141
+ const targetMismatch =
142
+ existingContact && existingContact.id !== invite.contactId;
143
+
144
+ if (
145
+ existingChannel &&
146
+ existingChannel.status === "active" &&
147
+ !targetMismatch
148
+ ) {
139
149
  return { ok: true, type: "already_member", memberId: existingChannel.id };
140
150
  }
141
151
 
@@ -146,17 +156,11 @@ export function redeemInvite(params: {
146
156
  return { ok: false, reason: "invalid_token" };
147
157
  }
148
158
 
149
- // Guardian channels must not be reactivated via regular invite redemption —
150
- // their lifecycle is managed exclusively through the guardian binding flow.
151
- if (existingContact && existingContact.role === "guardian") {
152
- return { ok: false, reason: "invalid_token" };
153
- }
154
-
155
159
  // Inactive member reactivation: when the user already has a member record
156
160
  // in a non-active state (revoked/pending), reactivate it via upsertContactChannel
157
161
  // and consume an invite use atomically. The fresh-member path below also
158
162
  // uses upsertContactChannel to keep contacts in sync.
159
- if (existingChannel) {
163
+ if (existingChannel && !targetMismatch) {
160
164
  // Sentinel error used to trigger a transaction rollback when the invite
161
165
  // was concurrently revoked/expired between pre-validation and write time.
162
166
  const STALE_INVITE = Symbol("stale_invite");
@@ -195,6 +199,7 @@ export function redeemInvite(params: {
195
199
  inviteId: invite.id,
196
200
  verifiedAt: Date.now(),
197
201
  verifiedVia: "invite",
202
+ contactId: invite.contactId,
198
203
  });
199
204
 
200
205
  const recorded = recordInviteUse({
@@ -226,6 +231,16 @@ export function redeemInvite(params: {
226
231
 
227
232
  // Fresh member creation: upsert into contacts tables and consume an invite
228
233
  // use atomically, mirroring the reactivation path above.
234
+ // When the invite targets a specific contact (targetMismatch path), preserve
235
+ // the target contact's guardian-assigned display name if it has one.
236
+ let freshDisplayName = displayName;
237
+ if (invite.contactId) {
238
+ const targetContact = getContact(invite.contactId);
239
+ if (targetContact?.displayName?.trim().length) {
240
+ freshDisplayName = targetContact.displayName;
241
+ }
242
+ }
243
+
229
244
  const STALE_INVITE_FRESH = Symbol("stale_invite_fresh");
230
245
  let freshResult: ReturnType<typeof upsertContactChannel> | undefined;
231
246
  try {
@@ -235,13 +250,14 @@ export function redeemInvite(params: {
235
250
  sourceChannel,
236
251
  externalUserId,
237
252
  externalChatId,
238
- displayName,
253
+ displayName: freshDisplayName,
239
254
  username,
240
255
  status: "active",
241
256
  policy: "allow",
242
257
  inviteId: invite.id,
243
258
  verifiedAt: Date.now(),
244
259
  verifiedVia: "invite",
260
+ contactId: invite.contactId,
245
261
  });
246
262
 
247
263
  const recorded = recordInviteUse({
@@ -346,7 +362,16 @@ export function redeemVoiceInviteCode(params: {
346
362
  const existingVoiceChannel = voiceContactResult?.channel ?? null;
347
363
  const voiceContact = voiceContactResult?.contact ?? null;
348
364
 
349
- if (existingVoiceChannel && existingVoiceChannel.status === "active") {
365
+ // If the invite targets a specific contact and the sender's existing channel
366
+ // belongs to a different contact, ignore the existing match — the invite
367
+ // should bind the sender's identity to the target contact, not the existing one.
368
+ const targetMismatch = voiceContact && voiceContact.id !== invite.contactId;
369
+
370
+ if (
371
+ existingVoiceChannel &&
372
+ existingVoiceChannel.status === "active" &&
373
+ !targetMismatch
374
+ ) {
350
375
  return {
351
376
  ok: true,
352
377
  type: "already_member",
@@ -359,21 +384,21 @@ export function redeemVoiceInviteCode(params: {
359
384
  return { ok: false, reason: "invalid_or_expired" };
360
385
  }
361
386
 
362
- // Guardian channels must not be reactivated via regular invite redemption —
363
- // their lifecycle is managed exclusively through the guardian binding flow.
364
- if (voiceContact && voiceContact.role === "guardian") {
365
- return { ok: false, reason: "invalid_or_expired" };
366
- }
367
-
368
387
  // Atomic redemption: upsert member + consume invite use in a transaction
369
388
  const STALE_INVITE = Symbol("stale_invite");
370
389
  let memberId: string | undefined;
371
390
 
372
- // Reactivation should not overwrite a guardian-managed nickname (same
373
- // protection as the token-based redemption path above).
374
- const preservedDisplayName = voiceContact?.displayName?.trim().length
391
+ // When the invite targets a specific contact (targetMismatch path), preserve
392
+ // the target contact's guardian-assigned display name if it has one.
393
+ let preservedDisplayName = voiceContact?.displayName?.trim().length
375
394
  ? voiceContact.displayName
376
395
  : (invite.friendName ?? undefined);
396
+ if (targetMismatch && invite.contactId) {
397
+ const targetContact = getContact(invite.contactId);
398
+ if (targetContact?.displayName?.trim().length) {
399
+ preservedDisplayName = targetContact.displayName;
400
+ }
401
+ }
377
402
 
378
403
  try {
379
404
  getSqlite()
@@ -388,6 +413,7 @@ export function redeemVoiceInviteCode(params: {
388
413
  inviteId: invite.id,
389
414
  verifiedAt: Date.now(),
390
415
  verifiedVia: "invite",
416
+ contactId: invite.contactId,
391
417
  });
392
418
  memberId = writeResult!.channel.id;
393
419
 
@@ -488,7 +514,17 @@ export function redeemInviteByCode(params: {
488
514
  const existingChannel = contactResult?.channel ?? null;
489
515
  const existingContact = contactResult?.contact ?? null;
490
516
 
491
- if (existingChannel && existingChannel.status === "active") {
517
+ // If the invite targets a specific contact and the sender's existing channel
518
+ // belongs to a different contact, ignore the existing match — the invite
519
+ // should bind the sender's identity to the target contact, not the existing one.
520
+ const targetMismatch =
521
+ existingContact && existingContact.id !== invite.contactId;
522
+
523
+ if (
524
+ existingChannel &&
525
+ existingChannel.status === "active" &&
526
+ !targetMismatch
527
+ ) {
492
528
  return { ok: true, type: "already_member", memberId: existingChannel.id };
493
529
  }
494
530
 
@@ -499,15 +535,9 @@ export function redeemInviteByCode(params: {
499
535
  return { ok: false, reason: "invalid_token" };
500
536
  }
501
537
 
502
- // Guardian channels must not be reactivated via regular invite redemption —
503
- // their lifecycle is managed exclusively through the guardian binding flow.
504
- if (existingContact && existingContact.role === "guardian") {
505
- return { ok: false, reason: "invalid_token" };
506
- }
507
-
508
538
  // Inactive member reactivation: reactivate via upsertContactChannel and consume
509
539
  // an invite use atomically.
510
- if (existingChannel) {
540
+ if (existingChannel && !targetMismatch) {
511
541
  const STALE_INVITE_REACTIVATE = Symbol("stale_invite_reactivate");
512
542
  const canonicalMemberId = existingChannel.externalUserId
513
543
  ? canonicalizeInboundIdentity(
@@ -543,6 +573,7 @@ export function redeemInviteByCode(params: {
543
573
  inviteId: invite.id,
544
574
  verifiedAt: Date.now(),
545
575
  verifiedVia: "invite",
576
+ contactId: invite.contactId,
546
577
  });
547
578
 
548
579
  const recorded = recordInviteUse({
@@ -571,6 +602,16 @@ export function redeemInviteByCode(params: {
571
602
 
572
603
  // Fresh member creation: upsert into contacts tables and consume an invite
573
604
  // use atomically.
605
+ // When the invite targets a specific contact (targetMismatch path), preserve
606
+ // the target contact's guardian-assigned display name if it has one.
607
+ let freshDisplayName = displayName;
608
+ if (invite.contactId) {
609
+ const targetContact = getContact(invite.contactId);
610
+ if (targetContact?.displayName?.trim().length) {
611
+ freshDisplayName = targetContact.displayName;
612
+ }
613
+ }
614
+
574
615
  const STALE_INVITE_FRESH = Symbol("stale_invite_fresh");
575
616
  let freshResult: ReturnType<typeof upsertContactChannel> | undefined;
576
617
  try {
@@ -580,13 +621,14 @@ export function redeemInviteByCode(params: {
580
621
  sourceChannel,
581
622
  externalUserId,
582
623
  externalChatId,
583
- displayName,
624
+ displayName: freshDisplayName,
584
625
  username,
585
626
  status: "active",
586
627
  policy: "allow",
587
628
  inviteId: invite.id,
588
629
  verifiedAt: Date.now(),
589
630
  verifiedVia: "invite",
631
+ contactId: invite.contactId,
590
632
  });
591
633
 
592
634
  const recorded = recordInviteUse({
@@ -12,11 +12,13 @@ import { startInviteCall } from "../calls/call-domain.js";
12
12
  import { isChannelId } from "../channels/types.js";
13
13
  import {
14
14
  createInvite,
15
+ findById,
15
16
  findByTokenHash,
16
17
  hashToken,
17
18
  type IngressInvite,
18
19
  type InviteStatus,
19
20
  listInvites,
21
+ markInviteExpired,
20
22
  revokeInvite,
21
23
  } from "../memory/invite-store.js";
22
24
  import {
@@ -24,7 +26,6 @@ import {
24
26
  DEFAULT_USER_REFERENCE,
25
27
  resolveGuardianName,
26
28
  } from "../prompts/user-reference.js";
27
- import { getLogger } from "../util/logger.js";
28
29
  import { isValidE164 } from "../util/phone.js";
29
30
  import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js";
30
31
  import {
@@ -39,8 +40,6 @@ import {
39
40
  type VoiceRedemptionOutcome,
40
41
  } from "./invite-redemption-service.js";
41
42
 
42
- const log = getLogger("invite-service");
43
-
44
43
  // ---------------------------------------------------------------------------
45
44
  // Response shapes — used by both HTTP routes and message handlers
46
45
  // ---------------------------------------------------------------------------
@@ -160,11 +159,16 @@ export async function createIngressInvite(params: {
160
159
  voiceCodeDigits?: number;
161
160
  friendName?: string;
162
161
  guardianName?: string;
162
+ contactId: string;
163
163
  }): Promise<IngressResult<InviteResponseData>> {
164
164
  if (!params.sourceChannel) {
165
165
  return { ok: false, error: "sourceChannel is required for create" };
166
166
  }
167
167
 
168
+ if (!params.contactId) {
169
+ return { ok: false, error: "contactId is required for create" };
170
+ }
171
+
168
172
  // For voice invites: generate a one-time numeric code, hash it, and pass
169
173
  // the hash to the store. The plaintext code is included in the response
170
174
  // exactly once and never stored.
@@ -214,6 +218,7 @@ export async function createIngressInvite(params: {
214
218
 
215
219
  const { invite, rawToken } = createInvite({
216
220
  sourceChannel: params.sourceChannel,
221
+ contactId: params.contactId,
217
222
  note: params.note,
218
223
  maxUses: params.maxUses,
219
224
  expiresInMs: params.expiresInMs,
@@ -254,25 +259,8 @@ export async function createIngressInvite(params: {
254
259
  });
255
260
  }
256
261
 
257
- // For voice invites with a known phone number, initiate an outbound call
258
- // so the contact is prompted to enter their code immediately.
259
- if (
260
- params.sourceChannel === "phone" &&
261
- params.expectedExternalUserId &&
262
- params.friendName &&
263
- effectiveGuardianName
264
- ) {
265
- // Fire-and-forget: don't block invite creation on call initiation
266
- startInviteCall({
267
- phoneNumber: params.expectedExternalUserId,
268
- friendName: params.friendName,
269
- guardianName: effectiveGuardianName,
270
- }).catch((err) => {
271
- log.warn(
272
- { err, inviteId: invite.id },
273
- "Failed to initiate outbound invite call",
274
- );
275
- });
262
+ if (isVoice && params.friendName) {
263
+ guardianInstruction = `${params.friendName} will need this code when they answer. Share it with them first.`;
276
264
  }
277
265
 
278
266
  // Voice invites must not expose the token — callers must redeem via the
@@ -316,6 +304,36 @@ export function revokeIngressInvite(
316
304
  return { ok: true, data: inviteToResponse(revoked) };
317
305
  }
318
306
 
307
+ export async function triggerInviteCall(
308
+ inviteId: string,
309
+ ): Promise<IngressResult<{ callSid: string }>> {
310
+ if (!inviteId) return { ok: false, error: "inviteId is required" };
311
+ const invite = findById(inviteId);
312
+ if (!invite) return { ok: false, error: "Invite not found" };
313
+ if (invite.status !== "active")
314
+ return { ok: false, error: "Invite is not active" };
315
+ if (invite.expiresAt && invite.expiresAt <= Date.now()) {
316
+ markInviteExpired(invite.id);
317
+ return { ok: false, error: "Invite has expired" };
318
+ }
319
+ if (invite.sourceChannel !== "phone")
320
+ return { ok: false, error: "Only phone invites support call triggering" };
321
+ if (
322
+ !invite.expectedExternalUserId ||
323
+ !invite.friendName ||
324
+ !invite.guardianName
325
+ ) {
326
+ return { ok: false, error: "Invite is missing required voice metadata" };
327
+ }
328
+ const result = await startInviteCall({
329
+ phoneNumber: invite.expectedExternalUserId,
330
+ friendName: invite.friendName,
331
+ guardianName: invite.guardianName,
332
+ });
333
+ if (!result.ok) return { ok: false, error: result.error };
334
+ return { ok: true, data: { callSid: result.callSid } };
335
+ }
336
+
319
337
  export function redeemIngressInvite(params: {
320
338
  token?: string;
321
339
  externalUserId?: string;
@@ -57,7 +57,7 @@ export async function validateTwilioWebhook(
57
57
  ): Promise<{ body: string } | Response> {
58
58
  const rawBody = await req.text();
59
59
 
60
- const authToken = TwilioConversationRelayProvider.getAuthToken();
60
+ const authToken = await TwilioConversationRelayProvider.getAuthToken();
61
61
 
62
62
  if (!authToken) {
63
63
  log.error(
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { buildToolDefinitions } from "../../daemon/session-tool-setup.js";
12
- import { getOrCreateConversation } from "../../memory/conversation-key-store.js";
12
+ import { getConversationByKey } from "../../memory/conversation-key-store.js";
13
13
  import {
14
14
  createTimeout,
15
15
  userMessage,
@@ -53,10 +53,15 @@ async function handleBtw(
53
53
  );
54
54
  }
55
55
 
56
- const mapping = getOrCreateConversation(conversationKey);
57
- const session = await deps.sendMessageDeps.getOrCreateSession(
58
- mapping.conversationId,
59
- );
56
+ // Look up an existing conversation — never create one. BTW is ephemeral
57
+ // (the file header promises "No messages are persisted"), so we must not
58
+ // call getOrCreateConversation which would insert a DB row. When no
59
+ // conversation exists (e.g. greeting generation for a draft thread), we
60
+ // still get a usable session via getOrCreateSession with the raw key; the
61
+ // session lives only in memory and disappears on restart.
62
+ const mapping = getConversationByKey(conversationKey);
63
+ const sessionId = mapping?.conversationId ?? conversationKey;
64
+ const session = await deps.sendMessageDeps.getOrCreateSession(sessionId);
60
65
 
61
66
  const messages = [...session.getMessages(), userMessage(content.trim())];
62
67
  const tools = buildToolDefinitions();
@@ -315,16 +315,53 @@ export function handleListMessages(
315
315
  let prevAssistantTimestamp = 0;
316
316
  const messages: RuntimeMessagePayload[] = parsed.map((m) => {
317
317
  let msgAttachments: RuntimeAttachmentMetadata[] = [];
318
- if (m.role === "assistant" && m.id) {
319
- const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id);
320
- if (linked.length > 0) {
321
- msgAttachments = linked.map((a) => ({
322
- id: a.id,
323
- filename: a.originalFilename,
324
- mimeType: a.mimeType,
325
- sizeBytes: a.sizeBytes,
326
- kind: a.kind,
327
- }));
318
+ if (m.id) {
319
+ if (m.role === "user") {
320
+ // Use metadata-only query first to avoid loading large base64
321
+ // blobs for non-image attachments (documents, audio). Then
322
+ // selectively fetch full data only for images so the client can
323
+ // generate thumbnails for inline display on history restore.
324
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id);
325
+ if (linked.length > 0) {
326
+ msgAttachments = linked.map((a) => {
327
+ if (a.mimeType.startsWith("image/")) {
328
+ const full = attachmentsStore.getAttachmentById(a.id);
329
+ return {
330
+ id: a.id,
331
+ filename: a.originalFilename,
332
+ mimeType: a.mimeType,
333
+ sizeBytes: a.sizeBytes,
334
+ kind: a.kind,
335
+ ...(full?.dataBase64 ? { data: full.dataBase64 } : {}),
336
+ ...(a.thumbnailBase64
337
+ ? { thumbnailData: a.thumbnailBase64 }
338
+ : {}),
339
+ };
340
+ }
341
+ return {
342
+ id: a.id,
343
+ filename: a.originalFilename,
344
+ mimeType: a.mimeType,
345
+ sizeBytes: a.sizeBytes,
346
+ kind: a.kind,
347
+ ...(a.thumbnailBase64
348
+ ? { thumbnailData: a.thumbnailBase64 }
349
+ : {}),
350
+ };
351
+ });
352
+ }
353
+ } else {
354
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id);
355
+ if (linked.length > 0) {
356
+ msgAttachments = linked.map((a) => ({
357
+ id: a.id,
358
+ filename: a.originalFilename,
359
+ mimeType: a.mimeType,
360
+ sizeBytes: a.sizeBytes,
361
+ kind: a.kind,
362
+ ...(a.thumbnailBase64 ? { thumbnailData: a.thumbnailBase64 } : {}),
363
+ }));
364
+ }
328
365
  }
329
366
  }
330
367
 
@@ -20,8 +20,8 @@ import type { RouteDefinition } from "../../../http-router.js";
20
20
  /**
21
21
  * GET /v1/integrations/slack/channel/config
22
22
  */
23
- export function handleGetSlackChannelConfig(): Response {
24
- const result = getSlackChannelConfig();
23
+ export async function handleGetSlackChannelConfig(): Promise<Response> {
24
+ const result = await getSlackChannelConfig();
25
25
  return Response.json(result);
26
26
  }
27
27
 
@@ -20,8 +20,8 @@ import type { RouteDefinition } from "../../http-router.js";
20
20
  /**
21
21
  * GET /v1/integrations/telegram/config
22
22
  */
23
- export function handleGetTelegramConfig(): Response {
24
- const result = getTelegramConfig();
23
+ export async function handleGetTelegramConfig(): Promise<Response> {
24
+ const result = await getTelegramConfig();
25
25
  return Response.json(result);
26
26
  }
27
27
 
@@ -66,10 +66,10 @@ function pruneAssistantPhoneNumbers(
66
66
  /**
67
67
  * GET /v1/integrations/twilio/config
68
68
  */
69
- export function handleGetTwilioConfig(): Response {
70
- const hasCredentials = hasTwilioCredentials();
69
+ export async function handleGetTwilioConfig(): Promise<Response> {
70
+ const hasCredentials = await hasTwilioCredentials();
71
71
  const accountSid = hasCredentials
72
- ? getTwilioCredentials().accountSid
72
+ ? (await getTwilioCredentials()).accountSid
73
73
  : undefined;
74
74
  const raw = loadRawConfig();
75
75
  const twilio = (raw?.twilio ?? {}) as Record<string, unknown>;
@@ -100,7 +100,7 @@ export async function handleSetTwilioCredentials(
100
100
  return Response.json(
101
101
  {
102
102
  success: false,
103
- hasCredentials: hasTwilioCredentials(),
103
+ hasCredentials: await hasTwilioCredentials(),
104
104
  error: "accountSid and authToken are required",
105
105
  },
106
106
  { status: 400 },
@@ -120,7 +120,7 @@ export async function handleSetTwilioCredentials(
120
120
  const errBody = await res.text();
121
121
  return Response.json({
122
122
  success: false,
123
- hasCredentials: hasTwilioCredentials(),
123
+ hasCredentials: await hasTwilioCredentials(),
124
124
  error: `Twilio API validation failed (${res.status}): ${errBody}`,
125
125
  });
126
126
  }
@@ -128,7 +128,7 @@ export async function handleSetTwilioCredentials(
128
128
  const message = err instanceof Error ? err.message : String(err);
129
129
  return Response.json({
130
130
  success: false,
131
- hasCredentials: hasTwilioCredentials(),
131
+ hasCredentials: await hasTwilioCredentials(),
132
132
  error: `Failed to validate Twilio credentials: ${message}`,
133
133
  });
134
134
  }
@@ -231,7 +231,7 @@ export async function handleClearTwilioCredentials(): Promise<Response> {
231
231
  * GET /v1/integrations/twilio/numbers
232
232
  */
233
233
  export async function handleListTwilioNumbers(): Promise<Response> {
234
- if (!hasTwilioCredentials()) {
234
+ if (!(await hasTwilioCredentials())) {
235
235
  return Response.json({
236
236
  success: false,
237
237
  hasCredentials: false,
@@ -239,7 +239,7 @@ export async function handleListTwilioNumbers(): Promise<Response> {
239
239
  });
240
240
  }
241
241
 
242
- const { accountSid, authToken } = getTwilioCredentials();
242
+ const { accountSid, authToken } = await getTwilioCredentials();
243
243
  const numbers = await listIncomingPhoneNumbers(accountSid, authToken);
244
244
 
245
245
  return Response.json({ success: true, hasCredentials: true, numbers });
@@ -253,7 +253,7 @@ export async function handleListTwilioNumbers(): Promise<Response> {
253
253
  export async function handleProvisionTwilioNumber(
254
254
  req: Request,
255
255
  ): Promise<Response> {
256
- if (!hasTwilioCredentials()) {
256
+ if (!(await hasTwilioCredentials())) {
257
257
  return Response.json({
258
258
  success: false,
259
259
  hasCredentials: false,
@@ -265,7 +265,7 @@ export async function handleProvisionTwilioNumber(
265
265
  country?: string;
266
266
  areaCode?: string;
267
267
  };
268
- const { accountSid, authToken } = getTwilioCredentials();
268
+ const { accountSid, authToken } = await getTwilioCredentials();
269
269
  const country = body.country ?? "US";
270
270
 
271
271
  const available = await searchAvailableNumbers(
@@ -324,7 +324,7 @@ export async function handleAssignTwilioNumber(
324
324
  return Response.json(
325
325
  {
326
326
  success: false,
327
- hasCredentials: hasTwilioCredentials(),
327
+ hasCredentials: await hasTwilioCredentials(),
328
328
  error: "phoneNumber is required",
329
329
  },
330
330
  { status: 400 },
@@ -339,9 +339,9 @@ export async function handleAssignTwilioNumber(
339
339
 
340
340
  // Best-effort webhook configuration when credentials are available
341
341
  let webhookWarning: string | undefined;
342
- if (hasTwilioCredentials()) {
342
+ if (await hasTwilioCredentials()) {
343
343
  const { accountSid: acctSid, authToken: acctToken } =
344
- getTwilioCredentials();
344
+ await getTwilioCredentials();
345
345
  const webhookResult = await syncTwilioWebhooks(
346
346
  body.phoneNumber,
347
347
  acctSid,
@@ -353,7 +353,7 @@ export async function handleAssignTwilioNumber(
353
353
 
354
354
  return Response.json({
355
355
  success: true,
356
- hasCredentials: hasTwilioCredentials(),
356
+ hasCredentials: await hasTwilioCredentials(),
357
357
  phoneNumber: body.phoneNumber,
358
358
  warning: webhookWarning,
359
359
  });
@@ -367,7 +367,7 @@ export async function handleAssignTwilioNumber(
367
367
  export async function handleReleaseTwilioNumber(
368
368
  req: Request,
369
369
  ): Promise<Response> {
370
- if (!hasTwilioCredentials()) {
370
+ if (!(await hasTwilioCredentials())) {
371
371
  return Response.json({
372
372
  success: false,
373
373
  hasCredentials: false,
@@ -389,7 +389,7 @@ export async function handleReleaseTwilioNumber(
389
389
  });
390
390
  }
391
391
 
392
- const { accountSid, authToken } = getTwilioCredentials();
392
+ const { accountSid, authToken } = await getTwilioCredentials();
393
393
 
394
394
  await releasePhoneNumber(accountSid, authToken, phoneNumber);
395
395
 
@@ -416,7 +416,7 @@ export function twilioRouteDefinitions(): RouteDefinition[] {
416
416
  {
417
417
  endpoint: "integrations/twilio/config",
418
418
  method: "GET",
419
- handler: () => handleGetTwilioConfig(),
419
+ handler: async () => handleGetTwilioConfig(),
420
420
  },
421
421
  {
422
422
  endpoint: "integrations/twilio/credentials",
@@ -2,10 +2,11 @@
2
2
  * Route handlers for invite management.
3
3
  *
4
4
  * Invites:
5
- * GET /v1/contacts/invites — list invites
6
- * POST /v1/contacts/invites — create an invite (supports voice)
7
- * DELETE /v1/contacts/invites/:id — revoke an invite
8
- * POST /v1/contacts/invites/redeem — redeem an invite (token or voice code)
5
+ * GET /v1/contacts/invites — list invites
6
+ * POST /v1/contacts/invites — create an invite (supports voice)
7
+ * DELETE /v1/contacts/invites/:id — revoke an invite
8
+ * POST /v1/contacts/invites/redeem — redeem an invite (token or voice code)
9
+ * POST /v1/contacts/invites/:id/call — trigger an outbound call for a phone invite
9
10
  */
10
11
 
11
12
  import type { RouteDefinition } from "../http-router.js";
@@ -15,6 +16,7 @@ import {
15
16
  redeemIngressInvite,
16
17
  redeemVoiceInviteCode,
17
18
  revokeIngressInvite,
19
+ triggerInviteCall,
18
20
  } from "../invite-service.js";
19
21
 
20
22
  // ---------------------------------------------------------------------------
@@ -57,6 +59,7 @@ export async function handleCreateInvite(req: Request): Promise<Response> {
57
59
  voiceCodeDigits: body.voiceCodeDigits as number | undefined,
58
60
  friendName: body.friendName as string | undefined,
59
61
  guardianName: body.guardianName as string | undefined,
62
+ contactId: body.contactId as string,
60
63
  });
61
64
 
62
65
  if (!result.ok) {
@@ -141,6 +144,22 @@ export async function handleRedeemInvite(req: Request): Promise<Response> {
141
144
  return Response.json({ ok: true, invite: result.data });
142
145
  }
143
146
 
147
+ /**
148
+ * POST /v1/contacts/invites/:id/call
149
+ *
150
+ * Trigger an outbound call for a phone invite. The invite must be active and
151
+ * have sourceChannel "phone" with the required voice metadata populated.
152
+ */
153
+ export async function handleTriggerInviteCall(
154
+ inviteId: string,
155
+ ): Promise<Response> {
156
+ const result = await triggerInviteCall(inviteId);
157
+ if (!result.ok) {
158
+ return Response.json({ ok: false, error: result.error }, { status: 400 });
159
+ }
160
+ return Response.json({ ok: true, callSid: result.data.callSid });
161
+ }
162
+
144
163
  // ---------------------------------------------------------------------------
145
164
  // Route definitions
146
165
  // ---------------------------------------------------------------------------
@@ -168,5 +187,11 @@ export function inviteRouteDefinitions(): RouteDefinition[] {
168
187
  policyKey: "contacts/invites",
169
188
  handler: ({ params }) => handleRevokeInvite(params.id),
170
189
  },
190
+ {
191
+ endpoint: "contacts/invites/:id/call",
192
+ method: "POST",
193
+ policyKey: "contacts/invites",
194
+ handler: async ({ params }) => handleTriggerInviteCall(params.id),
195
+ },
171
196
  ];
172
197
  }