@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -26,11 +26,16 @@ export type GuardianActionMessageScenario =
26
26
  | 'guardian_followup_failed'
27
27
  | 'guardian_followup_declined_ack'
28
28
  | 'guardian_followup_clarification'
29
+ | 'guardian_pending_disambiguation'
29
30
  | 'guardian_expired_disambiguation'
30
31
  | 'guardian_followup_disambiguation'
31
32
  | 'guardian_stale_answered'
32
33
  | 'guardian_stale_expired'
33
34
  | 'guardian_stale_followup'
35
+ | 'guardian_stale_superseded'
36
+ | 'guardian_superseded_remap'
37
+ | 'guardian_unknown_code'
38
+ | 'guardian_auto_matched'
34
39
  | 'outbound_message_copy'
35
40
  | 'followup_message_sent'
36
41
  | 'followup_call_started'
@@ -48,6 +53,10 @@ export interface GuardianActionMessageContext {
48
53
  failureReason?: string;
49
54
  counterpartyPhone?: string;
50
55
  requestCodes?: string[];
56
+ /** The code the guardian provided that was not recognized. */
57
+ unknownCode?: string;
58
+ /** The code of the active request that supersedes the one the guardian targeted. */
59
+ activeRequestCode?: string;
51
60
  }
52
61
 
