@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
@@ -16,6 +16,7 @@ import { getConfig } from '../config/loader.js';
16
16
  import { createTimeout, extractToolUse, getConfiguredProvider, userMessage } from '../providers/provider-send-message.js';
17
17
  import type { ModelIntent } from '../providers/types.js';
18
18
  import { getLogger } from '../util/logger.js';
19
+ import { composeFallbackCopy } from './copy-composer.js';
19
20
  import { createDecision } from './decisions-store.js';
20
21
  import { getPreferenceSummary } from './preference-summary.js';
21
22
  import type { NotificationSignal, RoutingIntent } from './signal.js';
@@ -251,17 +252,7 @@ function buildFallbackDecision(
251
252
  };
252
253
  }
253
254
 
254
- const copy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {};
255
- for (const ch of selectedChannels) {
256
- const fallbackBody = isHighUrgencyAction
257
- ? `Action required: ${signal.sourceEventName}`
258
- : signal.sourceEventName;
259
- copy[ch] = {
260
- title: signal.sourceEventName,
261
- body: fallbackBody,
262
- ...(ch === 'telegram' ? { deliveryText: fallbackBody } : {}),
263
- };
264
- }
255
+ const copy = composeFallbackCopy(signal, selectedChannels);
265
256
 
266
257
  return {
267
258
  shouldNotify: true,
@@ -452,7 +443,8 @@ export async function evaluateSignal(
452
443
  const provider = getConfiguredProvider();
453
444
  if (!provider) {
454
445
  log.warn('Configured provider unavailable for notification decision, using fallback');
455
- const decision = buildFallbackDecision(signal, availableChannels);
446
+ let decision = buildFallbackDecision(signal, availableChannels);
447
+ decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
456
448
  decision.persistedDecisionId = persistDecision(signal, decision);
457
449
  return decision;
458
450
  }
@@ -466,6 +458,7 @@ export async function evaluateSignal(
466
458
  decision = buildFallbackDecision(signal, availableChannels);
467
459
  }
468
460
 
461
+ decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
469
462
  decision.persistedDecisionId = persistDecision(signal, decision);
470
463
 
471
464
  return decision;
@@ -600,6 +593,50 @@ export function enforceRoutingIntent(
600
593
  return decision;
601
594
  }
602
595
 
596
+ // ── Conversation affinity enforcement ───────────────────────────────────
597
+
598
+ /**
599
+ * Enforce conversation affinity on a decision.
600
+ *
601
+ * When the signal carries a conversationAffinityHint (per-channel map of
602
+ * conversationId), override the decision's threadActions for those channels
603
+ * to `reuse_existing` with the hinted conversationId. This is a
604
+ * deterministic post-decision guard that prevents the LLM from routing
605
+ * guardian questions for the same call session to different conversations.
606
+ */
607
+ export function enforceConversationAffinity(
608
+ decision: NotificationDecision,
609
+ affinityHint: Partial<Record<string, string>> | undefined,
610
+ ): NotificationDecision {
611
+ if (!affinityHint) return decision;
612
+
613
+ const entries = Object.entries(affinityHint).filter(
614
+ ([, conversationId]) => typeof conversationId === 'string' && conversationId.length > 0,
615
+ );
616
+ if (entries.length === 0) return decision;
617
+
618
+ const enforced = { ...decision };
619
+ const threadActions: Partial<Record<NotificationChannel, ThreadAction>> = {
620
+ ...(decision.threadActions ?? {}),
621
+ };
622
+
623
+ for (const [channel, conversationId] of entries) {
624
+ threadActions[channel as NotificationChannel] = {
625
+ action: 'reuse_existing',
626
+ conversationId: conversationId!,
627
+ };
628
+ }
629
+
630
+ enforced.threadActions = threadActions;
631
+
632
+ log.info(
633
+ { affinityHint },
634
+ 'Conversation affinity enforcement: overrode threadActions for hinted channels',
635
+ );
636
+
637
+ return enforced;
638
+ }
639
+
603
640
  // ── Persistence ────────────────────────────────────────────────────────
604
641
 
605
642
  function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
@@ -133,6 +133,12 @@ export interface EmitSignalParams {
133
133
  routingIntent?: RoutingIntent;
134
134
  /** Free-form hints from the source for the decision engine. */
135
135
  routingHints?: Record<string, unknown>;
136
+ /**
137
+ * Per-channel conversation affinity hint. Forces the decision engine to
138
+ * reuse the specified conversation for the given channel(s), bypassing
139
+ * LLM thread-routing judgment. Keyed by channel name, value is conversationId.
140
+ */
141
+ conversationAffinityHint?: Partial<Record<string, string>>;
136
142
  /** Optional deduplication key. */
137
143
  dedupeKey?: string;
138
144
  /**
@@ -177,6 +183,7 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
177
183
  attentionHints: params.attentionHints,
178
184
  routingIntent: params.routingIntent,
179
185
  routingHints: params.routingHints,
186
+ conversationAffinityHint: params.conversationAffinityHint,
180
187
  };
181
188
 
182
189
  try {
@@ -27,4 +27,11 @@ export interface NotificationSignal {
27
27
  routingIntent?: RoutingIntent;
28
28
  /** Free-form hints from the source for the decision engine (e.g. preferred channels). */
29
29
  routingHints?: Record<string, unknown>;
30
+ /**
31
+ * Per-channel conversation affinity hint. When set, the decision engine
32
+ * must force thread reuse to the specified conversation for that channel,
33
+ * bypassing LLM judgment. Used to enforce deterministic guardian thread
34
+ * affinity within a call session.
35
+ */
36
+ conversationAffinityHint?: Partial<Record<string, string>>;
30
37
  }
@@ -140,7 +140,8 @@ export function composeThreadSeed(
140
140
  const parts: string[] = [];
141
141
  if (copy.title && copy.title !== 'Notification') parts.push(copy.title);
142
142
  if (copy.body) parts.push(copy.body);
143
- if (signal.attentionHints.requiresAction && parts.length > 0) {
143
+ const alreadyMentionsAction = parts.some((part) => /\baction required\b/i.test(part));
144
+ if (signal.attentionHints.requiresAction && parts.length > 0 && !alreadyMentionsAction) {
144
145
  parts.push('Action required.');
145
146
  }
146
147
  if (parts.length > 0) {
@@ -7,6 +7,8 @@
7
7
  * same approval flow can be reused across transports.
8
8
  */
9
9
 
10
+ import type { GuardianDecisionAction } from './guardian-decision-types.js';
11
+
10
12
  // ---------------------------------------------------------------------------
11
13
  // Approval actions
12
14
  // ---------------------------------------------------------------------------
@@ -20,12 +22,20 @@ export interface ApprovalActionOption {
20
22
  label: string;
21
23
  }
22
24
 
23
- /** Default action options presented to users across all channels. */
24
- export const DEFAULT_APPROVAL_ACTIONS: readonly ApprovalActionOption[] = [
25
- { id: 'approve_once', label: 'Approve once' },
26
- { id: 'approve_always', label: 'Approve always' },
27
- { id: 'reject', label: 'Reject' },
28
- ] as const;
25
+ /**
26
+ * Map `GuardianDecisionAction[]` to `ApprovalActionOption[]` so channel
27
+ * prompt payloads can be derived from the unified decision action set.
28
+ * The `action` field from GuardianDecisionAction maps to the `id` field
29
+ * on ApprovalActionOption (both are canonical action identifiers).
30
+ */
31
+ export function toApprovalActionOptions(
32
+ actions: GuardianDecisionAction[],
33
+ ): ApprovalActionOption[] {
34
+ return actions.map(a => ({
35
+ id: a.action as ApprovalAction,
36
+ label: a.label,
37
+ }));
38
+ }
29
39
 
30
40
  // ---------------------------------------------------------------------------
31
41
  // Approval prompt
@@ -17,7 +17,8 @@ import type {
17
17
  ApprovalUIMetadata,
18
18
  ChannelApprovalPrompt,
19
19
  } from './channel-approval-types.js';
20
- import { DEFAULT_APPROVAL_ACTIONS } from './channel-approval-types.js';
20
+ import { toApprovalActionOptions } from './channel-approval-types.js';
21
+ import { buildDecisionActions, buildPlainTextFallback } from './guardian-decision-types.js';
21
22
  import * as pendingInteractions from './pending-interactions.js';
22
23
 
23
24
  /** Summary of a pending interaction, used by channel approval flows. */
@@ -69,6 +70,11 @@ export function getApprovalInfoByConversation(conversationId: string): PendingAp
69
70
 
70
71
  /**
71
72
  * Internal helper: turn a PendingApprovalInfo into a ChannelApprovalPrompt.
73
+ *
74
+ * Derives actions from the shared `buildDecisionActions` builder defined in
75
+ * guardian-decision-types.ts, then maps them to the channel-facing
76
+ * `ApprovalActionOption` shape. This ensures channel button sets are always
77
+ * consistent with the unified `GuardianDecisionPrompt` type.
72
78
  */
73
79
  function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApprovalPrompt {
74
80
  const promptText = composeApprovalMessage({
@@ -76,15 +82,11 @@ function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApproval
76
82
  toolName: info.toolName,
77
83
  });
78
84
 
79
- // Hide "approve always" when persistent trust rules are disallowed for this invocation.
80
- const actions = info.persistentDecisionsAllowed === false
81
- ? DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always')
82
- : [...DEFAULT_APPROVAL_ACTIONS];
83
-
84
- // Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
85
- const plainTextFallback = info.persistentDecisionsAllowed === false
86
- ? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
87
- : `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
85
+ const decisionActions = buildDecisionActions({
86
+ persistentDecisionsAllowed: info.persistentDecisionsAllowed,
87
+ });
88
+ const actions = toApprovalActionOptions(decisionActions);
89
+ const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
88
90
 
89
91
  return { promptText, actions, plainTextFallback };
90
92
  }
@@ -199,6 +201,10 @@ export function handleChannelDecision(
199
201
  * Build an approval prompt that includes context about which non-guardian
200
202
  * user is requesting the action. Sent to the guardian's chat so they
201
203
  * can approve or deny on behalf of the requester.
204
+ *
205
+ * Uses the shared `buildDecisionActions` builder with `forGuardianOnBehalf`
206
+ * set to true, which excludes `approve_always` since guardians cannot
207
+ * permanently allowlist tools on behalf of requesters.
202
208
  */
203
209
  export function buildGuardianApprovalPrompt(
204
210
  info: PendingApprovalInfo,
@@ -210,11 +216,9 @@ export function buildGuardianApprovalPrompt(
210
216
  requesterIdentifier,
211
217
  });
212
218
 
213
- // Guardian approvals are always one-time decisions — "approve always"
214
- // doesn't make sense when the guardian is approving on behalf of someone else.
215
- const actions = DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always');
216
-
217
- const plainTextFallback = `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
219
+ const decisionActions = buildDecisionActions({ forGuardianOnBehalf: true });
220
+ const actions = toApprovalActionOptions(decisionActions);
221
+ const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
218
222
 
219
223
  return { promptText, actions, plainTextFallback };
220
224
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Channel invite transport abstraction.
3
+ *
4
+ * Defines a transport interface for building shareable invite links and
5
+ * extracting inbound invite tokens from channel-specific payloads. Each
6
+ * channel (Telegram, SMS, Slack, etc.) registers an adapter that knows
7
+ * how to construct deep links and parse incoming tokens for that channel.
8
+ *
9
+ * The transport layer is intentionally thin: it handles URL construction
10
+ * and token extraction only. Redemption logic lives in
11
+ * `invite-redemption-service.ts`.
12
+ */
13
+
14
+ import type { ChannelId } from '../channels/types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface InviteSharePayload {
21
+ /** The full URL the recipient can open to redeem the invite. */
22
+ url: string;
23
+ /** Human-readable text suitable for display alongside the link. */
24
+ displayText: string;
25
+ }
26
+
27
+ export interface ChannelInviteTransport {
28
+ /** The channel this transport handles. */
29
+ channel: ChannelId;
30
+
31
+ /**
32
+ * Build a shareable invite payload (URL + display text) from a raw token.
33
+ *
34
+ * The raw token is the base64url-encoded secret returned by
35
+ * `ingress-invite-store.createInvite`. The transport wraps it in a
36
+ * channel-specific deep link so the recipient can redeem the invite
37
+ * by clicking/tapping the link.
38
+ */
39
+ buildShareableInvite(params: {
40
+ rawToken: string;
41
+ sourceChannel: ChannelId;
42
+ }): InviteSharePayload;
43
+
44
+ /**
45
+ * Extract an invite token from an inbound channel message.
46
+ *
47
+ * Returns the raw token string (without the `iv_` prefix) if the
48
+ * message contains a valid invite token, or `undefined` otherwise.
49
+ */
50
+ extractInboundToken(params: {
51
+ commandIntent?: Record<string, unknown>;
52
+ content: string;
53
+ sourceMetadata?: Record<string, unknown>;
54
+ }): string | undefined;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Registry
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const registry = new Map<ChannelId, ChannelInviteTransport>();
62
+
63
+ /**
64
+ * Register a channel invite transport. Overwrites any previously registered
65
+ * transport for the same channel.
66
+ */
67
+ export function registerTransport(transport: ChannelInviteTransport): void {
68
+ registry.set(transport.channel, transport);
69
+ }
70
+
71
+ /**
72
+ * Look up the registered transport for a channel. Returns `undefined` when
73
+ * no transport has been registered for the given channel.
74
+ */
75
+ export function getTransport(channel: ChannelId): ChannelInviteTransport | undefined {
76
+ return registry.get(channel);
77
+ }
78
+
79
+ /**
80
+ * Reset the registry. Intended for tests only.
81
+ * @internal
82
+ */
83
+ export function _resetRegistry(): void {
84
+ registry.clear();
85
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Telegram channel invite transport adapter.
3
+ *
4
+ * Builds `https://t.me/<botUsername>?start=iv_<token>` deep links and
5
+ * extracts invite tokens from `/start iv_<token>` command payloads.
6
+ *
7
+ * The `iv_` prefix distinguishes invite tokens from `gv_` (guardian
8
+ * verification) tokens that use the same `/start` deep-link mechanism.
9
+ */
10
+
11
+ import type { ChannelId } from '../../channels/types.js';
12
+ import { getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
13
+ import {
14
+ type ChannelInviteTransport,
15
+ type InviteSharePayload,
16
+ registerTransport,
17
+ } from '../channel-invite-transport.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Bot username resolution
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Resolve the Telegram bot username from credential metadata, falling back
25
+ * to the TELEGRAM_BOT_USERNAME environment variable. Mirrors the resolution
26
+ * strategy used in `guardian-outbound-actions.ts`.
27
+ */
28
+ function getTelegramBotUsername(): string | undefined {
29
+ const meta = getCredentialMetadata('telegram', 'bot_token');
30
+ if (meta?.accountInfo && typeof meta.accountInfo === 'string' && meta.accountInfo.trim().length > 0) {
31
+ return meta.accountInfo.trim();
32
+ }
33
+ return process.env.TELEGRAM_BOT_USERNAME || undefined;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Token prefix
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const INVITE_TOKEN_PREFIX = 'iv_';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Transport implementation
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export const telegramInviteTransport: ChannelInviteTransport = {
47
+ channel: 'telegram' as ChannelId,
48
+
49
+ buildShareableInvite(params: {
50
+ rawToken: string;
51
+ sourceChannel: ChannelId;
52
+ }): InviteSharePayload {
53
+ const botUsername = getTelegramBotUsername();
54
+ if (!botUsername) {
55
+ throw new Error('Telegram bot username is not configured. Set up the Telegram integration first.');
56
+ }
57
+
58
+ const url = `https://t.me/${botUsername}?start=${INVITE_TOKEN_PREFIX}${params.rawToken}`;
59
+ return {
60
+ url,
61
+ displayText: `Open in Telegram: ${url}`,
62
+ };
63
+ },
64
+
65
+ extractInboundToken(params: {
66
+ commandIntent?: Record<string, unknown>;
67
+ content: string;
68
+ sourceMetadata?: Record<string, unknown>;
69
+ }): string | undefined {
70
+ // Primary path: structured command intent from the gateway.
71
+ // The gateway normalizes `/start <payload>` into
72
+ // `{ type: 'start', payload: '<payload>' }`.
73
+ if (
74
+ params.commandIntent &&
75
+ params.commandIntent.type === 'start' &&
76
+ typeof params.commandIntent.payload === 'string'
77
+ ) {
78
+ const payload = params.commandIntent.payload;
79
+ if (payload.startsWith(INVITE_TOKEN_PREFIX)) {
80
+ const token = payload.slice(INVITE_TOKEN_PREFIX.length);
81
+ // Reject empty or whitespace-only tokens
82
+ if (token.length > 0 && token.trim().length > 0) {
83
+ return token;
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ // Fallback: raw content parsing for `/start iv_<token>` messages.
90
+ // This handles cases where the gateway forwards the raw command text
91
+ // without a structured commandIntent.
92
+ const match = params.content.match(/^\/start\s+iv_(\S+)/);
93
+ if (match && match[1] && match[1].length > 0) {
94
+ return match[1];
95
+ }
96
+
97
+ return undefined;
98
+ },
99
+ };
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Auto-register on import
103
+ // ---------------------------------------------------------------------------
104
+
105
+ registerTransport(telegramInviteTransport);
@@ -7,10 +7,12 @@
7
7
  * consistently regardless of which channel the guardian answers on.
8
8
  */
9
9
 
10
+ import { mintGrantFromDecision } from '../approvals/approval-primitive.js';
10
11
  import type { GuardianActionRequest } from '../memory/guardian-action-store.js';
11
- import { createScopedApprovalGrant } from '../memory/scoped-approval-grants.js';
12
12
  import { getLogger } from '../util/logger.js';
13
+ import { runApprovalConversationTurn } from './approval-conversation-turn.js';
13
14
  import { parseApprovalDecision } from './channel-approval-parser.js';
15
+ import type { ApprovalConversationGenerator } from './http-types.js';
14
16
 
15
17
  const log = getLogger('guardian-action-grant-minter');
16
18
 
@@ -21,76 +23,131 @@ export const GUARDIAN_ACTION_GRANT_TTL_MS = 5 * 60 * 1000;
21
23
  * Mint a `tool_signature` scoped grant when a guardian-action request is
22
24
  * resolved and the request carries tool metadata (toolName + inputDigest).
23
25
  *
26
+ * Uses two-tier classification:
27
+ * 1. Deterministic fast path via parseApprovalDecision (exact keyword match).
28
+ * 2. LLM fallback via runApprovalConversationTurn when the deterministic
29
+ * parser returns null and an approvalConversationGenerator is provided.
30
+ *
24
31
  * Skips silently when:
25
32
  * - The resolved request has no toolName/inputDigest (informational consult).
26
- * - The guardian's answer is not an explicit approval (fail-closed).
33
+ * - The guardian's answer is not classified as approval by either tier (fail-closed).
27
34
  *
28
35
  * Fails silently on error -- grant minting is best-effort and must never
29
36
  * block the guardian-action answer flow.
30
37
  */
31
- export function tryMintGuardianActionGrant(params: {
32
- resolvedRequest: GuardianActionRequest;
38
+ export async function tryMintGuardianActionGrant(params: {
39
+ request: GuardianActionRequest;
33
40
  answerText: string;
34
41
  decisionChannel: string;
35
42
  guardianExternalUserId?: string;
36
- }): void {
37
- const { resolvedRequest, answerText, decisionChannel, guardianExternalUserId } = params;
43
+ approvalConversationGenerator?: ApprovalConversationGenerator;
44
+ }): Promise<void> {
45
+ const { request, answerText, decisionChannel, guardianExternalUserId, approvalConversationGenerator } = params;
38
46
 
39
47
  // Only mint for requests that carry tool metadata -- informational
40
48
  // ASK_GUARDIAN consults without tool context do not produce grants.
41
- if (!resolvedRequest.toolName || !resolvedRequest.inputDigest) {
49
+ if (!request.toolName || !request.inputDigest) {
42
50
  return;
43
51
  }
44
52
 
45
- // Gate on explicit affirmative guardian decisions (fail-closed).
46
- // Only mint when the deterministic parser recognises an approval keyword
47
- // ("yes", "approve", "allow", "go ahead", etc.). Unrecognised text
48
- // (e.g. "nope", "don't do that") is treated as non-approval and skipped,
49
- // preventing ambiguous answers from producing grants.
53
+ // Tier 1: Deterministic fast path -- try exact keyword matching first.
54
+ // Guardian-action invariant: grants are always one-time `tool_signature`
55
+ // scoped. We treat `approve_always` from the deterministic parser the
56
+ // same as `approve_once` -- the grant is still single-use. This keeps
57
+ // the guardian-action path aligned with the primary approval interception
58
+ // flow where guardians are limited to approve_once / reject.
50
59
  const decision = parseApprovalDecision(answerText);
51
- if (decision?.action !== 'approve_once' && decision?.action !== 'approve_always') {
60
+ let isApproval = decision?.action === 'approve_once' || decision?.action === 'approve_always';
61
+
62
+ // Tier 2: LLM fallback -- when the deterministic parser found no match
63
+ // and a generator is available, delegate to the conversational engine.
64
+ // Only allow approve_once (not approve_always) to keep guardian-action
65
+ // grants strictly one-time and consistent with guardian policy.
66
+ if (!isApproval && !decision && approvalConversationGenerator) {
67
+ try {
68
+ const llmResult = await runApprovalConversationTurn(
69
+ {
70
+ toolName: request.toolName,
71
+ allowedActions: ['approve_once', 'reject'],
72
+ role: 'guardian',
73
+ pendingApprovals: [{ requestId: request.id, toolName: request.toolName }],
74
+ userMessage: answerText,
75
+ },
76
+ approvalConversationGenerator,
77
+ );
78
+
79
+ isApproval = llmResult.disposition === 'approve_once';
80
+
81
+ log.info(
82
+ {
83
+ event: 'guardian_action_grant_llm_fallback',
84
+ toolName: request.toolName,
85
+ requestId: request.id,
86
+ answerText,
87
+ llmDisposition: llmResult.disposition,
88
+ matched: isApproval,
89
+ decisionChannel,
90
+ },
91
+ `LLM fallback classifier returned disposition: ${llmResult.disposition}`,
92
+ );
93
+ } catch (err) {
94
+ // Fail-closed: generator errors must not produce grants.
95
+ log.warn(
96
+ {
97
+ event: 'guardian_action_grant_llm_fallback_error',
98
+ toolName: request.toolName,
99
+ requestId: request.id,
100
+ err,
101
+ decisionChannel,
102
+ },
103
+ 'LLM fallback classifier threw an error; treating as non-approval (fail-closed)',
104
+ );
105
+ }
106
+ }
107
+
108
+ if (!isApproval) {
52
109
  log.info(
53
110
  {
54
111
  event: 'guardian_action_grant_skipped_no_approval',
55
- toolName: resolvedRequest.toolName,
56
- requestId: resolvedRequest.id,
112
+ toolName: request.toolName,
113
+ requestId: request.id,
57
114
  answerText,
58
115
  parsedAction: decision?.action ?? null,
59
116
  decisionChannel,
60
117
  },
61
- 'Skipped grant minting: guardian answer not classified as explicit approval',
118
+ 'Skipped grant minting: guardian answer not classified as approval',
62
119
  );
63
120
  return;
64
121
  }
65
122
 
66
- try {
67
- createScopedApprovalGrant({
68
- assistantId: resolvedRequest.assistantId,
69
- scopeMode: 'tool_signature',
70
- toolName: resolvedRequest.toolName,
71
- inputDigest: resolvedRequest.inputDigest,
72
- requestChannel: resolvedRequest.sourceChannel,
73
- decisionChannel,
74
- executionChannel: null,
75
- conversationId: resolvedRequest.sourceConversationId,
76
- callSessionId: resolvedRequest.callSessionId,
77
- guardianExternalUserId: guardianExternalUserId ?? null,
78
- expiresAt: new Date(Date.now() + GUARDIAN_ACTION_GRANT_TTL_MS).toISOString(),
79
- });
123
+ const result = mintGrantFromDecision({
124
+ assistantId: request.assistantId,
125
+ scopeMode: 'tool_signature',
126
+ toolName: request.toolName,
127
+ inputDigest: request.inputDigest,
128
+ requestChannel: request.sourceChannel,
129
+ decisionChannel,
130
+ executionChannel: null,
131
+ conversationId: request.sourceConversationId,
132
+ callSessionId: request.callSessionId,
133
+ guardianExternalUserId: guardianExternalUserId ?? null,
134
+ expiresAt: new Date(Date.now() + GUARDIAN_ACTION_GRANT_TTL_MS).toISOString(),
135
+ });
80
136
 
137
+ if (result.ok) {
81
138
  log.info(
82
139
  {
83
140
  event: 'guardian_action_grant_minted',
84
- toolName: resolvedRequest.toolName,
85
- requestId: resolvedRequest.id,
86
- callSessionId: resolvedRequest.callSessionId,
141
+ toolName: request.toolName,
142
+ requestId: request.id,
143
+ callSessionId: request.callSessionId,
87
144
  decisionChannel,
88
145
  },
89
146
  'Minted scoped approval grant for guardian-action answer resolution',
90
147
  );
91
- } catch (err) {
148
+ } else {
92
149
  log.error(
93
- { err, toolName: resolvedRequest.toolName, requestId: resolvedRequest.id },
150
+ { reason: result.reason, toolName: request.toolName, requestId: request.id },
94
151
  'Failed to mint scoped approval grant for guardian-action (non-fatal)',
95
152
  );
96
153
  }