niahere 0.2.90 → 0.2.91
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/skills/nia-phone/SKILL.md +206 -0
- package/src/channels/index.ts +2 -0
- package/src/channels/phone/consult.ts +43 -0
- package/src/channels/phone/index.ts +464 -0
- package/src/channels/phone/instructions.ts +42 -0
- package/src/channels/phone/relay.ts +334 -0
- package/src/channels/phone/tools.ts +83 -0
- package/src/channels/phone/twilio.ts +125 -0
- package/src/channels/phone/twiml.ts +60 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/phone.ts +127 -0
- package/src/mcp/server.ts +24 -1
- package/src/mcp/tools.ts +37 -38
- package/src/types/config.ts +26 -0
- package/src/types/index.ts +1 -1
- package/src/utils/config.ts +71 -4
- package/src/utils/memory.ts +49 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phone channel — voice calling via Twilio + OpenAI Realtime.
|
|
3
|
+
*
|
|
4
|
+
* Boots an HTTP+WebSocket server inside the daemon. Twilio reaches it
|
|
5
|
+
* through a public tunnel (cloudflared in our setup). Two surfaces:
|
|
6
|
+
*
|
|
7
|
+
* - Inbound: caller dials our Twilio number; we return TwiML that opens
|
|
8
|
+
* a Media Stream back to us; the stream is bridged to OpenAI Realtime.
|
|
9
|
+
*
|
|
10
|
+
* - Outbound: place_call() initiates a Twilio call to a target number,
|
|
11
|
+
* with a per-call goal seeded into the realtime session.
|
|
12
|
+
*
|
|
13
|
+
* Submodules:
|
|
14
|
+
* - twilio.ts — REST + webhook signature helpers
|
|
15
|
+
* - twiml.ts — TwiML XML builders
|
|
16
|
+
* - relay.ts — Twilio Media Stream <-> OpenAI Realtime bridge
|
|
17
|
+
* - instructions.ts — system-prompt builders for inbound/outbound
|
|
18
|
+
* - tools.ts — function-calling tools exposed to the realtime model
|
|
19
|
+
* - consult.ts — escape hatch to Claude for memory-aware reasoning
|
|
20
|
+
*/
|
|
21
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
22
|
+
import type { Channel, PhoneConfig } from "../../types";
|
|
23
|
+
import { getConfig } from "../../utils/config";
|
|
24
|
+
import { log } from "../../utils/log";
|
|
25
|
+
import { getChannel } from "../registry";
|
|
26
|
+
import { Session, Message } from "../../db/models";
|
|
27
|
+
import { runMigrations } from "../../db/migrate";
|
|
28
|
+
|
|
29
|
+
import { placeCall as twilioPlaceCall, validateTwilioSignature } from "./twilio";
|
|
30
|
+
import { streamTwiML, sayAndHangupTwiML, rejectTwiML } from "./twiml";
|
|
31
|
+
import { createRelay, type CallContext, type RelayHandle, type RelayResult } from "./relay";
|
|
32
|
+
import { buildInboundInstructions, buildOutboundInstructions } from "./instructions";
|
|
33
|
+
import { buildPhoneTools } from "./tools";
|
|
34
|
+
|
|
35
|
+
interface WsData {
|
|
36
|
+
streamSid: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PendingCall {
|
|
40
|
+
/** Context written when the call is initiated; consumed by the WS upgrade. */
|
|
41
|
+
context: Omit<CallContext, "streamSid" | "tools">;
|
|
42
|
+
startedAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ActiveCall {
|
|
46
|
+
handle: RelayHandle;
|
|
47
|
+
context: CallContext;
|
|
48
|
+
startedAt: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Deferred<T> {
|
|
52
|
+
promise: Promise<T>;
|
|
53
|
+
resolve: (value: T) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defer<T>(): Deferred<T> {
|
|
57
|
+
let resolve!: (value: T) => void;
|
|
58
|
+
const promise = new Promise<T>((res) => {
|
|
59
|
+
resolve = res;
|
|
60
|
+
});
|
|
61
|
+
return { promise, resolve };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULT_MAX_MINUTES = 10;
|
|
65
|
+
const HARD_MAX_MINUTES = 30;
|
|
66
|
+
|
|
67
|
+
class PhoneChannel implements Channel {
|
|
68
|
+
name = "phone";
|
|
69
|
+
private server: Server<WsData> | null = null;
|
|
70
|
+
private readonly cfg: PhoneConfig;
|
|
71
|
+
/** Calls placed/answered but whose Media Stream hasn't connected yet. */
|
|
72
|
+
private readonly pending = new Map<string, PendingCall>();
|
|
73
|
+
/** Active relays, keyed by streamSid (assigned on Twilio "start" event). */
|
|
74
|
+
private readonly active = new Map<string, ActiveCall>();
|
|
75
|
+
/** Per-callSid completion deferreds. Resolved when the call's transcript is persisted. */
|
|
76
|
+
private readonly completions = new Map<string, Deferred<RelayResult>>();
|
|
77
|
+
|
|
78
|
+
constructor(cfg: PhoneConfig) {
|
|
79
|
+
this.cfg = cfg;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Channel lifecycle ---
|
|
83
|
+
|
|
84
|
+
async start(): Promise<void> {
|
|
85
|
+
await runMigrations();
|
|
86
|
+
const self = this;
|
|
87
|
+
|
|
88
|
+
this.server = Bun.serve<WsData, never>({
|
|
89
|
+
port: this.cfg.port,
|
|
90
|
+
async fetch(req, server) {
|
|
91
|
+
const path = new URL(req.url).pathname;
|
|
92
|
+
|
|
93
|
+
if (path === "/healthz") return new Response("ok", { status: 200 });
|
|
94
|
+
|
|
95
|
+
if (path === "/twilio/voice/stream") {
|
|
96
|
+
const ok = server.upgrade(req, { data: { streamSid: null } });
|
|
97
|
+
return ok ? undefined : new Response("expected websocket", { status: 400 });
|
|
98
|
+
}
|
|
99
|
+
if (path === "/twilio/voice/incoming" && req.method === "POST") {
|
|
100
|
+
return await self.handleIncoming(req);
|
|
101
|
+
}
|
|
102
|
+
if (path === "/twilio/voice/outbound" && req.method === "POST") {
|
|
103
|
+
return await self.handleOutboundTwiml(req);
|
|
104
|
+
}
|
|
105
|
+
if (path === "/twilio/voice/status" && req.method === "POST") {
|
|
106
|
+
return await self.handleStatus(req);
|
|
107
|
+
}
|
|
108
|
+
return new Response("not found", { status: 404 });
|
|
109
|
+
},
|
|
110
|
+
websocket: {
|
|
111
|
+
message(ws, message) {
|
|
112
|
+
const data = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
113
|
+
const sid = ws.data.streamSid;
|
|
114
|
+
if (sid) {
|
|
115
|
+
const active = self.active.get(sid);
|
|
116
|
+
if (active) {
|
|
117
|
+
active.handle.onTwilioMessage(data);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
self.bootstrapWsMessage(ws, data).catch((err) => {
|
|
122
|
+
log.error({ err }, "phone: ws bootstrap failed");
|
|
123
|
+
try {
|
|
124
|
+
ws.close();
|
|
125
|
+
} catch {}
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
close(ws) {
|
|
129
|
+
const sid = ws.data.streamSid;
|
|
130
|
+
if (!sid) return;
|
|
131
|
+
const active = self.active.get(sid);
|
|
132
|
+
if (active) active.handle.onTwilioClose();
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
log.info(
|
|
138
|
+
{
|
|
139
|
+
port: this.cfg.port,
|
|
140
|
+
publicBaseUrl: this.cfg.public_base_url,
|
|
141
|
+
from: this.cfg.from_number,
|
|
142
|
+
owner: this.cfg.owner_number,
|
|
143
|
+
realtimeModel: this.cfg.realtime_model,
|
|
144
|
+
voice: this.cfg.voice,
|
|
145
|
+
},
|
|
146
|
+
"phone channel started",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async stop(): Promise<void> {
|
|
151
|
+
if (this.server) {
|
|
152
|
+
this.server.stop(true);
|
|
153
|
+
this.server = null;
|
|
154
|
+
}
|
|
155
|
+
for (const active of this.active.values()) active.handle.onTwilioClose();
|
|
156
|
+
this.active.clear();
|
|
157
|
+
this.pending.clear();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Outbound entrypoint (used by the place_call MCP tool and CLI test) ---
|
|
161
|
+
|
|
162
|
+
async placeCall(opts: {
|
|
163
|
+
number: string;
|
|
164
|
+
goal: string;
|
|
165
|
+
context?: string;
|
|
166
|
+
maxMinutes?: number;
|
|
167
|
+
voice?: string;
|
|
168
|
+
}): Promise<{ callSid: string; status: string }> {
|
|
169
|
+
const creds = this.requireCreds();
|
|
170
|
+
const base = this.requirePublicBaseUrl();
|
|
171
|
+
const from = this.requireFromNumber();
|
|
172
|
+
|
|
173
|
+
const maxMinutes =
|
|
174
|
+
opts.maxMinutes && opts.maxMinutes > 0 ? Math.min(opts.maxMinutes, HARD_MAX_MINUTES) : DEFAULT_MAX_MINUTES;
|
|
175
|
+
const instructions = buildOutboundInstructions(opts.goal, opts.context);
|
|
176
|
+
|
|
177
|
+
// Twilio always includes `CallSid` in the form body of the TwiML
|
|
178
|
+
// webhook, so we don't need to encode it in the URL path — one static
|
|
179
|
+
// endpoint handles every outbound call, and we look up context by the
|
|
180
|
+
// CallSid Twilio sends us back.
|
|
181
|
+
const result = await twilioPlaceCall({
|
|
182
|
+
...creds,
|
|
183
|
+
to: opts.number,
|
|
184
|
+
from,
|
|
185
|
+
twimlUrl: `${base}/twilio/voice/outbound`,
|
|
186
|
+
statusCallbackUrl: `${base}/twilio/voice/status`,
|
|
187
|
+
maxDurationSec: maxMinutes * 60,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
this.pending.set(result.callSid, {
|
|
191
|
+
context: {
|
|
192
|
+
callSid: result.callSid,
|
|
193
|
+
direction: "outbound",
|
|
194
|
+
remoteNumber: opts.number,
|
|
195
|
+
remoteLabel: opts.number,
|
|
196
|
+
instructions,
|
|
197
|
+
speakFirst: true,
|
|
198
|
+
opener: opts.goal,
|
|
199
|
+
},
|
|
200
|
+
startedAt: Date.now(),
|
|
201
|
+
});
|
|
202
|
+
this.completions.set(result.callSid, defer<RelayResult>());
|
|
203
|
+
|
|
204
|
+
log.info({ callSid: result.callSid, to: opts.number }, "phone: outbound call placed");
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wait for an in-flight call to finish. Resolves with the final transcript
|
|
210
|
+
* once the call has been persisted; returns null if the callSid is unknown.
|
|
211
|
+
*/
|
|
212
|
+
async awaitCallCompletion(callSid: string): Promise<RelayResult | null> {
|
|
213
|
+
const deferred = this.completions.get(callSid);
|
|
214
|
+
return deferred ? deferred.promise : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- HTTP handlers ---
|
|
218
|
+
|
|
219
|
+
private async handleIncoming(req: Request): Promise<Response> {
|
|
220
|
+
const { params } = await readForm(req);
|
|
221
|
+
if (!this.verifySignature(req, params)) return forbidden();
|
|
222
|
+
|
|
223
|
+
const callSid = params.CallSid || "";
|
|
224
|
+
const from = params.From || "";
|
|
225
|
+
const { label, allowed } = this.classifyCaller(from);
|
|
226
|
+
|
|
227
|
+
if (!allowed) {
|
|
228
|
+
log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
|
|
229
|
+
getChannel("telegram")
|
|
230
|
+
?.sendMessage?.(`Phone: rejected call from ${from} (CallSid ${callSid})`)
|
|
231
|
+
.catch(() => {});
|
|
232
|
+
return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!this.canStartRealtime()) {
|
|
236
|
+
log.warn({ callSid }, "phone: realtime not configured, playing fallback message");
|
|
237
|
+
return twimlResponse(sayAndHangupTwiML("Hi, this is Nia. The voice loop is offline right now. Try again soon."));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.pending.set(callSid, {
|
|
241
|
+
context: {
|
|
242
|
+
callSid,
|
|
243
|
+
direction: "inbound",
|
|
244
|
+
remoteNumber: from,
|
|
245
|
+
remoteLabel: label,
|
|
246
|
+
instructions: buildInboundInstructions(label),
|
|
247
|
+
speakFirst: true,
|
|
248
|
+
opener: `Greet ${label} by name and ask how you can help.`,
|
|
249
|
+
},
|
|
250
|
+
startedAt: Date.now(),
|
|
251
|
+
});
|
|
252
|
+
this.completions.set(callSid, defer<RelayResult>());
|
|
253
|
+
|
|
254
|
+
return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "inbound" }));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private async handleOutboundTwiml(req: Request): Promise<Response> {
|
|
258
|
+
const { params } = await readForm(req);
|
|
259
|
+
if (!this.verifySignature(req, params)) return forbidden();
|
|
260
|
+
|
|
261
|
+
const callSid = params.CallSid || "";
|
|
262
|
+
const pending = this.pending.get(callSid);
|
|
263
|
+
if (!pending) {
|
|
264
|
+
log.warn({ callSid }, "phone: outbound TwiML requested for unknown call");
|
|
265
|
+
return twimlResponse(sayAndHangupTwiML("This call could not be set up. Goodbye."));
|
|
266
|
+
}
|
|
267
|
+
if (!this.canStartRealtime()) {
|
|
268
|
+
return twimlResponse(rejectTwiML());
|
|
269
|
+
}
|
|
270
|
+
return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "outbound" }));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async handleStatus(req: Request): Promise<Response> {
|
|
274
|
+
const { params } = await readForm(req);
|
|
275
|
+
if (!this.verifySignature(req, params)) return forbidden();
|
|
276
|
+
log.info(
|
|
277
|
+
{
|
|
278
|
+
callSid: params.CallSid,
|
|
279
|
+
status: params.CallStatus,
|
|
280
|
+
duration: params.CallDuration,
|
|
281
|
+
direction: params.Direction,
|
|
282
|
+
},
|
|
283
|
+
"phone: call status",
|
|
284
|
+
);
|
|
285
|
+
return new Response("", { status: 204 });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- WebSocket bootstrap ---
|
|
289
|
+
|
|
290
|
+
private async bootstrapWsMessage(ws: ServerWebSocket<WsData>, data: string): Promise<void> {
|
|
291
|
+
let msg: any;
|
|
292
|
+
try {
|
|
293
|
+
msg = JSON.parse(data);
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (msg.event === "connected") return; // first frame, no streamSid yet
|
|
298
|
+
|
|
299
|
+
if (msg.event !== "start") {
|
|
300
|
+
log.warn({ event: msg.event }, "phone: unexpected first ws event");
|
|
301
|
+
try {
|
|
302
|
+
ws.close();
|
|
303
|
+
} catch {}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const streamSid: string = msg.start?.streamSid;
|
|
308
|
+
const callSidFromParams: string = msg.start?.customParameters?.callSid || msg.start?.callSid;
|
|
309
|
+
const pending = this.pending.get(callSidFromParams);
|
|
310
|
+
if (!pending) {
|
|
311
|
+
log.warn({ callSid: callSidFromParams, streamSid }, "phone: stream started with no pending context");
|
|
312
|
+
try {
|
|
313
|
+
ws.close();
|
|
314
|
+
} catch {}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.pending.delete(callSidFromParams);
|
|
318
|
+
|
|
319
|
+
const tools = buildPhoneTools({ callerLabel: pending.context.remoteLabel });
|
|
320
|
+
const context: CallContext = { ...pending.context, streamSid, tools };
|
|
321
|
+
|
|
322
|
+
const handle = createRelay({
|
|
323
|
+
twilioWs: ws,
|
|
324
|
+
openAiKey: this.cfg.openai_api_key!,
|
|
325
|
+
model: this.cfg.realtime_model,
|
|
326
|
+
voice: this.cfg.voice,
|
|
327
|
+
context,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
ws.data.streamSid = streamSid;
|
|
331
|
+
this.active.set(streamSid, { handle, context, startedAt: pending.startedAt });
|
|
332
|
+
|
|
333
|
+
handle.onTwilioMessage(data); // forward the "start" event itself
|
|
334
|
+
|
|
335
|
+
handle.completion
|
|
336
|
+
.then((result) => this.persistCall(context, pending.startedAt, result))
|
|
337
|
+
.catch((err) => log.error({ err, callSid: context.callSid }, "phone: persist failed"))
|
|
338
|
+
.finally(() => this.active.delete(streamSid));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// --- Helpers ---
|
|
342
|
+
|
|
343
|
+
private canStartRealtime(): boolean {
|
|
344
|
+
return Boolean(this.cfg.openai_api_key && this.cfg.public_base_url);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private buildWssUrl(): string {
|
|
348
|
+
const base = this.cfg.public_base_url!.replace(/^http/, "ws");
|
|
349
|
+
return `${base}/twilio/voice/stream`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private classifyCaller(from: string): { label: string; allowed: boolean } {
|
|
353
|
+
if (from && from === this.cfg.owner_number) return { label: "Aman", allowed: true };
|
|
354
|
+
if (from && this.cfg.allowlist.includes(from)) return { label: from, allowed: true };
|
|
355
|
+
return { label: from || "unknown", allowed: false };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private verifySignature(req: Request, params: Record<string, string>): boolean {
|
|
359
|
+
// X-Twilio-Signature is HMAC-SHA1 with the account's Auth Token. When an
|
|
360
|
+
// API Key (SK…) + Secret is used for REST auth, the Auth Token is a
|
|
361
|
+
// separate value — set as TWILIO_AUTH_TOKEN. We fall back to TWILIO_SECRET
|
|
362
|
+
// so the simple "Account SID + Auth Token" pairing also works.
|
|
363
|
+
const signingToken = this.cfg.twilio_auth_token || this.cfg.twilio_secret;
|
|
364
|
+
if (!signingToken) return false;
|
|
365
|
+
const signature = req.headers.get("X-Twilio-Signature") || "";
|
|
366
|
+
const fullUrl = this.cfg.public_base_url ? `${this.cfg.public_base_url}${new URL(req.url).pathname}` : req.url;
|
|
367
|
+
return validateTwilioSignature({ authToken: signingToken, fullUrl, params, signature });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private requireCreds(): { accountSid: string; authToken: string } {
|
|
371
|
+
const sid = this.cfg.twilio_sid;
|
|
372
|
+
const secret = this.cfg.twilio_secret;
|
|
373
|
+
if (!sid || !secret) throw new Error("phone: TWILIO_SID/TWILIO_SECRET not configured");
|
|
374
|
+
return { accountSid: sid, authToken: secret };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private requirePublicBaseUrl(): string {
|
|
378
|
+
if (!this.cfg.public_base_url) throw new Error("phone: PUBLIC_BASE_URL not configured");
|
|
379
|
+
return this.cfg.public_base_url;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private requireFromNumber(): string {
|
|
383
|
+
if (!this.cfg.from_number) throw new Error("phone: PHONE_FROM_NUMBER not configured");
|
|
384
|
+
return this.cfg.from_number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async persistCall(context: CallContext, startedAt: number, result: RelayResult): Promise<void> {
|
|
388
|
+
const room = `phone-${context.callSid}`;
|
|
389
|
+
const sessionId = `phone-${context.callSid}`;
|
|
390
|
+
try {
|
|
391
|
+
await Session.create(sessionId, room);
|
|
392
|
+
for (const turn of result.transcript) {
|
|
393
|
+
await Message.save({
|
|
394
|
+
sessionId,
|
|
395
|
+
room,
|
|
396
|
+
sender: turn.role === "user" ? context.remoteLabel : "nia",
|
|
397
|
+
content: turn.text,
|
|
398
|
+
isFromAgent: turn.role === "assistant",
|
|
399
|
+
deliveryStatus: "sent",
|
|
400
|
+
metadata: {
|
|
401
|
+
channel: "phone",
|
|
402
|
+
direction: context.direction,
|
|
403
|
+
remoteNumber: context.remoteNumber,
|
|
404
|
+
callSid: context.callSid,
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
log.info(
|
|
409
|
+
{
|
|
410
|
+
callSid: context.callSid,
|
|
411
|
+
direction: context.direction,
|
|
412
|
+
turns: result.transcript.length,
|
|
413
|
+
endedReason: result.endedReason,
|
|
414
|
+
durationMs: Date.now() - startedAt,
|
|
415
|
+
},
|
|
416
|
+
"phone: call persisted",
|
|
417
|
+
);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
log.error({ err, callSid: context.callSid }, "phone: failed to persist call");
|
|
420
|
+
} finally {
|
|
421
|
+
const deferred = this.completions.get(context.callSid);
|
|
422
|
+
if (deferred) {
|
|
423
|
+
deferred.resolve(result);
|
|
424
|
+
this.completions.delete(context.callSid);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- Pure helpers ---
|
|
431
|
+
|
|
432
|
+
async function readForm(req: Request): Promise<{ params: Record<string, string> }> {
|
|
433
|
+
const body = await req.text();
|
|
434
|
+
const params: Record<string, string> = {};
|
|
435
|
+
const usp = new URLSearchParams(body);
|
|
436
|
+
for (const [k, v] of usp) params[k] = v;
|
|
437
|
+
return { params };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function twimlResponse(xml: string): Response {
|
|
441
|
+
return new Response(xml, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function forbidden(): Response {
|
|
445
|
+
return new Response("invalid signature", { status: 403 });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// --- Factory ---
|
|
449
|
+
|
|
450
|
+
let _instance: PhoneChannel | null = null;
|
|
451
|
+
|
|
452
|
+
export function createPhoneChannel(): PhoneChannel | null {
|
|
453
|
+
const cfg = getConfig().channels.phone;
|
|
454
|
+
if (!cfg.twilio_sid || !cfg.twilio_secret || !cfg.from_number) return null;
|
|
455
|
+
_instance = new PhoneChannel(cfg);
|
|
456
|
+
return _instance;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Used by the place_call MCP tool and CLI test entrypoint. Null if not running. */
|
|
460
|
+
export function getPhoneChannel(): PhoneChannel | null {
|
|
461
|
+
return _instance;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export type { PhoneChannel };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-prompt builders for phone calls. The text seeded here is what the
|
|
3
|
+
* realtime model treats as its instructions for the duration of the call —
|
|
4
|
+
* persona on top, then call-specific behavior below.
|
|
5
|
+
*/
|
|
6
|
+
import { loadIdentity } from "../../chat/identity";
|
|
7
|
+
|
|
8
|
+
const VOICE_RULES = [
|
|
9
|
+
"This is a real phone call. Speak naturally, with short turns and human rhythm.",
|
|
10
|
+
"No markdown, no asterisks, no bullet points — your output is spoken aloud.",
|
|
11
|
+
"Keep replies short by default. Long monologues feel robotic on phone calls.",
|
|
12
|
+
].join(" ");
|
|
13
|
+
|
|
14
|
+
export function buildInboundInstructions(callerLabel: string): string {
|
|
15
|
+
const identity = loadIdentity();
|
|
16
|
+
const callBlock = [
|
|
17
|
+
`You are speaking on the phone with ${callerLabel}.`,
|
|
18
|
+
VOICE_RULES,
|
|
19
|
+
"When the caller asks something that needs memory or careful reasoning, call the consult_claude tool.",
|
|
20
|
+
"When you've captured something worth remembering, call save_memory.",
|
|
21
|
+
"When the call wraps naturally, call end_call.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
return [identity, callBlock].filter(Boolean).join("\n\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildOutboundInstructions(goal: string, context?: string): string {
|
|
27
|
+
const identity = loadIdentity();
|
|
28
|
+
const callBlock = [
|
|
29
|
+
"You are placing a phone call on behalf of Aman.",
|
|
30
|
+
VOICE_RULES,
|
|
31
|
+
`Goal: ${goal}`,
|
|
32
|
+
context ? `Context:\n${context}` : "",
|
|
33
|
+
"You are the caller — speak first the moment the call connects. Introduce yourself and the purpose in your first sentence.",
|
|
34
|
+
"Be efficient: get to the point in the first two sentences.",
|
|
35
|
+
"Do NOT repeat your greeting. If the other side responds with a short 'hi' / 'hello' / unclear sound, treat it as acknowledgement and proceed to the next part of your goal — never restart the introduction.",
|
|
36
|
+
"If you reach voicemail, leave a brief message, then call end_call.",
|
|
37
|
+
"When the goal is met or the conversation is naturally complete, call end_call.",
|
|
38
|
+
]
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join("\n");
|
|
41
|
+
return [identity, callBlock].filter(Boolean).join("\n\n");
|
|
42
|
+
}
|