53
62
  export interface ComposeGuardianActionMessageOptions {
@@ -181,6 +190,11 @@ export function getGuardianActionFallbackMessage(context: GuardianActionMessageC
181
190
  case 'guardian_followup_clarification':
182
191
  return "Sorry, I didn't quite catch that. Would you like to call them back, send them a message, or skip it for now?";
183
192
 
193
+ case 'guardian_pending_disambiguation':
194
+ return listedCodes
195
+ ? `You have multiple pending guardian questions. Please prefix your reply with the reference code (${listedCodes}) so I know which question you're answering.`
196
+ : 'You have multiple pending guardian questions. Please prefix your reply with the reference code so I know which question you\'re answering.';
197
+
184
198
  case 'guardian_expired_disambiguation':
185
199
  return listedCodes
186
200
  ? `You have multiple expired guardian questions. Please prefix your reply with the reference code (${listedCodes}) so I know which question you're answering.`
@@ -200,6 +214,22 @@ export function getGuardianActionFallbackMessage(context: GuardianActionMessageC
200
214
  case 'guardian_stale_followup':
201
215
  return 'It looks like this follow-up has already been handled. No further action is needed.';
202
216
 
217
+ case 'guardian_stale_superseded':
218
+ return 'This request is no longer active. The call has ended and no further action is needed.';
219
+
220
+ case 'guardian_unknown_code':
221
+ return context.unknownCode
222
+ ? `I don't recognize the code "${context.unknownCode}". Please check the reference code and try again.`
223
+ : "I don't recognize that reference code. Please check the code and try again.";
224
+
225
+ case 'guardian_auto_matched':
226
+ return 'Got it, routing your answer to the active request.';
227
+
228
+ case 'guardian_superseded_remap':
229
+ return context.questionText
230
+ ? `Got it! Your answer has been applied to the current active request: "${context.questionText}"`
231
+ : 'Got it! Your answer has been applied to the current active request on the call.';
232
+
203
233
  case 'outbound_message_copy':
204
234
  // This SMS is sent TO the original caller relaying the guardian's answer.
205
235
  // When lateAnswerText is available, include it — that's the whole point of message_back.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared types for the guardian decision primitive.
3
+ *
4
+ * All decision entrypoints (callback buttons, conversational engine, legacy
5
+ * parser, requester self-cancel) use these types to route through the
6
+ * unified `applyGuardianDecision` primitive.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Guardian decision prompt
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Structured model for prompts shown to guardians. */
14
+ export interface GuardianDecisionPrompt {
15
+ requestId: string;
16
+ /** Short human-readable code for the request. */
17
+ requestCode: string;
18
+ state: 'pending' | 'followup_awaiting_choice' | 'expired_superseded_with_active_call';
19
+ questionText: string;
20
+ toolName: string | null;
21
+ actions: GuardianDecisionAction[];
22
+ expiresAt: number;
23
+ conversationId: string;
24
+ callSessionId: string | null;
25
+ }
26
+
27
+ export interface GuardianDecisionAction {
28
+ /** Canonical action identifier. */
29
+ action: string;
30
+ /** Human-readable label for the action. */
31
+ label: string;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Shared decision action constants
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Canonical set of all guardian decision actions with their labels. */
39
+ export const GUARDIAN_DECISION_ACTIONS = {
40
+ approve_once: { action: 'approve_once', label: 'Approve once' },
41
+ approve_always: { action: 'approve_always', label: 'Approve always' },
42
+ reject: { action: 'reject', label: 'Reject' },
43
+ } as const satisfies Record<string, GuardianDecisionAction>;
44
+
45
+ /**
46
+ * Build the set of `GuardianDecisionAction` items appropriate for a prompt,
47
+ * respecting whether persistent decisions (approve_always) are allowed.
48
+ *
49
+ * When `persistentDecisionsAllowed` is `false`, the `approve_always` action
50
+ * is excluded. When `forGuardianOnBehalf` is `true` (guardian acting on behalf
51
+ * of a requester), `approve_always` is also excluded since guardians cannot
52
+ * permanently allowlist tools on behalf of others.
53
+ */
54
+ export function buildDecisionActions(opts?: {
55
+ persistentDecisionsAllowed?: boolean;
56
+ forGuardianOnBehalf?: boolean;
57
+ }): GuardianDecisionAction[] {
58
+ const showAlways = opts?.persistentDecisionsAllowed !== false && !opts?.forGuardianOnBehalf;
59
+ return [
60
+ GUARDIAN_DECISION_ACTIONS.approve_once,
61
+ ...(showAlways ? [GUARDIAN_DECISION_ACTIONS.approve_always] : []),
62
+ GUARDIAN_DECISION_ACTIONS.reject,
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * Build the plain-text fallback instruction string that matches the given
68
+ * set of decision actions. Ensures the text always includes parser-compatible
69
+ * keywords (yes/always/no) so text-based fallback remains actionable.
70
+ */
71
+ export function buildPlainTextFallback(
72
+ promptText: string,
73
+ actions: GuardianDecisionAction[],
74
+ ): string {
75
+ const hasAlways = actions.some(a => a.action === 'approve_always');
76
+ return hasAlways
77
+ ? `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`
78
+ : `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Apply decision result
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export interface ApplyGuardianDecisionResult {
86
+ applied: boolean;
87
+ reason?: 'stale' | 'identity_mismatch' | 'invalid_action' | 'not_found' | 'expired';
88
+ requestId?: string;
89
+ /** Feedback text when the action was parsed from user text. */
90
+ userText?: string;
91
+ }
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { existsSync, readFileSync } from 'node:fs';
9
- import { resolve } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { join, resolve } from 'node:path';
10
11
 
11
12
  import type { ServerWebSocket } from 'bun';
12
13
 
@@ -117,6 +118,10 @@ import {
117
118
  } from './routes/conversation-routes.js';
118
119
  import { handleDebug } from './routes/debug-routes.js';
119
120
  import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
121
+ import {
122
+ handleGuardianActionDecision,
123
+ handleGuardianActionsPending,
124
+ } from './routes/guardian-action-routes.js';
120
125
  import { handleGetIdentity,handleHealth } from './routes/identity-routes.js';
121
126
  import {
122
127
  handleBlockMember,
@@ -237,11 +242,24 @@ export class RuntimeHttpServer {
237
242
  this.pairingBroadcast = fn;
238
243
  }
239
244
 
245
+ /** Read the feature-flag client token from disk so it can be included in pairing approval responses. */
246
+ private readFeatureFlagToken(): string | undefined {
247
+ try {
248
+ const baseDir = process.env.BASE_DATA_DIR?.trim() || homedir();
249
+ const tokenPath = join(baseDir, '.vellum', 'feature-flag-token');
250
+ const token = readFileSync(tokenPath, 'utf-8').trim();
251
+ return token || undefined;
252
+ } catch {
253
+ return undefined;
254
+ }
255
+ }
256
+
240
257
  private get pairingContext(): PairingHandlerContext {
241
258
  const ipcBroadcast = this.pairingBroadcast;
242
259
  return {
243
260
  pairingStore: this.pairingStore,
244
261
  bearerToken: this.bearerToken,
262
+ featureFlagToken: this.readFeatureFlagToken(),
245
263
  pairingBroadcast: ipcBroadcast
246
264
  ? (msg) => {
247
265
  // Broadcast to IPC socket clients (local Unix socket)
@@ -690,6 +708,10 @@ export class RuntimeHttpServer {
690
708
  if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req);
691
709
  if (endpoint === 'pending-interactions' && req.method === 'GET') return handleListPendingInteractions(url);
692
710
 
711
+ // Guardian action endpoints — deterministic button-based decisions
712
+ if (endpoint === 'guardian-actions/pending' && req.method === 'GET') return handleGuardianActionsPending(req);
713
+ if (endpoint === 'guardian-actions/decision' && req.method === 'POST') return await handleGuardianActionDecision(req);
714
+
693
715
  // Contacts
694
716
  if (endpoint === 'contacts' && req.method === 'GET') return handleListContacts(url);
695
717
  if (endpoint === 'contacts/merge' && req.method === 'POST') return await handleMergeContacts(req);
@@ -22,6 +22,10 @@ import {
22
22
  revokeMember,
23
23
  upsertMember,
24
24
  } from '../memory/ingress-member-store.js';
25
+ import {
26
+ type InviteRedemptionOutcome,
27
+ redeemInvite as redeemInviteTyped,
28
+ } from './invite-redemption-service.js';
25
29
 
26
30
  // ---------------------------------------------------------------------------
27
31
  // Response shapes — used by both HTTP routes and IPC handlers
@@ -163,6 +167,24 @@ export function redeemIngressInvite(params: {
163
167
  return { ok: true, data: inviteToResponse(result.invite) };
164
168
  }
165
169
 
170
+ // ---------------------------------------------------------------------------
171
+ // Typed invite redemption — preferred entry point for new callers
172
+ // ---------------------------------------------------------------------------
173
+
174
+ export { type InviteRedemptionOutcome } from './invite-redemption-service.js';
175
+
176
+ export function redeemIngressInviteTyped(params: {
177
+ rawToken: string;
178
+ sourceChannel: string;
179
+ externalUserId?: string;
180
+ externalChatId?: string;
181
+ displayName?: string;
182
+ username?: string;
183
+ assistantId?: string;
184
+ }): InviteRedemptionOutcome {
185
+ return redeemInviteTyped(params);
186
+ }
187
+
166
188
  // ---------------------------------------------------------------------------
167
189
  // Member operations
168
190
  // ---------------------------------------------------------------------------
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Typed invite redemption engine.
3
+ *
4
+ * Wraps the low-level invite store primitives with channel-scoped enforcement
5
+ * and a discriminated-union outcome type so callers can handle every case
6
+ * deterministically. The raw token is accepted as input but is never logged,
7
+ * persisted, or returned in the outcome.
8
+ */
9
+
10
+ import { getSqlite } from '../memory/db.js';
11
+ import { findByTokenHash, hashToken, markInviteExpired, recordInviteUse,redeemInvite as storeRedeemInvite } from '../memory/ingress-invite-store.js';
12
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Outcome type
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type InviteRedemptionOutcome =
19
+ | { ok: true; type: 'redeemed'; memberId: string; inviteId: string }
20
+ | { ok: true; type: 'already_member'; memberId: string }
21
+ | { ok: false; reason: 'invalid_token' | 'expired' | 'revoked' | 'max_uses_reached' | 'channel_mismatch' | 'missing_identity' };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Error-string to typed-reason mapping
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const STORE_ERROR_TO_REASON: Record<string, InviteRedemptionOutcome & { ok: false } | undefined> = {
28
+ invite_not_found: { ok: false, reason: 'invalid_token' },
29
+ invite_expired: { ok: false, reason: 'expired' },
30
+ invite_revoked: { ok: false, reason: 'revoked' },
31
+ invite_redeemed: { ok: false, reason: 'max_uses_reached' },
32
+ invite_max_uses_reached: { ok: false, reason: 'max_uses_reached' },
33
+ invite_channel_mismatch: { ok: false, reason: 'channel_mismatch' },
34
+ };
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // redeemInvite
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export function redeemInvite(params: {
41
+ rawToken: string;
42
+ sourceChannel: string;
43
+ externalUserId?: string;
44
+ externalChatId?: string;
45
+ displayName?: string;
46
+ username?: string;
47
+ assistantId?: string;
48
+ }): InviteRedemptionOutcome {
49
+ const { rawToken, sourceChannel, externalUserId, externalChatId, displayName, username, assistantId } = params;
50
+
51
+ if (!externalUserId && !externalChatId) {
52
+ return { ok: false, reason: 'missing_identity' };
53
+ }
54
+
55
+ // Validate the invite token before any membership checks to prevent
56
+ // membership-status probing with arbitrary tokens.
57
+ const tokenHash = hashToken(rawToken);
58
+ const invite = findByTokenHash(tokenHash);
59
+
60
+ if (!invite) {
61
+ return { ok: false, reason: 'invalid_token' };
62
+ }
63
+
64
+ if (invite.status !== 'active') {
65
+ const mapped = STORE_ERROR_TO_REASON[`invite_${invite.status}`];
66
+ if (mapped) return mapped;
67
+ return { ok: false, reason: 'invalid_token' };
68
+ }
69
+
70
+ if (invite.expiresAt <= Date.now()) {
71
+ markInviteExpired(invite.id);
72
+ return { ok: false, reason: 'expired' };
73
+ }
74
+
75
+ if (invite.useCount >= invite.maxUses) {
76
+ return { ok: false, reason: 'max_uses_reached' };
77
+ }
78
+
79
+ // Enforce channel match: the token must belong to the channel the caller
80
+ // is redeeming from.
81
+ if (sourceChannel !== invite.sourceChannel) {
82
+ return { ok: false, reason: 'channel_mismatch' };
83
+ }
84
+
85
+ // Token is valid — now safe to check existing membership without leaking
86
+ // membership status to callers with bogus tokens.
87
+ const existingMember = findMember({
88
+ assistantId: assistantId ?? invite.assistantId,
89
+ sourceChannel,
90
+ externalUserId,
91
+ externalChatId,
92
+ });
93
+
94
+ if (existingMember && existingMember.status === 'active') {
95
+ return { ok: true, type: 'already_member', memberId: existingMember.id };
96
+ }
97
+
98
+ // Blocked members cannot bypass the guardian's explicit block via invite
99
+ // links. Return the same generic failure as an invalid token to avoid
100
+ // leaking membership status to the caller.
101
+ if (existingMember && existingMember.status === 'blocked') {
102
+ return { ok: false, reason: 'invalid_token' };
103
+ }
104
+
105
+ // Inactive member reactivation: when the user already has a member record
106
+ // in a non-active state (revoked/pending), reactivate it via upsertMember
107
+ // and consume an invite use atomically. Falling through to storeRedeemInvite
108
+ // would try to INSERT a new member row, hitting the unique-key constraint
109
+ // on the members table.
110
+ if (existingMember) {
111
+ // Sentinel error used to trigger a transaction rollback when the invite
112
+ // was concurrently revoked/expired between pre-validation and write time.
113
+ const STALE_INVITE = Symbol('stale_invite');
114
+
115
+ let reactivated: ReturnType<typeof upsertMember> | undefined;
116
+ try {
117
+ getSqlite().transaction(() => {
118
+ reactivated = upsertMember({
119
+ assistantId: assistantId ?? invite.assistantId,
120
+ sourceChannel,
121
+ externalUserId,
122
+ externalChatId,
123
+ displayName,
124
+ username,
125
+ status: 'active',
126
+ policy: 'allow',
127
+ inviteId: invite.id,
128
+ });
129
+
130
+ const recorded = recordInviteUse({
131
+ inviteId: invite.id,
132
+ externalUserId,
133
+ externalChatId,
134
+ });
135
+
136
+ // If the invite was revoked/expired between pre-validation and this
137
+ // write, recordInviteUse returns false — throw to roll back the
138
+ // member reactivation so the DB stays consistent.
139
+ if (!recorded) throw STALE_INVITE;
140
+ }).immediate();
141
+ } catch (err) {
142
+ if (err === STALE_INVITE) {
143
+ return { ok: false, reason: 'invalid_token' };
144
+ }
145
+ throw err;
146
+ }
147
+
148
+ return {
149
+ ok: true,
150
+ type: 'redeemed',
151
+ memberId: reactivated!.id,
152
+ inviteId: invite.id,
153
+ };
154
+ }
155
+
156
+ // Delegate to the store-level redeem which handles token lookup, expiry,
157
+ // use-count, and transactional member creation. Channel enforcement is
158
+ // applied by passing sourceChannel so the store checks it.
159
+ const result = storeRedeemInvite({
160
+ rawToken,
161
+ sourceChannel,
162
+ externalUserId,
163
+ externalChatId,
164
+ displayName,
165
+ username,
166
+ });
167
+
168
+ if ('error' in result) {
169
+ const mapped = STORE_ERROR_TO_REASON[result.error];
170
+ if (mapped) return mapped;
171
+ // Fallback for any unrecognized store error
172
+ return { ok: false, reason: 'invalid_token' };
173
+ }
174
+
175
+ return {
176
+ ok: true,
177
+ type: 'redeemed',
178
+ memberId: result.member.id,
179
+ inviteId: result.invite.id,
180
+ };
181
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Deterministic reply templates for invite token redemption outcomes.
3
+ *
4
+ * These messages are returned directly to the user without passing through
5
+ * the LLM pipeline, ensuring consistent and predictable responses for
6
+ * every invite redemption outcome.
7
+ */
8
+
9
+ import type { InviteRedemptionOutcome } from './invite-redemption-service.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Template strings
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const INVITE_REPLY_TEMPLATES = {
16
+ redeemed: "Welcome! You've been granted access via invite link.",
17
+ already_member: 'You already have access.',
18
+ invalid_token: 'This invite link is no longer valid.',
19
+ expired: 'This invite link is no longer valid.',
20
+ revoked: 'This invite link is no longer valid.',
21
+ max_uses_reached: 'This invite link is no longer valid.',
22
+ channel_mismatch: 'This invite link is not valid for this channel.',
23
+ missing_identity: 'Unable to process this invite. Please contact the person who shared it.',
24
+ generic_failure: 'Unable to process this invite. Please contact the person who shared it.',
25
+ } as const;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Outcome-to-reply resolver
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Map an `InviteRedemptionOutcome` to a deterministic reply string.
33
+ */
34
+ export function getInviteRedemptionReply(outcome: InviteRedemptionOutcome): string {
35
+ if (outcome.ok) {
36
+ return INVITE_REPLY_TEMPLATES[outcome.type];
37
+ }
38
+ return INVITE_REPLY_TEMPLATES[outcome.reason] ?? INVITE_REPLY_TEMPLATES.generic_failure;
39
+ }
@@ -179,7 +179,7 @@ export async function handleCancelCall(req: Request, callSessionId: string): Pro
179
179
  * Body: { answer: string }
180
180
  */
181
181
  export async function handleAnswerCall(req: Request, callSessionId: string): Promise<Response> {
182
- let body: { answer?: string };
182
+ let body: { answer?: string; pendingQuestionId?: string };
183
183
  try {
184
184
  body = await req.json() as typeof body;
185
185
  } catch {
@@ -193,6 +193,7 @@ export async function handleAnswerCall(req: Request, callSessionId: string): Pro
193
193
  const result = await answerCall({
194
194
  callSessionId,
195
195
  answer: body.answer ?? '',
196
+ pendingQuestionId: typeof body.pendingQuestionId === 'string' ? body.pendingQuestionId : undefined,
196
197
  });
197
198
 
198
199
  if (!result.ok) {