@vellumai/assistant 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +37 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +70 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +21 -17
  12. package/src/__tests__/channel-approvals.test.ts +48 -1
  13. package/src/__tests__/channel-guardian.test.ts +74 -22
  14. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  15. package/src/__tests__/config-schema.test.ts +2 -1
  16. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  17. package/src/__tests__/daemon-lifecycle.test.ts +13 -12
  18. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  19. package/src/__tests__/entity-search.test.ts +615 -0
  20. package/src/__tests__/handlers-twilio-config.test.ts +407 -0
  21. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  22. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  23. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  24. package/src/__tests__/run-orchestrator.test.ts +22 -0
  25. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  26. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  27. package/src/__tests__/twilio-routes.test.ts +39 -3
  28. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  29. package/src/__tests__/web-search.test.ts +1 -1
  30. package/src/__tests__/work-item-output.test.ts +110 -0
  31. package/src/calls/call-domain.ts +8 -5
  32. package/src/calls/call-orchestrator.ts +22 -11
  33. package/src/calls/twilio-config.ts +17 -11
  34. package/src/calls/twilio-rest.ts +276 -0
  35. package/src/calls/twilio-routes.ts +39 -1
  36. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  37. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  38. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  39. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  40. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  41. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  42. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  43. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  44. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  45. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  46. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  47. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  48. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  49. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  50. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  51. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  52. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  53. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  54. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  55. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  56. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  57. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  58. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  59. package/src/config/bundled-skills/messaging/SKILL.md +21 -6
  60. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  61. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  62. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  63. package/src/config/defaults.ts +2 -1
  64. package/src/config/schema.ts +9 -3
  65. package/src/config/system-prompt.ts +24 -0
  66. package/src/config/templates/IDENTITY.md +2 -2
  67. package/src/config/vellum-skills/catalog.json +6 -0
  68. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  69. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  70. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  71. package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
  72. package/src/daemon/handlers/config.ts +783 -9
  73. package/src/daemon/handlers/dictation.ts +182 -0
  74. package/src/daemon/handlers/identity.ts +14 -23
  75. package/src/daemon/handlers/index.ts +2 -0
  76. package/src/daemon/handlers/sessions.ts +2 -0
  77. package/src/daemon/handlers/shared.ts +3 -0
  78. package/src/daemon/handlers/work-items.ts +15 -7
  79. package/src/daemon/ipc-contract-inventory.json +10 -0
  80. package/src/daemon/ipc-contract.ts +108 -4
  81. package/src/daemon/lifecycle.ts +2 -0
  82. package/src/daemon/ride-shotgun-handler.ts +1 -1
  83. package/src/daemon/server.ts +6 -2
  84. package/src/daemon/session-agent-loop.ts +5 -1
  85. package/src/daemon/session-runtime-assembly.ts +55 -0
  86. package/src/daemon/session-tool-setup.ts +2 -0
  87. package/src/daemon/session.ts +11 -1
  88. package/src/inbound/public-ingress-urls.ts +3 -3
  89. package/src/memory/channel-guardian-store.ts +2 -1
  90. package/src/memory/db-init.ts +144 -0
  91. package/src/memory/job-handlers/media-processing.ts +100 -0
  92. package/src/memory/jobs-store.ts +2 -1
  93. package/src/memory/jobs-worker.ts +4 -0
  94. package/src/memory/media-store.ts +759 -0
  95. package/src/memory/retriever.ts +6 -1
  96. package/src/memory/schema.ts +98 -0
  97. package/src/memory/search/entity.ts +208 -25
  98. package/src/memory/search/ranking.ts +6 -1
  99. package/src/memory/search/types.ts +24 -0
  100. package/src/messaging/provider-types.ts +2 -0
  101. package/src/messaging/providers/sms/adapter.ts +204 -0
  102. package/src/messaging/providers/sms/client.ts +93 -0
  103. package/src/messaging/providers/sms/types.ts +7 -0
  104. package/src/permissions/checker.ts +16 -2
  105. package/src/runtime/approval-message-composer.ts +143 -0
  106. package/src/runtime/channel-approvals.ts +12 -4
  107. package/src/runtime/channel-guardian-service.ts +44 -18
  108. package/src/runtime/channel-readiness-service.ts +292 -0
  109. package/src/runtime/channel-readiness-types.ts +29 -0
  110. package/src/runtime/http-server.ts +53 -27
  111. package/src/runtime/http-types.ts +3 -0
  112. package/src/runtime/routes/call-routes.ts +2 -1
  113. package/src/runtime/routes/channel-routes.ts +67 -21
  114. package/src/runtime/run-orchestrator.ts +35 -2
  115. package/src/tools/assets/materialize.ts +2 -2
  116. package/src/tools/calls/call-start.ts +1 -0
  117. package/src/tools/credentials/vault.ts +1 -1
  118. package/src/tools/execution-target.ts +11 -1
  119. package/src/tools/network/web-search.ts +1 -1
  120. package/src/tools/types.ts +2 -0
  121. package/src/twitter/router.ts +1 -1
  122. package/src/util/platform.ts +35 -0
