callwright 1.0.0
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/.dockerignore +16 -0
- package/.env.example +21 -0
- package/Dockerfile +18 -0
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/config.example.json +18 -0
- package/dispatch-core.js +371 -0
- package/dispatch.js +55 -0
- package/docs/add-language-spec.md +207 -0
- package/docs/profile-learning-spec.md +134 -0
- package/examples/job.appointment-confirmation.json +24 -0
- package/examples/job.general-inquiry.ja.json +16 -0
- package/examples/job.reservation.json +19 -0
- package/fly.toml +35 -0
- package/generic-prompt.ja.md +125 -0
- package/generic-prompt.md +149 -0
- package/get-call.js +70 -0
- package/grounding.example.json +17 -0
- package/init.js +159 -0
- package/lang-core.js +476 -0
- package/lang-phrases.json +29 -0
- package/learn-core.js +257 -0
- package/learn.js +65 -0
- package/notify-core.js +113 -0
- package/package.json +77 -0
- package/paths.js +37 -0
- package/place_call.schema.json +173 -0
- package/providers/README.md +116 -0
- package/retry-core.js +121 -0
- package/scenario-profiles.json +112 -0
- package/server.js +876 -0
- package/setup-agent-from.js +96 -0
- package/setup-core.js +299 -0
- package/update-prompt.js +98 -0
- package/webhook/README.md +104 -0
- package/webhook/api/retell-webhook.js +111 -0
- package/webhook/package.json +13 -0
- package/webhook/vercel.json +9 -0
package/dispatch-core.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// callwright — dispatch core (reusable, no CLI / no process.argv).
|
|
2
|
+
//
|
|
3
|
+
// The validate -> profile-match -> compose-vars -> resolve-agent -> dispatch
|
|
4
|
+
// pipeline, callable in-process by BOTH the CLI (dispatch.js) and the MCP
|
|
5
|
+
// server. Pure of stdin/stdout side effects except where noted (buildReadback
|
|
6
|
+
// returns text; it does not print).
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const Ajv = require("ajv/dist/2020");
|
|
10
|
+
const addFormats = require("ajv-formats");
|
|
11
|
+
const paths = require("./paths");
|
|
12
|
+
const retry = require("./retry-core");
|
|
13
|
+
|
|
14
|
+
const SCHEMA_PATH = "place_call.schema.json";
|
|
15
|
+
const PROFILES_PATH = paths.PROFILES_PATH;
|
|
16
|
+
const CONFIG_PATH = paths.CONFIG_PATH;
|
|
17
|
+
const AGENTS_PATH = paths.AGENTS_PATH;
|
|
18
|
+
const RETELL_BASE = "https://api.retellai.com";
|
|
19
|
+
|
|
20
|
+
function loadJson(p, fb) {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fb; }
|
|
22
|
+
}
|
|
23
|
+
function loadConfig() { return loadJson(CONFIG_PATH, {}); }
|
|
24
|
+
|
|
25
|
+
// Per-language PHRASING. Code injects values; this data owns wording. Keyed by
|
|
26
|
+
// BCP-47 primary subtag; unknown languages fall back to English so a call never
|
|
27
|
+
// crashes for lack of a phrase block (it just speaks English helper text).
|
|
28
|
+
// Volume-first: the image ships en/ja seeds; a volume copy (runtime-added
|
|
29
|
+
// languages) is merged on top per language block. Not cached — the file is tiny
|
|
30
|
+
// and add_language may write to it at runtime.
|
|
31
|
+
const PHRASES_FILE = "lang-phrases.json";
|
|
32
|
+
function loadPhrases() {
|
|
33
|
+
const image = loadJson(PHRASES_FILE, { en: {} });
|
|
34
|
+
if (paths.DATA_DIR !== ".") {
|
|
35
|
+
const vol = loadJson(paths.volumePath(PHRASES_FILE), null);
|
|
36
|
+
if (vol) return { ...image, ...vol };
|
|
37
|
+
}
|
|
38
|
+
return image;
|
|
39
|
+
}
|
|
40
|
+
function phrasesFor(lang) {
|
|
41
|
+
const all = loadPhrases();
|
|
42
|
+
const L = String(lang || "en").toLowerCase();
|
|
43
|
+
return all[L] || all[L.split("-")[0]] || all.en || {};
|
|
44
|
+
}
|
|
45
|
+
// Minimal {key} interpolation; missing keys render as "".
|
|
46
|
+
function interp(tpl, vars = {}) {
|
|
47
|
+
return String(tpl || "").replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ""));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Backfill ONLY the always-safe basics from config (identity + callback). Facts
|
|
51
|
+
// are NOT auto-merged (data minimization: caller furnishes a per-call subset).
|
|
52
|
+
// A job may set principal.anonymous=true to opt out entirely (general inquiries).
|
|
53
|
+
function applyConfigBackfill(job, config = loadConfig()) {
|
|
54
|
+
const anon = job.principal && job.principal.anonymous === true;
|
|
55
|
+
if (config.principal && !anon) {
|
|
56
|
+
const cp = config.principal;
|
|
57
|
+
job.principal = job.principal || {};
|
|
58
|
+
if (job.principal.name == null) job.principal.name = cp.name;
|
|
59
|
+
if (job.principal.callback_number == null && cp.callback_number != null)
|
|
60
|
+
job.principal.callback_number = cp.callback_number;
|
|
61
|
+
}
|
|
62
|
+
return job;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let _validate = null;
|
|
66
|
+
function validateJob(job) {
|
|
67
|
+
if (!_validate) {
|
|
68
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
69
|
+
addFormats(ajv);
|
|
70
|
+
_validate = ajv.compile(loadJson(SCHEMA_PATH, {}));
|
|
71
|
+
}
|
|
72
|
+
const ok = _validate(job);
|
|
73
|
+
return {
|
|
74
|
+
ok,
|
|
75
|
+
errors: ok ? [] : _validate.errors.map((e) => `${e.instancePath || "(root)"} ${e.message}`),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Match an open-ended call_type to a scenario profile (name or alias, fuzzy).
|
|
80
|
+
function loadProfileFor(callType) {
|
|
81
|
+
const profiles = loadJson(PROFILES_PATH, null);
|
|
82
|
+
if (!profiles) return null;
|
|
83
|
+
const ct = (callType || "").toLowerCase();
|
|
84
|
+
for (const [name, p] of Object.entries(profiles)) {
|
|
85
|
+
const keys = [name, ...(p.aliases || [])].map((s) => s.toLowerCase());
|
|
86
|
+
if (keys.some((k) => ct === k || ct.includes(k) || k.includes(ct))) return { name, ...p };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- formatting ----
|
|
92
|
+
function prettyDate(iso) {
|
|
93
|
+
const d = new Date(iso + "T12:00:00");
|
|
94
|
+
if (isNaN(d)) return iso;
|
|
95
|
+
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
|
96
|
+
}
|
|
97
|
+
function pretty12h(t) {
|
|
98
|
+
const [h, m] = t.split(":").map(Number);
|
|
99
|
+
const ap = h >= 12 ? "PM" : "AM";
|
|
100
|
+
const h12 = ((h + 11) % 12) + 1;
|
|
101
|
+
return `${h12}:${String(m).padStart(2, "0")} ${ap}`;
|
|
102
|
+
}
|
|
103
|
+
function deriveWindows(req, flexMin) {
|
|
104
|
+
if (req.acceptable_windows && req.acceptable_windows.length) return req.acceptable_windows;
|
|
105
|
+
if (!req.preferred?.date || !req.preferred?.time) {
|
|
106
|
+
throw new Error("No acceptable_windows and no preferred date/time to derive from.");
|
|
107
|
+
}
|
|
108
|
+
const [h, m] = req.preferred.time.split(":").map(Number);
|
|
109
|
+
const base = h * 60 + m;
|
|
110
|
+
const fmt = (mins) => {
|
|
111
|
+
const hh = Math.floor((((mins % 1440) + 1440) % 1440) / 60);
|
|
112
|
+
const mm = ((mins % 60) + 60) % 60;
|
|
113
|
+
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
|
|
114
|
+
};
|
|
115
|
+
return [{ date: req.preferred.date, earliest: fmt(base - flexMin), latest: fmt(base + flexMin) }];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Compose the rich job -> Retell dynamic variables + call metadata.
|
|
119
|
+
// `lang` selects the per-language PHRASE block (lang-phrases.json) used for
|
|
120
|
+
// composed helper strings. composeCall has no language conditionals — phrasing
|
|
121
|
+
// is data; this function only injects values.
|
|
122
|
+
function composeCall(job, { lang = "en" } = {}) {
|
|
123
|
+
const LANG = String(lang || "en").toLowerCase();
|
|
124
|
+
const P = phrasesFor(LANG);
|
|
125
|
+
const profile = loadProfileFor(job.call_type);
|
|
126
|
+
|
|
127
|
+
const flex = job.overrides?.time_flexibility_minutes ?? profile?.default_flex_minutes ?? 45;
|
|
128
|
+
const windows = deriveWindows(job.request, flex);
|
|
129
|
+
const windowsStr = windows
|
|
130
|
+
.map((w, i) => `${i + 1}) ${prettyDate(w.date)}, ${pretty12h(w.earliest)}-${pretty12h(w.latest)}`)
|
|
131
|
+
.join(" ");
|
|
132
|
+
|
|
133
|
+
const details = job.scenario_details || {};
|
|
134
|
+
const facts = (job.principal && job.principal.facts) || {};
|
|
135
|
+
const missingRecommended = [];
|
|
136
|
+
if (profile?.recommended_details) {
|
|
137
|
+
for (const [key, why] of Object.entries(profile.recommended_details)) {
|
|
138
|
+
const present =
|
|
139
|
+
(details[key] != null && details[key] !== "") ||
|
|
140
|
+
(facts[key] != null && facts[key] !== "") ||
|
|
141
|
+
(key === "party_size" && job.request.party_size);
|
|
142
|
+
if (!present) missingRecommended.push({ key, why });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const detailSentences = Object.entries(details).map(([k, v]) => {
|
|
146
|
+
const label = k.replace(/_/g, " ");
|
|
147
|
+
return `${label.charAt(0).toUpperCase() + label.slice(1)}: ${v}.`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const principalName = (job.principal && job.principal.name) ? String(job.principal.name).trim() : "";
|
|
151
|
+
const hasName = principalName.length > 0;
|
|
152
|
+
const onBehalf = hasName ? interp(P.behalf_named, { name: principalName }) : "";
|
|
153
|
+
const principalRef = hasName ? principalName : P.principal_ref_anon;
|
|
154
|
+
|
|
155
|
+
const objective = job.request.summary;
|
|
156
|
+
// Trimmed/lowercased forms shared by the phrase templates (opening, voicemail).
|
|
157
|
+
const objectiveTrim = String(objective || "").trim().replace(/[.?!。、]+$/, "");
|
|
158
|
+
const objectiveLc = objectiveTrim.charAt(0).toLowerCase() + objectiveTrim.slice(1);
|
|
159
|
+
const serviceType = details.service_type ? ` (${details.service_type})` : "";
|
|
160
|
+
const objectiveDetail = job.request.party_size
|
|
161
|
+
? `${job.request.summary}${serviceType} (party of ${job.request.party_size}).`
|
|
162
|
+
: `${job.request.summary}${serviceType}.`;
|
|
163
|
+
|
|
164
|
+
// The spoken purpose line. PREFER an explicit, call-language opening_ask that the
|
|
165
|
+
// host LLM authored (it writes fluent, natural sentences). Only fall back to a
|
|
166
|
+
// simple, language-neutral derivation when opening_ask is absent.
|
|
167
|
+
const openingAsk = job.request.opening_ask
|
|
168
|
+
|| interp(P.opening_ask_fallback, { objective: objectiveTrim, objective_lc: objectiveLc });
|
|
169
|
+
|
|
170
|
+
const callbackNumber = (job.principal && job.principal.callback_number) || "";
|
|
171
|
+
const voicemailMessage = interp(
|
|
172
|
+
callbackNumber ? P.voicemail_callback : P.voicemail_no_callback,
|
|
173
|
+
{ behalf: onBehalf, objective: objectiveTrim, objective_lc: objectiveLc, callback: callbackNumber }
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const composeKnownFacts = (f) => {
|
|
177
|
+
const entries = Object.entries(f || {}).filter(([, v]) => v != null && v !== "");
|
|
178
|
+
if (!entries.length) return P.known_facts_none;
|
|
179
|
+
return entries
|
|
180
|
+
.map(([k, v]) => `- ${k.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}: ${v}`)
|
|
181
|
+
.join("\n");
|
|
182
|
+
};
|
|
183
|
+
const knownFacts = composeKnownFacts(facts);
|
|
184
|
+
|
|
185
|
+
const bookingNameLine = hasName
|
|
186
|
+
? interp(P.booking_name_line_named, { name: principalName })
|
|
187
|
+
: P.booking_name_line_anon;
|
|
188
|
+
|
|
189
|
+
// Default confirm list is built from per-language labels; party size is added
|
|
190
|
+
// only when present AND the language opts in (cf.party_size non-null).
|
|
191
|
+
const defaultConfirm = (() => {
|
|
192
|
+
const cf = P.confirm || { base: ["the date", "the time"], party_size: "the party size", join: ", " };
|
|
193
|
+
const parts = (cf.base || []).slice();
|
|
194
|
+
if (job.request.party_size && cf.party_size) parts.splice(2, 0, cf.party_size);
|
|
195
|
+
return parts.join(cf.join != null ? cf.join : ", ");
|
|
196
|
+
})();
|
|
197
|
+
const mustConfirm = (job.must_confirm && job.must_confirm.length)
|
|
198
|
+
? job.must_confirm.join(", ")
|
|
199
|
+
: (profile?.must_confirm ? profile.must_confirm.join(", ") : defaultConfirm);
|
|
200
|
+
|
|
201
|
+
const baseConstraints = (job.constraints && job.constraints.length) ? job.constraints.slice() : [];
|
|
202
|
+
const allConstraints = [...baseConstraints, ...detailSentences];
|
|
203
|
+
const noneText = P.none_text;
|
|
204
|
+
|
|
205
|
+
// Retry policy (default: exactly 1 retry on no-answer/busy/failed). Round-tripped
|
|
206
|
+
// in the dynamic vars so the webhook can decide from the call_ended payload and
|
|
207
|
+
// the incrementing attempt counter makes the chain self-terminating.
|
|
208
|
+
const retryPolicy = retry.parseRetryPolicy(job.overrides && job.overrides.retry);
|
|
209
|
+
const retryVars = retry.policyToVars(retryPolicy, 0);
|
|
210
|
+
|
|
211
|
+
// Per-call SMS notify intent (opt-in). Round-tripped so the call_analyzed
|
|
212
|
+
// webhook can text the user. Default off (results pulled via get_call_outcome).
|
|
213
|
+
const notifySms = !!(job.notify && job.notify.sms);
|
|
214
|
+
const notifyVars = { notify_sms: notifySms ? "1" : "0", notify_to: (job.notify && job.notify.to) || "" };
|
|
215
|
+
|
|
216
|
+
const vars = {
|
|
217
|
+
business_name: job.target.business_name,
|
|
218
|
+
// Round-tripped metadata (not spoken; echoed back in the call_analyzed
|
|
219
|
+
// webhook payload so auto-learn knows the scenario + language).
|
|
220
|
+
call_type: job.call_type || "",
|
|
221
|
+
call_lang: LANG,
|
|
222
|
+
// Retry policy round-trip (read back from the call_ended webhook payload).
|
|
223
|
+
...retryVars,
|
|
224
|
+
// SMS notify intent round-trip (read back from the call_analyzed webhook).
|
|
225
|
+
...notifyVars,
|
|
226
|
+
principal_name: principalName,
|
|
227
|
+
principal_ref: principalRef,
|
|
228
|
+
booking_name_line: bookingNameLine,
|
|
229
|
+
objective,
|
|
230
|
+
objective_detail: objectiveDetail,
|
|
231
|
+
opening_ask: openingAsk,
|
|
232
|
+
voicemail_message: voicemailMessage,
|
|
233
|
+
known_facts: knownFacts,
|
|
234
|
+
must_confirm: mustConfirm,
|
|
235
|
+
party_size: String(job.request.party_size ?? ""),
|
|
236
|
+
max_party_size: String(job.request.max_party_size ?? job.request.party_size ?? ""),
|
|
237
|
+
pref_date: job.request.preferred?.date ? prettyDate(job.request.preferred.date) : "",
|
|
238
|
+
pref_time: job.request.preferred?.time ? pretty12h(job.request.preferred.time) : "",
|
|
239
|
+
flex_minutes: String(flex),
|
|
240
|
+
acceptable_windows: windowsStr,
|
|
241
|
+
callback_number: (job.principal && job.principal.callback_number) ?? "",
|
|
242
|
+
special_constraints: allConstraints.length ? allConstraints.join(" ") : noneText,
|
|
243
|
+
preferences: (job.preferences && job.preferences.length) ? job.preferences.join("; ") : noneText,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Personal data types available to the agent (shared only if asked).
|
|
247
|
+
const piiKeys = [];
|
|
248
|
+
if (principalName) piiKeys.push("name");
|
|
249
|
+
if (job.principal && job.principal.callback_number) piiKeys.push("callback_number");
|
|
250
|
+
piiKeys.push(...Object.keys(facts).filter((k) => facts[k] != null && facts[k] !== ""));
|
|
251
|
+
|
|
252
|
+
return { profile, flex, windows, windowsStr, missingRecommended, detailSentences, vars, piiKeys, lang: LANG, phrases: P, retryPolicy };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Resolve agent: language variant (config.agents.by_lang[lang]) -> agents.json
|
|
256
|
+
// (call_type | default) -> config.agents.default -> env.
|
|
257
|
+
function resolveAgentId(callType, { config = loadConfig(), lang } = {}) {
|
|
258
|
+
const L = String(lang || "en").toLowerCase().split("-")[0];
|
|
259
|
+
if (L && L !== "en" && config.agents?.by_lang?.[L]) return config.agents.by_lang[L];
|
|
260
|
+
const reg = loadJson(AGENTS_PATH, null);
|
|
261
|
+
if (reg) {
|
|
262
|
+
const id = reg[callType] || reg.default || config.agents?.default;
|
|
263
|
+
if (id) return id;
|
|
264
|
+
}
|
|
265
|
+
if (config.agents?.default) return config.agents.default;
|
|
266
|
+
const raw = process.env.RETELL_AGENT_ID || "";
|
|
267
|
+
if (raw.trim().startsWith("{")) {
|
|
268
|
+
const map = JSON.parse(raw);
|
|
269
|
+
if (map[callType]) return map[callType];
|
|
270
|
+
}
|
|
271
|
+
if (raw) return raw;
|
|
272
|
+
throw new Error(`No agent for call_type "${callType}" (no agents.json/config default/env).`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Build a human-readable read-back (returns lines; does not print).
|
|
276
|
+
function buildReadback(job, composed, { testTo, agentId } = {}) {
|
|
277
|
+
const v = composed.vars;
|
|
278
|
+
const lines = [];
|
|
279
|
+
lines.push(`Call type: ${job.call_type}${composed.profile ? ` (profile: ${composed.profile.name})` : " (no profile — generic)"}`);
|
|
280
|
+
lines.push(`Calling: ${job.target.business_name} ${job.target.phone_number}`);
|
|
281
|
+
if (testTo) lines.push(` (TEST: actual dial routed to ${testTo})`);
|
|
282
|
+
if (agentId) lines.push(`Agent: ${agentId}`);
|
|
283
|
+
lines.push(`For: ${job.request.summary}`);
|
|
284
|
+
// Representative Style-B opening (warm hook -> clear AI disclosure). The exact
|
|
285
|
+
// wording is generated by the agent at call time; this mirrors the prompt so
|
|
286
|
+
// the review gate matches what's actually spoken. Phrasing is per-language data.
|
|
287
|
+
const openingPreview = interp(
|
|
288
|
+
(composed.phrases && composed.phrases.opening_preview) || "{opening_ask}",
|
|
289
|
+
{ opening_ask: v.opening_ask }
|
|
290
|
+
);
|
|
291
|
+
lines.push(`Opens with: "${openingPreview}"`);
|
|
292
|
+
if (job.request.party_size) lines.push(`Party: ${v.party_size} (max ${v.max_party_size})`);
|
|
293
|
+
lines.push(`Preferred: ${v.pref_date} at ${v.pref_time} (±${composed.flex} min)`);
|
|
294
|
+
lines.push(`Windows: ${v.acceptable_windows}`);
|
|
295
|
+
lines.push(`Details: ${composed.detailSentences.length ? composed.detailSentences.join(" ") : "(none)"}`);
|
|
296
|
+
lines.push(`Constraints: ${v.special_constraints}`);
|
|
297
|
+
lines.push(`Personal data sent (if asked): ${composed.piiKeys.length ? composed.piiKeys.join(", ") : "(none)"}`);
|
|
298
|
+
lines.push(`Outcome: pull on demand — ask for the result and it's fetched via get_call_outcome.`);
|
|
299
|
+
lines.push(`Retry: ${retry.describePolicy(composed.retryPolicy)}`);
|
|
300
|
+
if (v.notify_sms === "1") lines.push(`Notify: SMS to ${v.notify_to || "(your saved number)"} when done`);
|
|
301
|
+
if (composed.missingRecommended.length) {
|
|
302
|
+
lines.push("");
|
|
303
|
+
lines.push("⚠️ Profile recommends these details (missing — consider adding):");
|
|
304
|
+
for (const m of composed.missingRecommended) lines.push(` - ${m.key}: ${m.why}`);
|
|
305
|
+
}
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Place the outbound call via Retell. Returns the API response (has call_id).
|
|
310
|
+
async function dispatchCall({ key, from, agentId, toNumber, businessNumber, vars }) {
|
|
311
|
+
if (!key) throw new Error("Missing Retell API key.");
|
|
312
|
+
if (!from) throw new Error("Missing from-number.");
|
|
313
|
+
if (!agentId) throw new Error("Missing agent id.");
|
|
314
|
+
const resp = await fetch(`${RETELL_BASE}/v2/create-phone-call`, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
from_number: from,
|
|
319
|
+
to_number: toNumber || businessNumber,
|
|
320
|
+
override_agent_id: agentId,
|
|
321
|
+
retell_llm_dynamic_variables: vars,
|
|
322
|
+
}),
|
|
323
|
+
});
|
|
324
|
+
const text = await resp.text();
|
|
325
|
+
if (!resp.ok) throw new Error(`Retell create-phone-call ${resp.status}: ${text}`);
|
|
326
|
+
return JSON.parse(text);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Re-dial a finished call for a retry. Reuses the original call's from/to/agent
|
|
330
|
+
// and dynamic vars, bumping retry_attempt so the chain self-terminates. Returns
|
|
331
|
+
// the new Retell call object.
|
|
332
|
+
async function redialFromCall(call, { key = process.env.RETELL_API_KEY, attempt } = {}) {
|
|
333
|
+
const vars = retry.buildRetryVars(call.retell_llm_dynamic_variables || {}, attempt);
|
|
334
|
+
return dispatchCall({
|
|
335
|
+
key,
|
|
336
|
+
from: call.from_number,
|
|
337
|
+
agentId: call.agent_id || call.override_agent_id,
|
|
338
|
+
toNumber: call.to_number,
|
|
339
|
+
businessNumber: call.to_number,
|
|
340
|
+
vars,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Convenience: full pipeline from a raw job object to a placed (or dry-run) call.
|
|
345
|
+
// Returns { ok, errors?, readback, composed, call? }.
|
|
346
|
+
async function placeCall(rawJob, { lang = "en", go = false, testTo = null, agentOverride = null, key = process.env.RETELL_API_KEY, from = null } = {}) {
|
|
347
|
+
const config = loadConfig();
|
|
348
|
+
const job = applyConfigBackfill({ ...rawJob }, config);
|
|
349
|
+
const valid = validateJob(job);
|
|
350
|
+
if (!valid.ok) return { ok: false, errors: valid.errors };
|
|
351
|
+
const composed = composeCall(job, { lang });
|
|
352
|
+
// Resolve the agent up-front so the read-back can show it (don't dial yet).
|
|
353
|
+
let agentId = agentOverride;
|
|
354
|
+
if (!agentId) { try { agentId = resolveAgentId(job.call_type, { config, lang }); } catch { agentId = null; } }
|
|
355
|
+
const readback = buildReadback(job, composed, { testTo, agentId });
|
|
356
|
+
if (!go) return { ok: true, dryRun: true, readback, composed, agentId };
|
|
357
|
+
if (!agentId) throw new Error(`No agent resolved for call_type "${job.call_type}" (lang ${lang}). Run setup/configure.`);
|
|
358
|
+
const fromNumber = from || process.env.RETELL_FROM_NUMBER || config.retell?.from_number;
|
|
359
|
+
const call = await dispatchCall({
|
|
360
|
+
key, from: fromNumber, agentId, toNumber: testTo,
|
|
361
|
+
businessNumber: job.target.phone_number, vars: composed.vars,
|
|
362
|
+
});
|
|
363
|
+
return { ok: true, dryRun: false, readback, composed, call };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
module.exports = {
|
|
367
|
+
SCHEMA_PATH, PROFILES_PATH, CONFIG_PATH, AGENTS_PATH, RETELL_BASE,
|
|
368
|
+
loadConfig, applyConfigBackfill, validateJob, loadProfileFor,
|
|
369
|
+
prettyDate, pretty12h, deriveWindows, composeCall, resolveAgentId,
|
|
370
|
+
buildReadback, dispatchCall, redialFromCall, placeCall,
|
|
371
|
+
};
|
package/dispatch.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// callwright — CLI dispatcher (thin wrapper over dispatch-core.js).
|
|
2
|
+
//
|
|
3
|
+
// node dispatch.js <job.json> # dry-run: validate + read-back
|
|
4
|
+
// node dispatch.js <job.json> --go # actually place the call
|
|
5
|
+
// node dispatch.js <job.json> --to +1... # route the dial to a test number
|
|
6
|
+
// node dispatch.js <job.json> --lang ja # Japanese composed strings
|
|
7
|
+
// node dispatch.js <job.json> --agent agent_xxx # force a specific agent
|
|
8
|
+
//
|
|
9
|
+
// Env: RETELL_API_KEY (required for --go). from-number + agent resolve from
|
|
10
|
+
// config.json / agents.json (see `node init.js`).
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const core = require("./dispatch-core");
|
|
14
|
+
|
|
15
|
+
const jobFile = process.argv[2];
|
|
16
|
+
const GO = process.argv.includes("--go");
|
|
17
|
+
const toIdx = process.argv.indexOf("--to");
|
|
18
|
+
const TEST_TO = toIdx > -1 ? process.argv[toIdx + 1] : null;
|
|
19
|
+
const langIdx = process.argv.indexOf("--lang");
|
|
20
|
+
const LANG = (langIdx > -1 ? process.argv[langIdx + 1] : (process.env.CALL_LANG || "en")).toLowerCase();
|
|
21
|
+
const agentIdx = process.argv.indexOf("--agent");
|
|
22
|
+
const AGENT_OVERRIDE = agentIdx > -1 ? process.argv[agentIdx + 1] : null;
|
|
23
|
+
|
|
24
|
+
if (!jobFile) {
|
|
25
|
+
console.error("Usage: node dispatch.js <job.json> [--go] [--to +1...] [--lang ja] [--agent agent_xxx]");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rawJob = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
30
|
+
|
|
31
|
+
(async () => {
|
|
32
|
+
const result = await core.placeCall(rawJob, {
|
|
33
|
+
lang: LANG, go: GO, testTo: TEST_TO, agentOverride: AGENT_OVERRIDE,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!result.ok) {
|
|
37
|
+
console.error("❌ Job does NOT conform to place_call.schema.json:\n");
|
|
38
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log("✅ Job conforms to place_call.schema.json");
|
|
43
|
+
console.log("\n--- READ-BACK (confirm before dialing) ---");
|
|
44
|
+
console.log(result.readback.join("\n"));
|
|
45
|
+
|
|
46
|
+
if (result.dryRun) {
|
|
47
|
+
console.log("\n[dry run] Not dialing. Re-run with --go to place the call.");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log("\n📞 Call dispatched.");
|
|
52
|
+
console.log("call_id:", result.call.call_id);
|
|
53
|
+
console.log("dashboard:", `https://dashboard.retellai.com/call-history?history=${result.call.call_id}`);
|
|
54
|
+
console.log("Read outcome with: node get-call.js " + result.call.call_id);
|
|
55
|
+
})().catch((e) => { console.error("\n" + (e.message || e)); process.exit(1); });
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Spec: `add_language` — let an LLM add a call language at runtime
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Let the host LLM stand up a **new call language entirely in chat** — no code change, no
|
|
6
|
+
redeploy. After it runs, `place_call` with `lang:"<new>"` conducts a fully native call.
|
|
7
|
+
|
|
8
|
+
## Why it's blocked today
|
|
9
|
+
|
|
10
|
+
Adding a language currently has 5 parts; **2 are hardcoded in source** (require a deploy):
|
|
11
|
+
|
|
12
|
+
| Part | State today |
|
|
13
|
+
|---|---|
|
|
14
|
+
| Translated prompt (`generic-prompt.<lang>.md`) | file baked into the image |
|
|
15
|
+
| Retell agent (language code + a voice that speaks it) | created via `ensureGenericAgent` / `setup-agent-from.js` |
|
|
16
|
+
| Register in `config.agents.by_lang[<lang>]` | runtime-writable (volume) |
|
|
17
|
+
| **MCP `lang` enum** = `["en","ja"]` | ❌ hardcoded — rejects a new code |
|
|
18
|
+
| **`composeCall` `isJa` branches** (voicemail, booking line, "none" text, principal_ref, must_confirm default, read-back preview) | ❌ hardcoded — a 3rd language falls through to English |
|
|
19
|
+
|
|
20
|
+
## The core principle (generalizes the `opening_ask` refactor)
|
|
21
|
+
|
|
22
|
+
> **Code injects VALUES (language-agnostic). Per-language PROMPT FILES own all PHRASING.**
|
|
23
|
+
|
|
24
|
+
Adding a language needs code changes today *only because language-specific phrasing leaked into
|
|
25
|
+
code* (the `isJa` branches). We already proved the fix once: `opening_ask` used to be
|
|
26
|
+
code-composed with a brittle regex; we made the **LLM author the spoken line** and the breakage
|
|
27
|
+
vanished. The remaining `isJa` strings are the same debt. Eliminate them and the code becomes
|
|
28
|
+
language-agnostic — then a new language is pure data (prompt + agent + registration).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Prerequisite refactor (must land before the tool)
|
|
33
|
+
|
|
34
|
+
### 1. Open the `lang` enum ✅ DONE (2026-06-27, Fly v19)
|
|
35
|
+
`lang: z.enum(["en","ja"])` → a validated **free-form BCP-47 string** (validate *format*, not
|
|
36
|
+
membership). Unknown-but-registered languages route via `config.agents.by_lang`.
|
|
37
|
+
|
|
38
|
+
Implemented in `server.js`: `z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/)`. The
|
|
39
|
+
`place_call` handler also computes `langSupported` (en OR a registered `by_lang` agent) and
|
|
40
|
+
(a) surfaces a `language_warning` on dry-run for an unregistered language, and (b) **refuses to
|
|
41
|
+
dial** (`confirm:true`) an unregistered language so a non-English call can't silently use the EN
|
|
42
|
+
agent. Verified live: en→EN agent, ja→JA agent, fr→EN agent + warning (dry) / blocked (confirm),
|
|
43
|
+
bad format rejected by schema. NOTE: this changed the tool schema → MCP clients must reconnect.
|
|
44
|
+
|
|
45
|
+
### 2. Eliminate every `isJa` branch in `dispatch-core` ✅ DONE (2026-06-27)
|
|
46
|
+
Phrasing moved out of code into a per-language **data file `lang-phrases.json`** (keyed by BCP-47
|
|
47
|
+
primary subtag; unknown languages fall back to `en`). `composeCall` now injects VALUES and
|
|
48
|
+
interpolates them into the language's phrase templates via a tiny `interp("{key}", vars)` helper —
|
|
49
|
+
**no language conditionals remain**.
|
|
50
|
+
|
|
51
|
+
> **Refinement vs the original plan:** these strings became a dedicated `lang-phrases.json` rather
|
|
52
|
+
> than living inside the agent prompt `.md` files. Reason: the voicemail (`voicemail_option`
|
|
53
|
+
> `static_text`) and the read-back preview are composed *outside* the agent prompt, so a data file
|
|
54
|
+
> keyed by language is the correct realization of "phrasing is per-language data, not code." The
|
|
55
|
+
> agent prompt `.md` still owns what the agent *speaks live*; `lang-phrases.json` owns code-composed
|
|
56
|
+
> helper strings. **The `{{variable}}` contract was left identical**, so en/ja behavior and the
|
|
57
|
+
> existing prompts were untouched — verified byte-identical across a 256-case old-vs-new matrix.
|
|
58
|
+
|
|
59
|
+
| Code-composed string | New home (`lang-phrases.json` key) |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `voicemail_message` | `voicemail_callback` / `voicemail_no_callback` (inject `{objective}`/`{callback}`/`{behalf}`) |
|
|
62
|
+
| `booking_name_line` | `booking_name_line_named` / `booking_name_line_anon` (inject `{name}`) |
|
|
63
|
+
| `principal_ref` (anon) | `principal_ref_anon` |
|
|
64
|
+
| `known_facts` empty | `known_facts_none` |
|
|
65
|
+
| `must_confirm` default | `confirm.{base,party_size,join}` (party size added only if the language opts in) |
|
|
66
|
+
| `noneText` | `none_text` |
|
|
67
|
+
| `opening_ask` fallback | `opening_ask_fallback` |
|
|
68
|
+
| `openingPreview` (read-back) | `opening_preview` |
|
|
69
|
+
|
|
70
|
+
After this, `composeCall` emits values; `lang-phrases.json` renders phrasing. A new language is
|
|
71
|
+
now: add a `lang-phrases.<lang>` block + a `generic-prompt.<lang>.md` + an agent/voice.
|
|
72
|
+
|
|
73
|
+
### 3. Make the base prompt language-parameterized ✅ DONE (2026-06-27)
|
|
74
|
+
The English base prompt (`generic-prompt.md`) now carries the same language-handling rule the JA
|
|
75
|
+
prompt already proved (Identity section): conduct the call in the prompt's language by default;
|
|
76
|
+
translate any injected value written in another language rather than reading it verbatim (proper
|
|
77
|
+
nouns excepted); **but mirror the callee** — switch to their language if they use it, never refuse
|
|
78
|
+
it. Pushed live to the EN agent via `update-prompt.js`.
|
|
79
|
+
|
|
80
|
+
> **Implementation choice:** the rule is **baked per-language** (each translated prompt states it
|
|
81
|
+
> in its own language), not injected via a `{{call_language}}` variable. This matches the proven
|
|
82
|
+
> JA precedent and keeps the `{{variable}}` contract stable. Because `add_language` translates
|
|
83
|
+
> `generic-prompt.md` into the new language, every new language inherits the rule automatically —
|
|
84
|
+
> `generic-prompt.md` is now the canonical translation template.
|
|
85
|
+
|
|
86
|
+
### 4. Prompts become volume-resident for runtime-added languages ✅ DONE (2026-06-27)
|
|
87
|
+
Both the prompt files **and `lang-phrases.json`** are now resolved **volume-first**:
|
|
88
|
+
- `paths.resolveAsset(file)` returns a volume copy if one exists (`DATA_DIR/<file>`), else the
|
|
89
|
+
image-baked file. `setup-core.loadPromptText` uses it, so `generic-prompt.<lang>.md` for a
|
|
90
|
+
runtime-added language is read from the volume.
|
|
91
|
+
- `dispatch-core.loadPhrases` loads the image seed (en/ja) and **merges any volume
|
|
92
|
+
`lang-phrases.json` on top, per language block** — so a runtime-added `fr` block persists on the
|
|
93
|
+
volume while en/ja keep flowing from the image. Not cached (the file is tiny and `add_language`
|
|
94
|
+
may write it at runtime).
|
|
95
|
+
|
|
96
|
+
On prod the volume currently has none of these files, so everything falls back to the image exactly
|
|
97
|
+
as before (verified: en/ja byte-identical). The volume-first path only activates once `add_language`
|
|
98
|
+
writes `generic-prompt.<lang>.md` + an `fr`-style block to the volume. Guarded so local dev
|
|
99
|
+
(`DATA_DIR="."`) skips the volume lookup entirely.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## The `add_language` MCP tool (enabled by the refactor)
|
|
105
|
+
|
|
106
|
+
**Inputs**
|
|
107
|
+
- `lang` (BCP-47, e.g. `fr`, `es`, `de`, `zh-CN`) — required
|
|
108
|
+
- `display_name` (e.g. "French") — required
|
|
109
|
+
- `prompt_text` — the full translated agent prompt. The host LLM produces this by translating
|
|
110
|
+
the base prompt (translation is the LLM's strength). Optional: if omitted, the tool returns the
|
|
111
|
+
base prompt for the LLM to translate and resubmit.
|
|
112
|
+
- `voice_id` — a Retell voice that speaks the language. **Optional, with a default** (see below);
|
|
113
|
+
if provided it's validated against `list_voices(lang)`.
|
|
114
|
+
- `stt_language` — the agent STT locale (default = `lang`); single-language (we learned
|
|
115
|
+
multilingual STT *hurts* — JA/EN garbling)
|
|
116
|
+
|
|
117
|
+
**Steps**
|
|
118
|
+
1. Validate `lang` format; refuse if already registered (offer `update_language`).
|
|
119
|
+
2. Persist `prompt_text` to the volume as `generic-prompt.<lang>.md`.
|
|
120
|
+
3. Create the Retell LLM + agent: language code, `voice_id`, `stt_mode:"accurate"`, the standard
|
|
121
|
+
system tools (`press_digit`, `end_call`), voicemail option `{{voicemail_message}}`, post-call
|
|
122
|
+
analysis fields, wait-for-greeting + hold settings — i.e. `ensureGenericAgent`'s baked
|
|
123
|
+
behavior, parameterized by language/voice.
|
|
124
|
+
4. Register `config.agents.by_lang[lang] = agent_id`.
|
|
125
|
+
5. Return `{ agent_id, lang, voice_id }`.
|
|
126
|
+
|
|
127
|
+
**Companion tools**
|
|
128
|
+
- `list_voices(lang)` — filter Retell's catalog so the LLM can *pick* a valid voice (it can't
|
|
129
|
+
invent one).
|
|
130
|
+
- `update_language` / `remove_language`.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Companion: verify_language ✅ DONE (2026-06-28)
|
|
135
|
+
A dedicated quality-gate tool (closes the add_language loop). DRY-RUN returns a **review card**:
|
|
136
|
+
the exact phrase-composed spoken opening (greeting + ask + AI disclosure), the voicemail message,
|
|
137
|
+
name-handling line, and a native-review checklist — all routed through the language's own agent.
|
|
138
|
+
With `to:<phone>` + `confirm:true` it places a live self-call (defaulting `to` to the configured
|
|
139
|
+
`callback_number`) so the user judges accent/etiquette/disclosure by ear, then reads the transcript
|
|
140
|
+
via `get_call_outcome`. Pure helpers `buildVerificationJob` + `verificationCard` in `lang-core.js`,
|
|
141
|
+
covered by tests; live-verified on prod (dry-run review card for ja).
|
|
142
|
+
|
|
143
|
+
## Guardrails (this is where the care goes)
|
|
144
|
+
|
|
145
|
+
1. **Voice: default but overridable.** `voice_id` is optional. Resolution order:
|
|
146
|
+
1. explicit `voice_id` arg →
|
|
147
|
+
2. `config.defaults.voice_id` (the house voice) **if it appears in `list_voices(lang)`** →
|
|
148
|
+
3. `RETELL_VOICE_ID` env / `11labs-Brian` **if it speaks `lang`** →
|
|
149
|
+
4. else a sensible catalog pick for `lang`.
|
|
150
|
+
|
|
151
|
+
This mirrors today's live behavior — `voice_id || RETELL_VOICE_ID || "11labs-Brian"` in
|
|
152
|
+
`ensureGenericAgent`, where the multilingual `11labs-Brian` already drives **both** the EN and JA
|
|
153
|
+
agents. Defaulting to the house voice gives **voice consistency across languages for free**;
|
|
154
|
+
the user overrides anytime (pass `voice_id`, or later `update_language({lang, voice_id})`).
|
|
155
|
+
**Whatever is resolved — default or explicit — must validate against `list_voices(lang)`;**
|
|
156
|
+
reject a mismatch (the LLM can't invent a voice). The tool echoes the chosen voice + how to
|
|
157
|
+
change it. The verification call (guardrail 2) is the backstop that catches a mediocre default
|
|
158
|
+
by ear before production.
|
|
159
|
+
2. **Disclosure + etiquette must survive translation (legal + quality).** The AI-disclosure line
|
|
160
|
+
is legally required in some jurisdictions; politeness conventions (e.g. Japanese keigo,
|
|
161
|
+
greeting norms) matter. **Do not let an unreviewed machine-translated disclosure go live
|
|
162
|
+
silently.** Require, before first production use:
|
|
163
|
+
- a **verification test call** (`place_call` with `--to` your own number) in the new language,
|
|
164
|
+
and/or
|
|
165
|
+
- a read-back of the composed opening + disclosure for human/native approval.
|
|
166
|
+
3. **STT language.** Set the agent's STT to the single target locale + `accurate` (we learned
|
|
167
|
+
bilingual STT degrades quality).
|
|
168
|
+
4. **Promotion to the repo.** A runtime-added language lives on the volume. To ship it in the OSS
|
|
169
|
+
repo for other adopters, promote the volume prompt back into the image as
|
|
170
|
+
`generic-prompt.<lang>.md` (same seed-vs-volume flow as profiles).
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## What this does NOT change
|
|
175
|
+
|
|
176
|
+
- The job schema, scenario profiles, grounding/PII model — all language-agnostic already.
|
|
177
|
+
- Generic-agent behavior — unmatched/unknown still works; a language just adds native phrasing.
|
|
178
|
+
- The provider boundary — `add_language` is implemented per provider (Retell today); the
|
|
179
|
+
ElevenLabs equivalent would use `language_detection` + its voice catalog.
|
|
180
|
+
|
|
181
|
+
## Effort
|
|
182
|
+
|
|
183
|
+
Bounded. The **refactor** (open enum + remove `isJa` + prompt-owns-phrasing) is the real work —
|
|
184
|
+
and it's debt we'd want gone regardless. The **tool** is then thin: translate (LLM) → persist
|
|
185
|
+
prompt → create agent → register. Voice selection and a verification call are the only
|
|
186
|
+
human/catalog touchpoints.
|
|
187
|
+
|
|
188
|
+
## Status
|
|
189
|
+
|
|
190
|
+
**IMPLEMENTED & live (2026-06-27).** All 4 prerequisite refactor steps done, then the tooling
|
|
191
|
+
shipped in `lang-core.js` + 5 MCP tools (`list_voices`, `list_languages`, `add_language`,
|
|
192
|
+
`update_language`, `remove_language`). Covered by 20 `node:test` cases (`npm test`) and a full prod
|
|
193
|
+
integration test (added a real French agent → native French dry-run with no warning → deleted the
|
|
194
|
+
Retell agent + unregistered, all over the hosted MCP).
|
|
195
|
+
|
|
196
|
+
Notes on what shipped vs this spec:
|
|
197
|
+
- Phrasing lives in `lang-phrases.json` (data file), not inside the prompt `.md` (step-2 refinement).
|
|
198
|
+
- `add_language` is a **two-phase handshake**: call with `lang`+`display_name` to get `base_prompt`
|
|
199
|
+
+ `base_phrases` to translate, then resubmit with `prompt_text`+`phrases`.
|
|
200
|
+
- `voice_id` is optional (defaults to the English house voice with a strong recommendation +
|
|
201
|
+
accent-matched suggestions to override); it's validated for EXISTENCE only — Retell voices expose
|
|
202
|
+
`accent`, not a language field, so the verification call is the real language-quality gate.
|
|
203
|
+
- Per-language metadata persisted in `config.languages[primary]` ({agent_id, llm_id, voice_id,
|
|
204
|
+
language, display_name}); routing still uses `config.agents.by_lang[primary]`.
|
|
205
|
+
- Idempotent: reuses an existing `generic_<primary>` agent; cleans up an orphan LLM if agent
|
|
206
|
+
creation fails; English is protected; writes are atomic + serialized; region variants collapse to
|
|
207
|
+
one agent per primary subtag.
|