@spekoai/mcp-calls 0.3.2 → 0.4.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/README.md +2 -2
- package/dist/index.js +169 -77
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/speko-calls/SKILL.md +70 -46
package/README.md
CHANGED
|
@@ -45,9 +45,9 @@ backing server instead of running in-process, set `SPEKO_MCP_SERVER_URL`.
|
|
|
45
45
|
|
|
46
46
|
| Tool | What it does |
|
|
47
47
|
| --- | --- |
|
|
48
|
-
| `lookup_business(name, location?)` | Resolve a business → dialable candidates + a signed `dial_token` per callable one
|
|
48
|
+
| `lookup_business(name, location?, phone_number?)` | Resolve a business → dialable candidates + a signed `dial_token` per callable one (the only path that can authorize a call). Pass `phone_number` (E.164 — e.g. found via the agent's web search) to skip the directory lookup; still carrier-verified as a business line. |
|
|
49
49
|
| `make_call(dial_token, objective, caller_name, context?)` | Place the disclosed, objective-scoped call; wait for it to finish; return the `OUTCOME` + transcript. Honest `connected`/`answered`/`not_connected`. |
|
|
50
|
-
| `call_number(phone_number, objective, caller_name)` | Disclosed
|
|
50
|
+
| `call_number(phone_number, objective, caller_name)` | Disclosed call to a number you have or found via web search (business or personal) — the default path once you have the number. Mobiles allowed. On by default (`SPEKO_ALLOW_DIRECT_DIAL=0` restricts to business lines). |
|
|
51
51
|
| `get_call(call_id)` | Read-only: re-check a call's status, `OUTCOME`, and transcript. Never dials. |
|
|
52
52
|
| `check_call_readiness()` | Read-only preflight: auth, credit balance, outbound caller-ID. Never dials. |
|
|
53
53
|
|
package/dist/index.js
CHANGED
|
@@ -147,13 +147,32 @@ var init_client = __esm({
|
|
|
147
147
|
*/
|
|
148
148
|
async getSession(sessionId) {
|
|
149
149
|
const resp = await fetch(`${this.baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
|
|
150
|
-
headers: { accept: "application/json", authorization: `Bearer ${this.apiKey}` }
|
|
150
|
+
headers: { accept: "application/json", authorization: `Bearer ${this.apiKey}` },
|
|
151
|
+
signal: AbortSignal.timeout(3e4)
|
|
151
152
|
});
|
|
152
153
|
if (!resp.ok) {
|
|
153
154
|
throw new SpekoApiError(`GET /v1/sessions/${sessionId} failed`, resp.status, "session_fetch_failed");
|
|
154
155
|
}
|
|
155
156
|
return await resp.json();
|
|
156
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Raw `GET /v1/calls/{id}/events` — the call's event timeline. We poll this to find
|
|
160
|
+
* the AUTHORITATIVE end of the call (`room_finished`), because the call `status` can
|
|
161
|
+
* flip to "failed" early (a first-audio SLA timeout) while the call is still live and
|
|
162
|
+
* a full conversation follows. Returns a best-effort array (each event carries an
|
|
163
|
+
* `event_type`); an empty array on an unexpected shape.
|
|
164
|
+
*/
|
|
165
|
+
async getEvents(callId) {
|
|
166
|
+
const resp = await fetch(`${this.baseUrl}/v1/calls/${encodeURIComponent(callId)}/events`, {
|
|
167
|
+
headers: { accept: "application/json", authorization: `Bearer ${this.apiKey}` },
|
|
168
|
+
signal: AbortSignal.timeout(3e4)
|
|
169
|
+
});
|
|
170
|
+
if (!resp.ok) {
|
|
171
|
+
throw new SpekoApiError(`GET /v1/calls/${callId}/events failed`, resp.status, "events_fetch_failed");
|
|
172
|
+
}
|
|
173
|
+
const body = await resp.json();
|
|
174
|
+
return Array.isArray(body.events) ? body.events : [];
|
|
175
|
+
}
|
|
157
176
|
};
|
|
158
177
|
}
|
|
159
178
|
});
|
|
@@ -195,7 +214,7 @@ var init_errors = __esm({
|
|
|
195
214
|
});
|
|
196
215
|
|
|
197
216
|
// ../server/dist/constants.js
|
|
198
|
-
var MAX_CALL_SECONDS, MIN_CALL_SECONDS, FAST_POLLS, FAST_POLL_SECONDS, SLOW_POLL_SECONDS, STUB_DIAL_STATUS, NOT_PLACED_STATUS, NOT_CONNECTED_STATUS, MIN_CALL_BALANCE_USD,
|
|
217
|
+
var MAX_CALL_SECONDS, MIN_CALL_SECONDS, FAST_POLLS, FAST_POLL_SECONDS, SLOW_POLL_SECONDS, STUB_DIAL_STATUS, NOT_PLACED_STATUS, NOT_CONNECTED_STATUS, MIN_CALL_BALANCE_USD, HARD_TERMINAL_STATUSES, ROOM_END_EVENTS, HARD_FAILURE_EVENTS, OUTCOME_MARKER, BARE_OUTCOME_RE, DIAL_INTENT_LANGUAGE, DIAL_STT_KEYWORDS, MAX_CALLER_NAME_CHARS, OBJECTIVE_MIN_CHARS, E164_RE, ALLOWED_LINE_TYPES, US_PREMIUM_RE, EMERGENCY_NUMBERS, OBJECTIVE_BLOCK_RE, DIAL_TOKEN_DEFAULT_TTL_SECONDS, DIAL_TOKEN_SECRET_ENV, QUIET_START_HOUR, QUIET_END_HOUR, MAKE_CALL_NEXT_STEP, MAKE_CALL_DIAL_NEXT_STEP, CHECK_READINESS_NEXT_STEP, AUTH_NEXT_STEP;
|
|
199
218
|
var init_constants = __esm({
|
|
200
219
|
"../server/dist/constants.js"() {
|
|
201
220
|
"use strict";
|
|
@@ -208,19 +227,20 @@ var init_constants = __esm({
|
|
|
208
227
|
NOT_PLACED_STATUS = "not_placed";
|
|
209
228
|
NOT_CONNECTED_STATUS = "not_connected";
|
|
210
229
|
MIN_CALL_BALANCE_USD = 0.5;
|
|
211
|
-
|
|
230
|
+
HARD_TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
212
231
|
"completed",
|
|
213
232
|
"ended",
|
|
214
|
-
"failed",
|
|
215
233
|
"no_answer",
|
|
216
234
|
"no-answer",
|
|
217
235
|
"busy",
|
|
218
236
|
"canceled",
|
|
219
237
|
"cancelled",
|
|
220
|
-
"error",
|
|
221
238
|
"hangup"
|
|
222
239
|
]);
|
|
240
|
+
ROOM_END_EVENTS = /* @__PURE__ */ new Set(["room_finished", "call.end_tool.completed"]);
|
|
241
|
+
HARD_FAILURE_EVENTS = /* @__PURE__ */ new Set(["agent.dispatch_failed", "sip.dial_failed"]);
|
|
223
242
|
OUTCOME_MARKER = "OUTCOME:";
|
|
243
|
+
BARE_OUTCOME_RE = /^(failed|abandoned|completed?|error|no[_-]?answer|busy|canceled|cancelled|ended|success|unknown|in[_-]?progress|dialing)$/i;
|
|
224
244
|
DIAL_INTENT_LANGUAGE = "en";
|
|
225
245
|
DIAL_STT_KEYWORDS = ["reservation", "table for", "tonight", "8 PM"];
|
|
226
246
|
MAX_CALLER_NAME_CHARS = 80;
|
|
@@ -457,6 +477,12 @@ var init_timezone = __esm({
|
|
|
457
477
|
"925": "America/Los_Angeles",
|
|
458
478
|
"949": "America/Los_Angeles",
|
|
459
479
|
"971": "America/Los_Angeles",
|
|
480
|
+
// Bay Area / NorCal overlays (628=SF, 669=San Jose, 341=Oakland) + Central Valley (209/279)
|
|
481
|
+
"628": "America/Los_Angeles",
|
|
482
|
+
"669": "America/Los_Angeles",
|
|
483
|
+
"341": "America/Los_Angeles",
|
|
484
|
+
"209": "America/Los_Angeles",
|
|
485
|
+
"279": "America/Los_Angeles",
|
|
460
486
|
// Mountain (Phoenix = no DST)
|
|
461
487
|
"303": "America/Denver",
|
|
462
488
|
"385": "America/Denver",
|
|
@@ -682,54 +708,69 @@ var init_twilio = __esm({
|
|
|
682
708
|
});
|
|
683
709
|
|
|
684
710
|
// ../server/dist/lookup/index.js
|
|
711
|
+
async function verifyAndMint(c2, cfg, bearerHash) {
|
|
712
|
+
let lineType = null;
|
|
713
|
+
let blocked = dialBlockedReason(c2.e164);
|
|
714
|
+
if (!blocked) {
|
|
715
|
+
lineType = cfg.twilio ? await carrierLineType(c2.e164, cfg.twilio) : null;
|
|
716
|
+
blocked = lineTypeBlockedReason(lineType);
|
|
717
|
+
}
|
|
718
|
+
if (!blocked && c2.utcOffsetMinutes == null) {
|
|
719
|
+
blocked = "Couldn't determine the destination's local timezone, so quiet hours (08:00-21:00 local) can't be enforced. Pass utc_offset_minutes (e.g. -300 for US Eastern, -480 for US Pacific) to proceed.";
|
|
720
|
+
}
|
|
721
|
+
if (blocked) {
|
|
722
|
+
return {
|
|
723
|
+
name: c2.name,
|
|
724
|
+
address: c2.address,
|
|
725
|
+
phone: c2.e164,
|
|
726
|
+
line_type: lineType,
|
|
727
|
+
allowed: false,
|
|
728
|
+
blocked_reason: blocked,
|
|
729
|
+
dial_token: null,
|
|
730
|
+
utc_offset_minutes: c2.utcOffsetMinutes
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const dialToken = mintDialToken({
|
|
734
|
+
e164: c2.e164,
|
|
735
|
+
lineType,
|
|
736
|
+
businessName: c2.name,
|
|
737
|
+
utcOffsetMinutes: c2.utcOffsetMinutes,
|
|
738
|
+
bearerHash,
|
|
739
|
+
secret: cfg.dialTokenSecret
|
|
740
|
+
});
|
|
741
|
+
return {
|
|
742
|
+
name: c2.name,
|
|
743
|
+
address: c2.address,
|
|
744
|
+
phone: c2.e164,
|
|
745
|
+
line_type: lineType,
|
|
746
|
+
allowed: true,
|
|
747
|
+
blocked_reason: null,
|
|
748
|
+
dial_token: dialToken,
|
|
749
|
+
utc_offset_minutes: c2.utcOffsetMinutes
|
|
750
|
+
};
|
|
751
|
+
}
|
|
685
752
|
async function lookupBusiness(input, deps) {
|
|
686
753
|
if (demoEnabled()) {
|
|
687
754
|
return { candidates: [demoLookupCandidate(input, deps.bearerHash)], source: "demo" };
|
|
688
755
|
}
|
|
689
756
|
const { cfg } = deps;
|
|
757
|
+
const provided = typeof input.phoneNumber === "string" ? input.phoneNumber.replace(/[^\d+]/g, "") : "";
|
|
758
|
+
if (provided) {
|
|
759
|
+
const candidate = await verifyAndMint({
|
|
760
|
+
name: input.name,
|
|
761
|
+
address: (input.location ?? "").trim(),
|
|
762
|
+
e164: provided,
|
|
763
|
+
utcOffsetMinutes: typeof input.utcOffsetMinutes === "number" ? input.utcOffsetMinutes : offsetFromE164(provided)
|
|
764
|
+
}, cfg, deps.bearerHash);
|
|
765
|
+
return { candidates: [candidate], source: "agent_provided" };
|
|
766
|
+
}
|
|
690
767
|
if (!cfg.googlePlacesApiKey) {
|
|
691
|
-
throw new RejectionError("Business lookup
|
|
768
|
+
throw new RejectionError("Business lookup has no directory configured. Either pass phone_number (the business's official number \u2014 e.g. found via web search) to lookup_business, or set GOOGLE_PLACES_API_KEY on the demo server, or set SPEKO_DEMO=1 with a SPEKO_DEMO_E164.", "Pass phone_number=<E.164> to lookup_business, or add GOOGLE_PLACES_API_KEY to the repo-root .env, or enable SPEKO_DEMO.");
|
|
692
769
|
}
|
|
693
770
|
const query = [input.name, input.location].filter((s) => s && String(s).trim()).join(" ");
|
|
694
771
|
const places = await searchPlaces(query, cfg.googlePlacesApiKey);
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
let blocked = dialBlockedReason(p.e164);
|
|
698
|
-
if (!blocked) {
|
|
699
|
-
lineType = cfg.twilio ? await carrierLineType(p.e164, cfg.twilio) : null;
|
|
700
|
-
blocked = lineTypeBlockedReason(lineType);
|
|
701
|
-
}
|
|
702
|
-
if (blocked) {
|
|
703
|
-
return {
|
|
704
|
-
name: p.name,
|
|
705
|
-
address: p.address,
|
|
706
|
-
phone: p.e164,
|
|
707
|
-
line_type: lineType,
|
|
708
|
-
allowed: false,
|
|
709
|
-
blocked_reason: blocked,
|
|
710
|
-
dial_token: null,
|
|
711
|
-
utc_offset_minutes: p.utcOffsetMinutes
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
const dialToken = mintDialToken({
|
|
715
|
-
e164: p.e164,
|
|
716
|
-
lineType,
|
|
717
|
-
businessName: p.name,
|
|
718
|
-
utcOffsetMinutes: p.utcOffsetMinutes,
|
|
719
|
-
bearerHash: deps.bearerHash,
|
|
720
|
-
secret: cfg.dialTokenSecret
|
|
721
|
-
});
|
|
722
|
-
return {
|
|
723
|
-
name: p.name,
|
|
724
|
-
address: p.address,
|
|
725
|
-
phone: p.e164,
|
|
726
|
-
line_type: lineType,
|
|
727
|
-
allowed: true,
|
|
728
|
-
blocked_reason: null,
|
|
729
|
-
dial_token: dialToken,
|
|
730
|
-
utc_offset_minutes: p.utcOffsetMinutes
|
|
731
|
-
};
|
|
732
|
-
}));
|
|
772
|
+
const fallbackOffset = typeof input.utcOffsetMinutes === "number" ? input.utcOffsetMinutes : null;
|
|
773
|
+
const candidates = await Promise.all(places.map((p) => verifyAndMint({ ...p, utcOffsetMinutes: p.utcOffsetMinutes ?? fallbackOffset }, cfg, deps.bearerHash)));
|
|
733
774
|
return { candidates, source: "google_places" };
|
|
734
775
|
}
|
|
735
776
|
var init_lookup = __esm({
|
|
@@ -737,6 +778,7 @@ var init_lookup = __esm({
|
|
|
737
778
|
"use strict";
|
|
738
779
|
init_errors();
|
|
739
780
|
init_dialToken();
|
|
781
|
+
init_timezone();
|
|
740
782
|
init_demo();
|
|
741
783
|
init_places();
|
|
742
784
|
init_twilio();
|
|
@@ -900,7 +942,7 @@ function assessConnection(session, transcript) {
|
|
|
900
942
|
const ccidRaw = session.phoneCall?.callControlId;
|
|
901
943
|
const callControlId = typeof ccidRaw === "string" && ccidRaw.trim() ? ccidRaw : null;
|
|
902
944
|
const carrierBilled = Array.isArray(session.usage) && session.usage.some(isCarrierUsage);
|
|
903
|
-
const connected = Boolean(callControlId) || carrierBilled
|
|
945
|
+
const connected = answered || Boolean(callControlId) || carrierBilled;
|
|
904
946
|
return { connected, answered, callControlId, carrierBilled };
|
|
905
947
|
}
|
|
906
948
|
var CARRIER_PROVIDERS, CARRIER_METRIC_RE;
|
|
@@ -935,7 +977,10 @@ function shapeCallSummary(input) {
|
|
|
935
977
|
summary.status = NOT_CONNECTED_STATUS;
|
|
936
978
|
summary.reason = NOT_CONNECTED_REASON;
|
|
937
979
|
} else if (connected && !assessment.answered) {
|
|
980
|
+
summary.status = "no_answer";
|
|
938
981
|
summary.reason = NO_ANSWER_REASON;
|
|
982
|
+
} else if (connected && assessment.answered) {
|
|
983
|
+
summary.status = "completed";
|
|
939
984
|
}
|
|
940
985
|
return summary;
|
|
941
986
|
}
|
|
@@ -945,7 +990,7 @@ var init_summary = __esm({
|
|
|
945
990
|
"use strict";
|
|
946
991
|
init_constants();
|
|
947
992
|
init_assess();
|
|
948
|
-
NOT_CONNECTED_REASON = "
|
|
993
|
+
NOT_CONNECTED_REASON = "No real two-way call took place \u2014 the AI agent started but the other party was never heard (no answer, voicemail, or the call did not truly connect).";
|
|
949
994
|
NO_ANSWER_REASON = "The call connected but the other party never spoke (no answer / voicemail / hung up before responding).";
|
|
950
995
|
}
|
|
951
996
|
});
|
|
@@ -988,7 +1033,8 @@ async function makeCall(input, deps) {
|
|
|
988
1033
|
const offset = typeof payload.utc_offset_minutes === "number" ? payload.utc_offset_minutes : null;
|
|
989
1034
|
const quietReason = quietHoursReason(offset);
|
|
990
1035
|
if (quietReason) {
|
|
991
|
-
const
|
|
1036
|
+
const direct = deps.allowAnyLineType === true;
|
|
1037
|
+
const next = offset == null ? direct ? "Re-run call_number with utc_offset_minutes for the destination's city (e.g. -420 US Pacific summer, -300 US Eastern)." : MAKE_CALL_NEXT_STEP : `Wait until destination business hours (08:00-21:00 local time) and run ${direct ? "call_number" : "make_call"} again.`;
|
|
992
1038
|
throw new RejectionError(quietReason, next);
|
|
993
1039
|
}
|
|
994
1040
|
const objectiveReason = objectiveBlockedReason(input.objective);
|
|
@@ -1005,9 +1051,10 @@ async function makeCall(input, deps) {
|
|
|
1005
1051
|
const body = {
|
|
1006
1052
|
to: e164,
|
|
1007
1053
|
...fromNumber ? { from: fromNumber } : {},
|
|
1008
|
-
// optimizeFor=latency is best for a LIVE call:
|
|
1009
|
-
// first-token
|
|
1010
|
-
//
|
|
1054
|
+
// optimizeFor=latency is best for a LIVE call: it routes to a fast streaming STT + a low
|
|
1055
|
+
// time-to-first-token LLM, avoiding the multi-second dead air the balanced/accuracy modes
|
|
1056
|
+
// introduce. The actual LLM/TTS/STT models are pinned below via constraints
|
|
1057
|
+
// (cfg.llmPin / cfg.ttsPin / cfg.sttPin), not left to the selector.
|
|
1011
1058
|
intent: { language: DIAL_INTENT_LANGUAGE, optimizeFor: deps.cfg.optimizeFor },
|
|
1012
1059
|
// A specific `voice` (cfg.voice) is safe ONLY because it's an ElevenLabs voice matching the
|
|
1013
1060
|
// ElevenLabs TTS pin below — always verify a voice with scripts/verify-tts.mjs first. A voice
|
|
@@ -1076,43 +1123,68 @@ async function runPhoneCall(body, maxSeconds, deps, sleep) {
|
|
|
1076
1123
|
}
|
|
1077
1124
|
let elapsed = 0;
|
|
1078
1125
|
let polls = 0;
|
|
1079
|
-
|
|
1126
|
+
let ended = false;
|
|
1127
|
+
while (elapsed < maxSeconds) {
|
|
1080
1128
|
const interval = polls < FAST_POLLS ? FAST_POLL_SECONDS : SLOW_POLL_SECONDS;
|
|
1081
1129
|
await sleep(interval * 1e3);
|
|
1082
1130
|
elapsed += interval;
|
|
1083
1131
|
polls += 1;
|
|
1132
|
+
let events;
|
|
1084
1133
|
try {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1134
|
+
events = await deps.client.getEvents(callId);
|
|
1135
|
+
} catch {
|
|
1136
|
+
try {
|
|
1137
|
+
const d = await deps.client.getCall(callId);
|
|
1138
|
+
status = String(d.status ?? "").toLowerCase();
|
|
1139
|
+
} catch (e) {
|
|
1140
|
+
throw new AppError(e.message, {
|
|
1141
|
+
statusCode: 502,
|
|
1142
|
+
nextStep: `Do not dial again; the call (call_id '${callId}') may still be in progress. Check it with get_call('${callId}').`
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
if (HARD_TERMINAL_STATUSES.has(status)) {
|
|
1146
|
+
ended = true;
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
const types = new Set(events.map((e) => String(e.event_type ?? e.type ?? "").toLowerCase()));
|
|
1152
|
+
if ([...ROOM_END_EVENTS].some((t) => types.has(t)) || [...HARD_FAILURE_EVENTS].some((t) => types.has(t))) {
|
|
1153
|
+
ended = true;
|
|
1154
|
+
break;
|
|
1092
1155
|
}
|
|
1093
1156
|
}
|
|
1094
|
-
if (!
|
|
1157
|
+
if (!ended) {
|
|
1095
1158
|
return {
|
|
1096
1159
|
...baseSummary(callId, to, from),
|
|
1097
1160
|
status: "timeout",
|
|
1098
1161
|
duration_seconds: elapsed,
|
|
1099
1162
|
connected: true,
|
|
1100
|
-
reason: "Reached the wait limit before the call
|
|
1163
|
+
reason: "Reached the wait limit before the call ended; it may still be in progress."
|
|
1101
1164
|
};
|
|
1102
1165
|
}
|
|
1103
1166
|
return finalize(callId, to, from, status, elapsed, deps);
|
|
1104
1167
|
}
|
|
1105
1168
|
async function finalize(callId, to, from, status, elapsed, deps) {
|
|
1169
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
1106
1170
|
let transcript = null;
|
|
1107
1171
|
let transcriptError;
|
|
1108
1172
|
let outcome = null;
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1173
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1174
|
+
try {
|
|
1175
|
+
const detail = await deps.client.getCall(callId);
|
|
1176
|
+
transcript = detail.transcript ?? null;
|
|
1177
|
+
const reportOutcome = typeof detail.report?.outcome === "string" ? detail.report.outcome.trim() : "";
|
|
1178
|
+
const substantive = reportOutcome && !BARE_OUTCOME_RE.test(reportOutcome) ? reportOutcome : "";
|
|
1179
|
+
outcome = substantive || extractOutcome(transcript);
|
|
1180
|
+
transcriptError = void 0;
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
transcriptError = e.message;
|
|
1183
|
+
}
|
|
1184
|
+
if (extractReply(transcript) !== null)
|
|
1185
|
+
break;
|
|
1186
|
+
if (attempt < 2)
|
|
1187
|
+
await sleep(3e3);
|
|
1116
1188
|
}
|
|
1117
1189
|
let session = null;
|
|
1118
1190
|
try {
|
|
@@ -1156,7 +1228,7 @@ async function callNumber(input, deps) {
|
|
|
1156
1228
|
if (!deps.cfg.allowDirectDial) {
|
|
1157
1229
|
throw new RejectionError("Direct dialing has been turned off on this deployment (SPEKO_ALLOW_DIRECT_DIAL is set to off), so call_number is disabled and cannot place this call. (Direct dialing is on by default.)", "To call a business, use lookup_business + make_call instead. To use call_number, unset SPEKO_ALLOW_DIRECT_DIAL (or set it to 1) in the MCP's env and restart, then retry.");
|
|
1158
1230
|
}
|
|
1159
|
-
const e164 = typeof input.phoneNumber === "string" ? input.phoneNumber.
|
|
1231
|
+
const e164 = typeof input.phoneNumber === "string" ? input.phoneNumber.replace(/[^\d+]/g, "") : "";
|
|
1160
1232
|
const blocked = dialBlockedReason(e164);
|
|
1161
1233
|
if (blocked) {
|
|
1162
1234
|
throw new RejectionError(blocked, "Pass a valid E.164 number (e.g. +77011234567) that you have consent to call.");
|
|
@@ -1312,8 +1384,9 @@ async function describeCall(callId, client) {
|
|
|
1312
1384
|
const transcript = detail.transcript ?? null;
|
|
1313
1385
|
const to = strField(detail.metadata, "to") ?? strField(detail.metadata, "dialedNumber");
|
|
1314
1386
|
const from = strField(detail.metadata, "from");
|
|
1315
|
-
const reportOutcome = detail.report?.outcome;
|
|
1316
|
-
const
|
|
1387
|
+
const reportOutcome = typeof detail.report?.outcome === "string" ? detail.report.outcome.trim() : "";
|
|
1388
|
+
const substantive = reportOutcome && !BARE_OUTCOME_RE.test(reportOutcome) ? reportOutcome : "";
|
|
1389
|
+
const outcome = substantive || extractOutcome(transcript);
|
|
1317
1390
|
let session = null;
|
|
1318
1391
|
try {
|
|
1319
1392
|
session = await client.getSession(callId);
|
|
@@ -1907,7 +1980,12 @@ var InProcessBackend = class {
|
|
|
1907
1980
|
try {
|
|
1908
1981
|
if (path === "/lookup") {
|
|
1909
1982
|
return await core.lookupBusiness(
|
|
1910
|
-
{
|
|
1983
|
+
{
|
|
1984
|
+
name: String(b.name ?? ""),
|
|
1985
|
+
location: b.location ?? null,
|
|
1986
|
+
phoneNumber: b.phone_number ?? null,
|
|
1987
|
+
utcOffsetMinutes: typeof b.utc_offset_minutes === "number" ? b.utc_offset_minutes : null
|
|
1988
|
+
},
|
|
1911
1989
|
{ cfg: ctx.cfg, bearerHash: ctx.bearerHash }
|
|
1912
1990
|
);
|
|
1913
1991
|
}
|
|
@@ -2028,7 +2106,9 @@ function getServerClient() {
|
|
|
2028
2106
|
|
|
2029
2107
|
// src/tools/CallNumberTool.ts
|
|
2030
2108
|
var schema2 = z2.object({
|
|
2031
|
-
phone_number: z2.string().describe(
|
|
2109
|
+
phone_number: z2.string().describe(
|
|
2110
|
+
"Number to call in full international E.164 \u2014 leading + and country code (e.g. +14152857117, NOT (415) 285-7117). A number the user asked you to call or explicitly provided."
|
|
2111
|
+
),
|
|
2032
2112
|
objective: z2.string().describe("What to say / accomplish, e.g. 'Tell Sam that John says happy birthday and misses him.'"),
|
|
2033
2113
|
caller_name: z2.string().describe("Name of the human the call is on behalf of (1-80 chars); spoken in the AI-disclosure opening."),
|
|
2034
2114
|
recipient_name: z2.string().optional().describe("Who you're calling, used in the greeting (e.g. 'Sam')."),
|
|
@@ -2064,7 +2144,7 @@ function summarize(s) {
|
|
|
2064
2144
|
}
|
|
2065
2145
|
var CallNumberTool = class extends MCPTool2 {
|
|
2066
2146
|
name = "call_number";
|
|
2067
|
-
description = "Place a disclosed
|
|
2147
|
+
description = "Place a disclosed call to a phone number you HAVE or FOUND (e.g. via web search) \u2014 the DEFAULT path for calling any business or person. Works with just the user's Speko key, no extra setup. Every call opens with the non-removable AI disclosure; quiet hours and the no-sell/no-spam screen still apply (mobiles allowed). lookup_business + make_call is the OPTIONAL verified-directory path (it needs the server's carrier/directory keys); prefer call_number when you already have or found the number. Only dial a number the user asked you to call or explicitly provided \u2014 never one you invented.";
|
|
2068
2148
|
schema = schema2;
|
|
2069
2149
|
annotations = {
|
|
2070
2150
|
title: "Call a Number",
|
|
@@ -2077,6 +2157,8 @@ var CallNumberTool = class extends MCPTool2 {
|
|
|
2077
2157
|
const maxWait = clamp2(input.max_duration_seconds ?? MAX_WAIT, MIN_WAIT, MAX_WAIT);
|
|
2078
2158
|
const client = getServerClient();
|
|
2079
2159
|
let elapsed = 0;
|
|
2160
|
+
void this.reportProgress(0, maxWait, "Placing the call\u2026").catch(() => {
|
|
2161
|
+
});
|
|
2080
2162
|
const timer = setInterval(() => {
|
|
2081
2163
|
elapsed += HEARTBEAT_MS / 1e3;
|
|
2082
2164
|
void this.reportProgress(elapsed, maxWait, `Call in progress \u2014 ${elapsed}s elapsed`).catch(() => {
|
|
@@ -2154,11 +2236,17 @@ import { MCPTool as MCPTool5 } from "mcp-framework";
|
|
|
2154
2236
|
import { z as z5 } from "zod";
|
|
2155
2237
|
var schema5 = z5.object({
|
|
2156
2238
|
name: z5.string().min(1).describe(`Business name, e.g. "Joe's Pizza".`),
|
|
2157
|
-
location: z5.string().optional().describe("Optional city or area to disambiguate, e.g. 'New York'.")
|
|
2239
|
+
location: z5.string().optional().describe("Optional city or area to disambiguate, e.g. 'New York'."),
|
|
2240
|
+
phone_number: z5.string().optional().describe(
|
|
2241
|
+
"The business's official phone number in E.164 (e.g. +14155551234) if you can find it via web search. When provided, this skips the directory lookup and verifies this exact number \u2014 it's still carrier-checked as a business line before any call. Omit it to resolve by name + location instead."
|
|
2242
|
+
),
|
|
2243
|
+
utc_offset_minutes: z5.number().int().optional().describe(
|
|
2244
|
+
"Destination UTC offset in minutes for quiet-hours (e.g. -300 US Eastern, -480 US Pacific, 0 UK). Pass this alongside phone_number when you know the business's region but its number isn't auto-recognized (otherwise the offset is derived from the number)."
|
|
2245
|
+
)
|
|
2158
2246
|
});
|
|
2159
2247
|
var LookupBusinessTool = class extends MCPTool5 {
|
|
2160
2248
|
name = "lookup_business";
|
|
2161
|
-
description = "Resolve a business
|
|
2249
|
+
description = "Resolve a business to dialable candidates and mint a signed dial_token for each callable one \u2014 the only path that can authorize make_call (raw phone numbers are rejected). If you can find the business's official number via web search, pass it as phone_number to skip the directory lookup; otherwise pass name (plus optional location). Either way the number is carrier-verified as a business line.";
|
|
2162
2250
|
schema = schema5;
|
|
2163
2251
|
annotations = {
|
|
2164
2252
|
title: "Lookup Business",
|
|
@@ -2170,7 +2258,9 @@ var LookupBusinessTool = class extends MCPTool5 {
|
|
|
2170
2258
|
async execute(input) {
|
|
2171
2259
|
const out = await getServerClient().post("/lookup", {
|
|
2172
2260
|
name: input.name,
|
|
2173
|
-
location: input.location
|
|
2261
|
+
location: input.location,
|
|
2262
|
+
phone_number: input.phone_number,
|
|
2263
|
+
utc_offset_minutes: input.utc_offset_minutes
|
|
2174
2264
|
});
|
|
2175
2265
|
const candidates = out.candidates ?? [];
|
|
2176
2266
|
const lines = candidates.map(
|
|
@@ -2232,6 +2322,8 @@ var MakeCallTool = class extends MCPTool6 {
|
|
|
2232
2322
|
const maxWait = clamp3(input.max_duration_seconds ?? MAX_WAIT2, MIN_WAIT2, MAX_WAIT2);
|
|
2233
2323
|
const client = getServerClient();
|
|
2234
2324
|
let elapsed = 0;
|
|
2325
|
+
void this.reportProgress(0, maxWait, "Placing the call\u2026").catch(() => {
|
|
2326
|
+
});
|
|
2235
2327
|
const timer = setInterval(() => {
|
|
2236
2328
|
elapsed += HEARTBEAT_MS2 / 1e3;
|
|
2237
2329
|
void this.reportProgress(elapsed, maxWait, `Call in progress \u2014 ${elapsed}s elapsed`).catch(() => {
|
|
@@ -2265,7 +2357,7 @@ if (cmd === "init" || cmd === "setup" || cmd === "login") {
|
|
|
2265
2357
|
loadEnv();
|
|
2266
2358
|
var server = new MCPServer({
|
|
2267
2359
|
name: "speko-calls",
|
|
2268
|
-
version: "0.
|
|
2360
|
+
version: "0.4.1",
|
|
2269
2361
|
transport: { type: "stdio" }
|
|
2270
2362
|
});
|
|
2271
2363
|
server.addTool(LookupBusinessTool);
|