@vellumai/assistant 0.3.3 → 0.3.5

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 (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -13,6 +13,7 @@
13
13
  import { getPendingConfirmationsByConversation, getRun } from '../memory/runs-store.js';
14
14
  import type { PendingRunInfo } from '../memory/runs-store.js';
15
15
  import { addRule } from '../permissions/trust-store.js';
16
+ import { getTool } from '../tools/registry.js';
16
17
  import type { RunOrchestrator } from './run-orchestrator.js';
17
18
  import { DEFAULT_APPROVAL_ACTIONS } from './channel-approval-types.js';
18
19
  import type {
@@ -20,6 +21,7 @@ import type {
20
21
  ApprovalUIMetadata,
21
22
  ApprovalDecisionResult,
22
23
  } from './channel-approval-types.js';
24
+ import { composeApprovalMessage } from './approval-message-composer.js';
23
25
 
24
26
  // ---------------------------------------------------------------------------
25
27
  // 1. Detect pending confirmations and build prompt
@@ -46,13 +48,17 @@ export function getChannelApprovalPrompt(
46
48
  * Internal helper: turn a PendingRunInfo into a ChannelApprovalPrompt.
47
49
  */
48
50
  function buildPromptFromRunInfo(info: PendingRunInfo): ChannelApprovalPrompt {
49
- const promptText = `The assistant wants to use the tool "${info.toolName}". Do you want to allow this?`;
51
+ const promptText = composeApprovalMessage({
52
+ scenario: 'standard_prompt',
53
+ toolName: info.toolName,
54
+ });
50
55
 
51
56
  // Hide "approve always" when persistent trust rules are disallowed for this invocation.
52
57
  const actions = info.persistentDecisionsAllowed === false
53
58
  ? DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always')
54
59
  : [...DEFAULT_APPROVAL_ACTIONS];
55
60
 
61
+ // Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
56
62
  const plainTextFallback = info.persistentDecisionsAllowed === false
57
63
  ? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
58
64
  : `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
@@ -101,11 +107,17 @@ export function handleChannelDecision(
101
107
  conversationId: string,
102
108
  decision: ApprovalDecisionResult,
103
109
  orchestrator: RunOrchestrator,
110
+ decisionContext?: string,
104
111
  ): HandleDecisionResult {
105
112
  const pending = getPendingConfirmationsByConversation(conversationId);
106
113
  if (pending.length === 0) return { applied: false };
107
114
 
108
- const info = pending[0];
115
+ // Callback-based decisions include a run ID and must resolve to that exact
116
+ // pending confirmation. Plain-text decisions still apply to the first prompt.
117
+ const info = decision.runId
118
+ ? pending.find((candidate) => candidate.runId === decision.runId)
119
+ : pending[0];
120
+ if (!info) return { applied: false };
109
121
 
110
122
  if (decision.action === 'approve_always') {
111
123
  // Only persist a trust rule when the confirmation explicitly allows persistence
@@ -121,8 +133,13 @@ export function handleChannelDecision(
121
133
  ) {
122
134
  const pattern = confirmation.allowlistOptions[0].pattern;
123
135
  const scope = confirmation.scopeOptions[0].scope;
136
+ // Only persist executionTarget for skill-origin tools — core tools don't
137
+ // set it in their PolicyContext, so a persisted value would prevent the
138
+ // rule from ever matching on subsequent permission checks.
139
+ const tool = getTool(confirmation.toolName);
140
+ const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined;
124
141
  addRule(confirmation.toolName, pattern, scope, 'allow', 100, {
125
- executionTarget: confirmation.executionTarget,
142
+ executionTarget,
126
143
  });
127
144
  }
128
145
  // When persistence is not allowed or options are missing, the decision
@@ -131,7 +148,9 @@ export function handleChannelDecision(
131
148
 
132
149
  // Map channel-level action to the permission system's UserDecision type.
133
150
  const userDecision = decision.action === 'reject' ? 'deny' as const : 'allow' as const;
134
- const result = orchestrator.submitDecision(info.runId, userDecision);
151
+ const result = decisionContext === undefined
152
+ ? orchestrator.submitDecision(info.runId, userDecision)
153
+ : orchestrator.submitDecision(info.runId, userDecision, decisionContext);
135
154
 
136
155
  return {
137
156
  applied: result === 'applied',
@@ -152,8 +171,11 @@ export function buildGuardianApprovalPrompt(
152
171
  info: PendingRunInfo,
153
172
  requesterIdentifier: string,
154
173
  ): ChannelApprovalPrompt {
155
- const promptText =
156
- `User ${requesterIdentifier} is requesting to run "${info.toolName}". Approve or deny?`;
174
+ const promptText = composeApprovalMessage({
175
+ scenario: 'guardian_prompt',
176
+ toolName: info.toolName,
177
+ requesterIdentifier,
178
+ });
157
179
 
158
180
  // Guardian approvals are always one-time decisions — "approve always"
159
181
  // doesn't make sense when the guardian is approving on behalf of someone else.
@@ -197,7 +219,7 @@ export function channelSupportsRichApprovalUI(channel: string): boolean {
197
219
  export function buildReminderPrompt(
198
220
  pendingPrompt: ChannelApprovalPrompt,
199
221
  ): ChannelApprovalPrompt {
200
- const reminderPrefix = "I'm still waiting for your decision on the previous request.";
222
+ const reminderPrefix = composeApprovalMessage({ scenario: 'reminder_prompt' });
201
223
  return {
202
224
  promptText: `${reminderPrefix}\n\n${pendingPrompt.promptText}`,
203
225
  actions: pendingPrompt.actions,
@@ -20,6 +20,7 @@ import {
20
20
  resetRateLimit,
21
21
  } from '../memory/channel-guardian-store.js';
22
22
  import type { GuardianBinding } from '../memory/channel-guardian-store.js';
23
+ import { composeApprovalMessage } from './approval-message-composer.js';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Constants
@@ -44,6 +45,8 @@ const RATE_LIMIT_LOCKOUT_MS = 30 * 60 * 1000;
44
45
  export interface CreateChallengeResult {
45
46
  challengeId: string;
46
47
  secret: string;
48
+ verifyCommand: string;
49
+ ttlSeconds: number;
47
50
  instruction: string;
48
51
  }
49
52
 
@@ -63,19 +66,6 @@ function hashSecret(secret: string): string {
63
66
  // Service
64
67
  // ---------------------------------------------------------------------------
65
68
 
66
- /**
67
- * Build a human-readable channel label for use in guardian challenge
68
- * instructions. Avoids hardcoding "Telegram" so SMS and future
69
- * channels get appropriate wording.
70
- */
71
- function channelLabel(channel: string): string {
72
- switch (channel) {
73
- case 'telegram': return 'Telegram';
74
- case 'sms': return 'SMS';
75
- default: return channel;
76
- }
77
- }
78
-
79
69
  /**
80
70
  * Create a new verification challenge for a guardian candidate.
81
71
  *
@@ -102,12 +92,19 @@ export function createVerificationChallenge(
102
92
  createdBySessionId: sessionId,
103
93
  });
104
94
 
105
- const label = channelLabel(channel);
95
+ const verifyCommand = `/guardian_verify ${secret}`;
96
+ const ttlSeconds = CHALLENGE_TTL_MS / 1000;
106
97
 
107
98
  return {
108
99
  challengeId,
109
100
  secret,
110
- instruction: `Send \`/guardian_verify ${secret}\` to your bot via ${label} within 10 minutes.`,
101
+ verifyCommand,
102
+ ttlSeconds,
103
+ instruction: composeApprovalMessage({
104
+ scenario: 'guardian_verify_challenge_setup',
105
+ verifyCommand,
106
+ ttlSeconds,
107
+ }),
111
108
  };
112
109
  }
113
110
 
@@ -129,13 +126,21 @@ export function validateAndConsumeChallenge(
129
126
  secret: string,
130
127
  actorExternalUserId: string,
131
128
  actorChatId: string,
129
+ actorUsername?: string,
130
+ actorDisplayName?: string,
132
131
  ): ValidateChallengeResult {
133
132
  // ── Rate-limit check ──
134
133
  const existing = getRateLimit(assistantId, channel, actorExternalUserId, actorChatId);
135
134
  if (existing && existing.lockedUntil !== null && Date.now() < existing.lockedUntil) {
136
135
  // Use the same generic failure message to avoid leaking whether the
137
136
  // actor is rate-limited vs. the code is genuinely wrong.
138
- return { success: false, reason: 'Verification failed. Please try again later.' };
137
+ return {
138
+ success: false,
139
+ reason: composeApprovalMessage({
140
+ scenario: 'guardian_verify_failed',
141
+ failureReason: 'The verification code is invalid or has expired.',
142
+ }),
143
+ };
139
144
  }
140
145
 
141
146
  const challengeHash = hashSecret(secret);
@@ -146,7 +151,13 @@ export function validateAndConsumeChallenge(
146
151
  assistantId, channel, actorExternalUserId, actorChatId,
147
152
  RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX_ATTEMPTS, RATE_LIMIT_LOCKOUT_MS,
148
153
  );
149
- return { success: false, reason: 'Verification failed. Please try again later.' };
154
+ return {
155
+ success: false,
156
+ reason: composeApprovalMessage({
157
+ scenario: 'guardian_verify_failed',
158
+ failureReason: 'The verification code is invalid or has expired.',
159
+ }),
160
+ };
150
161
  }
151
162
 
152
163
  if (Date.now() > challenge.expiresAt) {
@@ -154,7 +165,13 @@ export function validateAndConsumeChallenge(
154
165
  assistantId, channel, actorExternalUserId, actorChatId,
155
166
  RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX_ATTEMPTS, RATE_LIMIT_LOCKOUT_MS,
156
167
  );
157
- return { success: false, reason: 'Verification failed. Please try again later.' };
168
+ return {
169
+ success: false,
170
+ reason: composeApprovalMessage({
171
+ scenario: 'guardian_verify_failed',
172
+ failureReason: 'The verification code is invalid or has expired.',
173
+ }),
174
+ };
158
175
  }
159
176
 
160
177
  // Consume the challenge so it cannot be reused
@@ -166,6 +183,14 @@ export function validateAndConsumeChallenge(
166
183
  // Revoke any existing active binding before creating a new one
167
184
  storeRevokeBinding(assistantId, channel);
168
185
 
186
+ const metadata: Record<string, string> = {};
187
+ if (actorUsername && actorUsername.trim().length > 0) {
188
+ metadata.username = actorUsername.trim();
189
+ }
190
+ if (actorDisplayName && actorDisplayName.trim().length > 0) {
191
+ metadata.displayName = actorDisplayName.trim();
192
+ }
193
+
169
194
  // Create the new guardian binding
170
195
  const binding = createBinding({
171
196
  assistantId,
@@ -173,6 +198,7 @@ export function validateAndConsumeChallenge(
173
198
  guardianExternalUserId: actorExternalUserId,
174
199
  guardianDeliveryChatId: actorChatId,
175
200
  verifiedVia: 'challenge',
201
+ metadataJson: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
176
202
  });
177
203
 
178
204
  return { success: true, bindingId: binding.id };
@@ -0,0 +1,292 @@
1
+ import type {
2
+ ChannelId,
3
+ ChannelProbe,
4
+ ChannelReadinessSnapshot,
5
+ ReadinessCheckResult,
6
+ } from './channel-readiness-types.js';
7
+ import {
8
+ hasTwilioCredentials,
9
+ getTollFreeVerificationStatus,
10
+ getPhoneNumberSid,
11
+ } from '../calls/twilio-rest.js';
12
+ import { getSecureKey } from '../security/secure-keys.js';
13
+ import { loadRawConfig } from '../config/loader.js';
14
+
15
+ /** Remote check results are cached for 5 minutes before being considered stale. */
16
+ export const REMOTE_TTL_MS = 5 * 60 * 1000;
17
+
18
+ // ── SMS Probe ───────────────────────────────────────────────────────────────
19
+
20
+ function hasIngressConfigured(): boolean {
21
+ try {
22
+ const raw = loadRawConfig();
23
+ const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
24
+ const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
25
+ const enabled = (ingress.enabled as boolean | undefined) ?? (publicBaseUrl ? true : false);
26
+ return enabled && publicBaseUrl.length > 0;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ const smsProbe: ChannelProbe = {
33
+ channel: 'sms',
34
+ runLocalChecks(): ReadinessCheckResult[] {
35
+ const results: ReadinessCheckResult[] = [];
36
+
37
+ const hasCreds = hasTwilioCredentials();
38
+ results.push({
39
+ name: 'twilio_credentials',
40
+ passed: hasCreds,
41
+ message: hasCreds
42
+ ? 'Twilio credentials are configured'
43
+ : 'Twilio Account SID and Auth Token are not configured',
44
+ });
45
+
46
+ let hasPhone = !!process.env.TWILIO_PHONE_NUMBER;
47
+ if (!hasPhone) {
48
+ try {
49
+ const raw = loadRawConfig();
50
+ const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
51
+ hasPhone = !!(smsConfig.phoneNumber as string);
52
+ } catch { /* ignore */ }
53
+ }
54
+ if (!hasPhone) {
55
+ hasPhone = !!getSecureKey('credential:twilio:phone_number');
56
+ }
57
+ results.push({
58
+ name: 'phone_number',
59
+ passed: hasPhone,
60
+ message: hasPhone
61
+ ? 'Phone number is assigned'
62
+ : 'No phone number assigned',
63
+ });
64
+
65
+ const hasIngress = hasIngressConfigured();
66
+ results.push({
67
+ name: 'ingress',
68
+ passed: hasIngress,
69
+ message: hasIngress
70
+ ? 'Public ingress URL is configured'
71
+ : 'Public ingress URL is not configured or disabled',
72
+ });
73
+
74
+ return results;
75
+ },
76
+ async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
77
+ if (!hasTwilioCredentials()) return [];
78
+
79
+ const accountSid = getSecureKey('credential:twilio:account_sid');
80
+ const authToken = getSecureKey('credential:twilio:auth_token');
81
+ if (!accountSid || !authToken) return [];
82
+
83
+ // Resolve the assigned phone number using fallback chain
84
+ const raw = loadRawConfig();
85
+ const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
86
+ const phoneNumber = (smsConfig.phoneNumber as string)
87
+ || getSecureKey('credential:twilio:phone_number')
88
+ || process.env.TWILIO_PHONE_NUMBER
89
+ || '';
90
+ if (!phoneNumber) return [];
91
+
92
+ // Only toll-free numbers need verification checks
93
+ const tollFreePrefixes = ['+1800', '+1833', '+1844', '+1855', '+1866', '+1877', '+1888'];
94
+ const isTollFree = tollFreePrefixes.some((prefix) => phoneNumber.startsWith(prefix));
95
+ if (!isTollFree) return [];
96
+
97
+ try {
98
+ const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
99
+ if (!phoneSid) {
100
+ return [{
101
+ name: 'toll_free_verification',
102
+ passed: false,
103
+ message: `Phone number ${phoneNumber} not found on Twilio account`,
104
+ }];
105
+ }
106
+
107
+ const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
108
+ if (!verification) {
109
+ return [{
110
+ name: 'toll_free_verification',
111
+ passed: false,
112
+ message: 'No toll-free verification submitted. Verification is required for SMS sending.',
113
+ }];
114
+ }
115
+
116
+ const approved = verification.status === 'TWILIO_APPROVED';
117
+ return [{
118
+ name: 'toll_free_verification',
119
+ passed: approved,
120
+ message: `toll_free_verification: ${verification.status}`,
121
+ }];
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ return [{
125
+ name: 'toll_free_verification',
126
+ passed: false,
127
+ message: `Failed to check toll-free verification: ${message}`,
128
+ }];
129
+ }
130
+ },
131
+ };
132
+
133
+ // ── Telegram Probe ──────────────────────────────────────────────────────────
134
+
135
+ const telegramProbe: ChannelProbe = {
136
+ channel: 'telegram',
137
+ runLocalChecks(): ReadinessCheckResult[] {
138
+ const results: ReadinessCheckResult[] = [];
139
+
140
+ const hasBotToken = !!getSecureKey('credential:telegram:bot_token');
141
+ results.push({
142
+ name: 'bot_token',
143
+ passed: hasBotToken,
144
+ message: hasBotToken
145
+ ? 'Telegram bot token is configured'
146
+ : 'Telegram bot token is not configured',
147
+ });
148
+
149
+ const hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
150
+ results.push({
151
+ name: 'webhook_secret',
152
+ passed: hasWebhookSecret,
153
+ message: hasWebhookSecret
154
+ ? 'Telegram webhook secret is configured'
155
+ : 'Telegram webhook secret is not configured',
156
+ });
157
+
158
+ const hasIngress = hasIngressConfigured();
159
+ results.push({
160
+ name: 'ingress',
161
+ passed: hasIngress,
162
+ message: hasIngress
163
+ ? 'Public ingress URL is configured'
164
+ : 'Public ingress URL is not configured or disabled',
165
+ });
166
+
167
+ return results;
168
+ },
169
+ // Telegram has no remote checks currently
170
+ };
171
+
172
+ // ── Service ─────────────────────────────────────────────────────────────────
173
+
174
+ export class ChannelReadinessService {
175
+ private probes = new Map<ChannelId, ChannelProbe>();
176
+ private snapshots = new Map<ChannelId, ChannelReadinessSnapshot>();
177
+
178
+ registerProbe(probe: ChannelProbe): void {
179
+ this.probes.set(probe.channel, probe);
180
+ }
181
+
182
+ /**
183
+ * Get readiness snapshots for the specified channel (or all registered channels).
184
+ * Local checks always run inline. Remote checks run only when `includeRemote`
185
+ * is true and the cache is stale or missing.
186
+ */
187
+ async getReadiness(
188
+ channel?: ChannelId,
189
+ includeRemote?: boolean,
190
+ ): Promise<ChannelReadinessSnapshot[]> {
191
+ const channels = channel
192
+ ? [channel]
193
+ : Array.from(this.probes.keys());
194
+
195
+ const results: ChannelReadinessSnapshot[] = [];
196
+ for (const ch of channels) {
197
+ const probe = this.probes.get(ch);
198
+ if (!probe) {
199
+ results.push(this.unsupportedSnapshot(ch));
200
+ continue;
201
+ }
202
+
203
+ const localChecks = probe.runLocalChecks();
204
+ let remoteChecks: ReadinessCheckResult[] | undefined;
205
+ let remoteChecksFreshlyFetched = false;
206
+ let stale = false;
207
+
208
+ const cached = this.snapshots.get(ch);
209
+ const now = Date.now();
210
+
211
+ if (includeRemote && probe.runRemoteChecks) {
212
+ const cacheExpired = !cached || !cached.remoteChecks || (now - cached.checkedAt) >= REMOTE_TTL_MS;
213
+ if (cacheExpired) {
214
+ remoteChecks = await probe.runRemoteChecks();
215
+ remoteChecksFreshlyFetched = true;
216
+ } else {
217
+ // Reuse cached remote checks
218
+ remoteChecks = cached.remoteChecks;
219
+ }
220
+ } else if (cached?.remoteChecks) {
221
+ // Surface cached remote checks even when not explicitly requested,
222
+ // but mark stale if TTL has elapsed
223
+ remoteChecks = cached.remoteChecks;
224
+ stale = (now - cached.checkedAt) >= REMOTE_TTL_MS;
225
+ }
226
+
227
+ const allLocalPassed = localChecks.every((c) => c.passed);
228
+ const allRemotePassed = remoteChecks ? remoteChecks.every((c) => c.passed) : true;
229
+ const ready = allLocalPassed && allRemotePassed;
230
+
231
+ const reasons: Array<{ code: string; text: string }> = [];
232
+ for (const check of localChecks) {
233
+ if (!check.passed) {
234
+ reasons.push({ code: check.name, text: check.message });
235
+ }
236
+ }
237
+ if (remoteChecks) {
238
+ for (const check of remoteChecks) {
239
+ if (!check.passed) {
240
+ reasons.push({ code: check.name, text: check.message });
241
+ }
242
+ }
243
+ }
244
+
245
+ const snapshot: ChannelReadinessSnapshot = {
246
+ channel: ch,
247
+ ready,
248
+ checkedAt: (remoteChecks && cached && !remoteChecksFreshlyFetched) ? cached.checkedAt : now,
249
+ stale,
250
+ reasons,
251
+ localChecks,
252
+ remoteChecks,
253
+ };
254
+
255
+ this.snapshots.set(ch, snapshot);
256
+ results.push(snapshot);
257
+ }
258
+
259
+ return results;
260
+ }
261
+
262
+ /** Clear cached snapshot for a specific channel, forcing re-evaluation on next call. */
263
+ invalidateChannel(channel: ChannelId): void {
264
+ this.snapshots.delete(channel);
265
+ }
266
+
267
+ /** Clear all cached snapshots. */
268
+ invalidateAll(): void {
269
+ this.snapshots.clear();
270
+ }
271
+
272
+ private unsupportedSnapshot(channel: ChannelId): ChannelReadinessSnapshot {
273
+ return {
274
+ channel,
275
+ ready: false,
276
+ checkedAt: Date.now(),
277
+ stale: false,
278
+ reasons: [{ code: 'unsupported_channel', text: `Channel ${channel} is not supported` }],
279
+ localChecks: [],
280
+ };
281
+ }
282
+ }
283
+
284
+ // ── Factory ─────────────────────────────────────────────────────────────────
285
+
286
+ /** Create a service instance with built-in SMS and Telegram probes registered. */
287
+ export function createReadinessService(): ChannelReadinessService {
288
+ const service = new ChannelReadinessService();
289
+ service.registerProbe(smsProbe);
290
+ service.registerProbe(telegramProbe);
291
+ return service;
292
+ }
@@ -0,0 +1,29 @@
1
+ // Channel readiness types — reusable primitive for all channels.
2
+
3
+ /** Logical channel identifier. Well-known channels have literal types; custom channels use string. */
4
+ export type ChannelId = 'sms' | 'telegram' | string;
5
+
6
+ /** Result of a single readiness check (local or remote). */
7
+ export interface ReadinessCheckResult {
8
+ name: string;
9
+ passed: boolean;
10
+ message: string;
11
+ }
12
+
13
+ /** Point-in-time snapshot of a channel's readiness state. */
14
+ export interface ChannelReadinessSnapshot {
15
+ channel: ChannelId;
16
+ ready: boolean;
17
+ checkedAt: number;
18
+ stale: boolean;
19
+ reasons: Array<{ code: string; text: string }>;
20
+ localChecks: ReadinessCheckResult[];
21
+ remoteChecks?: ReadinessCheckResult[];
22
+ }
23
+
24
+ /** Probe interface that channels implement to provide readiness checks. */
25
+ export interface ChannelProbe {
26
+ channel: ChannelId;
27
+ runLocalChecks(): ReadinessCheckResult[];
28
+ runRemoteChecks?(): Promise<ReadinessCheckResult[]>;
29
+ }
@@ -52,7 +52,8 @@ export async function deliverApprovalPrompt(
52
52
  chatId: string,
53
53
  text: string,
54
54
  approval: ApprovalUIMetadata,
55
+ assistantId?: string,
55
56
  bearerToken?: string,
56
57
  ): Promise<void> {
57
- await deliverChannelReply(callbackUrl, { chatId, text, approval }, bearerToken);
58
+ await deliverChannelReply(callbackUrl, { chatId, text, approval, assistantId }, bearerToken);
58
59
  }