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/init.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// callwright — first-run setup wizard (terminal on-ramp).
|
|
3
|
+
//
|
|
4
|
+
// node init.js # interactive (or scripted via piped stdin)
|
|
5
|
+
// node init.js status # print what's configured / missing
|
|
6
|
+
//
|
|
7
|
+
// Thin CLI over setup-core.js — the SAME functions the MCP server's
|
|
8
|
+
// configure / get_setup_status / run_provisioning tools call. Keeps a single
|
|
9
|
+
// source of truth for setup logic across the local and hosted paths.
|
|
10
|
+
|
|
11
|
+
const readline = require("node:readline/promises");
|
|
12
|
+
const { stdin, stdout } = require("node:process");
|
|
13
|
+
const core = require("./setup-core");
|
|
14
|
+
|
|
15
|
+
// Support both interactive (TTY) and scripted (piped stdin) use.
|
|
16
|
+
const INTERACTIVE = Boolean(stdin.isTTY);
|
|
17
|
+
let rl = null;
|
|
18
|
+
let queuedLines = [];
|
|
19
|
+
function readAllStdin() {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
let buf = "";
|
|
22
|
+
stdin.setEncoding("utf8");
|
|
23
|
+
stdin.on("data", (d) => (buf += d));
|
|
24
|
+
stdin.on("end", () => resolve(buf));
|
|
25
|
+
stdin.on("error", () => resolve(buf));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function ask(q, def) {
|
|
30
|
+
const suffix = def ? ` [${def}]` : "";
|
|
31
|
+
let ans;
|
|
32
|
+
if (INTERACTIVE) {
|
|
33
|
+
ans = (await rl.question(`${q}${suffix}: `)).trim();
|
|
34
|
+
} else {
|
|
35
|
+
ans = (queuedLines.length ? queuedLines.shift() : "").trim();
|
|
36
|
+
console.log(`${q}${suffix}: ${ans}`);
|
|
37
|
+
}
|
|
38
|
+
return ans || def || "";
|
|
39
|
+
}
|
|
40
|
+
async function askYesNo(q, defYes) {
|
|
41
|
+
const ans = (await ask(`${q} (${defYes ? "Y/n" : "y/N"})`)).toLowerCase();
|
|
42
|
+
if (!ans) return defYes;
|
|
43
|
+
return ans.startsWith("y");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `node init.js status` — non-interactive status report.
|
|
47
|
+
function printStatus() {
|
|
48
|
+
const s = core.setupStatus();
|
|
49
|
+
console.log("\n=== callwright setup status ===");
|
|
50
|
+
console.log("Ready:", s.ready ? "yes" : "no");
|
|
51
|
+
console.log(" from_number: ", s.have.from_number || "(missing)");
|
|
52
|
+
console.log(" default_agent: ", s.have.default_agent || "(missing)");
|
|
53
|
+
console.log(" name: ", s.have.name || "(missing)");
|
|
54
|
+
console.log(" callback: ", s.have.callback_number || "(missing)");
|
|
55
|
+
console.log(" fact keys: ", s.have.fact_keys.length ? s.have.fact_keys.join(", ") : "(none)");
|
|
56
|
+
if (!s.ready) console.log("Missing:", [...s.infra_missing, ...s.basics_missing].join(", "));
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function wizard() {
|
|
61
|
+
console.log("\n=== callwright setup ===\n");
|
|
62
|
+
if (INTERACTIVE) {
|
|
63
|
+
rl = readline.createInterface({ input: stdin, output: stdout });
|
|
64
|
+
} else {
|
|
65
|
+
queuedLines = (await readAllStdin()).split(/\r?\n/);
|
|
66
|
+
}
|
|
67
|
+
const config = core.loadConfig();
|
|
68
|
+
|
|
69
|
+
// --- 1. API key ---
|
|
70
|
+
let key = process.env.RETELL_API_KEY;
|
|
71
|
+
if (key) {
|
|
72
|
+
console.log("Using RETELL_API_KEY from environment.");
|
|
73
|
+
} else {
|
|
74
|
+
key = await ask("Retell API key (key_...)");
|
|
75
|
+
if (!key) { console.error("An API key is required: https://dashboard.retellai.com/apiKey"); process.exit(1); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let agents;
|
|
79
|
+
try {
|
|
80
|
+
agents = await core.verifyKey(key);
|
|
81
|
+
console.log(`Key verified - ${agents.length} agent(s) in this workspace.`);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.error("Could not verify API key:\n" + e.message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- 2. Phone number ---
|
|
88
|
+
const res = await core.resolveFromNumber(key, config.retell.from_number);
|
|
89
|
+
let fromNumber = res.from_number;
|
|
90
|
+
if (!fromNumber && res.reason === "no_numbers") {
|
|
91
|
+
console.log("\nNo phone numbers in this Retell workspace.");
|
|
92
|
+
console.log(" Buy one in the dashboard (Phone Numbers -> Buy), then re-run init.");
|
|
93
|
+
fromNumber = await ask("Or paste a number to use now (E.164, +1...)", config.retell.from_number);
|
|
94
|
+
} else if (!fromNumber && res.reason === "multiple") {
|
|
95
|
+
console.log("\nYour Retell numbers:");
|
|
96
|
+
res.numbers.forEach((n, i) => console.log(` ${i + 1}) ${n.phone_number}${n.nickname ? " (" + n.nickname + ")" : ""}`));
|
|
97
|
+
const pick = await ask("Pick a number to call FROM", "1");
|
|
98
|
+
fromNumber = res.numbers[(parseInt(pick, 10) || 1) - 1].phone_number;
|
|
99
|
+
} else {
|
|
100
|
+
console.log(`\nFrom number: ${fromNumber}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- 3. Generic agent (reuse or create) ---
|
|
104
|
+
let agentRes = await core.ensureGenericAgent(key, { agents, defaultId: config.agents.default });
|
|
105
|
+
if (agentRes.reused) {
|
|
106
|
+
console.log(`Reusing generic agent: ${agentRes.agent_id}${agentRes.matchedByName ? " (matched by name)" : ""}`);
|
|
107
|
+
} else if (agentRes.created) {
|
|
108
|
+
console.log(`Created generic agent: ${agentRes.agent_id}`);
|
|
109
|
+
}
|
|
110
|
+
core.applyInfra(config, { from_number: fromNumber, agent_id: agentRes.agent_id, by_lang: core.detectLanguageAgents(agents) });
|
|
111
|
+
|
|
112
|
+
// --- 4. Principal identity + standing facts ---
|
|
113
|
+
console.log("\n--- About you (the principal) ---");
|
|
114
|
+
const name = await ask("Your name (used when booking under a name)", config.principal.name);
|
|
115
|
+
const callback = await ask("Callback number (E.164, shared only if a business asks)", config.principal.callback_number);
|
|
116
|
+
core.setPrincipal(config, { name, callback_number: callback });
|
|
117
|
+
|
|
118
|
+
console.log("\nStanding facts: durable personal data the agent shares ONLY IF asked");
|
|
119
|
+
console.log("(e.g. service_address, member_id, insurance_provider). A STORE - each");
|
|
120
|
+
console.log("call sends only the minimal relevant subset.");
|
|
121
|
+
config.principal.facts = config.principal.facts || {};
|
|
122
|
+
if (Object.keys(config.principal.facts).length) {
|
|
123
|
+
console.log("Current facts:", Object.keys(config.principal.facts).join(", "));
|
|
124
|
+
}
|
|
125
|
+
if (await askYesNo("Add/edit standing facts now?", false)) {
|
|
126
|
+
const facts = {};
|
|
127
|
+
for (;;) {
|
|
128
|
+
const k = await ask(" fact key (snake_case, blank to finish)");
|
|
129
|
+
if (!k) break;
|
|
130
|
+
const v = await ask(` value for ${k}`, config.principal.facts[k]);
|
|
131
|
+
if (v) facts[k] = v;
|
|
132
|
+
}
|
|
133
|
+
core.setPrincipal(config, { facts });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- 5. Report-to ---
|
|
137
|
+
console.log("\n--- Where to send call outcomes (optional) ---");
|
|
138
|
+
const existingRt = config.report_to || {};
|
|
139
|
+
if (await askYesNo("Set a default report-to channel?", true)) {
|
|
140
|
+
const ch = (await ask("Channel (email/sms)", existingRt.channel || "email")).toLowerCase();
|
|
141
|
+
const addr = await ask(ch === "sms" ? "SMS number (E.164)" : "Email address", existingRt.address);
|
|
142
|
+
if (ch && addr) config.report_to = { channel: ch, address: addr };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- 6. Write config ---
|
|
146
|
+
core.saveConfig(config);
|
|
147
|
+
console.log(`\nWrote ${core.CONFIG_PATH}.`);
|
|
148
|
+
printStatus();
|
|
149
|
+
console.log("Place a call with:");
|
|
150
|
+
console.log(" node dispatch.js <job.json> # dry-run read-back");
|
|
151
|
+
console.log(" node dispatch.js <job.json> --go # actually dial");
|
|
152
|
+
console.log("\n(Only RETELL_API_KEY needs to be in your environment now.)\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sub = process.argv[2];
|
|
156
|
+
const run = sub === "status" ? async () => printStatus() : wizard;
|
|
157
|
+
run()
|
|
158
|
+
.catch((e) => { console.error("\nSetup failed:\n" + (e.message || e)); process.exitCode = 1; })
|
|
159
|
+
.finally(() => { if (rl) rl.close(); });
|
package/lang-core.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// callwright — language management core (add/update/remove a call language at
|
|
2
|
+
// runtime). Pure-ish: Retell API calls are injectable (deps.api) for testing;
|
|
3
|
+
// file/config IO uses the real volume (paths). composeCall stays language-
|
|
4
|
+
// agnostic — adding a language is DATA: a translated prompt + phrase block +
|
|
5
|
+
// a Retell agent registered in config.agents.by_lang.
|
|
6
|
+
//
|
|
7
|
+
// English is the built-in base (managed in the repo); add_language is for
|
|
8
|
+
// ADDITIONAL languages only.
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const paths = require("./paths");
|
|
12
|
+
const setup = require("./setup-core");
|
|
13
|
+
|
|
14
|
+
const PHRASES_FILE = "lang-phrases.json";
|
|
15
|
+
const LANG_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
16
|
+
const MAX_PROMPT = 50000;
|
|
17
|
+
|
|
18
|
+
// Common BCP-47 primary subtag -> Retell agent language code.
|
|
19
|
+
const RETELL_LANG_MAP = {
|
|
20
|
+
en: "en-US", ja: "ja-JP", fr: "fr-FR", es: "es-ES", de: "de-DE", zh: "zh-CN",
|
|
21
|
+
ko: "ko-KR", pt: "pt-PT", it: "it-IT", nl: "nl-NL", hi: "hi-IN", ru: "ru-RU",
|
|
22
|
+
pl: "pl-PL", tr: "tr-TR", ar: "ar-SA", id: "id-ID", vi: "vi-VN", th: "th-TH",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Primary subtag -> the `accent` label Retell uses, for voice suggestions.
|
|
26
|
+
const ACCENT_HINT = {
|
|
27
|
+
en: "American", ja: "Japanese", fr: "French", es: "Spanish", de: "German",
|
|
28
|
+
zh: "Chinese", ko: "Korean", pt: "Portuguese", it: "Italian", nl: "Dutch",
|
|
29
|
+
hi: "Indian", ru: "Russian", ar: "Arabic", tr: "Turkish",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Placeholder groups each phrase template MUST preserve (>=1 per group).
|
|
33
|
+
const PLACEHOLDER_REQS = {
|
|
34
|
+
opening_ask_fallback: [["objective", "objective_lc"]],
|
|
35
|
+
voicemail_callback: [["objective", "objective_lc"], ["callback"]],
|
|
36
|
+
voicemail_no_callback: [["objective", "objective_lc"]],
|
|
37
|
+
booking_name_line_named: [["name"]],
|
|
38
|
+
opening_preview: [["opening_ask"]],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const REQUIRED_PROMPT_VARS = ["objective", "opening_ask", "voicemail_message", "acceptable_windows"];
|
|
42
|
+
|
|
43
|
+
// ---- pure helpers ----
|
|
44
|
+
function normalizeLang(lang) {
|
|
45
|
+
const l = String(lang || "").toLowerCase().trim();
|
|
46
|
+
const valid = LANG_RE.test(l);
|
|
47
|
+
return { lang: l, primary: l.split("-")[0], valid };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toRetellLanguage(primary, override) {
|
|
51
|
+
if (override) return { language: override, guessed: false };
|
|
52
|
+
if (RETELL_LANG_MAP[primary]) return { language: RETELL_LANG_MAP[primary], guessed: false };
|
|
53
|
+
return { language: `${primary}-${primary.toUpperCase()}`, guessed: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validatePromptText(text) {
|
|
57
|
+
const errors = [];
|
|
58
|
+
const s = String(text || "");
|
|
59
|
+
if (!s.trim()) errors.push("prompt_text is empty.");
|
|
60
|
+
if (s.length > MAX_PROMPT) errors.push(`prompt_text too long (${s.length} > ${MAX_PROMPT}).`);
|
|
61
|
+
for (const v of REQUIRED_PROMPT_VARS) {
|
|
62
|
+
if (!s.includes(`{{${v}}}`)) errors.push(`prompt_text must keep the {{${v}}} variable.`);
|
|
63
|
+
}
|
|
64
|
+
return { ok: errors.length === 0, errors };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validatePhrases(phrases, seed) {
|
|
68
|
+
const errors = [];
|
|
69
|
+
if (!phrases || typeof phrases !== "object") return { ok: false, errors: ["phrases must be an object."] };
|
|
70
|
+
for (const key of Object.keys(seed)) {
|
|
71
|
+
if (!(key in phrases)) { errors.push(`Missing phrase key: ${key}`); continue; }
|
|
72
|
+
const sv = seed[key];
|
|
73
|
+
const pv = phrases[key];
|
|
74
|
+
if (key === "confirm") {
|
|
75
|
+
if (!pv || typeof pv !== "object" || !Array.isArray(pv.base) || pv.base.length === 0
|
|
76
|
+
|| typeof pv.join !== "string" || !(pv.party_size === null || typeof pv.party_size === "string")) {
|
|
77
|
+
errors.push("confirm must be { base: [strings], party_size: string|null, join: string }.");
|
|
78
|
+
}
|
|
79
|
+
} else if (typeof sv === "string") {
|
|
80
|
+
if (typeof pv !== "string") errors.push(`${key} must be a string.`);
|
|
81
|
+
}
|
|
82
|
+
const reqs = PLACEHOLDER_REQS[key];
|
|
83
|
+
if (reqs && typeof pv === "string") {
|
|
84
|
+
for (const group of reqs) {
|
|
85
|
+
if (!group.some((ph) => pv.includes(`{${ph}}`))) {
|
|
86
|
+
errors.push(`${key} must keep the placeholder ${group.map((g) => `{${g}}`).join(" or ")}.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { ok: errors.length === 0, errors };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function suggestVoices(voices, primary, limit = 6) {
|
|
95
|
+
const want = (ACCENT_HINT[primary] || "").toLowerCase();
|
|
96
|
+
const list = (voices || []).filter((v) => want && String(v.accent || "").toLowerCase().includes(want));
|
|
97
|
+
return list.slice(0, limit).map((v) => ({
|
|
98
|
+
voice_id: v.voice_id, voice_name: v.voice_name, provider: v.provider, accent: v.accent, gender: v.gender,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveVoiceId(voices, voice_id, primary) {
|
|
103
|
+
if (voice_id) {
|
|
104
|
+
const found = (voices || []).some((v) => v.voice_id === voice_id);
|
|
105
|
+
if (!found) {
|
|
106
|
+
return { error: `Voice "${voice_id}" is not in the Retell catalog. Use list_voices to pick a real voice_id.`, suggestions: suggestVoices(voices, primary) };
|
|
107
|
+
}
|
|
108
|
+
return { voice_id };
|
|
109
|
+
}
|
|
110
|
+
return { voice_id: "11labs-Brian", default: true, suggestions: suggestVoices(voices, primary) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- io helpers ----
|
|
114
|
+
function atomicWrite(file, content) {
|
|
115
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
116
|
+
fs.writeFileSync(tmp, content);
|
|
117
|
+
try { fs.renameSync(tmp, file); }
|
|
118
|
+
catch { fs.writeFileSync(file, content); try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } }
|
|
119
|
+
}
|
|
120
|
+
function readVolumePhrases() {
|
|
121
|
+
try { return JSON.parse(fs.readFileSync(paths.volumePath(PHRASES_FILE), "utf8")); } catch { return {}; }
|
|
122
|
+
}
|
|
123
|
+
function writePhraseBlock(primary, block) {
|
|
124
|
+
const cur = readVolumePhrases();
|
|
125
|
+
cur[primary] = block;
|
|
126
|
+
atomicWrite(paths.volumePath(PHRASES_FILE), JSON.stringify(cur, null, 2));
|
|
127
|
+
}
|
|
128
|
+
function removePhraseBlock(primary) {
|
|
129
|
+
const cur = readVolumePhrases();
|
|
130
|
+
delete cur[primary];
|
|
131
|
+
atomicWrite(paths.volumePath(PHRASES_FILE), JSON.stringify(cur, null, 2));
|
|
132
|
+
}
|
|
133
|
+
function enSeed() {
|
|
134
|
+
try { return JSON.parse(fs.readFileSync(PHRASES_FILE, "utf8")).en || {}; } catch { return {}; }
|
|
135
|
+
}
|
|
136
|
+
const defaultApi = (key) => (m, p, b) => setup.apiCall(key, m, p, b);
|
|
137
|
+
|
|
138
|
+
// Serialize add/update/remove so concurrent volume writes can't interleave.
|
|
139
|
+
let _chain = Promise.resolve();
|
|
140
|
+
function withLock(fn) {
|
|
141
|
+
const run = _chain.then(fn, fn);
|
|
142
|
+
_chain = run.then(() => {}, () => {});
|
|
143
|
+
return run;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- voices ----
|
|
147
|
+
async function listVoices(key, { accent, query, limit = 40 } = {}, deps = {}) {
|
|
148
|
+
const api = deps.api || defaultApi(key);
|
|
149
|
+
let voices = [];
|
|
150
|
+
try { voices = await api("GET", "/list-voices"); } catch (e) { return { error: `Could not list voices: ${e.message}` }; }
|
|
151
|
+
let list = voices || [];
|
|
152
|
+
if (accent) list = list.filter((v) => String(v.accent || "").toLowerCase().includes(String(accent).toLowerCase()));
|
|
153
|
+
if (query) {
|
|
154
|
+
const q = String(query).toLowerCase();
|
|
155
|
+
list = list.filter((v) => `${v.voice_name} ${v.provider} ${v.accent}`.toLowerCase().includes(q));
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
total: (voices || []).length,
|
|
159
|
+
showing: Math.min(list.length, limit),
|
|
160
|
+
voices: list.slice(0, limit).map((v) => ({
|
|
161
|
+
voice_id: v.voice_id, voice_name: v.voice_name, provider: v.provider,
|
|
162
|
+
accent: v.accent, gender: v.gender, age: v.age, preview_audio_url: v.preview_audio_url,
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- add ----
|
|
168
|
+
async function addLanguage(key, args, deps = {}) {
|
|
169
|
+
const api = deps.api || defaultApi(key);
|
|
170
|
+
return withLock(() => _addLanguage(api, args || {}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function _addLanguage(api, { lang, display_name, prompt_text, phrases, voice_id, language, stt_mode, interruption_sensitivity }) {
|
|
174
|
+
const { lang: L, primary, valid } = normalizeLang(lang);
|
|
175
|
+
if (!valid) return { error: `Invalid language code "${lang}". Use a BCP-47 code like "fr" or "pt-BR".` };
|
|
176
|
+
if (primary === "en") return { error: "English is the built-in base language (managed in the repo, generic-prompt.md). add_language is for ADDITIONAL languages." };
|
|
177
|
+
|
|
178
|
+
const config = setup.loadConfig();
|
|
179
|
+
if (config.agents?.by_lang?.[primary] || config.languages?.[primary]) {
|
|
180
|
+
const a = config.agents?.by_lang?.[primary];
|
|
181
|
+
return { error: `Language "${primary}" is already registered${a ? ` (agent ${a})` : ""}. Use update_language to change it, or remove_language first.` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const seed = enSeed();
|
|
185
|
+
if (!prompt_text || !phrases) {
|
|
186
|
+
return {
|
|
187
|
+
needs_translation: true,
|
|
188
|
+
lang: primary,
|
|
189
|
+
display_name: display_name || primary,
|
|
190
|
+
base_prompt: setup.loadPromptText("generic-prompt.md"),
|
|
191
|
+
base_phrases: seed,
|
|
192
|
+
instructions: `Translate base_prompt and base_phrases into ${display_name || primary} (${primary}). Preserve every {{variable}} and {placeholder} EXACTLY — do not translate the tokens inside the braces. Then call add_language again with prompt_text and phrases (and ideally a native-accent voice_id from list_voices).`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pv = validatePromptText(prompt_text);
|
|
197
|
+
if (!pv.ok) return { error: "Invalid prompt_text.", details: pv.errors };
|
|
198
|
+
const phv = validatePhrases(phrases, seed);
|
|
199
|
+
if (!phv.ok) return { error: "Invalid phrases.", details: phv.errors };
|
|
200
|
+
|
|
201
|
+
let voices = [];
|
|
202
|
+
try { voices = await api("GET", "/list-voices"); } catch { voices = []; }
|
|
203
|
+
const vr = resolveVoiceId(voices, voice_id, primary);
|
|
204
|
+
if (vr.error) return { error: vr.error, suggestions: vr.suggestions };
|
|
205
|
+
const resolvedVoice = vr.voice_id;
|
|
206
|
+
|
|
207
|
+
const { language: retellLang, guessed } = toRetellLanguage(primary, language);
|
|
208
|
+
const stt = stt_mode || "accurate";
|
|
209
|
+
const interrupt = (typeof interruption_sensitivity === "number") ? interruption_sensitivity : 0.5;
|
|
210
|
+
if (interrupt < 0 || interrupt > 1) return { error: "interruption_sensitivity must be between 0 and 1." };
|
|
211
|
+
|
|
212
|
+
const promptFileName = `generic-prompt.${primary}.md`;
|
|
213
|
+
try {
|
|
214
|
+
atomicWrite(paths.volumePath(promptFileName), String(prompt_text));
|
|
215
|
+
writePhraseBlock(primary, phrases);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return { error: `Failed to write language assets to the volume: ${e.message}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Reuse an existing generic_<primary> agent (idempotent re-run) instead of
|
|
221
|
+
// creating a duplicate. Uses the unified v2 endpoint (GET /list-agents removed
|
|
222
|
+
// 2026-07-31); read `items` and filter to voice agents.
|
|
223
|
+
let agents = [];
|
|
224
|
+
try {
|
|
225
|
+
const resp = await api("POST", "/v2/list-agents", { filter_criteria: { channel: { type: "string", op: "eq", value: "voice" } } });
|
|
226
|
+
agents = Array.isArray(resp) ? resp : (resp.items || []);
|
|
227
|
+
} catch { agents = []; }
|
|
228
|
+
const existing = (agents || []).find((a) => (a.agent_name || "").toLowerCase() === `generic_${primary}`);
|
|
229
|
+
|
|
230
|
+
let agent_id, llm_id;
|
|
231
|
+
if (existing) {
|
|
232
|
+
agent_id = existing.agent_id;
|
|
233
|
+
// The v2 list response omits response_engine; fetch the agent detail to get
|
|
234
|
+
// the llm_id so we can refresh its prompt on reuse.
|
|
235
|
+
llm_id = existing.response_engine?.llm_id || null;
|
|
236
|
+
if (!llm_id) { try { const d = await api("GET", `/get-agent/${agent_id}`); llm_id = d.response_engine?.llm_id || null; } catch { /* best effort */ } }
|
|
237
|
+
if (llm_id) { try { await api("PATCH", `/update-retell-llm/${llm_id}`, { general_prompt: setup.loadPromptText(promptFileName) }); } catch { /* best effort */ } }
|
|
238
|
+
try { await api("PATCH", `/update-agent/${agent_id}`, { voice_id: resolvedVoice, language: retellLang }); } catch { /* best effort */ }
|
|
239
|
+
} else {
|
|
240
|
+
let llm;
|
|
241
|
+
try {
|
|
242
|
+
llm = await api("POST", "/create-retell-llm", {
|
|
243
|
+
general_prompt: setup.loadPromptText(promptFileName),
|
|
244
|
+
start_speaker: "user",
|
|
245
|
+
begin_message: "",
|
|
246
|
+
begin_after_user_silence_ms: 8000,
|
|
247
|
+
general_tools: [
|
|
248
|
+
{ type: "end_call", name: "end_call", description: "End the call when done." },
|
|
249
|
+
{ type: "press_digit", name: "press_digit", description: "Press a keypad digit to navigate an automated phone menu (IVR).", delay_ms: 1500 },
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return { error: `Failed to create the Retell LLM: ${e.message}`, note: "Volume files were written; re-run add_language with the same inputs to retry." };
|
|
254
|
+
}
|
|
255
|
+
llm_id = llm.llm_id;
|
|
256
|
+
let agent;
|
|
257
|
+
try {
|
|
258
|
+
agent = await api("POST", "/create-agent", {
|
|
259
|
+
response_engine: { type: "retell-llm", llm_id },
|
|
260
|
+
voice_id: resolvedVoice,
|
|
261
|
+
agent_name: `generic_${primary}`,
|
|
262
|
+
language: retellLang,
|
|
263
|
+
max_call_duration_ms: 600000,
|
|
264
|
+
interruption_sensitivity: interrupt,
|
|
265
|
+
stt_mode: stt,
|
|
266
|
+
voicemail_option: { action: { type: "static_text", text: "{{voicemail_message}}" } },
|
|
267
|
+
post_call_analysis_data: setup.POST_CALL_ANALYSIS,
|
|
268
|
+
webhook_events: ["call_started", "call_ended", "call_analyzed"],
|
|
269
|
+
...(setup.webhookUrl() ? { webhook_url: setup.webhookUrl() } : {}),
|
|
270
|
+
});
|
|
271
|
+
} catch (e) {
|
|
272
|
+
try { await api("DELETE", `/delete-retell-llm/${llm_id}`); } catch { /* ignore */ }
|
|
273
|
+
return { error: `Failed to create the Retell agent: ${e.message}`, note: "Volume files were written and the orphan LLM was cleaned up; re-run add_language with the same inputs to retry." };
|
|
274
|
+
}
|
|
275
|
+
agent_id = agent.agent_id;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Register (config saved LAST so a mid-flight failure leaves it unregistered/safe).
|
|
279
|
+
config.agents = config.agents || {};
|
|
280
|
+
config.agents.by_lang = { ...(config.agents.by_lang || {}), [primary]: agent_id };
|
|
281
|
+
config.languages = { ...(config.languages || {}), [primary]: { agent_id, llm_id, voice_id: resolvedVoice, language: retellLang, display_name: display_name || primary } };
|
|
282
|
+
setup.saveConfig(config);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
lang: primary,
|
|
287
|
+
display_name: display_name || primary,
|
|
288
|
+
agent_id,
|
|
289
|
+
llm_id,
|
|
290
|
+
voice_id: resolvedVoice,
|
|
291
|
+
language: retellLang,
|
|
292
|
+
language_guessed: guessed || undefined,
|
|
293
|
+
voice_note: vr.default ? `Defaulted to ${resolvedVoice} (the English house voice). For a natural-sounding ${display_name || primary} call, consider a native-accent voice (list_voices) and update_language.` : undefined,
|
|
294
|
+
voice_suggestions: vr.default ? vr.suggestions : undefined,
|
|
295
|
+
next: `Run a VERIFICATION CALL to your own number before any production use: place_call with lang:"${primary}", test_to:"<your number>", confirm:true. Have a native speaker review the opening, the AI disclosure, and etiquette. Then it's ready.`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- update ----
|
|
300
|
+
async function updateLanguage(key, args, deps = {}) {
|
|
301
|
+
const api = deps.api || defaultApi(key);
|
|
302
|
+
return withLock(() => _updateLanguage(api, args || {}));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function _updateLanguage(api, { lang, prompt_text, phrases, voice_id, language, interruption_sensitivity, stt_mode }) {
|
|
306
|
+
const { primary, valid } = normalizeLang(lang);
|
|
307
|
+
if (!valid) return { error: "Invalid language code." };
|
|
308
|
+
if (primary === "en") return { error: "English base is managed in the repo, not via update_language." };
|
|
309
|
+
|
|
310
|
+
const config = setup.loadConfig();
|
|
311
|
+
const meta = config.languages?.[primary];
|
|
312
|
+
const agent_id = config.agents?.by_lang?.[primary] || meta?.agent_id;
|
|
313
|
+
if (!agent_id) return { error: `Language "${primary}" is not registered. Use add_language first.` };
|
|
314
|
+
|
|
315
|
+
let llm_id = meta?.llm_id;
|
|
316
|
+
if (!llm_id) { try { const a = await api("GET", `/get-agent/${agent_id}`); llm_id = a.response_engine?.llm_id; } catch { /* ignore */ } }
|
|
317
|
+
|
|
318
|
+
const seed = enSeed();
|
|
319
|
+
const changed = {};
|
|
320
|
+
const promptFileName = `generic-prompt.${primary}.md`;
|
|
321
|
+
|
|
322
|
+
if (prompt_text) {
|
|
323
|
+
const pv = validatePromptText(prompt_text);
|
|
324
|
+
if (!pv.ok) return { error: "Invalid prompt_text.", details: pv.errors };
|
|
325
|
+
atomicWrite(paths.volumePath(promptFileName), String(prompt_text));
|
|
326
|
+
if (llm_id) { await api("PATCH", `/update-retell-llm/${llm_id}`, { general_prompt: setup.loadPromptText(promptFileName) }); }
|
|
327
|
+
changed.prompt = true;
|
|
328
|
+
}
|
|
329
|
+
if (phrases) {
|
|
330
|
+
const phv = validatePhrases(phrases, seed);
|
|
331
|
+
if (!phv.ok) return { error: "Invalid phrases.", details: phv.errors };
|
|
332
|
+
writePhraseBlock(primary, phrases);
|
|
333
|
+
changed.phrases = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const agentPatch = {};
|
|
337
|
+
if (voice_id) {
|
|
338
|
+
let voices = [];
|
|
339
|
+
try { voices = await api("GET", "/list-voices"); } catch { voices = []; }
|
|
340
|
+
const vr = resolveVoiceId(voices, voice_id, primary);
|
|
341
|
+
if (vr.error) return { error: vr.error, suggestions: vr.suggestions };
|
|
342
|
+
agentPatch.voice_id = vr.voice_id;
|
|
343
|
+
}
|
|
344
|
+
if (language) agentPatch.language = language;
|
|
345
|
+
if (typeof interruption_sensitivity === "number") {
|
|
346
|
+
if (interruption_sensitivity < 0 || interruption_sensitivity > 1) return { error: "interruption_sensitivity must be between 0 and 1." };
|
|
347
|
+
agentPatch.interruption_sensitivity = interruption_sensitivity;
|
|
348
|
+
}
|
|
349
|
+
if (stt_mode) agentPatch.stt_mode = stt_mode;
|
|
350
|
+
if (Object.keys(agentPatch).length) { await api("PATCH", `/update-agent/${agent_id}`, agentPatch); changed.agent = Object.keys(agentPatch); }
|
|
351
|
+
|
|
352
|
+
config.languages = config.languages || {};
|
|
353
|
+
config.languages[primary] = {
|
|
354
|
+
...(meta || { agent_id, llm_id }),
|
|
355
|
+
...(agentPatch.voice_id ? { voice_id: agentPatch.voice_id } : {}),
|
|
356
|
+
...(agentPatch.language ? { language: agentPatch.language } : {}),
|
|
357
|
+
};
|
|
358
|
+
setup.saveConfig(config);
|
|
359
|
+
return { ok: true, lang: primary, changed };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- remove ----
|
|
363
|
+
async function removeLanguage(key, args, deps = {}) {
|
|
364
|
+
const api = deps.api || defaultApi(key);
|
|
365
|
+
return withLock(() => _removeLanguage(api, args || {}));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function _removeLanguage(api, { lang, delete_agent }) {
|
|
369
|
+
const { primary, valid } = normalizeLang(lang);
|
|
370
|
+
if (!valid) return { error: "Invalid language code." };
|
|
371
|
+
if (primary === "en") return { error: "Cannot remove the built-in English base language." };
|
|
372
|
+
|
|
373
|
+
const config = setup.loadConfig();
|
|
374
|
+
const meta = config.languages?.[primary];
|
|
375
|
+
const agent_id = config.agents?.by_lang?.[primary] || meta?.agent_id;
|
|
376
|
+
if (!agent_id && !meta) return { error: `Language "${primary}" is not registered.` };
|
|
377
|
+
|
|
378
|
+
let agent_deleted = false;
|
|
379
|
+
if (delete_agent && agent_id) {
|
|
380
|
+
let llm_id = meta?.llm_id;
|
|
381
|
+
if (!llm_id) { try { const a = await api("GET", `/get-agent/${agent_id}`); llm_id = a.response_engine?.llm_id; } catch { /* ignore */ } }
|
|
382
|
+
try { await api("DELETE", `/delete-agent/${agent_id}`); agent_deleted = true; } catch { /* leave note */ }
|
|
383
|
+
if (llm_id) { try { await api("DELETE", `/delete-retell-llm/${llm_id}`); } catch { /* ignore */ } }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (config.agents?.by_lang) delete config.agents.by_lang[primary];
|
|
387
|
+
if (config.languages) delete config.languages[primary];
|
|
388
|
+
setup.saveConfig(config);
|
|
389
|
+
|
|
390
|
+
try { fs.rmSync(paths.volumePath(`generic-prompt.${primary}.md`), { force: true }); } catch { /* ignore */ }
|
|
391
|
+
try { removePhraseBlock(primary); } catch { /* ignore */ }
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
lang: primary,
|
|
396
|
+
agent_deleted,
|
|
397
|
+
note: delete_agent
|
|
398
|
+
? undefined
|
|
399
|
+
: `Unregistered "${primary}" and removed its volume assets. The Retell agent ${agent_id || ""} still exists in your Retell dashboard (pass delete_agent:true to delete it too).`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---- verification ----
|
|
404
|
+
// Build a representative anonymous general-inquiry job to verify a language's
|
|
405
|
+
// composed phrasing. Pass target-language samples for the truest review; if
|
|
406
|
+
// omitted, composeCall falls back to the language's own opening_ask_fallback.
|
|
407
|
+
function buildVerificationJob(primary, { business_name, sample_summary, sample_opening_ask } = {}) {
|
|
408
|
+
const d = new Date(Date.now() + 7 * 86400000);
|
|
409
|
+
const date = d.toISOString().slice(0, 10);
|
|
410
|
+
const request = {
|
|
411
|
+
summary: sample_summary || "availability inquiry (verification call)",
|
|
412
|
+
preferred: { date, time: "18:00" },
|
|
413
|
+
};
|
|
414
|
+
if (sample_opening_ask) request.opening_ask = sample_opening_ask;
|
|
415
|
+
return {
|
|
416
|
+
call_type: "general_inquiry",
|
|
417
|
+
target: { business_name: business_name || "Verification call", phone_number: "+10000000000" },
|
|
418
|
+
principal: { anonymous: true },
|
|
419
|
+
request,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Extract a human/native review card from a composed verification call: the
|
|
424
|
+
// exact spoken opening (greeting wrapper + ask + AI disclosure) and voicemail,
|
|
425
|
+
// which are the phrase-block-derived strings a native speaker must sign off on.
|
|
426
|
+
function verificationCard(composed, { lang, display_name, agent_id, voice_id, language } = {}) {
|
|
427
|
+
const v = composed.vars || {};
|
|
428
|
+
const tpl = (composed.phrases && composed.phrases.opening_preview) || "{opening_ask}";
|
|
429
|
+
const opening = String(tpl).replace(/\{(\w+)\}/g, (_, k) => (v[k] != null ? String(v[k]) : ""));
|
|
430
|
+
return {
|
|
431
|
+
lang,
|
|
432
|
+
display_name: display_name || lang,
|
|
433
|
+
agent_id: agent_id || null,
|
|
434
|
+
voice_id: voice_id || null,
|
|
435
|
+
language: language || null,
|
|
436
|
+
opening_spoken: opening,
|
|
437
|
+
voicemail_spoken: v.voicemail_message || "",
|
|
438
|
+
name_handling: v.booking_name_line || "",
|
|
439
|
+
review_checklist: [
|
|
440
|
+
"Is the AI disclosure present, clear, and natural (not buried, never claims to be human)?",
|
|
441
|
+
"Is the greeting/etiquette appropriate for the culture (e.g. keigo in Japanese, tu/vous in French)?",
|
|
442
|
+
"Does the voicemail message sound natural and leave who/why/callback clearly?",
|
|
443
|
+
"Any awkward or literal machine-translation phrasing to fix via update_language?",
|
|
444
|
+
"Does the voice accent/gender suit the language? (change via update_language voice_id)",
|
|
445
|
+
],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---- status ----
|
|
450
|
+
function listLanguages(config = setup.loadConfig()) {
|
|
451
|
+
const out = [{ lang: "en", display_name: "English", base: true, agent_id: config.agents?.default || null }];
|
|
452
|
+
const langs = config.languages || {};
|
|
453
|
+
const byLang = config.agents?.by_lang || {};
|
|
454
|
+
const volPhr = readVolumePhrases();
|
|
455
|
+
for (const k of new Set([...Object.keys(langs), ...Object.keys(byLang)])) {
|
|
456
|
+
const m = langs[k] || {};
|
|
457
|
+
out.push({
|
|
458
|
+
lang: k,
|
|
459
|
+
display_name: m.display_name || k,
|
|
460
|
+
agent_id: byLang[k] || m.agent_id || null,
|
|
461
|
+
voice_id: m.voice_id || null,
|
|
462
|
+
language: m.language || null,
|
|
463
|
+
prompt_on_volume: fs.existsSync(paths.volumePath(`generic-prompt.${k}.md`)),
|
|
464
|
+
phrases_on_volume: !!volPhr[k],
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = {
|
|
471
|
+
normalizeLang, toRetellLanguage, validatePromptText, validatePhrases,
|
|
472
|
+
suggestVoices, resolveVoiceId, listVoices,
|
|
473
|
+
addLanguage, updateLanguage, removeLanguage, listLanguages,
|
|
474
|
+
buildVerificationJob, verificationCard,
|
|
475
|
+
RETELL_LANG_MAP, ACCENT_HINT,
|
|
476
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_README": "Per-language PHRASING for composed helper strings. Code injects VALUES; this file owns wording. Keyed by BCP-47 primary subtag (en, ja, ...). Unknown languages fall back to 'en'. Placeholders in {braces} are filled by dispatch-core.composeCall. Adding a language = add a block here + a generic-prompt.<lang>.md + a voice that speaks it (see docs/add-language-spec.md). Keep strings faithful to native etiquette; the AI-disclosure line is legally sensitive — have a native speaker review before production.",
|
|
3
|
+
"en": {
|
|
4
|
+
"principal_ref_anon": "the person I'm assisting",
|
|
5
|
+
"behalf_named": " on behalf of {name}",
|
|
6
|
+
"opening_ask_fallback": "I'm calling about {objective_lc}. Could you help me with that?",
|
|
7
|
+
"voicemail_callback": "Hi, this is an AI assistant calling{behalf}. I'm calling to {objective_lc}. Please call back at {callback}. Thank you.",
|
|
8
|
+
"voicemail_no_callback": "Hi, this is an AI assistant calling{behalf}. I'm calling to {objective_lc}. Please call back at your convenience. Thank you.",
|
|
9
|
+
"known_facts_none": "None on file.",
|
|
10
|
+
"booking_name_line_named": "If this becomes a booking, or the business asks what name it's under, use **{name}**. Do not state the name before then.",
|
|
11
|
+
"booking_name_line_anon": "This is a general inquiry — no name or booking is involved. Do not give or invent a name.",
|
|
12
|
+
"confirm": { "base": ["the date", "the time"], "party_size": "the party size", "join": ", " },
|
|
13
|
+
"none_text": "None.",
|
|
14
|
+
"opening_preview": "Hi there! {opening_ask} I'm an AI assistant making this call on someone's behalf."
|
|
15
|
+
},
|
|
16
|
+
"ja": {
|
|
17
|
+
"principal_ref_anon": "依頼主",
|
|
18
|
+
"behalf_named": "",
|
|
19
|
+
"opening_ask_fallback": "{objective}についてお伺いしたく、お電話いたしました。少々よろしいでしょうか。",
|
|
20
|
+
"voicemail_callback": "お世話になっております。AIアシスタントでございます。{objective}の件でお電話いたしました。{callback}までご連絡ください。よろしくお願いいたします。",
|
|
21
|
+
"voicemail_no_callback": "お世話になっております。AIアシスタントでございます。{objective}の件でお電話いたしました。ご都合のよいときにご連絡ください。よろしくお願いいたします。",
|
|
22
|
+
"known_facts_none": "(特になし)",
|
|
23
|
+
"booking_name_line_named": "予約になる場合、または先方に「どちら様のお名前で?」と尋ねられた場合は、**{name}**とお伝えください。それ以前に名前を名乗らないでください。",
|
|
24
|
+
"booking_name_line_anon": "これは一般的なお問い合わせです。名前や予約は関係ありません。名前を伝えたり作ったりしないでください。",
|
|
25
|
+
"confirm": { "base": ["日付", "時間"], "party_size": null, "join": "、" },
|
|
26
|
+
"none_text": "特になし。",
|
|
27
|
+
"opening_preview": "お世話になっております。{opening_ask} このお電話は、ある方に代わってAIアシスタントがおかけしております。"
|
|
28
|
+
}
|
|
29
|
+
}
|