@vellumai/assistant 0.4.6 → 0.4.7
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 +23 -6
- package/bun.lock +51 -0
- package/docs/trusted-contact-access.md +8 -0
- package/package.json +2 -1
- package/src/__tests__/actor-token-service.test.ts +4 -4
- package/src/__tests__/call-controller.test.ts +37 -0
- package/src/__tests__/channel-delivery-store.test.ts +2 -2
- package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
- package/src/__tests__/guardian-dispatch.test.ts +39 -1
- package/src/__tests__/guardian-routing-state.test.ts +8 -30
- package/src/__tests__/non-member-access-request.test.ts +7 -0
- package/src/__tests__/notification-decision-fallback.test.ts +232 -0
- package/src/__tests__/notification-decision-strategy.test.ts +304 -8
- package/src/__tests__/notification-guardian-path.test.ts +38 -1
- package/src/__tests__/relay-server.test.ts +65 -5
- package/src/__tests__/send-endpoint-busy.test.ts +29 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +1 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -2
- package/src/__tests__/trusted-contact-multichannel.test.ts +1 -1
- package/src/calls/call-controller.ts +15 -0
- package/src/calls/relay-server.ts +45 -11
- package/src/calls/types.ts +1 -0
- package/src/daemon/providers-setup.ts +0 -8
- package/src/daemon/session-slash.ts +35 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +1 -1
- package/src/memory/schema.ts +19 -0
- package/src/notifications/README.md +8 -1
- package/src/notifications/copy-composer.ts +160 -30
- package/src/notifications/decision-engine.ts +98 -1
- package/src/runtime/actor-refresh-token-service.ts +309 -0
- package/src/runtime/actor-refresh-token-store.ts +157 -0
- package/src/runtime/actor-token-service.ts +3 -3
- package/src/runtime/gateway-client.ts +239 -0
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +10 -24
- package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
- package/src/runtime/routes/pairing-routes.ts +60 -50
- package/src/types/qrcode.d.ts +10 -0
|
@@ -37,24 +37,43 @@ mock.module('../util/logger.js', () => ({
|
|
|
37
37
|
}));
|
|
38
38
|
|
|
39
39
|
let mockTelegramBinding: unknown = null;
|
|
40
|
+
let mockVellumBinding: unknown = null;
|
|
40
41
|
|
|
41
42
|
mock.module('../memory/channel-guardian-store.js', () => ({
|
|
42
43
|
getActiveBinding: (_assistantId: string, channel: string) => {
|
|
43
44
|
if (channel === 'telegram') return mockTelegramBinding;
|
|
45
|
+
if (channel === 'vellum') return mockVellumBinding;
|
|
44
46
|
return null;
|
|
45
47
|
},
|
|
48
|
+
createBinding: (params: Record<string, unknown>) => ({
|
|
49
|
+
id: `binding-${Date.now()}`,
|
|
50
|
+
...params,
|
|
51
|
+
status: 'active',
|
|
52
|
+
verifiedAt: Date.now(),
|
|
53
|
+
verifiedVia: 'test',
|
|
54
|
+
metadataJson: null,
|
|
55
|
+
createdAt: Date.now(),
|
|
56
|
+
updatedAt: Date.now(),
|
|
57
|
+
}),
|
|
58
|
+
listActiveBindingsByAssistant: () => mockVellumBinding ? [mockVellumBinding] : [],
|
|
46
59
|
}));
|
|
47
60
|
|
|
48
61
|
mock.module('../config/loader.js', () => ({
|
|
49
62
|
getConfig: () => ({
|
|
50
63
|
ui: {},
|
|
51
|
-
|
|
64
|
+
|
|
52
65
|
calls: {
|
|
53
66
|
userConsultTimeoutSeconds: 120,
|
|
54
67
|
},
|
|
55
68
|
}),
|
|
56
69
|
}));
|
|
57
70
|
|
|
71
|
+
// Mock guardian-vellum-migration to use a stable principal, avoiding UNIQUE
|
|
72
|
+
// constraint errors when ensureVellumGuardianBinding is called across tests.
|
|
73
|
+
mock.module('../runtime/guardian-vellum-migration.js', () => ({
|
|
74
|
+
ensureVellumGuardianBinding: () => 'test-principal-id',
|
|
75
|
+
}));
|
|
76
|
+
|
|
58
77
|
const emitCalls: unknown[] = [];
|
|
59
78
|
let mockThreadCreated: ThreadCreatedInfo | null = null;
|
|
60
79
|
let mockEmitResult: {
|
|
@@ -114,12 +133,30 @@ function resetTables(): void {
|
|
|
114
133
|
db.run('DELETE FROM canonical_guardian_requests');
|
|
115
134
|
db.run('DELETE FROM guardian_action_deliveries');
|
|
116
135
|
db.run('DELETE FROM guardian_action_requests');
|
|
136
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
117
137
|
db.run('DELETE FROM call_pending_questions');
|
|
118
138
|
db.run('DELETE FROM call_events');
|
|
119
139
|
db.run('DELETE FROM call_sessions');
|
|
120
140
|
db.run('DELETE FROM conversations');
|
|
141
|
+
|
|
121
142
|
emitCalls.length = 0;
|
|
122
143
|
mockTelegramBinding = null;
|
|
144
|
+
// Pre-seed vellum binding so the self-healing path in dispatchGuardianQuestion
|
|
145
|
+
// never triggers (avoids UNIQUE constraint violations on repeated dispatches).
|
|
146
|
+
mockVellumBinding = {
|
|
147
|
+
id: 'binding-vellum-test',
|
|
148
|
+
assistantId: 'self',
|
|
149
|
+
channel: 'vellum',
|
|
150
|
+
guardianExternalUserId: 'vellum-guardian',
|
|
151
|
+
guardianDeliveryChatId: 'local',
|
|
152
|
+
guardianPrincipalId: 'test-principal-id',
|
|
153
|
+
status: 'active',
|
|
154
|
+
verifiedAt: Date.now(),
|
|
155
|
+
verifiedVia: 'test',
|
|
156
|
+
metadataJson: null,
|
|
157
|
+
createdAt: Date.now(),
|
|
158
|
+
updatedAt: Date.now(),
|
|
159
|
+
};
|
|
123
160
|
mockThreadCreated = null;
|
|
124
161
|
mockEmitResult = {
|
|
125
162
|
signalId: 'sig-1',
|
|
@@ -44,6 +44,13 @@ mock.module('../util/logger.js', () => ({
|
|
|
44
44
|
}),
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
|
+
// ── Identity helpers mock ─────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
let mockAssistantName: string | null = 'Vellum';
|
|
50
|
+
mock.module('../daemon/identity-helpers.js', () => ({
|
|
51
|
+
getAssistantName: () => mockAssistantName,
|
|
52
|
+
}));
|
|
53
|
+
|
|
47
54
|
// ── Config mock ─────────────────────────────────────────────────────
|
|
48
55
|
|
|
49
56
|
const mockConfig = {
|
|
@@ -1915,10 +1922,11 @@ describe('relay-server', () => {
|
|
|
1915
1922
|
// Should be in the name capture state (not denied)
|
|
1916
1923
|
expect(relay.getConnectionState()).toBe('awaiting_name');
|
|
1917
1924
|
|
|
1918
|
-
// Should have sent the name capture prompt
|
|
1925
|
+
// Should have sent the name capture prompt with assistant name + guardian label
|
|
1919
1926
|
const textMessages = ws.sentMessages
|
|
1920
1927
|
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
1921
1928
|
.filter((m) => m.type === 'text');
|
|
1929
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('Hi, this is Vellum,'))).toBe(true);
|
|
1922
1930
|
expect(textMessages.some((m) => (m.token ?? '').includes("don't recognize this number"))).toBe(true);
|
|
1923
1931
|
expect(textMessages.some((m) => (m.token ?? '').includes('Can I get your name'))).toBe(true);
|
|
1924
1932
|
|
|
@@ -1929,6 +1937,46 @@ describe('relay-server', () => {
|
|
|
1929
1937
|
relay.destroy();
|
|
1930
1938
|
});
|
|
1931
1939
|
|
|
1940
|
+
test('inbound voice: unknown caller name capture uses fallback when assistant name is unavailable', async () => {
|
|
1941
|
+
const prevName = mockAssistantName;
|
|
1942
|
+
mockAssistantName = null;
|
|
1943
|
+
try {
|
|
1944
|
+
ensureConversation('conv-invite-no-name');
|
|
1945
|
+
const session = createCallSession({
|
|
1946
|
+
conversationId: 'conv-invite-no-name',
|
|
1947
|
+
provider: 'twilio',
|
|
1948
|
+
fromNumber: '+15558885556',
|
|
1949
|
+
toNumber: '+15551111111',
|
|
1950
|
+
assistantId: 'self',
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
const { ws, relay } = createMockWs(session.id);
|
|
1954
|
+
|
|
1955
|
+
await relay.handleMessage(JSON.stringify({
|
|
1956
|
+
type: 'setup',
|
|
1957
|
+
callSid: 'CA_invite_no_name',
|
|
1958
|
+
from: '+15558885556',
|
|
1959
|
+
to: '+15551111111',
|
|
1960
|
+
}));
|
|
1961
|
+
|
|
1962
|
+
expect(relay.getConnectionState()).toBe('awaiting_name');
|
|
1963
|
+
|
|
1964
|
+
// Fallback prompt should NOT include assistant name but should include guardian label
|
|
1965
|
+
const textMessages = ws.sentMessages
|
|
1966
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
1967
|
+
.filter((m) => m.type === 'text');
|
|
1968
|
+
const promptText = textMessages.map((m) => m.token ?? '').join('');
|
|
1969
|
+
expect(promptText).toContain("Hi, this is my guardian's assistant.");
|
|
1970
|
+
expect(promptText).not.toContain('Vellum');
|
|
1971
|
+
expect(promptText).toContain("don't recognize this number");
|
|
1972
|
+
expect(promptText).toContain('Can I get your name');
|
|
1973
|
+
|
|
1974
|
+
relay.destroy();
|
|
1975
|
+
} finally {
|
|
1976
|
+
mockAssistantName = prevName;
|
|
1977
|
+
}
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1932
1980
|
// ── Friend-initiated in-call guardian approval flow ────────────────────
|
|
1933
1981
|
|
|
1934
1982
|
test('name capture flow: caller provides name and enters guardian decision wait', async () => {
|
|
@@ -2156,7 +2204,7 @@ describe('relay-server', () => {
|
|
|
2156
2204
|
relay.destroy();
|
|
2157
2205
|
});
|
|
2158
2206
|
|
|
2159
|
-
test('name capture flow: approved access request activates caller
|
|
2207
|
+
test('name capture flow: approved access request activates caller with deterministic handoff copy', async () => {
|
|
2160
2208
|
ensureConversation('conv-access-approved');
|
|
2161
2209
|
const session = createCallSession({
|
|
2162
2210
|
conversationId: 'conv-access-approved',
|
|
@@ -2166,9 +2214,10 @@ describe('relay-server', () => {
|
|
|
2166
2214
|
assistantId: 'self',
|
|
2167
2215
|
});
|
|
2168
2216
|
|
|
2169
|
-
|
|
2217
|
+
// Track provider calls to verify no LLM turn is triggered on approval
|
|
2218
|
+
const providerCallCountBefore = mockSendMessage.mock.calls.length;
|
|
2170
2219
|
|
|
2171
|
-
const { relay } = createMockWs(session.id);
|
|
2220
|
+
const { ws, relay } = createMockWs(session.id);
|
|
2172
2221
|
|
|
2173
2222
|
await relay.handleMessage(JSON.stringify({
|
|
2174
2223
|
type: 'setup',
|
|
@@ -2209,9 +2258,20 @@ describe('relay-server', () => {
|
|
|
2209
2258
|
// Should have transitioned to connected state
|
|
2210
2259
|
expect(relay.getConnectionState()).toBe('connected');
|
|
2211
2260
|
|
|
2212
|
-
// Verify
|
|
2261
|
+
// Verify deterministic handoff copy was sent (not an LLM-generated response)
|
|
2262
|
+
const textMessages = ws.sentMessages
|
|
2263
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
2264
|
+
.filter((m) => m.type === 'text');
|
|
2265
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('said I can speak with you. How can I help?'))).toBe(true);
|
|
2266
|
+
|
|
2267
|
+
// Verify no provider (LLM) call was made as part of the approval handoff
|
|
2268
|
+
expect(mockSendMessage.mock.calls.length).toBe(providerCallCountBefore);
|
|
2269
|
+
|
|
2270
|
+
// Verify events — including assistant_spoke for transcript parity
|
|
2213
2271
|
const events = getCallEvents(session.id);
|
|
2214
2272
|
expect(events.some((e) => e.eventType === 'inbound_acl_access_approved')).toBe(true);
|
|
2273
|
+
expect(events.some((e) => e.eventType === 'assistant_spoke')).toBe(true);
|
|
2274
|
+
expect(events.some((e) => e.eventType === 'inbound_acl_post_approval_handoff_spoken')).toBe(true);
|
|
2215
2275
|
|
|
2216
2276
|
// Session should be in_progress
|
|
2217
2277
|
const updated = getCallSession(session.id);
|
|
@@ -15,6 +15,7 @@ import { afterAll, beforeEach, describe, expect, mock,test } from 'bun:test';
|
|
|
15
15
|
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
16
16
|
import type { Session } from '../daemon/session.js';
|
|
17
17
|
import { createCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
|
|
18
|
+
import { createBinding } from '../memory/channel-guardian-store.js';
|
|
18
19
|
import { getOrCreateConversation } from '../memory/conversation-key-store.js';
|
|
19
20
|
|
|
20
21
|
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'send-endpoint-busy-test-')));
|
|
@@ -41,7 +42,7 @@ mock.module('../util/logger.js', () => ({
|
|
|
41
42
|
mock.module('../config/loader.js', () => ({
|
|
42
43
|
getConfig: () => ({
|
|
43
44
|
ui: {},
|
|
44
|
-
|
|
45
|
+
|
|
45
46
|
model: 'test',
|
|
46
47
|
provider: 'test',
|
|
47
48
|
apiKeys: {},
|
|
@@ -51,6 +52,23 @@ mock.module('../config/loader.js', () => ({
|
|
|
51
52
|
}),
|
|
52
53
|
}));
|
|
53
54
|
|
|
55
|
+
// Mock guardian-vellum-migration to use a stable principal matching the one
|
|
56
|
+
// in createCanonicalGuardianRequest calls below ('test-principal-id').
|
|
57
|
+
mock.module('../runtime/guardian-vellum-migration.js', () => ({
|
|
58
|
+
ensureVellumGuardianBinding: () => 'test-principal-id',
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
// Mock local-actor-identity to return a stable guardian context that uses
|
|
62
|
+
// the same principal as the canonical requests created in tests.
|
|
63
|
+
mock.module('../runtime/local-actor-identity.js', () => ({
|
|
64
|
+
resolveLocalIpcGuardianContext: () => ({
|
|
65
|
+
sourceChannel: 'vellum',
|
|
66
|
+
trustClass: 'guardian',
|
|
67
|
+
guardianPrincipalId: 'test-principal-id',
|
|
68
|
+
guardianExternalUserId: 'test-principal-id',
|
|
69
|
+
}),
|
|
70
|
+
}));
|
|
71
|
+
|
|
54
72
|
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
55
73
|
import type { AssistantEvent } from '../runtime/assistant-event.js';
|
|
56
74
|
import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
|
|
@@ -217,7 +235,17 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
217
235
|
db.run('DELETE FROM conversation_keys');
|
|
218
236
|
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
219
237
|
db.run('DELETE FROM canonical_guardian_requests');
|
|
238
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
220
239
|
pendingInteractions.clear();
|
|
240
|
+
|
|
241
|
+
createBinding({
|
|
242
|
+
assistantId: 'self',
|
|
243
|
+
channel: 'vellum',
|
|
244
|
+
guardianExternalUserId: 'guardian-vellum',
|
|
245
|
+
guardianDeliveryChatId: 'vellum',
|
|
246
|
+
guardianPrincipalId: 'test-principal-id',
|
|
247
|
+
});
|
|
248
|
+
|
|
221
249
|
eventHub = new AssistantEventHub();
|
|
222
250
|
});
|
|
223
251
|
|
|
@@ -103,6 +103,7 @@ let mockGuardianBinding: Record<string, unknown> | null = {
|
|
|
103
103
|
channel: 'telegram',
|
|
104
104
|
guardianExternalUserId: 'guardian-1',
|
|
105
105
|
guardianDeliveryChatId: 'guardian-chat-1',
|
|
106
|
+
guardianPrincipalId: 'test-principal-id',
|
|
106
107
|
status: 'active',
|
|
107
108
|
};
|
|
108
109
|
|
|
@@ -259,6 +260,7 @@ describe('(a) target flow: trusted-contact inline guardian approval end-to-end',
|
|
|
259
260
|
channel: 'telegram',
|
|
260
261
|
guardianExternalUserId: 'guardian-1',
|
|
261
262
|
guardianDeliveryChatId: 'guardian-chat-1',
|
|
263
|
+
guardianPrincipalId: 'test-principal-id',
|
|
262
264
|
status: 'active',
|
|
263
265
|
};
|
|
264
266
|
});
|
|
@@ -368,6 +370,7 @@ describe('(b) prompt-path flow: confirmation_request bridges to guardian', () =>
|
|
|
368
370
|
channel: 'telegram',
|
|
369
371
|
guardianExternalUserId: 'guardian-1',
|
|
370
372
|
guardianDeliveryChatId: 'guardian-chat-1',
|
|
373
|
+
guardianPrincipalId: 'test-principal-id',
|
|
371
374
|
status: 'active',
|
|
372
375
|
};
|
|
373
376
|
});
|
|
@@ -550,6 +553,7 @@ describe('(d) unknown actor flow: fail-closed with no interactive approval', ()
|
|
|
550
553
|
channel: 'telegram',
|
|
551
554
|
guardianExternalUserId: 'guardian-1',
|
|
552
555
|
guardianDeliveryChatId: 'guardian-chat-1',
|
|
556
|
+
guardianPrincipalId: 'test-principal-id',
|
|
553
557
|
status: 'active',
|
|
554
558
|
};
|
|
555
559
|
});
|
|
@@ -734,6 +738,7 @@ describe('(f) timeout/stale flow: stale guardian decision after inline wait time
|
|
|
734
738
|
channel: 'telegram',
|
|
735
739
|
guardianExternalUserId: 'guardian-1',
|
|
736
740
|
guardianDeliveryChatId: 'guardian-chat-1',
|
|
741
|
+
guardianPrincipalId: 'test-principal-id',
|
|
737
742
|
status: 'active',
|
|
738
743
|
};
|
|
739
744
|
});
|
|
@@ -948,6 +953,7 @@ describe('cross-milestone integration checks', () => {
|
|
|
948
953
|
channel: 'telegram',
|
|
949
954
|
guardianExternalUserId: 'guardian-1',
|
|
950
955
|
guardianDeliveryChatId: 'guardian-chat-1',
|
|
956
|
+
guardianPrincipalId: 'test-principal-id',
|
|
951
957
|
status: 'active',
|
|
952
958
|
};
|
|
953
959
|
});
|
|
@@ -405,8 +405,8 @@ describe('trusted contact activated notification signal', () => {
|
|
|
405
405
|
// Verify payload
|
|
406
406
|
const payload = activatedSignals[0].contextPayload as Record<string, unknown>;
|
|
407
407
|
expect(payload.sourceChannel).toBe('telegram');
|
|
408
|
-
expect(payload.
|
|
409
|
-
expect(payload.
|
|
408
|
+
expect(payload.actorExternalId).toBe('requester-user-456');
|
|
409
|
+
expect(payload.conversationExternalId).toBe('chat-123');
|
|
410
410
|
|
|
411
411
|
// Verify deduplication key includes the user identity
|
|
412
412
|
const dedupeKey = activatedSignals[0].dedupeKey as string;
|
|
@@ -221,7 +221,7 @@ for (const config of CHANNEL_CONFIGS) {
|
|
|
221
221
|
// Guardian notification helper was called for the correct channel
|
|
222
222
|
expect(notifyGuardianCalls.length).toBe(1);
|
|
223
223
|
expect(notifyGuardianCalls[0].sourceChannel).toBe(config.channel);
|
|
224
|
-
expect(notifyGuardianCalls[0].
|
|
224
|
+
expect(notifyGuardianCalls[0].actorExternalId).toBe(config.senderExternalUserId);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
test('verification creates active member for channel', () => {
|
|
@@ -281,6 +281,21 @@ export class CallController {
|
|
|
281
281
|
this.guardianContext = ctx;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Mark the next caller utterance as an opening acknowledgment so it
|
|
286
|
+
* receives the [CALL_OPENING_ACK] marker. Used after deterministic
|
|
287
|
+
* transitions (e.g. post-approval handoff) to ensure the next LLM
|
|
288
|
+
* turn continues naturally without reintroduction.
|
|
289
|
+
*
|
|
290
|
+
* Also resets the silence timer so the "Are you still there?" nudge
|
|
291
|
+
* fires at the correct interval after the deterministic handoff copy.
|
|
292
|
+
*/
|
|
293
|
+
markNextCallerTurnAsOpeningAck(): void {
|
|
294
|
+
this.awaitingOpeningAck = true;
|
|
295
|
+
this.lastSentWasOpener = false;
|
|
296
|
+
this.resetSilenceTimer();
|
|
297
|
+
}
|
|
298
|
+
|
|
284
299
|
/**
|
|
285
300
|
* Kick off the first outbound call utterance from the assistant.
|
|
286
301
|
*/
|
|
@@ -11,6 +11,7 @@ import { randomInt } from 'node:crypto';
|
|
|
11
11
|
import type { ServerWebSocket } from 'bun';
|
|
12
12
|
|
|
13
13
|
import { getConfig } from '../config/loader.js';
|
|
14
|
+
import { getAssistantName } from '../daemon/identity-helpers.js';
|
|
14
15
|
import { getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
|
|
15
16
|
import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
|
|
16
17
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
@@ -1104,10 +1105,14 @@ export class RelayConnection {
|
|
|
1104
1105
|
this.accessRequestFromNumber = fromNumber;
|
|
1105
1106
|
this.connectionState = 'awaiting_name';
|
|
1106
1107
|
|
|
1107
|
-
this.
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1108
|
+
const guardianLabel = this.resolveGuardianLabel();
|
|
1109
|
+
const assistantName = this.resolveAssistantLabel();
|
|
1110
|
+
|
|
1111
|
+
const greeting = assistantName
|
|
1112
|
+
? `Hi, this is ${assistantName}, ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`
|
|
1113
|
+
: `Hi, this is ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`;
|
|
1114
|
+
|
|
1115
|
+
this.sendTextToken(greeting, true);
|
|
1111
1116
|
|
|
1112
1117
|
// Start a timeout so silent callers don't keep the call open indefinitely.
|
|
1113
1118
|
// Uses a 30-second window — enough time to speak a name but short enough
|
|
@@ -1325,15 +1330,31 @@ export class RelayConnection {
|
|
|
1325
1330
|
'Access request approved — caller activated and continuing call',
|
|
1326
1331
|
);
|
|
1327
1332
|
|
|
1328
|
-
//
|
|
1329
|
-
// through
|
|
1333
|
+
// Deliver deterministic transition copy directly via TTS instead of
|
|
1334
|
+
// routing through handleUserInstruction, which would start a fresh
|
|
1335
|
+
// model turn and risk reintroduction/disclosure reset.
|
|
1330
1336
|
const guardianLabel = this.resolveGuardianLabel();
|
|
1337
|
+
const handoffText = `Great! ${guardianLabel} said I can speak with you. How can I help?`;
|
|
1338
|
+
this.sendTextToken(handoffText, true);
|
|
1339
|
+
|
|
1340
|
+
// Record the deterministic handoff as an assistant_spoke event and
|
|
1341
|
+
// fire the transcript notifier so it appears in conversation history
|
|
1342
|
+
// and real-time transcript subscribers — matching the parity of text
|
|
1343
|
+
// spoken through the normal runTurn() pipeline.
|
|
1344
|
+
recordCallEvent(this.callSessionId, 'assistant_spoke', { text: handoffText });
|
|
1345
|
+
const session = getCallSession(this.callSessionId);
|
|
1346
|
+
if (session) {
|
|
1347
|
+
fireCallTranscriptNotifier(session.conversationId, this.callSessionId, 'assistant', handoffText);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_post_approval_handoff_spoken', {
|
|
1351
|
+
from: fromNumber,
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
// Mark the next caller utterance as an opening acknowledgment so the
|
|
1355
|
+
// LLM continues naturally without emitting a fresh introduction.
|
|
1331
1356
|
if (this.controller) {
|
|
1332
|
-
this.controller.
|
|
1333
|
-
`Great, ${guardianLabel} approved! Now how can I help you?`,
|
|
1334
|
-
).catch((err) => {
|
|
1335
|
-
log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting');
|
|
1336
|
-
});
|
|
1357
|
+
this.controller.markNextCallerTurnAsOpeningAck();
|
|
1337
1358
|
}
|
|
1338
1359
|
}
|
|
1339
1360
|
|
|
@@ -1672,6 +1693,19 @@ export class RelayConnection {
|
|
|
1672
1693
|
return 'my guardian';
|
|
1673
1694
|
}
|
|
1674
1695
|
|
|
1696
|
+
/**
|
|
1697
|
+
* Resolve the assistant's display name from identity configuration.
|
|
1698
|
+
* Returns the trimmed name or null if unavailable.
|
|
1699
|
+
*/
|
|
1700
|
+
private resolveAssistantLabel(): string | null {
|
|
1701
|
+
try {
|
|
1702
|
+
const name = getAssistantName();
|
|
1703
|
+
return name?.trim() || null;
|
|
1704
|
+
} catch {
|
|
1705
|
+
return null;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1675
1709
|
/**
|
|
1676
1710
|
* Generate a non-repetitive heartbeat message for the caller based
|
|
1677
1711
|
* on the current sequence counter and guardian label.
|
package/src/calls/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
|
|
2
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' | 'inbound_acl_name_capture_started' | 'inbound_acl_name_captured' | 'inbound_acl_name_capture_timeout' | 'inbound_acl_access_approved' | 'inbound_acl_access_denied' | 'inbound_acl_access_timeout' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed' | 'voice_guardian_wait_heartbeat_sent' | 'voice_guardian_wait_prompt_classified' | 'voice_guardian_wait_callback_offer_sent' | 'voice_guardian_wait_callback_opt_in_set' | 'voice_guardian_wait_callback_opt_in_declined'
|
|
3
|
+
| 'inbound_acl_post_approval_handoff_spoken'
|
|
3
4
|
| 'callback_handoff_notified'
|
|
4
5
|
| 'callback_handoff_failed';
|
|
5
6
|
export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
|
|
@@ -21,32 +21,24 @@ import { slackProvider as slackWatcherProvider } from '../watcher/providers/slac
|
|
|
21
21
|
const log = getLogger('lifecycle');
|
|
22
22
|
|
|
23
23
|
export async function initializeProvidersAndTools(config: AssistantConfig): Promise<void> {
|
|
24
|
-
console.log('[Daemon] Initializing providers and tools...');
|
|
25
24
|
log.info('Daemon startup: initializing providers and tools');
|
|
26
25
|
initializeProviders(config);
|
|
27
|
-
console.log('[Daemon] Providers initialized');
|
|
28
26
|
await initializeTools();
|
|
29
|
-
console.log('[Daemon] Tools initialized');
|
|
30
27
|
|
|
31
28
|
// Start MCP servers and register their tools
|
|
32
29
|
if (config.mcp?.servers && Object.keys(config.mcp.servers).length > 0) {
|
|
33
|
-
console.log('[MCP] Initializing MCP servers:', Object.keys(config.mcp.servers).join(', '));
|
|
34
30
|
const manager = getMcpServerManager();
|
|
35
31
|
try {
|
|
36
32
|
const serverToolInfos = await manager.start(config.mcp);
|
|
37
33
|
for (const { serverId, serverConfig, tools } of serverToolInfos) {
|
|
38
|
-
console.log(`[MCP] Server "${serverId}" connected — discovered ${tools.length} tools:`, tools.map(t => t.name).join(', '));
|
|
39
34
|
const mcpTools = createMcpToolsFromServer(tools, serverId, serverConfig, manager);
|
|
40
35
|
registerMcpTools(mcpTools);
|
|
41
|
-
console.log(`[MCP] Registered ${mcpTools.length} tools from "${serverId}":`, mcpTools.map(t => t.name).join(', '));
|
|
42
36
|
}
|
|
43
37
|
} catch (err) {
|
|
44
|
-
console.error('[MCP] Server initialization failed:', err);
|
|
45
38
|
log.error({ err }, 'MCP server initialization failed — continuing without MCP tools');
|
|
46
39
|
}
|
|
47
40
|
}
|
|
48
41
|
|
|
49
|
-
console.log('[Daemon] Providers and tools initialization complete');
|
|
50
42
|
log.info('Daemon startup: providers and tools initialized');
|
|
51
43
|
}
|
|
52
44
|
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import QRCode from 'qrcode';
|
|
2
6
|
|
|
3
7
|
import { getGatewayPort, getIngressPublicBaseUrl } from '../config/env.js';
|
|
4
8
|
import { getConfig, loadRawConfig, saveRawConfig } from '../config/loader.js';
|
|
@@ -11,13 +15,14 @@ import {
|
|
|
11
15
|
rewriteKnownSlashCommandPrompt,
|
|
12
16
|
} from '../skills/slash-commands.js';
|
|
13
17
|
import { getLocalIPv4 } from '../util/network-info.js';
|
|
18
|
+
import { getWorkspaceDir } from '../util/platform.js';
|
|
14
19
|
import { getAssistantName } from './identity-helpers.js';
|
|
15
20
|
import type { PairingStore } from './pairing-store.js';
|
|
16
21
|
|
|
17
22
|
export type SlashResolution =
|
|
18
23
|
| { kind: 'passthrough'; content: string }
|
|
19
24
|
| { kind: 'rewritten'; content: string; skillId: string }
|
|
20
|
-
| { kind: 'unknown'; message: string };
|
|
25
|
+
| { kind: 'unknown'; message: string; qrFilename?: string };
|
|
21
26
|
|
|
22
27
|
// ── /pair command — module-level pairing context ────────────────────
|
|
23
28
|
|
|
@@ -349,6 +354,27 @@ export function resolveSlash(content: string, context?: SlashContext): SlashReso
|
|
|
349
354
|
|
|
350
355
|
// ── /pair command ────────────────────────────────────────────────────
|
|
351
356
|
|
|
357
|
+
function buildPairingQRCodeFilename(): string {
|
|
358
|
+
const now = new Date();
|
|
359
|
+
const ts = [
|
|
360
|
+
now.getFullYear(),
|
|
361
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
362
|
+
String(now.getDate()).padStart(2, '0'),
|
|
363
|
+
String(now.getHours()).padStart(2, '0'),
|
|
364
|
+
String(now.getMinutes()).padStart(2, '0'),
|
|
365
|
+
String(now.getSeconds()).padStart(2, '0'),
|
|
366
|
+
].join('');
|
|
367
|
+
return `code${ts}.png`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function savePairingQRCodePng(payloadJson: string, filename: string): Promise<void> {
|
|
371
|
+
const qrDir = join(getWorkspaceDir(), 'pairing-qr');
|
|
372
|
+
mkdirSync(qrDir, { recursive: true });
|
|
373
|
+
const qrPngPath = join(qrDir, filename);
|
|
374
|
+
const pngBuffer = await QRCode.toBuffer(payloadJson, { type: 'png', width: 512 });
|
|
375
|
+
writeFileSync(qrPngPath, pngBuffer);
|
|
376
|
+
}
|
|
377
|
+
|
|
352
378
|
function resolvePairCommand(content: string): SlashResolution | null {
|
|
353
379
|
if (content.trim() !== '/pair') return null;
|
|
354
380
|
|
|
@@ -402,6 +428,12 @@ function resolvePairCommand(content: string): SlashResolution | null {
|
|
|
402
428
|
payload.localLanUrl = localLanUrl;
|
|
403
429
|
}
|
|
404
430
|
|
|
431
|
+
// Save QR code as PNG to the workspace pairing-qr folder (fire-and-forget
|
|
432
|
+
// so the synchronous slash resolution is not blocked).
|
|
433
|
+
const payloadJson = JSON.stringify(payload);
|
|
434
|
+
const qrFilename = buildPairingQRCodeFilename();
|
|
435
|
+
savePairingQRCodePng(payloadJson, qrFilename).catch(() => {});
|
|
436
|
+
|
|
405
437
|
const lines = [
|
|
406
438
|
'Pairing Ready\n',
|
|
407
439
|
'Scan the QR code below with the Vellum iOS app, or use the pairing payload to connect manually.\n',
|
|
@@ -414,10 +446,11 @@ function resolvePairCommand(content: string): SlashResolution | null {
|
|
|
414
446
|
lines.push(`LAN URL: ${localLanUrl}`);
|
|
415
447
|
}
|
|
416
448
|
lines.push(
|
|
449
|
+
`\nQR code saved to pairing-qr/${qrFilename}`,
|
|
417
450
|
'\nThis pairing request expires in 5 minutes. Run `/pair` again to generate a new one.',
|
|
418
451
|
);
|
|
419
452
|
|
|
420
|
-
return { kind: 'unknown', message: lines.join('\n') };
|
|
453
|
+
return { kind: 'unknown', message: lines.join('\n'), qrFilename };
|
|
421
454
|
}
|
|
422
455
|
|
|
423
456
|
// ── Provider Ordering Error Detection ────────────────────────────────
|
package/src/memory/db-init.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDb } from './db-connection.js';
|
|
2
2
|
import {
|
|
3
3
|
addCoreColumns,
|
|
4
|
+
createActorRefreshTokenRecordsTable,
|
|
4
5
|
createActorTokenRecordsTable,
|
|
5
6
|
createAssistantInboxTables,
|
|
6
7
|
createCallSessionsTables,
|
|
@@ -175,6 +176,9 @@ export function initializeDb(): void {
|
|
|
175
176
|
// 28. Actor token records (hash-only actor token persistence)
|
|
176
177
|
createActorTokenRecordsTable(database);
|
|
177
178
|
|
|
179
|
+
// 28b. Actor refresh token records (rotating refresh tokens with family tracking)
|
|
180
|
+
createActorRefreshTokenRecordsTable(database);
|
|
181
|
+
|
|
178
182
|
// 29. Guardian principal ID columns on channel_guardian_bindings and canonical_guardian_requests
|
|
179
183
|
migrateGuardianPrincipalIdColumns(database);
|
|
180
184
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { DrizzleDb } from '../db-connection.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create the actor_refresh_token_records table for hash-only refresh token persistence.
|
|
5
|
+
*
|
|
6
|
+
* Stores the SHA-256 hash of each refresh token with family tracking,
|
|
7
|
+
* device binding, and dual expiry (absolute + inactivity).
|
|
8
|
+
* The raw token plaintext is never stored.
|
|
9
|
+
*/
|
|
10
|
+
export function createActorRefreshTokenRecordsTable(database: DrizzleDb): void {
|
|
11
|
+
database.run(/*sql*/ `
|
|
12
|
+
CREATE TABLE IF NOT EXISTS actor_refresh_token_records (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
token_hash TEXT NOT NULL,
|
|
15
|
+
family_id TEXT NOT NULL,
|
|
16
|
+
assistant_id TEXT NOT NULL,
|
|
17
|
+
guardian_principal_id TEXT NOT NULL,
|
|
18
|
+
hashed_device_id TEXT NOT NULL,
|
|
19
|
+
platform TEXT NOT NULL,
|
|
20
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
21
|
+
issued_at INTEGER NOT NULL,
|
|
22
|
+
absolute_expires_at INTEGER NOT NULL,
|
|
23
|
+
inactivity_expires_at INTEGER NOT NULL,
|
|
24
|
+
last_used_at INTEGER,
|
|
25
|
+
created_at INTEGER NOT NULL,
|
|
26
|
+
updated_at INTEGER NOT NULL
|
|
27
|
+
)
|
|
28
|
+
`);
|
|
29
|
+
|
|
30
|
+
// Token hash lookup (any status — needed for replay detection)
|
|
31
|
+
database.run(
|
|
32
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
|
|
33
|
+
ON actor_refresh_token_records(token_hash)`,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Unique active refresh token per device binding.
|
|
37
|
+
// DROP first so that databases that already created the older non-unique
|
|
38
|
+
// index with the same name get upgraded to UNIQUE.
|
|
39
|
+
database.run(/*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`);
|
|
40
|
+
database.run(
|
|
41
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
|
|
42
|
+
ON actor_refresh_token_records(assistant_id, guardian_principal_id, hashed_device_id)
|
|
43
|
+
WHERE status = 'active'`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Family lookup for replay detection (revoke entire family)
|
|
47
|
+
database.run(
|
|
48
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family
|
|
49
|
+
ON actor_refresh_token_records(family_id)`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -40,6 +40,7 @@ export { migrateGuardianActionSupersession } from './035-guardian-action-superse
|
|
|
40
40
|
export { migrateNormalizePhoneIdentities } from './036-normalize-phone-identities.js';
|
|
41
41
|
export { migrateVoiceInviteColumns } from './037-voice-invite-columns.js';
|
|
42
42
|
export { createActorTokenRecordsTable } from './038-actor-token-records.js';
|
|
43
|
+
export { createActorRefreshTokenRecordsTable } from './039-actor-refresh-token-records.js';
|
|
43
44
|
export { createCoreTables } from './100-core-tables.js';
|
|
44
45
|
export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
|
|
45
46
|
export { addCoreColumns } from './102-alter-table-columns.js';
|
|
@@ -96,7 +96,7 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
|
|
|
96
96
|
description: 'Normalize phone-like identity fields to E.164 format across guardian bindings, verification challenges, canonical requests, ingress members, and rate limits',
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
|
-
key: '
|
|
99
|
+
key: 'migration_backfill_guardian_principal_id_v3',
|
|
100
100
|
version: 15,
|
|
101
101
|
description: 'Backfill guardianPrincipalId for existing channel_guardian_bindings and canonical_guardian_requests rows, expire unresolvable pending requests',
|
|
102
102
|
},
|