@vellumai/assistant 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +2 -0
- package/README.md +45 -18
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +391 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +397 -135
- package/src/__tests__/channel-approvals.test.ts +99 -3
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +261 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +636 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +480 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +85 -22
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +24 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +40 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +58 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +819 -22
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +114 -4
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +111 -504
- package/src/daemon/session-agent-loop.ts +10 -15
- package/src/daemon/session-runtime-assembly.ts +115 -44
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +19 -2
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1163 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +11 -1
- package/src/memory/media-store.ts +759 -0
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +36 -2
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +99 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +26 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +29 -7
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +65 -28
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +237 -103
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +43 -3
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +10 -2
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +43 -1
- package/src/util/retry.ts +4 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const TEST_DIR = '/tmp/vellum-user-ref-test';
|
|
5
|
+
|
|
6
|
+
mock.module('../util/platform.js', () => ({
|
|
7
|
+
getWorkspacePromptPath: (file: string) => join(TEST_DIR, file),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Mutable state the tests control
|
|
11
|
+
let mockFileExists = false;
|
|
12
|
+
let mockFileContent = '';
|
|
13
|
+
|
|
14
|
+
mock.module('node:fs', () => ({
|
|
15
|
+
existsSync: (path: string) => {
|
|
16
|
+
if (path === join(TEST_DIR, 'USER.md')) return mockFileExists;
|
|
17
|
+
return false;
|
|
18
|
+
},
|
|
19
|
+
readFileSync: (path: string, _encoding: string) => {
|
|
20
|
+
if (path === join(TEST_DIR, 'USER.md') && mockFileExists) return mockFileContent;
|
|
21
|
+
throw new Error(`ENOENT: no such file: ${path}`);
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Import after mocks are in place
|
|
26
|
+
const { resolveUserReference } = await import('../config/user-reference.js');
|
|
27
|
+
|
|
28
|
+
describe('resolveUserReference', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mockFileExists = false;
|
|
31
|
+
mockFileContent = '';
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('returns "my human" when USER.md does not exist', () => {
|
|
35
|
+
mockFileExists = false;
|
|
36
|
+
expect(resolveUserReference()).toBe('my human');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('returns "my human" when preferred name field is empty', () => {
|
|
40
|
+
mockFileExists = true;
|
|
41
|
+
mockFileContent = [
|
|
42
|
+
'## Onboarding Snapshot',
|
|
43
|
+
'',
|
|
44
|
+
'- Preferred name/reference:',
|
|
45
|
+
'- Goals:',
|
|
46
|
+
'- Locale:',
|
|
47
|
+
].join('\n');
|
|
48
|
+
expect(resolveUserReference()).toBe('my human');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns the configured name when it is set', () => {
|
|
52
|
+
mockFileExists = true;
|
|
53
|
+
mockFileContent = [
|
|
54
|
+
'## Onboarding Snapshot',
|
|
55
|
+
'',
|
|
56
|
+
'- Preferred name/reference: John',
|
|
57
|
+
'- Goals: ship fast',
|
|
58
|
+
'- Locale: en-US',
|
|
59
|
+
].join('\n');
|
|
60
|
+
expect(resolveUserReference()).toBe('John');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('trims whitespace around the configured name', () => {
|
|
64
|
+
mockFileExists = true;
|
|
65
|
+
mockFileContent = '- Preferred name/reference: Alice \n';
|
|
66
|
+
expect(resolveUserReference()).toBe('Alice');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -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.
|
|
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
|
+
});
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import Anthropic from '@anthropic-ai/sdk';
|
|
10
10
|
import { getConfig } from '../config/loader.js';
|
|
11
|
+
import { resolveUserReference } from '../config/user-reference.js';
|
|
11
12
|
import { getLogger } from '../util/logger.js';
|
|
12
13
|
import {
|
|
13
14
|
getCallSession,
|
|
@@ -25,9 +26,21 @@ const log = getLogger('call-orchestrator');
|
|
|
25
26
|
|
|
26
27
|
type OrchestratorState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
|
|
27
28
|
|
|
28
|
-
const
|
|
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;
|
|
29
34
|
const END_CALL_MARKER = '[END_CALL]';
|
|
30
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
|
+
|
|
31
44
|
export class CallOrchestrator {
|
|
32
45
|
private callSessionId: string;
|
|
33
46
|
private relay: RelayConnection;
|
|
@@ -43,6 +56,8 @@ export class CallOrchestrator {
|
|
|
43
56
|
private task: string | null;
|
|
44
57
|
/** Instructions queued while an LLM turn is in-flight or during waiting_on_user */
|
|
45
58
|
private pendingInstructions: string[] = [];
|
|
59
|
+
/** Monotonic run id used to suppress stale turn side effects after interruption. */
|
|
60
|
+
private llmRunVersion = 0;
|
|
46
61
|
|
|
47
62
|
constructor(callSessionId: string, relay: RelayConnection, task: string | null) {
|
|
48
63
|
this.callSessionId = callSessionId;
|
|
@@ -64,20 +79,30 @@ export class CallOrchestrator {
|
|
|
64
79
|
* Handle a final caller utterance from the ConversationRelay.
|
|
65
80
|
*/
|
|
66
81
|
async handleCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): Promise<void> {
|
|
82
|
+
const interruptedInFlight = this.state === 'processing' || this.state === 'speaking';
|
|
67
83
|
// If we're already processing or speaking, abort the in-flight generation
|
|
68
|
-
if (
|
|
84
|
+
if (interruptedInFlight) {
|
|
69
85
|
this.abortController.abort();
|
|
70
86
|
this.abortController = new AbortController();
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
this.state = 'processing';
|
|
74
90
|
this.resetSilenceTimer();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
const callerContent = this.formatCallerUtterance(transcript, speaker);
|
|
92
|
+
|
|
93
|
+
// Preserve strict role alternation for Anthropic. If the last message
|
|
94
|
+
// is already user-role (e.g. interrupted run never appended assistant,
|
|
95
|
+
// or a second caller prompt arrives before assistant completion), merge
|
|
96
|
+
// this utterance into that same user turn.
|
|
97
|
+
const lastMessage = this.conversationHistory[this.conversationHistory.length - 1];
|
|
98
|
+
if (lastMessage?.role === 'user') {
|
|
99
|
+
lastMessage.content = `${lastMessage.content}\n${callerContent}`;
|
|
100
|
+
} else {
|
|
101
|
+
this.conversationHistory.push({
|
|
102
|
+
role: 'user',
|
|
103
|
+
content: callerContent,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
81
106
|
|
|
82
107
|
await this.runLlm();
|
|
83
108
|
}
|
|
@@ -168,8 +193,15 @@ export class CallOrchestrator {
|
|
|
168
193
|
* Handle caller interrupting the assistant's speech.
|
|
169
194
|
*/
|
|
170
195
|
handleInterrupt(): void {
|
|
196
|
+
const wasSpeaking = this.state === 'speaking';
|
|
171
197
|
this.abortController.abort();
|
|
172
198
|
this.abortController = new AbortController();
|
|
199
|
+
this.llmRunVersion++;
|
|
200
|
+
// Explicitly terminate the in-progress TTS turn so the relay can
|
|
201
|
+
// immediately hand control back to the caller after barge-in.
|
|
202
|
+
if (wasSpeaking) {
|
|
203
|
+
this.relay.sendTextToken('', true);
|
|
204
|
+
}
|
|
173
205
|
this.state = 'idle';
|
|
174
206
|
}
|
|
175
207
|
|
|
@@ -196,13 +228,14 @@ export class CallOrchestrator {
|
|
|
196
228
|
: '1. Begin the conversation naturally.';
|
|
197
229
|
|
|
198
230
|
return [
|
|
199
|
-
|
|
231
|
+
`You are on a live phone call on behalf of ${resolveUserReference()}.`,
|
|
200
232
|
this.task ? `Task: ${this.task}` : '',
|
|
201
233
|
'',
|
|
202
234
|
'You are speaking directly to the person who answered the phone.',
|
|
203
235
|
'Respond naturally and conversationally — speak as you would in a real phone conversation.',
|
|
204
236
|
'',
|
|
205
237
|
'IMPORTANT RULES:',
|
|
238
|
+
'0. When introducing yourself, refer to yourself as an assistant. Avoid the phrase "AI assistant" unless directly asked.',
|
|
206
239
|
disclosureRule,
|
|
207
240
|
'2. Be concise — phone conversations should be brief and natural.',
|
|
208
241
|
'3. If the callee asks something you don\'t know, include [ASK_USER: your question here] in your response along with a hold message like "Let me check on that for you."',
|
|
@@ -239,6 +272,8 @@ export class CallOrchestrator {
|
|
|
239
272
|
}
|
|
240
273
|
|
|
241
274
|
const client = new Anthropic({ apiKey });
|
|
275
|
+
const runVersion = ++this.llmRunVersion;
|
|
276
|
+
const runSignal = this.abortController.signal;
|
|
242
277
|
|
|
243
278
|
try {
|
|
244
279
|
this.state = 'speaking';
|
|
@@ -255,7 +290,7 @@ export class CallOrchestrator {
|
|
|
255
290
|
content: m.content,
|
|
256
291
|
})),
|
|
257
292
|
},
|
|
258
|
-
{ signal:
|
|
293
|
+
{ signal: runSignal },
|
|
259
294
|
);
|
|
260
295
|
|
|
261
296
|
// Buffer incoming tokens so we can strip control markers ([ASK_USER:...], [END_CALL])
|
|
@@ -264,6 +299,7 @@ export class CallOrchestrator {
|
|
|
264
299
|
let ttsBuffer = '';
|
|
265
300
|
|
|
266
301
|
const flushSafeText = (_force: boolean): void => {
|
|
302
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
267
303
|
if (ttsBuffer.length === 0) return;
|
|
268
304
|
const bracketIdx = ttsBuffer.indexOf('[');
|
|
269
305
|
if (bracketIdx === -1) {
|
|
@@ -290,8 +326,12 @@ export class CallOrchestrator {
|
|
|
290
326
|
const afterBracket = ttsBuffer;
|
|
291
327
|
const couldBeControl =
|
|
292
328
|
'[ASK_USER:'.startsWith(afterBracket) ||
|
|
329
|
+
'[USER_ANSWERED:'.startsWith(afterBracket) ||
|
|
330
|
+
'[USER_INSTRUCTION:'.startsWith(afterBracket) ||
|
|
293
331
|
'[END_CALL]'.startsWith(afterBracket) ||
|
|
294
332
|
afterBracket.startsWith('[ASK_USER:') ||
|
|
333
|
+
afterBracket.startsWith('[USER_ANSWERED:') ||
|
|
334
|
+
afterBracket.startsWith('[USER_INSTRUCTION:') ||
|
|
295
335
|
afterBracket === '[END_CALL' ||
|
|
296
336
|
afterBracket.startsWith('[END_CALL]');
|
|
297
337
|
|
|
@@ -312,23 +352,20 @@ export class CallOrchestrator {
|
|
|
312
352
|
};
|
|
313
353
|
|
|
314
354
|
stream.on('text', (text) => {
|
|
355
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
315
356
|
ttsBuffer += text;
|
|
316
357
|
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '');
|
|
320
|
-
}
|
|
321
|
-
if (ttsBuffer.includes(END_CALL_MARKER)) {
|
|
322
|
-
ttsBuffer = ttsBuffer.replace(END_CALL_MARKER, '');
|
|
323
|
-
}
|
|
358
|
+
// Remove complete control markers before text reaches TTS.
|
|
359
|
+
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
324
360
|
|
|
325
361
|
flushSafeText(false);
|
|
326
362
|
});
|
|
327
363
|
|
|
328
364
|
const finalMessage = await stream.finalMessage();
|
|
365
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
329
366
|
|
|
330
367
|
// Final sweep: strip any remaining control markers from the buffer
|
|
331
|
-
ttsBuffer = ttsBuffer
|
|
368
|
+
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
332
369
|
if (ttsBuffer.length > 0) {
|
|
333
370
|
this.relay.sendTextToken(ttsBuffer, false);
|
|
334
371
|
}
|
|
@@ -345,7 +382,7 @@ export class CallOrchestrator {
|
|
|
345
382
|
// Record the assistant response
|
|
346
383
|
this.conversationHistory.push({ role: 'assistant', content: responseText });
|
|
347
384
|
recordCallEvent(this.callSessionId, 'assistant_spoke', { text: responseText });
|
|
348
|
-
const spokenText = responseText
|
|
385
|
+
const spokenText = stripInternalSpeechMarkers(responseText).trim();
|
|
349
386
|
if (spokenText.length > 0) {
|
|
350
387
|
const session = getCallSession(this.callSessionId);
|
|
351
388
|
if (session) {
|
|
@@ -354,7 +391,7 @@ export class CallOrchestrator {
|
|
|
354
391
|
}
|
|
355
392
|
|
|
356
393
|
// Check for ASK_USER pattern
|
|
357
|
-
const askMatch = responseText.match(
|
|
394
|
+
const askMatch = responseText.match(ASK_USER_CAPTURE_REGEX);
|
|
358
395
|
if (askMatch) {
|
|
359
396
|
const questionText = askMatch[1];
|
|
360
397
|
createPendingQuestion(this.callSessionId, questionText);
|
|
@@ -412,8 +449,25 @@ export class CallOrchestrator {
|
|
|
412
449
|
this.flushPendingInstructions();
|
|
413
450
|
} catch (err: unknown) {
|
|
414
451
|
// Aborted requests are expected (interruptions, rapid utterances)
|
|
415
|
-
if (err
|
|
416
|
-
log.debug(
|
|
452
|
+
if (this.isExpectedAbortError(err) || runSignal.aborted) {
|
|
453
|
+
log.debug(
|
|
454
|
+
{
|
|
455
|
+
callSessionId: this.callSessionId,
|
|
456
|
+
errName: err instanceof Error ? err.name : typeof err,
|
|
457
|
+
stale: !this.isCurrentRun(runVersion),
|
|
458
|
+
},
|
|
459
|
+
'LLM request aborted',
|
|
460
|
+
);
|
|
461
|
+
if (this.isCurrentRun(runVersion)) {
|
|
462
|
+
this.state = 'idle';
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (!this.isCurrentRun(runVersion)) {
|
|
467
|
+
log.debug(
|
|
468
|
+
{ callSessionId: this.callSessionId, errName: err instanceof Error ? err.name : typeof err },
|
|
469
|
+
'Ignoring stale LLM streaming error from superseded turn',
|
|
470
|
+
);
|
|
417
471
|
return;
|
|
418
472
|
}
|
|
419
473
|
log.error({ err, callSessionId: this.callSessionId }, 'LLM streaming error');
|
|
@@ -423,6 +477,15 @@ export class CallOrchestrator {
|
|
|
423
477
|
}
|
|
424
478
|
}
|
|
425
479
|
|
|
480
|
+
private isExpectedAbortError(err: unknown): boolean {
|
|
481
|
+
if (!(err instanceof Error)) return false;
|
|
482
|
+
return err.name === 'AbortError' || err.name === 'APIUserAbortError';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private isCurrentRun(runVersion: number): boolean {
|
|
486
|
+
return runVersion === this.llmRunVersion;
|
|
487
|
+
}
|
|
488
|
+
|
|
426
489
|
/**
|
|
427
490
|
* Drain any instructions that were queued while the LLM was active.
|
|
428
491
|
* Each instruction is appended as a user message (now correctly after
|
|
@@ -13,20 +13,26 @@ export interface TwilioConfig {
|
|
|
13
13
|
wssBaseUrl: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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('
|
|
54
|
+
throw new Error('Twilio phone number not configured.');
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
log.debug('Twilio config loaded successfully');
|