@@ -3,12 +3,14 @@ import type { Message } from '../providers/types.js';
3
3
  import {
4
4
  applyRuntimeInjections,
5
5
  injectChannelCapabilityContext,
6
+ injectGuardianContext,
6
7
  injectTemporalContext,
7
8
  resolveChannelCapabilities,
8
9
  stripChannelCapabilityContext,
10
+ stripGuardianContext,
9
11
  stripTemporalContext,
10
12
  } from '../daemon/session-runtime-assembly.js';
11
- import type { ChannelCapabilities } from '../daemon/session-runtime-assembly.js';
13
+ import type { ChannelCapabilities, GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
12
14
  import { buildChannelAwarenessSection } from '../config/system-prompt.js';
13
15
 
14
16
  // ---------------------------------------------------------------------------
@@ -277,6 +279,12 @@ describe('buildChannelAwarenessSection', () => {
277
279
  const section = buildChannelAwarenessSection();
278
280
  expect(section).toContain('computer-control permissions on non-dashboard');
279
281
  });
282
+
283
+ test('includes guardian context contract for channel actors', () => {
284
+ const section = buildChannelAwarenessSection();
285
+ expect(section).toContain('<guardian_context>');
286
+ expect(section).toContain('Never infer guardian status');
287
+ });
280
288
  });
281
289
 
282
290
  // ---------------------------------------------------------------------------
@@ -474,3 +482,79 @@ describe('applyRuntimeInjections with temporalContext', () => {
474
482
  expect(result[0].content.length).toBe(1);
475
483
  });
