vellum 0.2.0 → 0.2.1
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/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- package/src/__tests__/browser-skill-endstate.test.ts +1 -5
- package/src/__tests__/call-orchestrator.test.ts +328 -0
- package/src/__tests__/call-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +476 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
- package/src/__tests__/config-schema.test.ts +49 -0
- package/src/__tests__/doordash-session.test.ts +9 -0
- package/src/__tests__/ipc-snapshot.test.ts +34 -0
- package/src/__tests__/registry.test.ts +13 -8
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
- package/src/__tests__/run-orchestrator.test.ts +3 -3
- package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
- package/src/__tests__/runtime-runs-http.test.ts +1 -19
- package/src/__tests__/runtime-runs.test.ts +7 -7
- package/src/__tests__/session-queue.test.ts +50 -0
- package/src/__tests__/turn-commit.test.ts +56 -0
- package/src/__tests__/workspace-git-service.test.ts +217 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
- package/src/bundler/app-bundler.ts +29 -12
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-orchestrator.ts +364 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +229 -0
- package/src/calls/relay-server.ts +298 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +169 -0
- package/src/calls/twilio-routes.ts +236 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/doordash.ts +5 -24
- package/src/config/bundled-skills/doordash/SKILL.md +104 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
- package/src/config/defaults.ts +11 -0
- package/src/config/schema.ts +57 -0
- package/src/config/system-prompt.ts +50 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/handlers/config.ts +30 -0
- package/src/daemon/handlers/index.ts +6 -0
- package/src/daemon/handlers/work-items.ts +142 -2
- package/src/daemon/ipc-contract-inventory.json +12 -0
- package/src/daemon/ipc-contract.ts +52 -0
- package/src/daemon/lifecycle.ts +27 -5
- package/src/daemon/server.ts +10 -12
- package/src/daemon/session-tool-setup.ts +6 -0
- package/src/daemon/session.ts +40 -1
- package/src/index.ts +2 -0
- package/src/media/gemini-image-service.ts +1 -1
- package/src/memory/db.ts +266 -0
- package/src/memory/schema.ts +42 -0
- package/src/runtime/http-server.ts +189 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +6 -6
- package/src/runtime/routes/channel-routes.ts +16 -18
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +32 -5
- package/src/tools/calls/call-end.ts +117 -0
- package/src/tools/calls/call-start.ts +134 -0
- package/src/tools/calls/call-status.ts +97 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/registry.ts +2 -4
- package/src/tools/tasks/index.ts +2 -0
- package/src/tools/tasks/task-delete.ts +49 -8
- package/src/tools/tasks/task-run.ts +9 -1
- package/src/tools/tasks/work-item-enqueue.ts +93 -3
- package/src/tools/tasks/work-item-list.ts +10 -25
- package/src/tools/tasks/work-item-remove.ts +112 -0
- package/src/tools/tasks/work-item-update.ts +186 -0
- package/src/tools/tool-manifest.ts +39 -31
- package/src/tools/ui-surface/definitions.ts +3 -0
- package/src/work-items/work-item-store.ts +209 -0
- package/src/workspace/commit-message-enrichment-service.ts +260 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +187 -32
- package/src/workspace/heartbeat-service.ts +70 -13
- package/src/workspace/turn-commit.ts +39 -49
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-driven call orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Manages the conversation loop for an active phone call: receives caller
|
|
5
|
+
* utterances, sends them to Claude via the Anthropic streaming API, and
|
|
6
|
+
* streams text tokens back through the RelayConnection for real-time TTS.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
10
|
+
import { getConfig } from '../config/loader.js';
|
|
11
|
+
import { getLogger } from '../util/logger.js';
|
|
12
|
+
import {
|
|
13
|
+
getCallSession,
|
|
14
|
+
updateCallSession,
|
|
15
|
+
recordCallEvent,
|
|
16
|
+
createPendingQuestion,
|
|
17
|
+
expirePendingQuestions,
|
|
18
|
+
} from './call-store.js';
|
|
19
|
+
import { MAX_CALL_DURATION_MS, USER_CONSULTATION_TIMEOUT_MS, SILENCE_TIMEOUT_MS } from './call-constants.js';
|
|
20
|
+
import type { RelayConnection } from './relay-server.js';
|
|
21
|
+
import { registerCallOrchestrator, unregisterCallOrchestrator, fireCallQuestionNotifier, fireCallCompletionNotifier } from './call-state.js';
|
|
22
|
+
|
|
23
|
+
const log = getLogger('call-orchestrator');
|
|
24
|
+
|
|
25
|
+
type OrchestratorState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
|
|
26
|
+
|
|
27
|
+
const ASK_USER_REGEX = /\[ASK_USER:\s*(.+?)\]/;
|
|
28
|
+
const END_CALL_MARKER = '[END_CALL]';
|
|
29
|
+
|
|
30
|
+
export class CallOrchestrator {
|
|
31
|
+
private callSessionId: string;
|
|
32
|
+
private relay: RelayConnection;
|
|
33
|
+
private state: OrchestratorState = 'idle';
|
|
34
|
+
private conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
|
35
|
+
private abortController: AbortController = new AbortController();
|
|
36
|
+
private callStartTime: number = Date.now();
|
|
37
|
+
private silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
private durationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
private durationWarningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
private consultationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
|
+
private durationEndTimer: ReturnType<typeof setTimeout> | null = null;
|
|
42
|
+
private task: string | null;
|
|
43
|
+
|
|
44
|
+
constructor(callSessionId: string, relay: RelayConnection, task: string | null) {
|
|
45
|
+
this.callSessionId = callSessionId;
|
|
46
|
+
this.relay = relay;
|
|
47
|
+
this.task = task;
|
|
48
|
+
this.startDurationTimer();
|
|
49
|
+
this.resetSilenceTimer();
|
|
50
|
+
registerCallOrchestrator(callSessionId, this);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns the current orchestrator state.
|
|
55
|
+
*/
|
|
56
|
+
getState(): OrchestratorState {
|
|
57
|
+
return this.state;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handle a final caller utterance from the ConversationRelay.
|
|
62
|
+
*/
|
|
63
|
+
async handleCallerUtterance(transcript: string): Promise<void> {
|
|
64
|
+
// If we're already processing or speaking, abort the in-flight generation
|
|
65
|
+
if (this.state === 'processing' || this.state === 'speaking') {
|
|
66
|
+
this.abortController.abort();
|
|
67
|
+
this.abortController = new AbortController();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.state = 'processing';
|
|
71
|
+
this.resetSilenceTimer();
|
|
72
|
+
|
|
73
|
+
// Append caller utterance
|
|
74
|
+
this.conversationHistory.push({ role: 'user', content: transcript });
|
|
75
|
+
|
|
76
|
+
await this.runLlm();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Called when the user (in the chat UI) answers a pending question.
|
|
81
|
+
*/
|
|
82
|
+
async handleUserAnswer(answerText: string): Promise<boolean> {
|
|
83
|
+
if (this.state !== 'waiting_on_user') {
|
|
84
|
+
log.warn(
|
|
85
|
+
{ callSessionId: this.callSessionId, state: this.state },
|
|
86
|
+
'handleUserAnswer called but orchestrator is not in waiting_on_user state',
|
|
87
|
+
);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Clear the consultation timeout
|
|
92
|
+
if (this.consultationTimer) {
|
|
93
|
+
clearTimeout(this.consultationTimer);
|
|
94
|
+
this.consultationTimer = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.state = 'processing';
|
|
98
|
+
updateCallSession(this.callSessionId, { status: 'in_progress' });
|
|
99
|
+
|
|
100
|
+
// Append the user's answer as a special message the model recognizes
|
|
101
|
+
this.conversationHistory.push({ role: 'user', content: `[USER_ANSWERED: ${answerText}]` });
|
|
102
|
+
|
|
103
|
+
await this.runLlm();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle caller interrupting the assistant's speech.
|
|
109
|
+
*/
|
|
110
|
+
handleInterrupt(): void {
|
|
111
|
+
this.abortController.abort();
|
|
112
|
+
this.abortController = new AbortController();
|
|
113
|
+
this.state = 'idle';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Tear down all timers and abort any in-flight work.
|
|
118
|
+
*/
|
|
119
|
+
destroy(): void {
|
|
120
|
+
if (this.silenceTimer) clearTimeout(this.silenceTimer);
|
|
121
|
+
if (this.durationTimer) clearTimeout(this.durationTimer);
|
|
122
|
+
if (this.durationWarningTimer) clearTimeout(this.durationWarningTimer);
|
|
123
|
+
if (this.consultationTimer) clearTimeout(this.consultationTimer);
|
|
124
|
+
if (this.durationEndTimer) { clearTimeout(this.durationEndTimer); this.durationEndTimer = null; }
|
|
125
|
+
this.abortController.abort();
|
|
126
|
+
unregisterCallOrchestrator(this.callSessionId);
|
|
127
|
+
log.info({ callSessionId: this.callSessionId }, 'CallOrchestrator destroyed');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Private ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
private buildSystemPrompt(): string {
|
|
133
|
+
return [
|
|
134
|
+
'You are on a live phone call on behalf of your user.',
|
|
135
|
+
this.task ? `Task: ${this.task}` : '',
|
|
136
|
+
'',
|
|
137
|
+
'You are speaking directly to the person who answered the phone.',
|
|
138
|
+
'Respond naturally and conversationally — speak as you would in a real phone conversation.',
|
|
139
|
+
'',
|
|
140
|
+
'IMPORTANT RULES:',
|
|
141
|
+
'1. At the very beginning of the call, disclose that you are an AI assistant calling on behalf of the user.',
|
|
142
|
+
'2. Be concise — phone conversations should be brief and natural.',
|
|
143
|
+
'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."',
|
|
144
|
+
'4. If the callee provides information preceded by [USER_ANSWERED: ...], use that answer naturally in the conversation.',
|
|
145
|
+
'5. When the call\'s purpose is fulfilled, include [END_CALL] in your response along with a polite goodbye.',
|
|
146
|
+
'6. Do not make up information — ask the user if unsure.',
|
|
147
|
+
'7. Keep responses short — 1-3 sentences is ideal for phone conversation.',
|
|
148
|
+
]
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Run the LLM with the current conversation history and stream
|
|
155
|
+
* the response back through the relay.
|
|
156
|
+
*/
|
|
157
|
+
private async runLlm(): Promise<void> {
|
|
158
|
+
const apiKey = getConfig().apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
|
|
159
|
+
if (!apiKey) {
|
|
160
|
+
log.error({ callSessionId: this.callSessionId }, 'No Anthropic API key available');
|
|
161
|
+
this.relay.sendTextToken('I\'m sorry, I\'m having a technical issue. Please try again later.', true);
|
|
162
|
+
this.state = 'idle';
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const client = new Anthropic({ apiKey });
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
this.state = 'speaking';
|
|
170
|
+
|
|
171
|
+
const stream = client.messages.stream(
|
|
172
|
+
{
|
|
173
|
+
model: 'claude-sonnet-4-20250514',
|
|
174
|
+
max_tokens: 512,
|
|
175
|
+
system: this.buildSystemPrompt(),
|
|
176
|
+
messages: this.conversationHistory.map((m) => ({
|
|
177
|
+
role: m.role,
|
|
178
|
+
content: m.content,
|
|
179
|
+
})),
|
|
180
|
+
},
|
|
181
|
+
{ signal: this.abortController.signal },
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Buffer incoming tokens so we can strip control markers ([ASK_USER:...], [END_CALL])
|
|
185
|
+
// before they reach TTS. We hold text whenever an unmatched '[' appears, since it
|
|
186
|
+
// could be the start of a control marker.
|
|
187
|
+
let ttsBuffer = '';
|
|
188
|
+
|
|
189
|
+
const flushSafeText = (_force: boolean): void => {
|
|
190
|
+
if (ttsBuffer.length === 0) return;
|
|
191
|
+
const bracketIdx = ttsBuffer.indexOf('[');
|
|
192
|
+
if (bracketIdx === -1) {
|
|
193
|
+
// No bracket at all — safe to flush everything
|
|
194
|
+
this.relay.sendTextToken(ttsBuffer, false);
|
|
195
|
+
ttsBuffer = '';
|
|
196
|
+
} else {
|
|
197
|
+
// Flush everything before the bracket
|
|
198
|
+
if (bracketIdx > 0) {
|
|
199
|
+
this.relay.sendTextToken(ttsBuffer.slice(0, bracketIdx), false);
|
|
200
|
+
ttsBuffer = ttsBuffer.slice(bracketIdx);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Only hold the buffer if the bracket text could be the start of a
|
|
204
|
+
// known control marker. Otherwise flush immediately so ordinary
|
|
205
|
+
// bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
|
|
206
|
+
//
|
|
207
|
+
// The check must be bidirectional:
|
|
208
|
+
// - When the buffer is shorter than the prefix (e.g. "[ASK"), the
|
|
209
|
+
// buffer is a prefix of the control tag → hold it.
|
|
210
|
+
// - When the buffer is longer than the prefix (e.g. "[ASK_USER: what"),
|
|
211
|
+
// the buffer starts with the control tag prefix → hold it (the
|
|
212
|
+
// variable-length payload hasn't been closed yet).
|
|
213
|
+
const afterBracket = ttsBuffer;
|
|
214
|
+
const couldBeControl =
|
|
215
|
+
'[ASK_USER:'.startsWith(afterBracket) ||
|
|
216
|
+
'[END_CALL]'.startsWith(afterBracket) ||
|
|
217
|
+
afterBracket.startsWith('[ASK_USER:') ||
|
|
218
|
+
afterBracket.startsWith('[END_CALL');
|
|
219
|
+
|
|
220
|
+
if (!couldBeControl) {
|
|
221
|
+
// Not a control marker prefix — flush up to the next '[' (if any)
|
|
222
|
+
// so we don't accidentally flush a later partial control marker.
|
|
223
|
+
const nextBracket = ttsBuffer.indexOf('[', 1);
|
|
224
|
+
if (nextBracket === -1) {
|
|
225
|
+
this.relay.sendTextToken(ttsBuffer, false);
|
|
226
|
+
ttsBuffer = '';
|
|
227
|
+
} else {
|
|
228
|
+
this.relay.sendTextToken(ttsBuffer.slice(0, nextBracket), false);
|
|
229
|
+
ttsBuffer = ttsBuffer.slice(nextBracket);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Otherwise hold it — might be a control marker still being streamed
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
stream.on('text', (text) => {
|
|
237
|
+
ttsBuffer += text;
|
|
238
|
+
|
|
239
|
+
// If the buffer contains a complete control marker, strip it
|
|
240
|
+
if (ASK_USER_REGEX.test(ttsBuffer)) {
|
|
241
|
+
ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '');
|
|
242
|
+
}
|
|
243
|
+
if (ttsBuffer.includes(END_CALL_MARKER)) {
|
|
244
|
+
ttsBuffer = ttsBuffer.replace(END_CALL_MARKER, '');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
flushSafeText(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const finalMessage = await stream.finalMessage();
|
|
251
|
+
|
|
252
|
+
// Final sweep: strip any remaining control markers from the buffer
|
|
253
|
+
ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '');
|
|
254
|
+
if (ttsBuffer.length > 0) {
|
|
255
|
+
this.relay.sendTextToken(ttsBuffer, false);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Signal end of this turn's speech
|
|
259
|
+
this.relay.sendTextToken('', true);
|
|
260
|
+
|
|
261
|
+
const responseText =
|
|
262
|
+
finalMessage.content
|
|
263
|
+
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
|
264
|
+
.map((b) => b.text)
|
|
265
|
+
.join('') || '';
|
|
266
|
+
|
|
267
|
+
// Record the assistant response
|
|
268
|
+
this.conversationHistory.push({ role: 'assistant', content: responseText });
|
|
269
|
+
recordCallEvent(this.callSessionId, 'assistant_spoke', { text: responseText });
|
|
270
|
+
|
|
271
|
+
// Check for ASK_USER pattern
|
|
272
|
+
const askMatch = responseText.match(ASK_USER_REGEX);
|
|
273
|
+
if (askMatch) {
|
|
274
|
+
const questionText = askMatch[1];
|
|
275
|
+
createPendingQuestion(this.callSessionId, questionText);
|
|
276
|
+
this.state = 'waiting_on_user';
|
|
277
|
+
updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
|
|
278
|
+
recordCallEvent(this.callSessionId, 'user_question_asked', { question: questionText });
|
|
279
|
+
|
|
280
|
+
// Notify the conversation that a question was asked
|
|
281
|
+
const session = getCallSession(this.callSessionId);
|
|
282
|
+
if (session) {
|
|
283
|
+
fireCallQuestionNotifier(session.conversationId, this.callSessionId, questionText);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Set a consultation timeout
|
|
287
|
+
this.consultationTimer = setTimeout(() => {
|
|
288
|
+
if (this.state === 'waiting_on_user') {
|
|
289
|
+
log.info({ callSessionId: this.callSessionId }, 'User consultation timed out');
|
|
290
|
+
this.relay.sendTextToken(
|
|
291
|
+
'I\'m sorry, I wasn\'t able to get that information in time. Let me move on.',
|
|
292
|
+
true,
|
|
293
|
+
);
|
|
294
|
+
this.state = 'idle';
|
|
295
|
+
updateCallSession(this.callSessionId, { status: 'in_progress' });
|
|
296
|
+
expirePendingQuestions(this.callSessionId);
|
|
297
|
+
}
|
|
298
|
+
}, USER_CONSULTATION_TIMEOUT_MS);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check for END_CALL marker
|
|
303
|
+
if (responseText.includes(END_CALL_MARKER)) {
|
|
304
|
+
this.relay.endSession('Call completed');
|
|
305
|
+
updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
|
|
306
|
+
recordCallEvent(this.callSessionId, 'call_ended', { reason: 'completed' });
|
|
307
|
+
|
|
308
|
+
// Notify the conversation that the call completed
|
|
309
|
+
const endSession = getCallSession(this.callSessionId);
|
|
310
|
+
if (endSession) {
|
|
311
|
+
fireCallCompletionNotifier(endSession.conversationId, this.callSessionId);
|
|
312
|
+
}
|
|
313
|
+
this.state = 'idle';
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Normal turn complete
|
|
318
|
+
this.state = 'idle';
|
|
319
|
+
} catch (err: unknown) {
|
|
320
|
+
// Aborted requests are expected (interruptions, rapid utterances)
|
|
321
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
322
|
+
log.debug({ callSessionId: this.callSessionId }, 'LLM request aborted');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
log.error({ err, callSessionId: this.callSessionId }, 'LLM streaming error');
|
|
326
|
+
this.relay.sendTextToken('I\'m sorry, I encountered a technical issue. Could you repeat that?', true);
|
|
327
|
+
this.state = 'idle';
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private startDurationTimer(): void {
|
|
332
|
+
const warningMs = MAX_CALL_DURATION_MS - 2 * 60 * 1000; // 2 minutes before max
|
|
333
|
+
|
|
334
|
+
this.durationWarningTimer = setTimeout(() => {
|
|
335
|
+
log.info({ callSessionId: this.callSessionId }, 'Call duration warning');
|
|
336
|
+
this.relay.sendTextToken(
|
|
337
|
+
'Just to let you know, we\'re running low on time for this call.',
|
|
338
|
+
true,
|
|
339
|
+
);
|
|
340
|
+
}, warningMs);
|
|
341
|
+
|
|
342
|
+
this.durationTimer = setTimeout(() => {
|
|
343
|
+
log.info({ callSessionId: this.callSessionId }, 'Call duration limit reached');
|
|
344
|
+
this.relay.sendTextToken(
|
|
345
|
+
'I\'m sorry, but we\'ve reached the maximum time for this call. Thank you for your time. Goodbye!',
|
|
346
|
+
true,
|
|
347
|
+
);
|
|
348
|
+
// Give TTS a moment to play, then end
|
|
349
|
+
this.durationEndTimer = setTimeout(() => {
|
|
350
|
+
this.relay.endSession('Maximum call duration reached');
|
|
351
|
+
updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
|
|
352
|
+
recordCallEvent(this.callSessionId, 'call_ended', { reason: 'max_duration' });
|
|
353
|
+
}, 3000);
|
|
354
|
+
}, MAX_CALL_DURATION_MS);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private resetSilenceTimer(): void {
|
|
358
|
+
if (this.silenceTimer) clearTimeout(this.silenceTimer);
|
|
359
|
+
this.silenceTimer = setTimeout(() => {
|
|
360
|
+
log.info({ callSessionId: this.callSessionId }, 'Silence timeout triggered');
|
|
361
|
+
this.relay.sendTextToken('Are you still there?', true);
|
|
362
|
+
}, SILENCE_TIMEOUT_MS);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call session notifiers and orchestrator registry.
|
|
3
|
+
*
|
|
4
|
+
* Follows the same notifier pattern as watch-state.ts: module-level Maps
|
|
5
|
+
* with register/unregister/fire helpers keyed by conversationId.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getLogger } from '../util/logger.js';
|
|
9
|
+
import type { CallOrchestrator } from './call-orchestrator.js';
|
|
10
|
+
|
|
11
|
+
const log = getLogger('call-state');
|
|
12
|
+
|
|
13
|
+
// ── Question notifiers ──────────────────────────────────────────────
|
|
14
|
+
const questionNotifiers = new Map<string, (callSessionId: string, question: string) => void>();
|
|
15
|
+
|
|
16
|
+
export function registerCallQuestionNotifier(
|
|
17
|
+
conversationId: string,
|
|
18
|
+
callback: (callSessionId: string, question: string) => void,
|
|
19
|
+
): void {
|
|
20
|
+
questionNotifiers.set(conversationId, callback);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function unregisterCallQuestionNotifier(conversationId: string): void {
|
|
24
|
+
questionNotifiers.delete(conversationId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fireCallQuestionNotifier(conversationId: string, callSessionId: string, question: string): void {
|
|
28
|
+
questionNotifiers.get(conversationId)?.(callSessionId, question);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Completion notifiers ────────────────────────────────────────────
|
|
32
|
+
const completionNotifiers = new Map<string, (callSessionId: string) => void>();
|
|
33
|
+
|
|
34
|
+
export function registerCallCompletionNotifier(
|
|
35
|
+
conversationId: string,
|
|
36
|
+
callback: (callSessionId: string) => void,
|
|
37
|
+
): void {
|
|
38
|
+
completionNotifiers.set(conversationId, callback);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function unregisterCallCompletionNotifier(conversationId: string): void {
|
|
42
|
+
completionNotifiers.delete(conversationId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function fireCallCompletionNotifier(conversationId: string, callSessionId: string): void {
|
|
46
|
+
completionNotifiers.get(conversationId)?.(callSessionId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Active orchestrator registry ────────────────────────────────────
|
|
50
|
+
const activeCallOrchestrators = new Map<string, CallOrchestrator>();
|
|
51
|
+
|
|
52
|
+
export function registerCallOrchestrator(callSessionId: string, orchestrator: CallOrchestrator): void {
|
|
53
|
+
activeCallOrchestrators.set(callSessionId, orchestrator);
|
|
54
|
+
log.info({ callSessionId }, 'Call orchestrator registered');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function unregisterCallOrchestrator(callSessionId: string): void {
|
|
58
|
+
activeCallOrchestrators.delete(callSessionId);
|
|
59
|
+
log.info({ callSessionId }, 'Call orchestrator unregistered');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getCallOrchestrator(callSessionId: string): CallOrchestrator | undefined {
|
|
63
|
+
return activeCallOrchestrators.get(callSessionId);
|
|
64
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { eq, and, notInArray, desc } from 'drizzle-orm';
|
|
2
|
+
import { v4 as uuid } from 'uuid';
|
|
3
|
+
import { getDb } from '../memory/db.js';
|
|
4
|
+
import { callSessions, callEvents, callPendingQuestions } from '../memory/schema.js';
|
|
5
|
+
import type { CallSession, CallEvent, CallPendingQuestion, CallEventType } from './types.js';
|
|
6
|
+
import { getLogger } from '../util/logger.js';
|
|
7
|
+
|
|
8
|
+
const log = getLogger('call-store');
|
|
9
|
+
|
|
10
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function parseCallSession(row: typeof callSessions.$inferSelect): CallSession {
|
|
13
|
+
return {
|
|
14
|
+
id: row.id,
|
|
15
|
+
conversationId: row.conversationId,
|
|
16
|
+
provider: row.provider,
|
|
17
|
+
providerCallSid: row.providerCallSid,
|
|
18
|
+
fromNumber: row.fromNumber,
|
|
19
|
+
toNumber: row.toNumber,
|
|
20
|
+
task: row.task,
|
|
21
|
+
status: row.status as CallSession['status'],
|
|
22
|
+
startedAt: row.startedAt,
|
|
23
|
+
endedAt: row.endedAt,
|
|
24
|
+
lastError: row.lastError,
|
|
25
|
+
createdAt: row.createdAt,
|
|
26
|
+
updatedAt: row.updatedAt,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseCallEvent(row: typeof callEvents.$inferSelect): CallEvent {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
callSessionId: row.callSessionId,
|
|
34
|
+
eventType: row.eventType as CallEvent['eventType'],
|
|
35
|
+
payloadJson: row.payloadJson,
|
|
36
|
+
createdAt: row.createdAt,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parsePendingQuestion(row: typeof callPendingQuestions.$inferSelect): CallPendingQuestion {
|
|
41
|
+
return {
|
|
42
|
+
id: row.id,
|
|
43
|
+
callSessionId: row.callSessionId,
|
|
44
|
+
questionText: row.questionText,
|
|
45
|
+
status: row.status as CallPendingQuestion['status'],
|
|
46
|
+
askedAt: row.askedAt,
|
|
47
|
+
answeredAt: row.answeredAt,
|
|
48
|
+
answerText: row.answerText,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Call Sessions ────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export function createCallSession(opts: {
|
|
55
|
+
conversationId: string;
|
|
56
|
+
provider: string;
|
|
57
|
+
fromNumber: string;
|
|
58
|
+
toNumber: string;
|
|
59
|
+
task?: string;
|
|
60
|
+
}): CallSession {
|
|
61
|
+
const db = getDb();
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const session = {
|
|
64
|
+
id: uuid(),
|
|
65
|
+
conversationId: opts.conversationId,
|
|
66
|
+
provider: opts.provider,
|
|
67
|
+
providerCallSid: null,
|
|
68
|
+
fromNumber: opts.fromNumber,
|
|
69
|
+
toNumber: opts.toNumber,
|
|
70
|
+
task: opts.task ?? null,
|
|
71
|
+
status: 'initiated' as const,
|
|
72
|
+
startedAt: null,
|
|
73
|
+
endedAt: null,
|
|
74
|
+
lastError: null,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
};
|
|
78
|
+
db.insert(callSessions).values(session).run();
|
|
79
|
+
return session;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getCallSession(id: string): CallSession | null {
|
|
83
|
+
const db = getDb();
|
|
84
|
+
const row = db.select().from(callSessions).where(eq(callSessions.id, id)).get();
|
|
85
|
+
if (!row) return null;
|
|
86
|
+
return parseCallSession(row);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getCallSessionByCallSid(callSid: string): CallSession | null {
|
|
90
|
+
const db = getDb();
|
|
91
|
+
const row = db
|
|
92
|
+
.select()
|
|
93
|
+
.from(callSessions)
|
|
94
|
+
.where(eq(callSessions.providerCallSid, callSid))
|
|
95
|
+
.get();
|
|
96
|
+
if (!row) return null;
|
|
97
|
+
return parseCallSession(row);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getActiveCallSessionForConversation(conversationId: string): CallSession | null {
|
|
101
|
+
const db = getDb();
|
|
102
|
+
const row = db
|
|
103
|
+
.select()
|
|
104
|
+
.from(callSessions)
|
|
105
|
+
.where(
|
|
106
|
+
and(
|
|
107
|
+
eq(callSessions.conversationId, conversationId),
|
|
108
|
+
notInArray(callSessions.status, ['completed', 'failed']),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
.orderBy(desc(callSessions.createdAt))
|
|
112
|
+
.get();
|
|
113
|
+
if (!row) return null;
|
|
114
|
+
return parseCallSession(row);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function updateCallSession(
|
|
118
|
+
id: string,
|
|
119
|
+
updates: Partial<Pick<CallSession, 'status' | 'providerCallSid' | 'startedAt' | 'endedAt' | 'lastError'>>,
|
|
120
|
+
): void {
|
|
121
|
+
const db = getDb();
|
|
122
|
+
db.update(callSessions)
|
|
123
|
+
.set({ ...updates, updatedAt: Date.now() })
|
|
124
|
+
.where(eq(callSessions.id, id))
|
|
125
|
+
.run();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Call Events ──────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export function recordCallEvent(
|
|
131
|
+
callSessionId: string,
|
|
132
|
+
eventType: CallEventType,
|
|
133
|
+
payload?: Record<string, unknown>,
|
|
134
|
+
): CallEvent {
|
|
135
|
+
const db = getDb();
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const event = {
|
|
138
|
+
id: uuid(),
|
|
139
|
+
callSessionId,
|
|
140
|
+
eventType,
|
|
141
|
+
payloadJson: JSON.stringify(payload ?? {}),
|
|
142
|
+
createdAt: now,
|
|
143
|
+
};
|
|
144
|
+
db.insert(callEvents).values(event).run();
|
|
145
|
+
return event;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getCallEvents(callSessionId: string): CallEvent[] {
|
|
149
|
+
const db = getDb();
|
|
150
|
+
const rows = db
|
|
151
|
+
.select()
|
|
152
|
+
.from(callEvents)
|
|
153
|
+
.where(eq(callEvents.callSessionId, callSessionId))
|
|
154
|
+
.orderBy(callEvents.createdAt)
|
|
155
|
+
.all();
|
|
156
|
+
return rows.map(parseCallEvent);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Pending Questions ────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export function createPendingQuestion(callSessionId: string, questionText: string): CallPendingQuestion {
|
|
162
|
+
const db = getDb();
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const question = {
|
|
165
|
+
id: uuid(),
|
|
166
|
+
callSessionId,
|
|
167
|
+
questionText,
|
|
168
|
+
status: 'pending' as const,
|
|
169
|
+
askedAt: now,
|
|
170
|
+
answeredAt: null,
|
|
171
|
+
answerText: null,
|
|
172
|
+
};
|
|
173
|
+
db.insert(callPendingQuestions).values(question).run();
|
|
174
|
+
return question;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getPendingQuestion(callSessionId: string): CallPendingQuestion | null {
|
|
178
|
+
const db = getDb();
|
|
179
|
+
const row = db
|
|
180
|
+
.select()
|
|
181
|
+
.from(callPendingQuestions)
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(callPendingQuestions.callSessionId, callSessionId),
|
|
185
|
+
eq(callPendingQuestions.status, 'pending'),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
.orderBy(desc(callPendingQuestions.askedAt))
|
|
189
|
+
.limit(1)
|
|
190
|
+
.get();
|
|
191
|
+
if (!row) return null;
|
|
192
|
+
return parsePendingQuestion(row);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function answerPendingQuestion(id: string, answerText: string): void {
|
|
196
|
+
const db = getDb();
|
|
197
|
+
db.update(callPendingQuestions)
|
|
198
|
+
.set({
|
|
199
|
+
status: 'answered',
|
|
200
|
+
answerText,
|
|
201
|
+
answeredAt: Date.now(),
|
|
202
|
+
})
|
|
203
|
+
.where(
|
|
204
|
+
and(
|
|
205
|
+
eq(callPendingQuestions.id, id),
|
|
206
|
+
eq(callPendingQuestions.status, 'pending'),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
.run();
|
|
210
|
+
// Drizzle's .run() returns void for bun:sqlite, so verify via raw client.
|
|
211
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
212
|
+
const check = raw.query('SELECT status FROM call_pending_questions WHERE id = ?').get(id) as { status: string } | null;
|
|
213
|
+
if (!check || check.status !== 'answered') {
|
|
214
|
+
log.warn({ questionId: id }, 'answerPendingQuestion: no rows updated — question may have already been answered or expired');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function expirePendingQuestions(callSessionId: string): void {
|
|
219
|
+
const db = getDb();
|
|
220
|
+
db.update(callPendingQuestions)
|
|
221
|
+
.set({ status: 'expired' })
|
|
222
|
+
.where(
|
|
223
|
+
and(
|
|
224
|
+
eq(callPendingQuestions.callSessionId, callSessionId),
|
|
225
|
+
eq(callPendingQuestions.status, 'pending'),
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
.run();
|
|
229
|
+
}
|