@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
@@ -76,6 +76,7 @@ import { handleApprovalInterception } from './guardian-approval-interception.js'
76
76
  import { deliverGeneratedApprovalPrompt } from './guardian-approval-prompt.js';
77
77
 
78
78
  import '../channel-invite-transports/telegram.js';
79
+ import '../channel-invite-transports/voice.js';
79
80
 
80
81
  const log = getLogger('runtime-http');
81
82
 
@@ -229,7 +230,7 @@ export async function handleChannelInbound(
229
230
  typeof (rawCommandIntentForAcl as Record<string, unknown>).payload === 'string' &&
230
231
  ((rawCommandIntentForAcl as Record<string, unknown>).payload as string).startsWith('gv_');
231
232
 
232
- // Parse invite token from /start iv_<token> commands using the transport
233
+ // Parse invite token from /start payloads using the channel transport
233
234
  // adapter. The token is extracted once here so both the ACL bypass and
234
235
  // the intercept handler can reference it without re-parsing.
235
236
  const commandIntentForAcl = rawCommandIntentForAcl && typeof rawCommandIntentForAcl === 'object' && !Array.isArray(rawCommandIntentForAcl)
@@ -291,7 +292,7 @@ export async function handleChannelInbound(
291
292
  }
292
293
 
293
294
  // ── Invite token intercept (non-member) ──
294
- // /start iv_<token> deep links grant access without guardian approval.
295
+ // /start invite deep links grant access without guardian approval.
295
296
  // Intercept here — before the deny gate — so valid invites short-circuit
296
297
  // the ACL rejection and never reach the agent pipeline.
297
298
  if (inviteToken && denyNonMember) {
@@ -774,6 +775,21 @@ export async function handleChannelInbound(
774
775
  const guardianVerifyOutcome: 'verified' | 'failed' = verifyResult.success ? 'verified' : 'failed';
775
776
 
776
777
  if (verifyResult.success) {
778
+ const existingMember = (canonicalSenderId ?? rawSenderId)
779
+ ? findMember({
780
+ assistantId: canonicalAssistantId,
781
+ sourceChannel,
782
+ externalUserId: canonicalSenderId ?? rawSenderId!,
783
+ externalChatId,
784
+ })
785
+ : null;
786
+ const memberMatchesSender = existingMember?.externalUserId
787
+ ? canonicalizeInboundIdentity(sourceChannel, existingMember.externalUserId) === (canonicalSenderId ?? rawSenderId)
788
+ : false;
789
+ const preservedDisplayName = memberMatchesSender && existingMember?.displayName?.trim().length
790
+ ? existingMember.displayName
791
+ : body.senderName;
792
+
777
793
  upsertMember({
778
794
  assistantId: canonicalAssistantId,
779
795
  sourceChannel,
@@ -781,7 +797,8 @@ export async function handleChannelInbound(
781
797
  externalChatId,
782
798
  status: 'active',
783
799
  policy: 'allow',
784
- displayName: body.senderName,
800
+ // Keep guardian-curated member name stable across re-verification.
801
+ displayName: preservedDisplayName,
785
802
  username: body.senderUsername,
786
803
  });
787
804
 
@@ -898,8 +915,14 @@ export async function handleChannelInbound(
898
915
  externalChatId,
899
916
  senderExternalUserId: rawSenderId,
900
917
  senderUsername: body.senderUsername,
918
+ senderDisplayName: body.senderName,
901
919
  });
902
920
 
921
+ // Hoisted flag: set by the canonical guardian reply router when the invite
922
+ // handoff bypass fires. Prevents legacy approval interception from swallowing
923
+ // the message when other approvals are pending in the same chat.
924
+ let skipApprovalInterception = false;
925
+
903
926
  // ── Canonical guardian reply router ──
904
927
  // Attempts to route inbound messages through the canonical decision pipeline
905
928
  // before falling through to the legacy approval interception. Handles
@@ -910,7 +933,7 @@ export async function handleChannelInbound(
910
933
  replyCallbackUrl &&
911
934
  (trimmedContent.length > 0 || hasCallbackData) &&
912
935
  rawSenderId &&
913
- guardianCtx.actorRole === 'guardian'
936
+ guardianCtx.trustClass === 'guardian'
914
937
  ) {
915
938
  // Compute destination-scoped pending request hints so the router can
916
939
  // discover canonical requests delivered to this chat even when the
@@ -983,13 +1006,21 @@ export async function handleChannelInbound(
983
1006
  requestId: routerResult.requestId,
984
1007
  });
985
1008
  }
1009
+
1010
+ if (routerResult.skipApprovalInterception) {
1011
+ skipApprovalInterception = true;
1012
+ }
986
1013
  }
987
1014
 
988
1015
  // ── Approval interception ──
989
1016
  // Keep this active whenever callback context is available.
1017
+ // Skipped when the canonical router flagged skipApprovalInterception (e.g.
1018
+ // invite handoff bypass) to prevent the legacy interceptor from swallowing
1019
+ // messages that should reach the assistant.
990
1020
  if (
991
1021
  replyCallbackUrl &&
992
- !result.duplicate
1022
+ !result.duplicate &&
1023
+ !skipApprovalInterception
993
1024
  ) {
994
1025
  const approvalResult = await handleApprovalInterception({
995
1026
  conversationId: result.conversationId,
@@ -1369,7 +1400,7 @@ function startPendingApprovalPromptWatcher(params: {
1369
1400
  conversationId: string;
1370
1401
  sourceChannel: ChannelId;
1371
1402
  externalChatId: string;
1372
- guardianActorRole: GuardianContext['actorRole'];
1403
+ guardianTrustClass: GuardianContext['trustClass'];
1373
1404
  replyCallbackUrl: string;
1374
1405
  bearerToken?: string;
1375
1406
  assistantId?: string;
@@ -1379,7 +1410,7 @@ function startPendingApprovalPromptWatcher(params: {
1379
1410
  conversationId,
1380
1411
  sourceChannel,
1381
1412
  externalChatId,
1382
- guardianActorRole,
1413
+ guardianTrustClass,
1383
1414
  replyCallbackUrl,
1384
1415
  bearerToken,
1385
1416
  assistantId,
@@ -1388,7 +1419,7 @@ function startPendingApprovalPromptWatcher(params: {
1388
1419
 
1389
1420
  // Approval prompt delivery is guardian-only. Non-guardian and unverified
1390
1421
  // actors must never receive approval prompt broadcasts for the conversation.
1391
- if (guardianActorRole !== 'guardian') {
1422
+ if (guardianTrustClass !== 'guardian') {
1392
1423
  return () => {};
1393
1424
  }
1394
1425
 
@@ -1470,7 +1501,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1470
1501
  conversationId,
1471
1502
  sourceChannel,
1472
1503
  externalChatId,
1473
- guardianActorRole: guardianCtx.actorRole,
1504
+ guardianTrustClass: guardianCtx.trustClass,
1474
1505
  replyCallbackUrl,
1475
1506
  bearerToken,
1476
1507
  assistantId,
@@ -1494,7 +1525,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1494
1525
  },
1495
1526
  assistantId,
1496
1527
  guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
1497
- isInteractive: guardianCtx.actorRole === 'guardian',
1528
+ isInteractive: guardianCtx.trustClass === 'guardian',
1498
1529
  ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
1499
1530
  },
1500
1531
  sourceChannel,
@@ -8,10 +8,10 @@
8
8
  * POST /v1/ingress/members/:id/block — block a member
9
9
  *
10
10
  * Invites:
11
- * GET /v1/ingress/invites — list invites
12
- * POST /v1/ingress/invites — create an invite
13
- * DELETE /v1/ingress/invites/:id — revoke an invite
14
- * POST /v1/ingress/invites/redeem — redeem an invite
11
+ * GET /v1/ingress/invites — list invites
12
+ * POST /v1/ingress/invites — create an invite (supports voice)
13
+ * DELETE /v1/ingress/invites/:id — revoke an invite
14
+ * POST /v1/ingress/invites/redeem — redeem an invite (token or voice code)
15
15
  */
16
16
 
17
17
  import {
@@ -20,6 +20,7 @@ import {
20
20
  listIngressInvites,
21
21
  listIngressMembers,
22
22
  redeemIngressInvite,
23
+ redeemVoiceInviteCode,
23
24
  revokeIngressInvite,
24
25
  revokeIngressMember,
25
26
  upsertIngressMember,
@@ -130,6 +131,11 @@ export function handleListInvites(url: URL): Response {
130
131
 
131
132
  /**
132
133
  * POST /v1/ingress/invites
134
+ *
135
+ * For voice invites, pass `sourceChannel: "voice"` with required
136
+ * `expectedExternalUserId` (E.164 phone). Voice codes are always 6 digits.
137
+ * The response will include a one-time `voiceCode` field that must be
138
+ * communicated to the invited user out-of-band.
133
139
  */
134
140
  export async function handleCreateInvite(req: Request): Promise<Response> {
135
141
  const body = (await req.json()) as Record<string, unknown>;
@@ -139,6 +145,8 @@ export async function handleCreateInvite(req: Request): Promise<Response> {
139
145
  note: body.note as string | undefined,
140
146
  maxUses: body.maxUses as number | undefined,
141
147
  expiresInMs: body.expiresInMs as number | undefined,
148
+ expectedExternalUserId: body.expectedExternalUserId as string | undefined,
149
+ voiceCodeDigits: body.voiceCodeDigits as number | undefined,
142
150
  });
143
151
 
144
152
  if (!result.ok) {
@@ -161,10 +169,50 @@ export function handleRevokeInvite(inviteId: string): Response {
161
169
 
162
170
  /**
163
171
  * POST /v1/ingress/invites/redeem
172
+ *
173
+ * Unified invite redemption endpoint. Supports two modes:
174
+ *
175
+ * 1. **Token-based** (existing): pass `token`, `sourceChannel`, `externalUserId`, etc.
176
+ * 2. **Voice code** (new): pass `code` and `callerExternalUserId` (E.164 phone).
177
+ * Optionally pass `assistantId`.
178
+ *
179
+ * The presence of `code` in the body selects voice-code redemption.
164
180
  */
165
181
  export async function handleRedeemInvite(req: Request): Promise<Response> {
166
182
  const body = (await req.json()) as Record<string, unknown>;
167
183
 
184
+ // Voice-code redemption path: triggered when `code` is present
185
+ if (body.code != null) {
186
+ const callerExternalUserId = body.callerExternalUserId as string | undefined;
187
+ const code = body.code as string | undefined;
188
+
189
+ if (!callerExternalUserId || !code) {
190
+ return Response.json(
191
+ { ok: false, error: 'callerExternalUserId and code are required' },
192
+ { status: 400 },
193
+ );
194
+ }
195
+
196
+ const result = redeemVoiceInviteCode({
197
+ assistantId: body.assistantId as string | undefined,
198
+ callerExternalUserId,
199
+ sourceChannel: 'voice',
200
+ code,
201
+ });
202
+
203
+ if (!result.ok) {
204
+ return Response.json({ ok: false, error: result.reason }, { status: 400 });
205
+ }
206
+
207
+ return Response.json({
208
+ ok: true,
209
+ type: result.type,
210
+ memberId: result.memberId,
211
+ ...(result.type === 'redeemed' ? { inviteId: result.inviteId } : {}),
212
+ });
213
+ }
214
+
215
+ // Token-based redemption path (default)
168
216
  const result = redeemIngressInvite({
169
217
  token: body.token as string | undefined,
170
218
  externalUserId: body.externalUserId as string | undefined,
@@ -75,6 +75,9 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont
75
75
 
76
76
  const result = ctx.pairingStore.beginRequest({ pairingRequestId, pairingSecret, deviceId, deviceName });
77
77
  if (!result.ok) {
78
+ if (result.reason === 'already_paired') {
79
+ return httpError('CONFLICT', 'This pairing request is already bound to another device', 409);
80
+ }
78
81
  const statusCode = result.reason === 'invalid_secret' ? 403 : result.reason === 'not_found' ? 403 : 410;
79
82
  return httpError('FORBIDDEN', 'Forbidden', statusCode);
80
83
  }
@@ -124,13 +124,13 @@ export function isGuardianControlPlaneInvocation(
124
124
  export function enforceGuardianOnlyPolicy(
125
125
  toolName: string,
126
126
  input: Record<string, unknown>,
127
- actorRole: string | undefined,
127
+ trustClass: string | undefined,
128
128
  ): { denied: boolean; reason?: string } {
129
129
  if (!isGuardianControlPlaneInvocation(toolName, input)) {
130
130
  return { denied: false };
131
131
  }
132
132
 
133
- if (actorRole === 'guardian' || actorRole === undefined) {
133
+ if (trustClass === 'guardian' || trustClass === undefined) {
134
134
  return { denied: false };
135
135
  }
136
136
 
@@ -1,7 +1,7 @@
1
1
  import { and, asc, eq, lte } from 'drizzle-orm';
2
2
  import { v4 as uuid } from 'uuid';
3
3
 
4
- import { getDb, rawChanges } from '../../memory/db.js';
4
+ import { getDb, rawRun } from '../../memory/db.js';
5
5
  import { reminders } from '../../memory/schema.js';
6
6
  import { cast,createRowMapper, parseJson } from '../../util/row-mapper.js';
7
7
 
@@ -105,14 +105,11 @@ export function listReminders(options?: { pendingOnly?: boolean }): ReminderRow[
105
105
  }
106
106
 
107
107
  export function cancelReminder(id: string): boolean {
108
- const db = getDb();
109
108
  const now = Date.now();
110
- db
111
- .update(reminders)
112
- .set({ status: 'cancelled', updatedAt: now })
113
- .where(and(eq(reminders.id, id), eq(reminders.status, 'pending')))
114
- .run();
115
- return rawChanges() > 0;
109
+ return rawRun(
110
+ 'UPDATE reminders SET status = ?, updated_at = ? WHERE id = ? AND status = ?',
111
+ 'cancelled', now, id, 'pending',
112
+ ) > 0;
116
113
  }
117
114
 
118
115
  /**
@@ -132,13 +129,12 @@ export function claimDueReminders(now: number): ReminderRow[] {
132
129
 
133
130
  const claimed: ReminderRow[] = [];
134
131
  for (const row of candidates) {
135
- db
136
- .update(reminders)
137
- .set({ status: 'firing', firedAt: now, updatedAt: now })
138
- .where(and(eq(reminders.id, row.id), eq(reminders.status, 'pending')))
139
- .run();
132
+ const changed = rawRun(
133
+ 'UPDATE reminders SET status = ?, fired_at = ?, updated_at = ? WHERE id = ? AND status = ?',
134
+ 'firing', now, now, row.id, 'pending',
135
+ );
140
136
 
141
- if (rawChanges() === 0) continue;
137
+ if (changed === 0) continue;
142
138
 
143
139
  claimed.push(parseRow({
144
140
  ...row,
@@ -10,8 +10,8 @@ import type { ExecutionTarget, Tool, ToolContext, ToolExecutionResult, ToolLifec
10
10
 
11
11
  const log = getLogger('tool-approval-handler');
12
12
 
13
- function isUntrustedGuardianActorRole(role: ToolContext['guardianActorRole']): boolean {
14
- return role === 'non-guardian' || role === 'unverified_channel';
13
+ function isUntrustedGuardianTrustClass(role: ToolContext['guardianTrustClass']): boolean {
14
+ return role === 'trusted_contact' || role === 'unknown';
15
15
  }
16
16
 
17
17
  function requiresGuardianApprovalForActor(
@@ -26,10 +26,10 @@ function requiresGuardianApprovalForActor(
26
26
  }
27
27
 
28
28
  function guardianApprovalDeniedMessage(
29
- actorRole: ToolContext['guardianActorRole'],
29
+ trustClass: ToolContext['guardianTrustClass'],
30
30
  toolName: string,
31
31
  ): string {
32
- if (actorRole === 'unverified_channel') {
32
+ if (trustClass === 'unknown') {
33
33
  return `Permission denied for "${toolName}": this action requires guardian approval from a verified channel identity.`;
34
34
  }
35
35
  return `Permission denied for "${toolName}": this action requires guardian approval and the current actor is not the guardian.`;
@@ -82,13 +82,13 @@ export class ToolApprovalHandler {
82
82
  }
83
83
 
84
84
  // Reject tool invocations targeting guardian control-plane endpoints from non-guardian actors.
85
- const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianActorRole);
85
+ const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianTrustClass);
86
86
  if (guardianCheck.denied) {
87
87
  log.warn({
88
88
  toolName: name,
89
89
  sessionId: context.sessionId,
90
90
  conversationId: context.conversationId,
91
- actorRole: context.guardianActorRole,
91
+ trustClass: context.guardianTrustClass,
92
92
  reason: 'guardian_only_policy',
93
93
  }, 'Guardian-only policy blocked tool invocation');
94
94
  const durationMs = Date.now() - startTime;
@@ -118,7 +118,7 @@ export class ToolApprovalHandler {
118
118
  let deferredConsumeParams: Parameters<typeof consumeGrantForInvocation>[0] | null = null;
119
119
 
120
120
  if (
121
- isUntrustedGuardianActorRole(context.guardianActorRole)
121
+ isUntrustedGuardianTrustClass(context.guardianTrustClass)
122
122
  && requiresGuardianApprovalForActor(name, input, executionTarget)
123
123
  ) {
124
124
  const inputDigest = computeToolApprovalDigest(name, input);
@@ -233,7 +233,7 @@ export class ToolApprovalHandler {
233
233
  toolName: name,
234
234
  sessionId: context.sessionId,
235
235
  conversationId: context.conversationId,
236
- actorRole: context.guardianActorRole,
236
+ trustClass: context.guardianTrustClass,
237
237
  executionTarget,
238
238
  grantId: grantResult.grant.id,
239
239
  }, 'Scoped grant consumed — allowing untrusted actor tool invocation');
@@ -273,7 +273,7 @@ export class ToolApprovalHandler {
273
273
  // actors remain fail-closed with no escalation.
274
274
  let escalationMessage: string | undefined;
275
275
  if (
276
- context.guardianActorRole === 'non-guardian'
276
+ context.guardianTrustClass === 'trusted_contact'
277
277
  && context.assistantId
278
278
  && context.executionChannel
279
279
  && context.requesterExternalUserId
@@ -308,12 +308,12 @@ export class ToolApprovalHandler {
308
308
  // If escalation.failed, fall through to generic denial message.
309
309
  }
310
310
 
311
- const reason = escalationMessage ?? guardianApprovalDeniedMessage(context.guardianActorRole, name);
311
+ const reason = escalationMessage ?? guardianApprovalDeniedMessage(context.guardianTrustClass, name);
312
312
  log.warn({
313
313
  toolName: name,
314
314
  sessionId: context.sessionId,
315
315
  conversationId: context.conversationId,
316
- actorRole: context.guardianActorRole,
316
+ trustClass: context.guardianTrustClass,
317
317
  executionTarget,
318
318
  reason: 'guardian_approval_required',
319
319
  grantMissReason: grantResult.reason,
@@ -137,8 +137,8 @@ export interface ToolContext {
137
137
  proxyApprovalCallback?: import('./network/script-proxy/types.js').ProxyApprovalCallback;
138
138
  /** Optional principal identifier propagated to sub-tool confirmation flows. */
139
139
  principal?: string;
140
- /** Guardian actor role for the session — used by the guardian control-plane policy gate. */
141
- guardianActorRole?: 'guardian' | 'non-guardian' | 'unverified_channel';
140
+ /** Inbound trust classification for the session — used by trust/policy gates. */
141
+ guardianTrustClass?: 'guardian' | 'trusted_contact' | 'unknown';
142
142
  /** Channel through which the tool invocation originates (e.g. 'telegram', 'voice'). Used for scoped grant consumption. */
143
143
  executionChannel?: string;
144
144
  /** Voice/call session ID, if the invocation originates from a call. Used for scoped grant consumption. */
@@ -3,12 +3,22 @@ import { join } from 'node:path';
3
3
  import { Writable } from 'node:stream';
4
4
 
5
5
  import pino from 'pino';
6
+ import type { PrettyOptions } from 'pino-pretty';
6
7
  import pinoPretty from 'pino-pretty';
7
8
 
8
9
  import { getDebugMode, getDebugStdoutLogs,getLogStderr } from '../config/env-registry.js';
9
10
  import { logSerializers } from './log-redact.js';
10
11
  import { getLogPath } from './platform.js';
11
12
 
13
+ /** Common pino-pretty options that inline [module] into the message prefix. */
14
+ function prettyOpts(extra?: PrettyOptions): PrettyOptions {
15
+ return {
16
+ messageFormat: '[{module}] {msg}',
17
+ ignore: 'module',
18
+ ...extra,
19
+ };
20
+ }
21
+
12
22
  export type LogFileConfig = {
13
23
  dir: string | undefined;
14
24
  retentionDays: number;
@@ -59,7 +69,7 @@ let activeLogFileConfig: LogFileConfig | null = null;
59
69
 
60
70
  function buildRotatingLogger(config: LogFileConfig): pino.Logger {
61
71
  if (!config.dir) {
62
- return pino({ name: 'assistant', serializers: logSerializers }, pinoPretty({ destination: 1 }));
72
+ return pino({ name: 'assistant', serializers: logSerializers }, pinoPretty(prettyOpts({ destination: 1 })));
63
73
  }
64
74
 
65
75
  if (!existsSync(config.dir)) {
@@ -68,9 +78,10 @@ function buildRotatingLogger(config: LogFileConfig): pino.Logger {
68
78
 
69
79
  const today = formatDate(new Date());
70
80
  const filePath = logFilePathForDate(config.dir, new Date());
71
- const fileStream = pino.destination({ dest: filePath, sync: false, mkdir: true, mode: 0o600 });
81
+ const fileDest = pino.destination({ dest: filePath, sync: false, mkdir: true, mode: 0o600 });
72
82
  // Tighten permissions on pre-existing log files that may have been created with looser modes
73
83
  try { chmodSync(filePath, 0o600); } catch { /* best-effort */ }
84
+ const fileStream = pinoPretty(prettyOpts({ destination: fileDest, colorize: false }));
74
85
 
75
86
  activeLogDate = today;
76
87
  activeLogFileConfig = config;
@@ -78,7 +89,7 @@ function buildRotatingLogger(config: LogFileConfig): pino.Logger {
78
89
  const level = getDebugMode() ? 'debug' : 'info';
79
90
 
80
91
  if (getDebugMode()) {
81
- const prettyStream = pinoPretty({ destination: 2 });
92
+ const prettyStream = pinoPretty(prettyOpts({ destination: 2 }));
82
93
  return pino(
83
94
  { name: 'assistant', level, serializers: logSerializers },
84
95
  pino.multistream([
@@ -92,7 +103,7 @@ function buildRotatingLogger(config: LogFileConfig): pino.Logger {
92
103
  { name: 'assistant', level, serializers: logSerializers },
93
104
  pino.multistream([
94
105
  { stream: fileStream, level: 'info' as const },
95
- { stream: pinoPretty({ destination: 1 }), level: 'info' as const },
106
+ { stream: pinoPretty(prettyOpts({ destination: 1 })), level: 'info' as const },
96
107
  ]),
97
108
  );
98
109
  }
@@ -135,12 +146,13 @@ function getRootLogger(): pino.Logger {
135
146
 
136
147
  try {
137
148
  const logPath = getLogPath();
138
- const fileStream = pino.destination({ dest: logPath, sync: false, mkdir: true, mode: 0o600 });
149
+ const fileDest = pino.destination({ dest: logPath, sync: false, mkdir: true, mode: 0o600 });
139
150
  // Tighten permissions on pre-existing log files that may have been created with looser modes
140
151
  try { chmodSync(logPath, 0o600); } catch { /* best-effort */ }
152
+ const fileStream = pinoPretty(prettyOpts({ destination: fileDest, colorize: false }));
141
153
 
142
154
  if (getDebugMode()) {
143
- const prettyStream = pinoPretty({ destination: 2 });
155
+ const prettyStream = pinoPretty(prettyOpts({ destination: 2 }));
144
156
  const multi = pino.multistream([
145
157
  { stream: fileStream, level: 'info' as const },
146
158
  { stream: prettyStream, level: 'debug' as const },
@@ -151,14 +163,14 @@ function getRootLogger(): pino.Logger {
151
163
  { level: 'info', serializers: logSerializers },
152
164
  pino.multistream([
153
165
  { stream: fileStream, level: 'info' as const },
154
- { stream: pinoPretty({ destination: 1 }), level: 'info' as const },
166
+ { stream: pinoPretty(prettyOpts({ destination: 1 })), level: 'info' as const },
155
167
  ]),
156
168
  );
157
169
  } else {
158
170
  rootLogger = pino({ level: 'info', serializers: logSerializers }, fileStream);
159
171
  }
160
172
  } catch {
161
- rootLogger = pino({ level: getDebugMode() ? 'debug' : 'info', serializers: logSerializers }, pinoPretty({ destination: 2 }));
173
+ rootLogger = pino({ level: getDebugMode() ? 'debug' : 'info', serializers: logSerializers }, pinoPretty(prettyOpts({ destination: 2 })));
162
174
  }
163
175
  }
164
176
  return rootLogger;
@@ -121,6 +121,15 @@ export function getDataDir(): string {
121
121
  return join(getWorkspaceDir(), 'data');
122
122
  }
123
123
 
124
+ /**
125
+ * Returns the embedding models directory (~/.vellum/workspace/embedding-models).
126
+ * Downloaded embedding runtime (onnxruntime-node, transformers bundle, model weights)
127
+ * is stored here, downloaded post-hatch rather than shipped with the app.
128
+ */
129
+ export function getEmbeddingModelsDir(): string {
130
+ return join(getWorkspaceDir(), 'embedding-models');
131
+ }
132
+
124
133
  /**
125
134
  * Returns the IPC blob directory (~/.vellum/workspace/data/ipc-blobs).
126
135
  * Temporary blob files for zero-copy IPC payloads live here.
@@ -357,6 +366,7 @@ export function ensureDataDir(): void {
357
366
  workspace,
358
367
  join(workspace, 'hooks'),
359
368
  join(workspace, 'skills'),
369
+ join(workspace, 'embedding-models'),
360
370
  // Data sub-dirs under workspace
361
371
  wsData,
362
372
  join(wsData, 'db'),
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Cryptographic voice invite code generation and hashing.
3
+ *
4
+ * Generates short numeric codes (default 6 digits) for voice-channel invite
5
+ * redemption. The plaintext code is returned once at creation time and never
6
+ * stored — only its SHA-256 hash is persisted.
7
+ */
8
+
9
+ import { createHash, randomInt } from 'node:crypto';
10
+
11
+ /**
12
+ * Generate a cryptographically random numeric code of the given length.
13
+ * Uses node:crypto randomInt for uniform distribution.
14
+ */
15
+ export function generateVoiceCode(digits: number = 6): string {
16
+ if (digits < 4 || digits > 10) {
17
+ throw new Error(`Voice code digit count must be between 4 and 10, got ${digits}`);
18
+ }
19
+ const min = Math.pow(10, digits - 1); // e.g. 100000 for 6 digits
20
+ const max = Math.pow(10, digits); // e.g. 1000000 for 6 digits
21
+ return String(randomInt(min, max));
22
+ }
23
+
24
+ /**
25
+ * SHA-256 hash a voice code for storage comparison.
26
+ */
27
+ export function hashVoiceCode(code: string): string {
28
+ return createHash('sha256').update(code).digest('hex');
29
+ }