476
484
  });
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // guardian_context
488
+ // ---------------------------------------------------------------------------
489
+
490
+ describe('injectGuardianContext', () => {
491
+ const baseUserMessage: Message = {
492
+ role: 'user',
493
+ content: [{ type: 'text', text: 'Can you text me updates?' }],
494
+ };
495
+
496
+ test('prepends guardian_context block to user message', () => {
497
+ const ctx: GuardianRuntimeContext = {
498
+ sourceChannel: 'sms',
499
+ actorRole: 'guardian',
500
+ guardianExternalUserId: 'guardian-user-1',
501
+ guardianChatId: '+15550001111',
502
+ requesterIdentifier: '+15550001111',
503
+ requesterExternalUserId: 'guardian-user-1',
504
+ requesterChatId: '+15550001111',
505
+ };
506
+
507
+ const result = injectGuardianContext(baseUserMessage, ctx);
508
+ expect(result.content.length).toBe(2);
509
+ const injected = result.content[0];
510
+ expect(injected.type).toBe('text');
511
+ const text = (injected as { type: 'text'; text: string }).text;
512
+ expect(text).toContain('<guardian_context>');
513
+ expect(text).toContain('actor_role: guardian');
514
+ expect(text).toContain('source_channel: sms');
515
+ expect(text).toContain('</guardian_context>');
516
+ });
517
+ });
518
+
519
+ describe('stripGuardianContext', () => {
520
+ test('strips guardian_context blocks from user messages', () => {
521
+ const messages: Message[] = [
522
+ {
523
+ role: 'user',
524
+ content: [
525
+ { type: 'text', text: '<guardian_context>\nactor_role: guardian\n</guardian_context>' },
526
+ { type: 'text', text: 'Hello' },
527
+ ],
528
+ },
529
+ ];
530
+ const result = stripGuardianContext(messages);
531
+ expect(result).toHaveLength(1);
532
+ expect(result[0].content).toHaveLength(1);
533
+ expect((result[0].content[0] as { type: 'text'; text: string }).text).toBe('Hello');
534
+ });
535
+ });
536
+
537
+ describe('applyRuntimeInjections with guardianContext', () => {
538
+ const baseMessages: Message[] = [
539
+ {
540
+ role: 'user',
541
+ content: [{ type: 'text', text: 'Help me send this over SMS.' }],
542
+ },
543
+ ];
544
+
545
+ test('injects guardian context when provided', () => {
546
+ const result = applyRuntimeInjections(baseMessages, {
547
+ guardianContext: {
548
+ sourceChannel: 'sms',
549
+ actorRole: 'non-guardian',
550
+ guardianExternalUserId: 'guardian-1',
551
+ requesterExternalUserId: 'requester-1',
552
+ requesterIdentifier: '+15550002222',
553
+ requesterChatId: '+15550002222',
554
+ },
555
+ });
556
+ expect(result).toHaveLength(1);
557
+ expect(result[0].content).toHaveLength(2);
558
+ expect((result[0].content[0] as { type: 'text'; text: string }).text).toContain('<guardian_context>');
559
+ });
560
+ });
@@ -0,0 +1,125 @@
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ const sendSmsMock = mock(async (..._args: unknown[]) => ({ messageSid: 'SM-mock-sid', status: 'queued' }));
4
+ const getOrCreateConversationMock = mock((_key: string) => ({ conversationId: 'conv-1' }));
5
+ const upsertOutboundBindingMock = mock((_input: Record<string, unknown>) => {});
6
+
7
+ let secureKeys: Record<string, string | undefined> = {
8
+ 'credential:twilio:account_sid': 'AC1234567890',
9
+ 'credential:twilio:auth_token': 'auth-token',
10
+ };
11
+
12
+ let configState: {
13
+ sms?: {
14
+ phoneNumber?: string;
15
+ assistantPhoneNumbers?: Record<string, string>;
16
+ };
17
+ } = {
18
+ sms: {},
19
+ };
20
+
21
+ mock.module('../security/secure-keys.js', () => ({
22
+ getSecureKey: (key: string) => secureKeys[key],
23
+ }));
24
+
25
+ mock.module('../util/platform.js', () => ({
26
+ readHttpToken: () => 'runtime-token',
27
+ }));
28
+
29
+ mock.module('../config/loader.js', () => ({
30
+ loadConfig: () => configState,
31
+ }));
32
+
33
+ mock.module('../memory/conversation-key-store.js', () => ({
34
+ getOrCreateConversation: (key: string) => getOrCreateConversationMock(key),
35
+ }));
36
+
37
+ mock.module('../memory/external-conversation-store.js', () => ({
38
+ upsertOutboundBinding: (input: Record<string, unknown>) => upsertOutboundBindingMock(input),
39
+ }));
40
+
41
+ mock.module('../messaging/providers/sms/client.js', () => ({
42
+ sendMessage: (
43
+ gatewayUrl: string,
44
+ bearerToken: string,
45
+ to: string,
46
+ text: string,
47
+ assistantId?: string,
48
+ ) => sendSmsMock(gatewayUrl, bearerToken, to, text, assistantId),
49
+ }));
50
+
51
+ import { smsMessagingProvider } from '../messaging/providers/sms/adapter.js';
52
+
53
+ describe('smsMessagingProvider', () => {
54
+ beforeEach(() => {
55
+ sendSmsMock.mockClear();
56
+ getOrCreateConversationMock.mockClear();
57
+ upsertOutboundBindingMock.mockClear();
58
+ secureKeys = {
59
+ 'credential:twilio:account_sid': 'AC1234567890',
60
+ 'credential:twilio:auth_token': 'auth-token',
61
+ };
62
+ configState = { sms: {} };
63
+ delete process.env.TWILIO_PHONE_NUMBER;
64
+ delete process.env.GATEWAY_INTERNAL_BASE_URL;
65
+ delete process.env.GATEWAY_PORT;
66
+ });
67
+
68
+ test('isConnected is true when assistant-scoped numbers exist', () => {
69
+ configState = {
70
+ sms: {
71
+ assistantPhoneNumbers: { 'ast-alpha': '+15550001111' },
72
+ },
73
+ };
74
+
75
+ expect(smsMessagingProvider.isConnected?.()).toBe(true);
76
+ });
77
+
78
+ test('sendMessage forwards explicit assistant scope and avoids outbound binding writes for non-self', async () => {
79
+ await smsMessagingProvider.sendMessage('', '+15550002222', 'hi', {
80
+ assistantId: 'ast-alpha',
81
+ });
82
+
83
+ expect(sendSmsMock).toHaveBeenCalledWith(
84
+ 'http://127.0.0.1:7830',
85
+ 'runtime-token',
86
+ '+15550002222',
87
+ 'hi',
88
+ 'ast-alpha',
89
+ );
90
+ expect(getOrCreateConversationMock).toHaveBeenCalledWith('asst:ast-alpha:sms:+15550002222');
91
+ expect(upsertOutboundBindingMock).not.toHaveBeenCalled();
92
+ });
93
+
94
+ test('sendMessage uses messageSid from gateway response as result ID', async () => {
95
+ sendSmsMock.mockImplementation(async () => ({ messageSid: 'SM-test-12345', status: 'queued' }));
96
+ const result = await smsMessagingProvider.sendMessage('', '+15550009999', 'sid test', {
97
+ assistantId: 'self',
98
+ });
99
+ expect(result.id).toBe('SM-test-12345');
100
+ });
101
+
102
+ test('sendMessage falls back to timestamp-based ID when messageSid is absent', async () => {
103
+ sendSmsMock.mockImplementation(async () => ({}));
104
+ const before = Date.now();
105
+ const result = await smsMessagingProvider.sendMessage('', '+15550009999', 'no sid', {
106
+ assistantId: 'self',
107
+ });
108
+ expect(result.id).toMatch(/^sms-\d+$/);
109
+ const ts = parseInt(result.id.replace('sms-', ''), 10);
110
+ expect(ts).toBeGreaterThanOrEqual(before);
111
+ });
112
+
113
+ test('sendMessage uses canonical self key and writes outbound binding for self scope', async () => {
114
+ await smsMessagingProvider.sendMessage('', '+15550003333', 'hello', {
115
+ assistantId: 'self',
116
+ });
117
+
118
+ expect(getOrCreateConversationMock).toHaveBeenCalledWith('sms:+15550003333');
119
+ expect(upsertOutboundBindingMock).toHaveBeenCalledWith({
120
+ conversationId: 'conv-1',
121
+ sourceChannel: 'sms',
122
+ externalChatId: '+15550003333',
123
+ });
124
+ });
125
+ });
@@ -87,7 +87,7 @@ import {
87
87
  updateCallSession,
88
88
  getCallEvents,
89
89
  } from '../calls/call-store.js';
