@vellumai/assistant 0.3.4 → 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 (122) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +37 -2
  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 +70 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +21 -17
  12. package/src/__tests__/channel-approvals.test.ts +48 -1
  13. package/src/__tests__/channel-guardian.test.ts +74 -22
  14. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  15. package/src/__tests__/config-schema.test.ts +2 -1
  16. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  17. package/src/__tests__/daemon-lifecycle.test.ts +13 -12
  18. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  19. package/src/__tests__/entity-search.test.ts +615 -0
  20. package/src/__tests__/handlers-twilio-config.test.ts +407 -0
  21. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  22. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  23. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  24. package/src/__tests__/run-orchestrator.test.ts +22 -0
  25. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  26. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  27. package/src/__tests__/twilio-routes.test.ts +39 -3
  28. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  29. package/src/__tests__/web-search.test.ts +1 -1
  30. package/src/__tests__/work-item-output.test.ts +110 -0
  31. package/src/calls/call-domain.ts +8 -5
  32. package/src/calls/call-orchestrator.ts +22 -11
  33. package/src/calls/twilio-config.ts +17 -11
  34. package/src/calls/twilio-rest.ts +276 -0
  35. package/src/calls/twilio-routes.ts +39 -1
  36. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  37. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  38. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  39. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  40. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  41. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  42. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  43. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  44. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  45. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  46. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  47. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  48. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  49. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  50. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  51. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  52. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  53. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  54. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  55. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  56. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  57. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  58. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  59. package/src/config/bundled-skills/messaging/SKILL.md +21 -6
  60. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  61. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  62. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  63. package/src/config/defaults.ts +2 -1
  64. package/src/config/schema.ts +9 -3
  65. package/src/config/system-prompt.ts +24 -0
  66. package/src/config/templates/IDENTITY.md +2 -2
  67. package/src/config/vellum-skills/catalog.json +6 -0
  68. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  69. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  70. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  71. package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
  72. package/src/daemon/handlers/config.ts +783 -9
  73. package/src/daemon/handlers/dictation.ts +182 -0
  74. package/src/daemon/handlers/identity.ts +14 -23
  75. package/src/daemon/handlers/index.ts +2 -0
  76. package/src/daemon/handlers/sessions.ts +2 -0
  77. package/src/daemon/handlers/shared.ts +3 -0
  78. package/src/daemon/handlers/work-items.ts +15 -7
  79. package/src/daemon/ipc-contract-inventory.json +10 -0
  80. package/src/daemon/ipc-contract.ts +108 -4
  81. package/src/daemon/lifecycle.ts +2 -0
  82. package/src/daemon/ride-shotgun-handler.ts +1 -1
  83. package/src/daemon/server.ts +6 -2
  84. package/src/daemon/session-agent-loop.ts +5 -1
  85. package/src/daemon/session-runtime-assembly.ts +55 -0
  86. package/src/daemon/session-tool-setup.ts +2 -0
  87. package/src/daemon/session.ts +11 -1
  88. package/src/inbound/public-ingress-urls.ts +3 -3
  89. package/src/memory/channel-guardian-store.ts +2 -1
  90. package/src/memory/db-init.ts +144 -0
  91. package/src/memory/job-handlers/media-processing.ts +100 -0
  92. package/src/memory/jobs-store.ts +2 -1
  93. package/src/memory/jobs-worker.ts +4 -0
  94. package/src/memory/media-store.ts +759 -0
  95. package/src/memory/retriever.ts +6 -1
  96. package/src/memory/schema.ts +98 -0
  97. package/src/memory/search/entity.ts +208 -25
  98. package/src/memory/search/ranking.ts +6 -1
  99. package/src/memory/search/types.ts +24 -0
  100. package/src/messaging/provider-types.ts +2 -0
  101. package/src/messaging/providers/sms/adapter.ts +204 -0
  102. package/src/messaging/providers/sms/client.ts +93 -0
  103. package/src/messaging/providers/sms/types.ts +7 -0
  104. package/src/permissions/checker.ts +16 -2
  105. package/src/runtime/approval-message-composer.ts +143 -0
  106. package/src/runtime/channel-approvals.ts +12 -4
  107. package/src/runtime/channel-guardian-service.ts +44 -18
  108. package/src/runtime/channel-readiness-service.ts +292 -0
  109. package/src/runtime/channel-readiness-types.ts +29 -0
  110. package/src/runtime/http-server.ts +53 -27
  111. package/src/runtime/http-types.ts +3 -0
  112. package/src/runtime/routes/call-routes.ts +2 -1
  113. package/src/runtime/routes/channel-routes.ts +67 -21
  114. package/src/runtime/run-orchestrator.ts +35 -2
  115. package/src/tools/assets/materialize.ts +2 -2
  116. package/src/tools/calls/call-start.ts +1 -0
  117. package/src/tools/credentials/vault.ts +1 -1
  118. package/src/tools/execution-target.ts +11 -1
  119. package/src/tools/network/web-search.ts +1 -1
  120. package/src/tools/types.ts +2 -0
  121. package/src/twitter/router.ts +1 -1
  122. package/src/util/platform.ts +35 -0
