@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.
- package/Dockerfile +2 -0
- package/README.md +37 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +70 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -17
- package/src/__tests__/channel-approvals.test.ts +48 -1
- package/src/__tests__/channel-guardian.test.ts +74 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/handlers-twilio-config.test.ts +407 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +22 -11
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +21 -6
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/system-prompt.ts +24 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/daemon/handlers/config.ts +783 -9
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +108 -4
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/ride-shotgun-handler.ts +1 -1
- package/src/daemon/server.ts +6 -2
- package/src/daemon/session-agent-loop.ts +5 -1
- package/src/daemon/session-runtime-assembly.ts +55 -0
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +11 -1
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-init.ts +144 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +4 -0
- package/src/memory/media-store.ts +759 -0
- package/src/memory/retriever.ts +6 -1
- package/src/memory/schema.ts +98 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +24 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +12 -4
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/http-server.ts +53 -27
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +67 -21
- package/src/runtime/run-orchestrator.ts +35 -2
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- 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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|