90
- import { resolveRelayUrl, handleStatusCallback, handleVoiceWebhook } from '../calls/twilio-routes.js';
90
+ import { resolveRelayUrl, buildWelcomeGreeting, handleStatusCallback, handleVoiceWebhook } from '../calls/twilio-routes.js';
91
91
  import { registerCallCompletionNotifier, unregisterCallCompletionNotifier } from '../calls/call-state.js';
92
92
 
93
93
  initializeDb();
@@ -119,14 +119,14 @@ function resetTables() {
119
119
  ensuredConvIds = new Set();
120
120
  }
121
121
 
122
- function createTestSession(convId: string, callSid: string) {
122
+ function createTestSession(convId: string, callSid: string, task = 'test task') {
123
123
  ensureConversation(convId);
124
124
  const session = createCallSession({
125
125
  conversationId: convId,
126
126
  provider: 'twilio',
127
127
  fromNumber: '+15550001111',
128
128
  toNumber: '+15559998888',
129
- task: 'test task',
129
+ task,
130
130
  });
131
131
  updateCallSession(session.id, { providerCallSid: callSid });
132
132
  return session;
@@ -416,6 +416,24 @@ describe('twilio webhook routes', () => {
416
416
  });
417
417
  });
418
418
 
419
+ describe('buildWelcomeGreeting', () => {
420
+ test('builds a contextual opener from task text', () => {
421
+ const greeting = buildWelcomeGreeting('check store hours for tomorrow');
422
+ expect(greeting).toBe('Hello, I am calling about check store hours for tomorrow. Is now a good time to talk?');
423
+ });
424
+
425
+ test('ignores appended Context block when building opener', () => {
426
+ const greeting = buildWelcomeGreeting('check store hours\n\nContext: Caller asked by email');
427
+ expect(greeting).toBe('Hello, I am calling about check store hours. Is now a good time to talk?');
428
+ expect(greeting).not.toContain('Context:');
429
+ });
430
+
431
+ test('uses configured greeting override when provided', () => {
432
+ const greeting = buildWelcomeGreeting('check store hours', 'Custom hello');
433
+ expect(greeting).toBe('Custom hello');
434
+ });
435
+ });
436
+
419
437
  // ── TwiML relay URL generation ──────────────────────────────────────