@@ -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
+ }
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  import { timingSafeEqual } from 'node:crypto';
12
12
  import { ConfigError, IngressBlockedError } from '../util/errors.js';
13
13
  import { getLogger } from '../util/logger.js';
14
- import { getWorkspacePromptPath } from '../util/platform.js';
14
+ import { getWorkspacePromptPath, readLockfile } from '../util/platform.js';
15
15
  import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
16
16
  import { loadConfig } from '../config/loader.js';
17
17
  import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
@@ -74,6 +74,7 @@ import { RelayConnection, activeRelayConnections } from '../calls/relay-server.j
74
74
  import type { RelayWebSocketData } from '../calls/relay-server.js';
75
75
  import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
76
76
  import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
77
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
77
78
 
78
79
  // Re-export shared types so existing consumers don't need to update imports
79
80
  export type {
@@ -107,6 +108,37 @@ function getGatewayBaseUrl(): string {
107
108
  /** Global hard cap on request body size (50 MB). Bun rejects larger payloads before they reach handlers. */
108
109
  const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
109
110
 
111
+ function parseGuardianRuntimeContext(value: unknown): GuardianRuntimeContext | undefined {
112
+ if (!value || typeof value !== 'object') return undefined;
113
+ const raw = value as Record<string, unknown>;
114
+ const actorRole = raw.actorRole;
115
+ if (
116
+ actorRole !== 'guardian'
117
+ && actorRole !== 'non-guardian'
118
+ && actorRole !== 'unverified_channel'
119
+ ) {
120
+ return undefined;
121
+ }
122
+ const sourceChannel = typeof raw.sourceChannel === 'string' && raw.sourceChannel.trim().length > 0
123
+ ? raw.sourceChannel
124
+ : undefined;
125
+ if (!sourceChannel) return undefined;
126
+ const denialReason =
127
+ raw.denialReason === 'no_binding' || raw.denialReason === 'no_identity'
128
+ ? raw.denialReason
129
+ : undefined;
130
+ return {
131
+ sourceChannel,
132
+ actorRole,
133
+ guardianChatId: typeof raw.guardianChatId === 'string' ? raw.guardianChatId : undefined,
134
+ guardianExternalUserId: typeof raw.guardianExternalUserId === 'string' ? raw.guardianExternalUserId : undefined,
135
+ requesterIdentifier: typeof raw.requesterIdentifier === 'string' ? raw.requesterIdentifier : undefined,
136
+ requesterExternalUserId: typeof raw.requesterExternalUserId === 'string' ? raw.requesterExternalUserId : undefined,
137
+ requesterChatId: typeof raw.requesterChatId === 'string' ? raw.requesterChatId : undefined,
138
+ denialReason,
139
+ };
140
+ }
141
+
110
142
  interface DiskSpaceInfo {
111
143
  path: string;
112
144
  totalMb: number;
@@ -762,7 +794,7 @@ export class RuntimeHttpServer {
762
794
 
763
795
  // ── Call API routes ───────────────────────────────────────────
764
796
  if (endpoint === 'calls/start' && req.method === 'POST') {
765
- return await handleStartCall(req);
797
+ return await handleStartCall(req, assistantId);
766
798
  }
767
799
 
768
800
  // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer, calls/:callSessionId/instruction
@@ -907,6 +939,10 @@ export class RuntimeHttpServer {
907
939
  const attachmentIds = Array.isArray(payload.attachmentIds) ? payload.attachmentIds as string[] : undefined;
908
940
  const sourceChannel = payload.sourceChannel as string;
909
941
  const sourceMetadata = payload.sourceMetadata as Record<string, unknown> | undefined;
942
+ const assistantId = typeof payload.assistantId === 'string'
943
+ ? payload.assistantId
944
+ : undefined;
945
+ const guardianContext = parseGuardianRuntimeContext(payload.guardianCtx);
910
946
 
911
947
  const metadataHintsRaw = sourceMetadata?.hints;
912
948
  const metadataHints = Array.isArray(metadataHintsRaw)
@@ -927,6 +963,8 @@ export class RuntimeHttpServer {
927
963
  hints: metadataHints.length > 0 ? metadataHints : undefined,
928
964
  uxBrief: metadataUxBrief,
929
965
  },
966
+ assistantId,
967
+ guardianContext,
930
968
  },
931
969
  );
932
970
  channelDeliveryStore.linkMessage(event.id, userMessageId);
@@ -940,9 +978,6 @@ export class RuntimeHttpServer {
940
978
  const externalChatId = typeof payload.externalChatId === 'string'
941
979
  ? payload.externalChatId
942
980
  : undefined;
943
- const assistantId = typeof payload.assistantId === 'string'
944
- ? payload.assistantId
945
- : undefined;
946
981
  if (externalChatId) {
947
982
  await this.deliverReplyViaCallback(
948
983
  event.conversationId,
@@ -1046,28 +1081,19 @@ export class RuntimeHttpServer {
1046
1081
  let cloud: string | undefined;
1047
1082
  let originSystem: string | undefined;
1048
1083
  try {
1049
- const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
1050
- const lockfilePaths = [
1051
- join(homedir, '.vellum.lock.json'),
1052
- join(homedir, '.vellum.lockfile.json'),
1053
- ];
1054
- for (const lockPath of lockfilePaths) {
1055
- if (!existsSync(lockPath)) continue;
1056
- const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
1057
- const assistants = lockData.assistants as Array<Record<string, unknown>> | undefined;
1058
- if (assistants && assistants.length > 0) {
1059
- // Use the most recently hatched assistant
1060
- const sorted = [...assistants].sort((a, b) => {
1061
- const dateA = new Date(a.hatchedAt as string || 0).getTime();
1062
- const dateB = new Date(b.hatchedAt as string || 0).getTime();
1063
- return dateB - dateA;
1064
- });
1065
- const latest = sorted[0];
1066
- assistantId = latest.assistantId as string | undefined;
1067
- cloud = latest.cloud as string | undefined;
1068
- originSystem = cloud === 'local' ? 'local' : cloud;
1069
- }
1070
- break;
1084
+ const lockData = readLockfile();
1085
+ const assistants = lockData?.assistants as Array<Record<string, unknown>> | undefined;
1086
+ if (assistants && assistants.length > 0) {
1087
+ // Use the most recently hatched assistant
1088
+ const sorted = [...assistants].sort((a, b) => {
1089
+ const dateA = new Date(a.hatchedAt as string || 0).getTime();
1090
+ const dateB = new Date(b.hatchedAt as string || 0).getTime();
1091
+ return dateB - dateA;
1092
+ });
1093
+ const latest = sorted[0];
1094
+ assistantId = latest.assistantId as string | undefined;
1095
+ cloud = latest.cloud as string | undefined;
1096
+ originSystem = cloud === 'local' ? 'local' : cloud;
1071
1097
  }
1072
1098
  } catch {
1073
1099
  // ignore — lockfile may not exist
@@ -2,6 +2,7 @@
2
2
  * Shared types for the runtime HTTP server and its route handlers.
3
3
  */
4
4
  import type { RunOrchestrator } from './run-orchestrator.js';
5
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
5
6
 
6
7
  export interface RuntimeMessageSessionOptions {
7
8
  transport?: {
@@ -9,6 +10,8 @@ export interface RuntimeMessageSessionOptions {
9
10
  hints?: string[];
10
11
  uxBrief?: string;
11
12
  };
13
+ assistantId?: string;
14
+ guardianContext?: GuardianRuntimeContext;
12
15
  }
13
16
 
14
17
  export type MessageProcessor = (
@@ -17,7 +17,7 @@ import { VALID_CALLER_IDENTITY_MODES } from '../../config/schema.js';
17
17
  *
18
18
  * Body: { phoneNumber: string; task: string; context?: string; conversationId: string; callerIdentityMode?: 'assistant_number' | 'user_number' }
19
19
  */
20
- export async function handleStartCall(req: Request): Promise<Response> {
20
+ export async function handleStartCall(req: Request, assistantId: string = 'self'): Promise<Response> {
21
21
  if (!getConfig().calls.enabled) {
22
22
  return Response.json(
23
23
  { error: 'Calls feature is disabled via configuration. Set calls.enabled to true to use this feature.' },
@@ -59,6 +59,7 @@ export async function handleStartCall(req: Request): Promise<Response> {
59
59
  task: body.task ?? '',
60
60
  context: body.context,
61
61
  conversationId: body.conversationId,
62
+ assistantId,
62
63
  callerIdentityMode: body.callerIdentityMode,
63
64
  });
64
65