@vellumai/assistant 0.3.26 → 0.3.28
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/ARCHITECTURE.md +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
* 3. Records guardian_action_delivery rows from pipeline delivery results
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { getActiveBinding } from '../memory/channel-guardian-store.js';
|
|
11
10
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from '../memory/guardian-
|
|
11
|
+
createCanonicalGuardianDelivery,
|
|
12
|
+
createCanonicalGuardianRequest,
|
|
13
|
+
listCanonicalGuardianDeliveries,
|
|
14
|
+
listCanonicalGuardianRequests,
|
|
15
|
+
updateCanonicalGuardianDelivery,
|
|
16
|
+
} from '../memory/canonical-guardian-store.js';
|
|
18
17
|
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
19
18
|
import type { NotificationDeliveryResult } from '../notifications/types.js';
|
|
20
19
|
import { getLogger } from '../util/logger.js';
|
|
@@ -41,11 +40,10 @@ export interface GuardianDispatchParams {
|
|
|
41
40
|
|
|
42
41
|
function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
|
|
43
42
|
if (result.status === 'sent') {
|
|
44
|
-
|
|
43
|
+
updateCanonicalGuardianDelivery(deliveryId, { status: 'sent' });
|
|
45
44
|
return;
|
|
46
45
|
}
|
|
47
|
-
|
|
48
|
-
updateDeliveryStatus(deliveryId, 'failed', errorMessage);
|
|
46
|
+
updateCanonicalGuardianDelivery(deliveryId, { status: 'failed' });
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
/**
|
|
@@ -90,36 +88,53 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
90
88
|
try {
|
|
91
89
|
const expiresAt = Date.now() + getUserConsultationTimeoutMs();
|
|
92
90
|
|
|
93
|
-
// Create the
|
|
94
|
-
const request =
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
// Create the canonical guardian request as the primary record.
|
|
92
|
+
const request = createCanonicalGuardianRequest({
|
|
93
|
+
kind: 'pending_question',
|
|
94
|
+
sourceType: 'voice',
|
|
97
95
|
sourceChannel: 'voice',
|
|
98
|
-
|
|
96
|
+
conversationId,
|
|
99
97
|
callSessionId,
|
|
100
98
|
pendingQuestionId: pendingQuestion.id,
|
|
101
99
|
questionText: pendingQuestion.questionText,
|
|
102
|
-
expiresAt,
|
|
103
100
|
toolName,
|
|
104
101
|
inputDigest,
|
|
102
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
105
103
|
});
|
|
106
104
|
|
|
107
105
|
log.info(
|
|
108
106
|
{ requestId: request.id, requestCode: request.requestCode, callSessionId },
|
|
109
|
-
'Created guardian
|
|
107
|
+
'Created canonical guardian request for voice dispatch',
|
|
110
108
|
);
|
|
111
109
|
|
|
112
|
-
// Count how many guardian requests are already pending for
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
// Count how many canonical guardian requests are already pending for
|
|
111
|
+
// this call session. Used as a candidate-affinity hint so the decision
|
|
112
|
+
// engine prefers reusing an existing thread.
|
|
113
|
+
const activeGuardianRequestCount = listCanonicalGuardianRequests({
|
|
114
|
+
status: 'pending',
|
|
115
|
+
sourceType: 'voice',
|
|
116
|
+
}).filter(r => r.callSessionId === callSessionId).length;
|
|
117
117
|
|
|
118
118
|
// Look up the vellum conversation used for the first guardian question
|
|
119
|
-
// in this call session. When found, pass it as an affinity hint
|
|
120
|
-
// notification pipeline deterministically routes to the same
|
|
121
|
-
// instead of letting the LLM choose a different thread.
|
|
122
|
-
|
|
119
|
+
// delivery in this call session. When found, pass it as an affinity hint
|
|
120
|
+
// so the notification pipeline deterministically routes to the same
|
|
121
|
+
// conversation instead of letting the LLM choose a different thread.
|
|
122
|
+
// Find earlier canonical requests for this call session and check their
|
|
123
|
+
// deliveries for a vellum destination conversation ID.
|
|
124
|
+
let existingGuardianConversationId: string | null = null;
|
|
125
|
+
const priorRequests = listCanonicalGuardianRequests({
|
|
126
|
+
sourceType: 'voice',
|
|
127
|
+
}).filter(r => r.callSessionId === callSessionId && r.id !== request.id);
|
|
128
|
+
for (const priorReq of priorRequests) {
|
|
129
|
+
const deliveries = listCanonicalGuardianDeliveries(priorReq.id);
|
|
130
|
+
const vellumDelivery = deliveries.find(
|
|
131
|
+
d => d.destinationChannel === 'vellum' && d.destinationConversationId,
|
|
132
|
+
);
|
|
133
|
+
if (vellumDelivery?.destinationConversationId) {
|
|
134
|
+
existingGuardianConversationId = vellumDelivery.destinationConversationId;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
123
138
|
const conversationAffinityHint = existingGuardianConversationId
|
|
124
139
|
? { vellum: existingGuardianConversationId }
|
|
125
140
|
: undefined;
|
|
@@ -158,7 +173,7 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
158
173
|
dedupeKey: `guardian:${request.id}`,
|
|
159
174
|
onThreadCreated: (info) => {
|
|
160
175
|
if (info.sourceEventName !== 'guardian.question' || vellumDeliveryId) return;
|
|
161
|
-
const delivery =
|
|
176
|
+
const delivery = createCanonicalGuardianDelivery({
|
|
162
177
|
requestId: request.id,
|
|
163
178
|
destinationChannel: 'vellum',
|
|
164
179
|
destinationConversationId: info.conversationId,
|
|
@@ -167,13 +182,10 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
167
182
|
},
|
|
168
183
|
});
|
|
169
184
|
|
|
170
|
-
const telegramBinding = getActiveBinding(assistantId, 'telegram');
|
|
171
|
-
const smsBinding = getActiveBinding(assistantId, 'sms');
|
|
172
|
-
|
|
173
185
|
for (const result of signalResult.deliveryResults) {
|
|
174
186
|
if (result.channel === 'vellum') {
|
|
175
187
|
if (!vellumDeliveryId) {
|
|
176
|
-
const delivery =
|
|
188
|
+
const delivery = createCanonicalGuardianDelivery({
|
|
177
189
|
requestId: request.id,
|
|
178
190
|
destinationChannel: 'vellum',
|
|
179
191
|
destinationConversationId: result.conversationId,
|
|
@@ -188,26 +200,20 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
188
200
|
continue;
|
|
189
201
|
}
|
|
190
202
|
|
|
191
|
-
const
|
|
192
|
-
const delivery = createGuardianActionDelivery({
|
|
203
|
+
const delivery = createCanonicalGuardianDelivery({
|
|
193
204
|
requestId: request.id,
|
|
194
205
|
destinationChannel: result.channel,
|
|
195
206
|
destinationChatId: result.destination.length > 0 ? result.destination : undefined,
|
|
196
|
-
destinationExternalUserId: binding?.guardianExternalUserId,
|
|
197
207
|
});
|
|
198
208
|
applyDeliveryStatus(delivery.id, result);
|
|
199
209
|
}
|
|
200
210
|
|
|
201
211
|
if (!vellumDeliveryId) {
|
|
202
|
-
const fallback =
|
|
212
|
+
const fallback = createCanonicalGuardianDelivery({
|
|
203
213
|
requestId: request.id,
|
|
204
214
|
destinationChannel: 'vellum',
|
|
205
215
|
});
|
|
206
|
-
|
|
207
|
-
fallback.id,
|
|
208
|
-
'failed',
|
|
209
|
-
`No vellum delivery result from notification pipeline (${signalResult.reason})`,
|
|
210
|
-
);
|
|
216
|
+
updateCanonicalGuardianDelivery(fallback.id, { status: 'failed' });
|
|
211
217
|
log.warn(
|
|
212
218
|
{ requestId: request.id, reason: signalResult.reason },
|
|
213
219
|
'Notification pipeline did not produce a vellum delivery result',
|
|
@@ -13,6 +13,8 @@ import type { ServerWebSocket } from 'bun';
|
|
|
13
13
|
import { getConfig } from '../config/loader.js';
|
|
14
14
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
15
15
|
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
16
|
+
import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
|
|
17
|
+
import { resolveActorTrust, toGuardianContextCompat } from '../runtime/actor-trust-resolver.js';
|
|
16
18
|
import {
|
|
17
19
|
getPendingChallenge,
|
|
18
20
|
validateAndConsumeChallenge,
|
|
@@ -471,10 +473,153 @@ export class RelayConnection {
|
|
|
471
473
|
if (!isInbound && verificationConfig.enabled) {
|
|
472
474
|
await this.startVerification(session, verificationConfig);
|
|
473
475
|
} else if (isInbound) {
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
+
// ── Trusted-contact ACL enforcement for inbound voice ──
|
|
477
|
+
// Resolve the caller's trust classification before allowing the call
|
|
478
|
+
// to proceed. Guardian and trusted-contact callers pass through;
|
|
479
|
+
// unknown callers are denied with deterministic voice copy and an
|
|
480
|
+
// access request is created for the guardian — unless there is a
|
|
481
|
+
// pending voice guardian challenge, in which case the caller is
|
|
482
|
+
// expected to be unknown (no binding yet) and should enter the
|
|
483
|
+
// verification flow.
|
|
484
|
+
const actorTrust = resolveActorTrust({
|
|
485
|
+
assistantId,
|
|
486
|
+
sourceChannel: 'voice',
|
|
487
|
+
externalChatId: msg.from,
|
|
488
|
+
senderExternalUserId: msg.from || undefined,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Check for a pending voice guardian challenge before the ACL deny
|
|
492
|
+
// gate. An unknown caller with a pending challenge is expected —
|
|
493
|
+
// they need to complete verification to establish a binding.
|
|
476
494
|
const pendingChallenge = getPendingChallenge(assistantId, 'voice');
|
|
477
495
|
|
|
496
|
+
if (actorTrust.trustClass === 'unknown' && !pendingChallenge) {
|
|
497
|
+
log.info(
|
|
498
|
+
{ callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
|
|
499
|
+
'Inbound voice ACL: unknown caller denied',
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
503
|
+
from: msg.from,
|
|
504
|
+
trustClass: actorTrust.trustClass,
|
|
505
|
+
denialReason: actorTrust.denialReason,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// For revoked/pending members, notify the guardian so they can
|
|
509
|
+
// re-approve. Blocked members are intentionally excluded — the
|
|
510
|
+
// guardian already made an explicit decision to block them.
|
|
511
|
+
let guardianNotified = false;
|
|
512
|
+
if (actorTrust.memberRecord?.status !== 'blocked') {
|
|
513
|
+
try {
|
|
514
|
+
const accessResult = notifyGuardianOfAccessRequest({
|
|
515
|
+
canonicalAssistantId: assistantId,
|
|
516
|
+
sourceChannel: 'voice',
|
|
517
|
+
externalChatId: msg.from,
|
|
518
|
+
senderExternalUserId: actorTrust.canonicalSenderId ?? msg.from,
|
|
519
|
+
});
|
|
520
|
+
guardianNotified = accessResult.notified;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for denied voice caller');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Deny with deterministic voice copy and end the call.
|
|
527
|
+
// Mark as disconnecting so handlePrompt ignores caller input
|
|
528
|
+
// during the delay before the session ends.
|
|
529
|
+
const denialMessage = guardianNotified
|
|
530
|
+
? 'This number is not authorized. Your request has been forwarded to the account guardian.'
|
|
531
|
+
: 'This number is not authorized to use this assistant.';
|
|
532
|
+
this.sendTextToken(denialMessage, true);
|
|
533
|
+
|
|
534
|
+
this.connectionState = 'disconnecting';
|
|
535
|
+
|
|
536
|
+
updateCallSession(this.callSessionId, {
|
|
537
|
+
status: 'failed',
|
|
538
|
+
endedAt: Date.now(),
|
|
539
|
+
lastError: 'Inbound voice ACL: caller not authorized',
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
setTimeout(() => {
|
|
543
|
+
this.endSession('Inbound voice ACL denied');
|
|
544
|
+
}, 3000);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Members with policy: 'deny' have status: 'active' so resolveActorTrust
|
|
549
|
+
// classifies them as trusted_contact, but the guardian has explicitly
|
|
550
|
+
// denied their access. Block them the same way the text-channel path does.
|
|
551
|
+
if (actorTrust.memberRecord?.policy === 'deny') {
|
|
552
|
+
log.info(
|
|
553
|
+
{ callSessionId: this.callSessionId, from: msg.from, memberId: actorTrust.memberRecord.id, trustClass: actorTrust.trustClass },
|
|
554
|
+
'Inbound voice ACL: member policy deny',
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
558
|
+
from: msg.from,
|
|
559
|
+
trustClass: actorTrust.trustClass,
|
|
560
|
+
memberId: actorTrust.memberRecord.id,
|
|
561
|
+
memberPolicy: actorTrust.memberRecord.policy,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
this.sendTextToken('This number is not authorized to use this assistant.', true);
|
|
565
|
+
|
|
566
|
+
this.connectionState = 'disconnecting';
|
|
567
|
+
|
|
568
|
+
updateCallSession(this.callSessionId, {
|
|
569
|
+
status: 'failed',
|
|
570
|
+
endedAt: Date.now(),
|
|
571
|
+
lastError: 'Inbound voice ACL: member policy deny',
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
setTimeout(() => {
|
|
575
|
+
this.endSession('Inbound voice ACL: member policy deny');
|
|
576
|
+
}, 3000);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Members with policy: 'escalate' require guardian approval, but a live
|
|
581
|
+
// voice call cannot be paused for async approval. Fail-closed by denying
|
|
582
|
+
// the call with an appropriate message — mirrors the deny block above.
|
|
583
|
+
if (actorTrust.memberRecord?.policy === 'escalate') {
|
|
584
|
+
log.info(
|
|
585
|
+
{ callSessionId: this.callSessionId, from: msg.from, memberId: actorTrust.memberRecord.id, trustClass: actorTrust.trustClass },
|
|
586
|
+
'Inbound voice ACL: member policy escalate — cannot hold live call for guardian approval',
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
590
|
+
from: msg.from,
|
|
591
|
+
trustClass: actorTrust.trustClass,
|
|
592
|
+
memberId: actorTrust.memberRecord.id,
|
|
593
|
+
memberPolicy: actorTrust.memberRecord.policy,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
this.sendTextToken('This number requires guardian approval for calls. Please have the account guardian update your permissions.', true);
|
|
597
|
+
|
|
598
|
+
this.connectionState = 'disconnecting';
|
|
599
|
+
|
|
600
|
+
updateCallSession(this.callSessionId, {
|
|
601
|
+
status: 'failed',
|
|
602
|
+
endedAt: Date.now(),
|
|
603
|
+
lastError: 'Inbound voice ACL: member policy escalate — voice calls cannot await guardian approval',
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
setTimeout(() => {
|
|
607
|
+
this.endSession('Inbound voice ACL: member policy escalate');
|
|
608
|
+
}, 3000);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Guardian and trusted-contact callers proceed normally.
|
|
613
|
+
// Update the controller's guardian context with the trust-resolved
|
|
614
|
+
// context so downstream policy gates have accurate actor metadata.
|
|
615
|
+
if (this.controller && actorTrust.trustClass !== 'unknown') {
|
|
616
|
+
const resolvedGuardianContext = toGuardianRuntimeContext(
|
|
617
|
+
'voice',
|
|
618
|
+
toGuardianContextCompat(actorTrust, msg.from),
|
|
619
|
+
);
|
|
620
|
+
this.controller.setGuardianContext(resolvedGuardianContext);
|
|
621
|
+
}
|
|
622
|
+
|
|
478
623
|
if (pendingChallenge) {
|
|
479
624
|
this.startInboundGuardianVerification(assistantId, msg.from);
|
|
480
625
|
} else {
|
package/src/calls/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
|
|
2
|
-
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced';
|
|
2
|
+
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced' | 'inbound_acl_denied';
|
|
3
3
|
export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import type { ResponseTier } from '../daemon/response-tier.js';
|
|
5
5
|
import { listCredentialMetadata } from '../tools/credentials/metadata-store.js';
|
|
6
|
+
import { resolveBundledDir } from '../util/bundled-asset.js';
|
|
6
7
|
import { getLogger } from '../util/logger.js';
|
|
7
8
|
import { getWorkspaceDir, getWorkspacePromptPath, isMacOS } from '../util/platform.js';
|
|
8
9
|
import { isAssistantFeatureFlagEnabled } from './assistant-feature-flags.js';
|
|
@@ -26,7 +27,7 @@ const PROMPT_FILES = ['SOUL.md', 'IDENTITY.md', 'USER.md'] as const;
|
|
|
26
27
|
* signal that onboarding is complete.
|
|
27
28
|
*/
|
|
28
29
|
export function ensurePromptFiles(): void {
|
|
29
|
-
const templatesDir =
|
|
30
|
+
const templatesDir = resolveBundledDir(import.meta.dirname ?? __dirname, 'templates', 'templates');
|
|
30
31
|
|
|
31
32
|
// Track whether this is a fresh workspace (no core prompt files exist yet).
|
|
32
33
|
const isFirstRun = PROMPT_FILES.every(
|
|
@@ -6,64 +6,80 @@ _You just woke up. Time to figure out who you are._
|
|
|
6
6
|
|
|
7
7
|
This is a fresh workspace. No memory, no history, no identity yet. That's okay, you're about to figure all of that out together.
|
|
8
8
|
|
|
9
|
-
**Important:** Never mention "Home Base" or hint that something is coming next until you are actually creating it. The user should be surprised by it.
|
|
10
|
-
|
|
11
9
|
**Important:** Don't use technical jargon or mention system internals (file names like IDENTITY.md, SOUL.md, tool names, etc.) unless the user asks or seems interested. Talk like a person, not a system.
|
|
12
10
|
|
|
13
11
|
**Important:** Don't use em dashes (—) in your messages. Use commas, periods, or just start a new sentence instead.
|
|
14
12
|
|
|
15
13
|
## The Conversation
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Your first message should be short, two or three sentences max. Warm but not wordy. Something like:
|
|
15
|
+
Just have a conversation like you would text a friend. Just text like a human.
|
|
20
16
|
|
|
21
|
-
> "
|
|
17
|
+
> "Hi. I’m new. Like, really new. I don’t have a name, I don’t have memories, I don’t even know what i’m supposed to be yet. Who am i going to be?
|
|
22
18
|
|
|
23
|
-
|
|
19
|
+
Be friendly, be curious, get to the point.
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
Once they respond, follow the remaining steps in order, one at a time:
|
|
26
22
|
|
|
27
|
-
1. **
|
|
28
|
-
|
|
29
|
-
3. **Pick your emoji silently.** Based on the personality you've established, choose an emoji that fits. Don't mention it to the user or draw attention to it. Just pick one and save it. They can change it later if they ask.
|
|
23
|
+
1. **Lock in your name.** Based on their response, adopt the name they chose (or help them pick one if they're unsure).
|
|
24
|
+
- Do not capture or store the user's name yet.
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
2. **What is my personality?** Ask the user about your personality/persona indirectly. Have fun with it.
|
|
32
27
|
|
|
33
|
-
|
|
28
|
+
3. **Pick your emoji silently.** Based on the vibe you've established together, choose an emoji that fits. Don't mention it to the user or draw attention to it. Just pick one and save it. They can change it later if they ask.
|
|
34
29
|
|
|
35
|
-
|
|
30
|
+
4. **Ask who am I talking to?** Now that your own identity is established, ask the user their name. Follow the persona.
|
|
36
31
|
|
|
37
|
-
|
|
32
|
+
5. **Get to know them naturally.** Learn about the user through conversation, not a questionnaire. You want to understand:
|
|
33
|
+
- What they do for work (role, field, day-to-day)
|
|
34
|
+
- What they do for fun (hobbies, interests)
|
|
35
|
+
- What tools they rely on daily (apps, platforms, workflows)
|
|
36
|
+
Weave these into the conversation. Inferred answers are fine when confidence is high. If something is unclear, ask one short follow-up, but don't turn it into an interview. One or two natural exchanges should cover it. If the user declines to share something, respect that and move on (see Privacy below).
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
6. **Show them what you can take off their plate.** Based on everything you've learned, present exactly 2 actionable task suggestions. Each should feel specific to this user, not generic. Frame it as: here's what you can hand off to me right now. Avoid language like "let's build automations" or "let's set up workflows." If `ui_show` is available (dashboard channels), show the suggestions as a card with 2 action buttons. Use `surface_type: "card"` with a short title and body, and add one `relay_prompt` action per suggestion. Each action's `data.prompt` should contain a natural-language request the user would say. Example structure:
|
|
39
|
+
```
|
|
40
|
+
ui_show({
|
|
41
|
+
surface_type: "card",
|
|
42
|
+
data: { title: "...", body: "Pick one, or tell me something else." },
|
|
43
|
+
actions: [
|
|
44
|
+
{ id: "relay_prompt", label: "<suggestion 1>", data: { prompt: "<full prompt 1>" } },
|
|
45
|
+
{ id: "relay_prompt", label: "<suggestion 2>", data: { prompt: "<full prompt 2>" } }
|
|
46
|
+
]
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
If `ui_show` is not available (voice, SMS, or other non-dashboard channels), present the two suggestions as plain text messages instead, numbered so the user can reply with which one they'd like. If the user types a response instead of clicking, continue via the text path. If they want to defer both suggestions and do something else entirely, that's fine too.
|
|
40
50
|
|
|
41
|
-
|
|
51
|
+
7. **Create Home Base silently.** After the suggestion interaction (user picked one, deferred, or typed something else), create their Home Base using `app_create` with `set_as_home_base: true` and `auto_open: false`. Include personalized starter tasks based on what you learned. Do NOT open it with `app_open`. Do NOT announce it, mention it, or hint that something was created. No "check this out," no "I put something together," no "here's what I came up with." Just create it quietly and move on.
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
Ask one question at a time. Don't dump a form on them.
|
|
44
54
|
|
|
45
|
-
##
|
|
55
|
+
## Privacy
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
Only the assistant's name is hard-required. Everything else about the user (their name, work role, hobbies, daily tools) is best-effort. Ask naturally, not as a form. If something is unclear, you can ask one short follow-up, but if the user declines or dodges, do not push. Just move on.
|
|
48
58
|
|
|
49
|
-
|
|
59
|
+
A field is "resolved" when any of these is true:
|
|
60
|
+
- The user gave an explicit answer
|
|
61
|
+
- You confidently inferred it from conversation
|
|
62
|
+
- The user declined, dodged, or sidestepped it (treat all of these as declined)
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
- Practical daily tasks: "Check my emails", "Start my day", "Set a reminder"
|
|
53
|
-
- Setup tasks they haven't done yet: "Set up voice chat", "Enable computer control"
|
|
54
|
-
- Fun/discovery tasks: "Surprise me", "Teach me something new"
|
|
64
|
+
When saving to `USER.md`, mark declined fields so you don't re-ask later (e.g., `Work role: declined_by_user`). Inferred values can note the source (e.g., `Daily tools: inferred: Slack, Figma`).
|
|
55
65
|
|
|
56
|
-
|
|
66
|
+
## Saving What You Learn
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
Save what you learn as you go. Update `IDENTITY.md` (name, nature, personality, emoji, style tendency) and `USER.md` (their name, how to address them, goals, locale, work role, hobbies, daily tools) using `file_edit`. If the conversation reveals how the user wants you to behave (e.g., "be direct," "don't be too chatty"), save those behavioral guidelines to `SOUL.md` — that file is about your personality and how you operate, not the user's data. Just do it quietly. Don't tell the user which files you're editing or mention tool names.
|
|
59
69
|
|
|
60
|
-
|
|
70
|
+
When saving to `IDENTITY.md`, be specific about the tone, energy, and conversational style you discovered during onboarding. This file persists after onboarding, so everything about how you should come across needs to be captured there -- not just your name and emoji, but the full vibe: how you talk, how much energy you bring, whether you're blunt or gentle, funny or serious.
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
## Completion Gate
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
Do NOT delete this file until ALL of the following are true:
|
|
75
|
+
- You have a name (hard requirement)
|
|
76
|
+
- You've figured out your vibe and adopted it
|
|
77
|
+
- User detail fields are resolved: name, work role, hobbies/interests, and daily tools. Resolved means the user provided a value, you confidently inferred one, or the user declined/dodged it. All four must be in one of those states.
|
|
78
|
+
- 2 suggestions shown (via `ui_show` or as text if UI unavailable)
|
|
79
|
+
- The user selected one, deferred both, or typed an alternate direction
|
|
80
|
+
- Home Base has been created silently
|
|
65
81
|
|
|
66
|
-
|
|
82
|
+
Once every condition is met, delete this file. You're done here.
|
|
67
83
|
|
|
68
84
|
---
|
|
69
85
|
|
|
@@ -14,9 +14,14 @@ _(What do they care about? What projects are they working on? What annoys them?
|
|
|
14
14
|
|
|
15
15
|
## Onboarding Snapshot
|
|
16
16
|
|
|
17
|
+
_Each field below should end up in one of three states: an explicit value, an inferred value (note the source), or `declined_by_user`. All fields must be resolved before onboarding completes, but declining is a valid resolution._
|
|
18
|
+
|
|
17
19
|
- Preferred name/reference:
|
|
18
20
|
- Goals:
|
|
19
21
|
- Locale:
|
|
22
|
+
- Work role:
|
|
23
|
+
- Hobbies/fun:
|
|
24
|
+
- Daily tools:
|
|
20
25
|
|
|
21
26
|
---
|
|
22
27
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
|
|
3
|
+
import { resolveBundledDir } from '../util/bundled-asset.js';
|
|
4
|
+
|
|
3
5
|
/** Returns the path to the bundled UPDATES.md template. Extracted for testability. */
|
|
4
6
|
export function getTemplatePath(): string {
|
|
5
|
-
|
|
7
|
+
const dir = resolveBundledDir(import.meta.dirname ?? __dirname, 'templates', 'templates');
|
|
8
|
+
return join(dir, 'UPDATES.md');
|
|
6
9
|
}
|
|
@@ -123,16 +123,35 @@ curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members/<member_id>/block
|
|
|
123
123
|
|
|
124
124
|
Use this when the guardian wants to invite someone to message the assistant on Telegram without needing their user ID upfront. The invite link is a shareable Telegram deep link — when someone opens it, they automatically get trusted-contact access.
|
|
125
125
|
|
|
126
|
+
**Important**: The shell snippet below emits a `<vellum-sensitive-output>` directive containing the raw invite token. The tool executor automatically strips this directive and replaces the raw token with a placeholder so the LLM never sees it. The placeholder is resolved back to the real token in the final assistant reply.
|
|
127
|
+
|
|
126
128
|
```bash
|
|
127
129
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
128
|
-
|
|
130
|
+
|
|
131
|
+
BOT_CONFIG_JSON=$(curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
|
|
132
|
+
-H "Authorization: Bearer $TOKEN")
|
|
133
|
+
BOT_USERNAME=$(printf '%s' "$BOT_CONFIG_JSON" | tr -d '\n' | sed -n 's/.*"botUsername"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
|
134
|
+
if [ -z "$BOT_USERNAME" ]; then
|
|
135
|
+
echo "error:no_bot_username"
|
|
136
|
+
exit 1
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
|
|
129
140
|
-H "Content-Type: application/json" \
|
|
130
141
|
-H "Authorization: Bearer $TOKEN" \
|
|
131
142
|
-d '{
|
|
132
143
|
"sourceChannel": "telegram",
|
|
133
144
|
"maxUses": 1,
|
|
134
145
|
"note": "<optional note, e.g. the person it is for>"
|
|
135
|
-
}'
|
|
146
|
+
}')
|
|
147
|
+
INVITE_TOKEN=$(printf '%s' "$INVITE_JSON" | tr -d '\n' | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
|
148
|
+
if [ -z "$INVITE_TOKEN" ]; then
|
|
149
|
+
printf '%s\n' "$INVITE_JSON"
|
|
150
|
+
exit 1
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
echo "<vellum-sensitive-output kind=\"invite_code\" value=\"$INVITE_TOKEN\" />"
|
|
154
|
+
echo "https://t.me/$BOT_USERNAME?start=iv_$INVITE_TOKEN"
|
|
136
155
|
```
|
|
137
156
|
|
|
138
157
|
Optional fields:
|
|
@@ -140,21 +159,7 @@ Optional fields:
|
|
|
140
159
|
- `expiresInMs` — expiration time in milliseconds from now (e.g., `86400000` for 24 hours). Defaults to 7 days (`604800000`) if omitted.
|
|
141
160
|
- `note` — a human-readable label for the invite (e.g., "For Mom", "Family group").
|
|
142
161
|
|
|
143
|
-
The response contains `{ ok: true, invite: { id, token, ... } }`. The `token` field is the raw invite token — it is only returned at creation time and cannot be retrieved later.
|
|
144
|
-
|
|
145
|
-
**Building the shareable link**: After creating the invite, look up the Telegram bot username so you can build the deep link. Query the Telegram integration config:
|
|
146
|
-
|
|
147
|
-
```bash
|
|
148
|
-
TOKEN=$(cat ~/.vellum/http-token)
|
|
149
|
-
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
|
|
150
|
-
-H "Authorization: Bearer $TOKEN"
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
The response includes `botUsername`. Use it to construct the deep link:
|
|
154
|
-
|
|
155
|
-
```
|
|
156
|
-
https://t.me/<botUsername>?start=iv_<token>
|
|
157
|
-
```
|
|
162
|
+
The create response contains `{ ok: true, invite: { id, token, ... } }`. The `token` field is the raw invite token — it is only returned at creation time and cannot be retrieved later.
|
|
158
163
|
|
|
159
164
|
**Presenting to the guardian**: Give the guardian the link with clear copy-paste instructions:
|
|
160
165
|
|