420
438
  // Call handleVoiceWebhook directly since direct routes are blocked.
421
439
 
@@ -446,6 +464,24 @@ describe('twilio webhook routes', () => {
446
464
  const twiml = await res.text();
447
465
  expect(twiml).toContain('wss://gateway.example.com/v1/calls/relay');
448
466
  });
467
+
468
+ test('TwiML welcome greeting is task-aware by default', async () => {
469
+ const session = createTestSession(
470
+ 'conv-twiml-3',
471
+ 'CA_twiml_3',
472
+ 'confirm appointment time\n\nContext: Prior email thread',
473
+ );
474
+ const req = makeVoiceRequest(session.id, { CallSid: 'CA_twiml_3' });
475
+
476
+ const res = await handleVoiceWebhook(req);
477
+
478
+ expect(res.status).toBe(200);
479
+ const twiml = await res.text();
480
+ expect(twiml).toContain(
481
+ 'welcomeGreeting="Hello, I am calling about confirm appointment time. Is now a good time to talk?"',
482
+ );
483
+ expect(twiml).not.toContain('Hello, how can I help you today?');
484
+ });
449
485
  });
450
486
 
451
487
  // ── Handler-level idempotency concurrency tests ─────────────────
@@ -100,7 +100,7 @@ describe('CLI error shaping', () => {
100
100
 
101
101
  test('routed non-session error with suggestAlternative emits structured JSON', () => {
102
102
  const err = Object.assign(
103
- new Error('OAuth is not configured. Set up OAuth credentials in Settings, or switch to browser strategy.'),
103
+ new Error('OAuth is not configured. Provide your X developer credentials here in the chat to set up OAuth, or switch to browser strategy.'),
104
104
  {
105
105
  pathUsed: 'oauth' as const,
106
106
  suggestAlternative: 'browser' as const,
@@ -110,7 +110,7 @@ describe('CLI error shaping', () => {
110
110
 
111
111
  expect(payload).toEqual({
112
112
  ok: false,
113
- error: 'OAuth is not configured. Set up OAuth credentials in Settings, or switch to browser strategy.',
113
+ error: 'OAuth is not configured. Provide your X developer credentials here in the chat to set up OAuth, or switch to browser strategy.',
114
114
  pathUsed: 'oauth',
115
115
  suggestAlternative: 'browser',
116
116
  });
@@ -414,7 +414,7 @@ async function executeWebSearch(
414
414
 
415
415
  if (!apiKey) {
416
416
  return {
417
- content: 'Error: No web search API key configured. Set PERPLEXITY_API_KEY or BRAVE_API_KEY environment variable, or configure a key in settings.',
417
+ content: 'Error: No web search API key configured. Provide a PERPLEXITY_API_KEY or BRAVE_API_KEY here in the chat using the secure credential prompt, or set it from the Settings page.',
418
418
  isError: true,
419
419
  };
420
420
  }
@@ -0,0 +1,110 @@
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import type { Database } from 'bun:sqlite';
4
+ import * as net from 'node:net';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const testDir = mkdtempSync(join(tmpdir(), 'work-item-output-test-'));
9
+
10
+ mock.module('../util/platform.js', () => ({
11
+ getDataDir: () => testDir,
12
+ isMacOS: () => process.platform === 'darwin',
13
+ isLinux: () => process.platform === 'linux',
14
+ isWindows: () => process.platform === 'win32',
15
+ getSocketPath: () => join(testDir, 'test.sock'),
16
+ getPidPath: () => join(testDir, 'test.pid'),
17
+ getDbPath: () => join(testDir, 'test.db'),
18
+ getLogPath: () => join(testDir, 'test.log'),
19
+ ensureDataDir: () => {},
20
+ migrateToDataLayout: () => {},
21
+ migrateToWorkspaceLayout: () => {},
22
+ }));
23
+
24
+ mock.module('../util/logger.js', () => ({
25
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ }));
29
+
30
+ mock.module('../config/loader.js', () => ({
31
+ getConfig: () => ({ memory: {} }),
32
+ }));
33
+
34
+ mock.module('./indexer.js', () => ({
35
+ indexMessageNow: () => {},
36
+ }));
37
+
38
+ import { addMessage, createConversation } from '../memory/conversation-store.js';
39
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
40
+ import { createTask, createTaskRun, updateTaskRun } from '../tasks/task-store.js';
41
+ import { createWorkItem, updateWorkItem } from '../work-items/work-item-store.js';
42
+ import { handleWorkItemOutput } from '../daemon/handlers/work-items.js';
43
+ import type { HandlerContext } from '../daemon/handlers/shared.js';
44
+
45
+ initializeDb();
46
+
47
+ afterAll(() => {
48
+ resetDb();
49
+ try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
50
+ });
51
+
52
+ function getRawDb(): Database {
53
+ return (getDb() as unknown as { $client: Database }).$client;
54
+ }
55
+
56
+ describe('handleWorkItemOutput', () => {
57
+ beforeEach(() => {
58
+ const raw = getRawDb();
59
+ raw.run('DELETE FROM task_runs');
60
+ raw.run('DELETE FROM work_items');
61
+ raw.run('DELETE FROM tasks');
62
+ raw.run('DELETE FROM messages');
63
+ raw.run('DELETE FROM conversations');
64
+ });
65
+
66
+ test('uses only the latest assistant text block for summary output', () => {
67
+ const task = createTask({
68
+ title: 'Delete weather report',
69
+ template: 'Delete weather_report_task.txt',
70
+ });
71
+ const run = createTaskRun(task.id);
72
+ const item = createWorkItem({
73
+ taskId: task.id,
74
+ title: 'Delete weather_report_task.txt',
75
+ });
76
+ const conversation = createConversation('Task output test');
77
+
78
+ updateTaskRun(run.id, {
79
+ status: 'completed',
80
+ conversationId: conversation.id,
81
+ finishedAt: Date.now(),
82
+ });
83
+ updateWorkItem(item.id, {
84
+ status: 'awaiting_review',
85
+ lastRunId: run.id,
86
+ lastRunConversationId: conversation.id,
87
+ lastRunStatus: 'completed',
88
+ });
89
+
90
+ addMessage(conversation.id, 'assistant', JSON.stringify([
91
+ { type: 'text', text: "I'll need to delete the weather report file from your Documents folder. This will permanently remove it." },
92
+ { type: 'text', text: "Looks like that file has already been deleted — it's no longer there. I'll mark this task as done." },
93
+ { type: 'text', text: 'The file is already deleted, so the task is complete.' },
94
+ ]));
95
+
96
+ const sent: Array<{ type: string; [key: string]: unknown }> = [];
97
+ const socket = {} as net.Socket;
98
+ const ctx = {
99
+ send: (_socket: net.Socket, msg: { type: string; [key: string]: unknown }) => sent.push(msg),
100
+ } as unknown as HandlerContext;
101
+
102
+ handleWorkItemOutput({ type: 'work_item_output', id: item.id }, socket, ctx);
103
+
104
+ expect(sent).toHaveLength(1);
105
+ const response = sent[0];
106
+ expect(response.type).toBe('work_item_output_response');
107
+ expect(response.success).toBe(true);
108
+ expect((response.output as { summary: string }).summary).toBe('The file is already deleted, so the task is complete.');
109
+ });
110
+ });
@@ -51,6 +51,7 @@ export type StartCallInput = {
51
51
  task: string;
52
52
  context?: string;
53
53
  conversationId: string;
54
+ assistantId?: string;
54
55
  callerIdentityMode?: 'assistant_number' | 'user_number';
55
56
  };
56
57
 
@@ -87,7 +88,8 @@ export type CallerIdentityResult =
87
88
  * - If `requestedMode` is provided but overrides are disabled, return an error.
88
89
  * - Otherwise, always use `assistant_number` (implicit default).
89
90
  *
90
- * For `assistant_number`: uses the Twilio phone number from `getTwilioConfig()`.
91
+ * For `assistant_number`: uses the Twilio phone number from
92
+ * `getTwilioConfig(assistantId)` so multi-assistant mappings are honored.
91
93
  * No eligibility check is performed — this is a fast path.
92
94
  * For `user_number`: uses `config.calls.callerIdentity.userNumber` or the
93
95
  * secure key `credential:twilio:user_phone_number`, then validates that the
@@ -96,6 +98,7 @@ export type CallerIdentityResult =
96
98
  export async function resolveCallerIdentity(
97
99
  config: AssistantConfig,
98
100
  requestedMode?: 'assistant_number' | 'user_number',
101
+ assistantId?: string,
99
102
  ): Promise<CallerIdentityResult> {
100
103
  const identityConfig = config.calls.callerIdentity;
101
104
  let mode: 'assistant_number' | 'user_number';
@@ -118,8 +121,8 @@ export async function resolveCallerIdentity(
118
121
  }
119
122
 
120
123
  if (mode === 'assistant_number') {
121
- const twilioConfig = getTwilioConfig();
122
- log.info({ mode, source, fromNumber: twilioConfig.phoneNumber }, 'Resolved caller identity');
124
+ const twilioConfig = getTwilioConfig(assistantId);
125
+ log.info({ mode, source, fromNumber: twilioConfig.phoneNumber, assistantId }, 'Resolved caller identity');
123
126
  return { ok: true, mode, fromNumber: twilioConfig.phoneNumber, source };
124
127
  }
125
128
 
@@ -175,7 +178,7 @@ export async function resolveCallerIdentity(
175
178
  * Initiate a new outbound call.
176
179
  */
177
180
  export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
178
- const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode } = input;
181
+ const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = 'self' } = input;
179
182
 
180
183
  if (!phoneNumber || typeof phoneNumber !== 'string') {
181
184
  return { ok: false, error: 'phone_number is required and must be a string', status: 400 };
@@ -204,7 +207,7 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
204
207
  const provider = new TwilioConversationRelayProvider();
205
208
 
206
209
  // Resolve which phone number to use as caller ID
207
- const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode);
210
+ const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode, assistantId);
208
211
  if (!identityResult.ok) {
209
212
  return { ok: false, error: identityResult.error, status: 400 };
210
213
  }
@@ -26,9 +26,21 @@ const log = getLogger('call-orchestrator');
26
26
 
27
27
  type OrchestratorState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
28
28
 
29
- const ASK_USER_REGEX = /\[ASK_USER:\s*(.+?)\]/;
29
+ const ASK_USER_CAPTURE_REGEX = /\[ASK_USER:\s*(.+?)\]/;
30
+ const ASK_USER_MARKER_REGEX = /\[ASK_USER:\s*.+?\]/g;
31
+ const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
32
+ const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
33
+ const END_CALL_MARKER_REGEX = /\[END_CALL\]/g;
30
34
  const END_CALL_MARKER = '[END_CALL]';
31
35
 
36
+ function stripInternalSpeechMarkers(text: string): string {
37
+ return text
38
+ .replace(ASK_USER_MARKER_REGEX, '')
39
+ .replace(USER_ANSWERED_MARKER_REGEX, '')
40
+ .replace(USER_INSTRUCTION_MARKER_REGEX, '')
41
+ .replace(END_CALL_MARKER_REGEX, '');
42
+ }
43
+
32
44
  export class CallOrchestrator {
33
45
  private callSessionId: string;
34
46
  private relay: RelayConnection;
@@ -314,8 +326,12 @@ export class CallOrchestrator {
314
326
  const afterBracket = ttsBuffer;
315
327
  const couldBeControl =
316
328
  '[ASK_USER:'.startsWith(afterBracket) ||
329
+ '[USER_ANSWERED:'.startsWith(afterBracket) ||
330
+ '[USER_INSTRUCTION:'.startsWith(afterBracket) ||
317
331
  '[END_CALL]'.startsWith(afterBracket) ||
318
332
  afterBracket.startsWith('[ASK_USER:') ||
333
+ afterBracket.startsWith('[USER_ANSWERED:') ||
334
+ afterBracket.startsWith('[USER_INSTRUCTION:') ||
319
335
  afterBracket === '[END_CALL' ||
320
336
  afterBracket.startsWith('[END_CALL]');
321
337
 
@@ -339,13 +355,8 @@ export class CallOrchestrator {
339
355
  if (!this.isCurrentRun(runVersion)) return;
340
356
  ttsBuffer += text;
341
357
 
342
- // If the buffer contains a complete control marker, strip it
343
- if (ASK_USER_REGEX.test(ttsBuffer)) {
344
- ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '');
345
- }
346
- if (ttsBuffer.includes(END_CALL_MARKER)) {
347
- ttsBuffer = ttsBuffer.replace(END_CALL_MARKER, '');
348
- }
358
+ // Remove complete control markers before text reaches TTS.
359
+ ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
349
360
 
350
361
  flushSafeText(false);
351
362
  });
@@ -354,7 +365,7 @@ export class CallOrchestrator {
354
365
  if (!this.isCurrentRun(runVersion)) return;
355
366
 
356
367
  // Final sweep: strip any remaining control markers from the buffer
357
- ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '');
368
+ ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
358
369
  if (ttsBuffer.length > 0) {
359
370
  this.relay.sendTextToken(ttsBuffer, false);
360
371
  }
@@ -371,7 +382,7 @@ export class CallOrchestrator {
371
382
  // Record the assistant response
372
383
  this.conversationHistory.push({ role: 'assistant', content: responseText });
373
384
  recordCallEvent(this.callSessionId, 'assistant_spoke', { text: responseText });
374
- const spokenText = responseText.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '').trim();
385
+ const spokenText = stripInternalSpeechMarkers(responseText).trim();
375
386
  if (spokenText.length > 0) {
376
387
  const session = getCallSession(this.callSessionId);
377
388
  if (session) {
@@ -380,7 +391,7 @@ export class CallOrchestrator {
380
391
  }
381
392
 
382
393
  // Check for ASK_USER pattern
383
- const askMatch = responseText.match(ASK_USER_REGEX);
394
+ const askMatch = responseText.match(ASK_USER_CAPTURE_REGEX);
384
395
  if (askMatch) {
385
396
  const questionText = askMatch[1];
386
397
  createPendingQuestion(this.callSessionId, questionText);
@@ -13,20 +13,26 @@ export interface TwilioConfig {
13
13
  wssBaseUrl: string;
14
14
  }
15
15
 
16
- export function getTwilioConfig(): TwilioConfig {
17
- const accountSid = getSecureKey('credential:twilio:account_sid');
18
- const authToken = getSecureKey('credential:twilio:auth_token');
19
- const config = loadConfig();
16
+ function resolveTwilioPhoneNumber(assistantId: string | undefined, config: ReturnType<typeof loadConfig>): string {
17
+ if (assistantId) {
18
+ const assistantPhone = config.sms?.assistantPhoneNumbers?.[assistantId];
19
+ if (assistantPhone) {
20
+ return assistantPhone;
21
+ }
22
+ }
20
23
 
21
- // Phone number resolution priority:
24
+ // Global fallback order:
22
25
  // 1. TWILIO_PHONE_NUMBER env var (explicit override)
23
26
  // 2. config file sms.phoneNumber (primary storage)
24
27
  // 3. credential:twilio:phone_number secure key (backward-compat fallback)
25
- const phoneNumber =
26
- process.env.TWILIO_PHONE_NUMBER ||
27
- config.sms?.phoneNumber ||
28
- getSecureKey('credential:twilio:phone_number') ||
29
- '';
28
+ return process.env.TWILIO_PHONE_NUMBER || config.sms?.phoneNumber || getSecureKey('credential:twilio:phone_number') || '';
29
+ }
30
+
31
+ export function getTwilioConfig(assistantId?: string): TwilioConfig {
32
+ const accountSid = getSecureKey('credential:twilio:account_sid');
33
+ const authToken = getSecureKey('credential:twilio:auth_token');
34
+ const config = loadConfig();
35
+ const phoneNumber = resolveTwilioPhoneNumber(assistantId, config);
30
36
  const webhookBaseUrl = getPublicBaseUrl(config);
31
37
 
32
38
  // Always use the centralized relay URL derived from the public ingress base URL.
@@ -45,7 +51,7 @@ export function getTwilioConfig(): TwilioConfig {
45
51
  throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
46
52
  }
47
53
  if (!phoneNumber) {
48
- throw new Error('TWILIO_PHONE_NUMBER not configured.');
54
+ throw new Error('Twilio phone number not configured.');
49
55
  }
50
56
 
51
57
  log.debug('Twilio config loaded successfully');