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/learn-core.js ADDED
@@ -0,0 +1,257 @@
1
+ // callwright — learning core (enrich existing scenario profiles AND stage
2
+ // candidates for NEW profiles). Deterministic heuristics only; the JUDGMENT
3
+ // calls the spec reserves for an LLM (canonicalize merge-vs-create,
4
+ // propose-and-confirm) happen in the MCP layer / host LLM, not here.
5
+ //
6
+ // The one invariant (docs/profile-learning-spec.md): a profile stores SCHEMA
7
+ // (field keys), never VALUES. Every guard here serves that invariant.
8
+
9
+ // ---- agent-uncertainty signals (the agent deferring instead of answering) ----
10
+ const DEFER_PATTERNS = [
11
+ /i'?m not sure/i, /i'?ll check with/i, /i will check with/i, /follow up/i,
12
+ /i don'?t (have|know)/i, /let me check with/i,
13
+ ];
14
+
15
+ // Map a business question to a GENERALIZED profile field (heuristic; LLM refines).
16
+ const QUESTION_TO_FIELD = [
17
+ { rx: /(type|kind|style) of (haircut|cut|service)|what (type|kind|service)/i, key: "service_type", why: "Type of service requested" },
18
+ { rx: /stylist|barber|who (will|would)|preferred (stylist|barber)/i, key: "stylist", why: "Preferred stylist/barber name" },
19
+ { rx: /how long|length of (your |the )?hair|hair length/i, key: "hair_length", why: "Current/target hair length" },
20
+ { rx: /new patient|been here before|existing patient|first (time|visit)/i, key: "new_or_existing", why: "New or existing patient" },
21
+ { rx: /insurance|provider|carrier/i, key: "insurance", why: "Insurance provider" },
22
+ { rx: /occasion|celebrat|birthday|anniversary/i, key: "occasion", why: "Special occasion, if any" },
23
+ { rx: /allerg|dietary|gluten|vegan|vegetarian/i, key: "dietary_notes", why: "Dietary restrictions/allergies" },
24
+ { rx: /destination|where (to|are you going)|drop.?off|going to/i, key: "destination", why: "Drop-off destination" },
25
+ { rx: /pick.?up (time|when)|what time.*pick|when.*pick.?up/i, key: "pickup_time", why: "Requested pickup time" },
26
+ { rx: /how many (people|passengers|in your party)|party size|number of (people|guests)/i, key: "party_size", why: "Number of people" },
27
+ { rx: /how (much )?luggage|how many (bags|suitcases)|baggage/i, key: "luggage", why: "Amount of luggage" },
28
+ { rx: /address|location|where (is it|do you live)|street|zip/i, key: "service_address", why: "Service/site address used to look up the appointment" },
29
+ { rx: /phone number|number on (the |your )?account|contact number|callback/i, key: "account_phone", why: "Phone number on the account for lookup" },
30
+ ];
31
+
32
+ const WEEKDAYS = new Set(["monday","tuesday","wednesday","thursday","friday","saturday","sunday","mon","tue","wed","thu","fri","sat","sun"]);
33
+ const MONTHS = new Set(["january","february","march","april","may","june","july","august","september","october","november","december","jan","feb","mar","apr","jun","jul","aug","sep","sept","oct","nov","dec"]);
34
+ const CONNECTORS = new Set(["to","for","with","at","in","on","near","by","from","of"]);
35
+ // Generic scenario tail-words that must NOT be stripped even after a connector.
36
+ const KEEP_TAIL = new Set(["inquiry","reservation","booking","confirmation","appointment","service","request","call"]);
37
+
38
+ // #1: reduce an instance-shaped call_type to its general scenario form.
39
+ function generalizeCallType(callType) {
40
+ const raw = String(callType || "").toLowerCase().trim();
41
+ let tokens = raw.split(/[\s_\-]+/).filter(Boolean);
42
+ const stripped = [];
43
+ // Cut at the first connector preposition (e.g. "..._to_haneda") UNLESS what
44
+ // follows is a generic scenario word (e.g. "type_of_service").
45
+ const ci = tokens.findIndex((t, i) => i > 0 && CONNECTORS.has(t));
46
+ if (ci > 0) {
47
+ const tail = tokens.slice(ci + 1);
48
+ if (!tail.some((t) => KEEP_TAIL.has(t))) {
49
+ stripped.push(...tokens.slice(ci));
50
+ tokens = tokens.slice(0, ci);
51
+ }
52
+ }
53
+ // Drop pure numbers, dates, weekdays, months (instance values).
54
+ tokens = tokens.filter((t) => {
55
+ if (/^\d+$/.test(t) || /^\d{4}-\d{2}-\d{2}$/.test(t) || WEEKDAYS.has(t) || MONTHS.has(t)) { stripped.push(t); return false; }
56
+ return true;
57
+ });
58
+ const general = tokens.join("_");
59
+ return { general: general || raw, stripped, changed: stripped.length > 0 };
60
+ }
61
+
62
+ // #3: a "field" whose key/description bakes in a specific value is a value in
63
+ // disguise — reject it from the profile (it belongs in the per-call job).
64
+ function looksInstanceValued(key, description = "") {
65
+ const k = String(key || "");
66
+ const d = String(description || "");
67
+ if (/\d/.test(k)) return true; // digits in a field key
68
+ if (/\d{4}-\d{2}-\d{2}/.test(d)) return true; // a date
69
+ if (/["'].+["']/.test(d)) return true; // a quoted literal
70
+ const segs = k.toLowerCase().split(/[_\-]+/).filter(Boolean);
71
+ // A generalized field is a short generic noun; >3 segments usually = value-baked.
72
+ if (segs.length > 3) return true;
73
+ // Trailing proper-noun-ish segment after a known generic head (destination_haneda).
74
+ const GENERIC_HEADS = new Set(["destination","party","date","time","name","stylist","location","address","city","airport"]);
75
+ if (segs.length >= 2 && GENERIC_HEADS.has(segs[0]) && !["size","type","range","window","preference","notes","time"].includes(segs[segs.length - 1])) {
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ function mapQuestionToField(question) {
82
+ return QUESTION_TO_FIELD.find((m) => m.rx.test(String(question || ""))) || null;
83
+ }
84
+
85
+ function parseTurns(transcript) {
86
+ const turns = [];
87
+ for (const line of String(transcript || "").split("\n")) {
88
+ const m = line.match(/^(Agent|User):\s?(.*)$/);
89
+ if (m) turns.push({ who: m[1], text: m[2] });
90
+ else if (turns.length) turns[turns.length - 1].text += " " + line.trim();
91
+ }
92
+ return turns;
93
+ }
94
+
95
+ // Detect moments the agent could not answer (deferred) + structured unanswered.
96
+ function detectGaps(call) {
97
+ const turns = parseTurns(call && call.transcript);
98
+ const gaps = [];
99
+ for (let i = 0; i < turns.length; i++) {
100
+ const t = turns[i];
101
+ if (t.who !== "Agent" || !DEFER_PATTERNS.some((rx) => rx.test(t.text))) continue;
102
+ let q = null;
103
+ for (let j = i - 1; j >= 0; j--) { if (turns[j].who === "User") { q = turns[j].text; break; } }
104
+ if (q) gaps.push({ question: q.trim(), agent: t.text.trim() });
105
+ }
106
+ const ua = call && call.call_analysis && call.call_analysis.custom_analysis_data
107
+ && call.call_analysis.custom_analysis_data.unanswered_questions;
108
+ if (ua && String(ua).trim()) gaps.push({ question: String(ua).trim(), agent: "(post-call analysis)" });
109
+ return gaps;
110
+ }
111
+
112
+ // #2 (first pass): existing fuzzy name/alias match.
113
+ function matchProfile(profiles, callType) {
114
+ const ct = String(callType || "").toLowerCase();
115
+ for (const [name, p] of Object.entries(profiles || {})) {
116
+ const keys = [name, ...((p && p.aliases) || [])].map((s) => s.toLowerCase());
117
+ if (keys.some((k) => ct === k || ct.includes(k) || k.includes(ct))) return name;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ // Enrich an existing profile from gaps. value-rejection guard (#3) applied.
123
+ // minCount (#4/#5): only PROMOTE a field to recommended_details once it has been
124
+ // seen >= minCount times (across this profile's learned history + this call).
125
+ // minCount=1 = promote on first sight (explicit/manual use); minCount>=2 =
126
+ // noise-guard for autonomous/webhook use. Idempotent: a call_id already in the
127
+ // learned log is skipped so a webhook redelivery can't double-count.
128
+ function enrichProfile(profile, gaps, callId, { minCount = 1 } = {}) {
129
+ profile.recommended_details = profile.recommended_details || {};
130
+ profile.learned = profile.learned || [];
131
+ if (callId && profile.learned.some((e) => e.call_id === callId)) {
132
+ return { added: [], skipped: true };
133
+ }
134
+ // Prior occurrence counts from history.
135
+ const counts = {};
136
+ for (const e of profile.learned) if (e.mapped_field) counts[e.mapped_field] = (counts[e.mapped_field] || 0) + 1;
137
+ const added = [];
138
+ for (const g of gaps || []) {
139
+ const map = mapQuestionToField(g.question);
140
+ const key = map && map.key;
141
+ if (!key) {
142
+ profile.learned.push({ call_id: callId, question: g.question, mapped_field: null, at: new Date().toISOString() });
143
+ continue;
144
+ }
145
+ if (looksInstanceValued(key, map.why)) {
146
+ profile.learned.push({ call_id: callId, question: g.question, mapped_field: null, rejected: "instance_valued", at: new Date().toISOString() });
147
+ continue;
148
+ }
149
+ profile.learned.push({ call_id: callId, question: g.question, mapped_field: key, at: new Date().toISOString() });
150
+ counts[key] = (counts[key] || 0) + 1;
151
+ if (!profile.recommended_details[key] && counts[key] >= minCount) {
152
+ profile.recommended_details[key] = map.why;
153
+ added.push(key);
154
+ }
155
+ }
156
+ return { added };
157
+ }
158
+
159
+ // #4: stage an unmatched (generalized) call_type; promote at N occurrences.
160
+ function stageCandidate(store, generalType, { callId, variant, gaps } = {}, N = 2) {
161
+ const now = new Date().toISOString();
162
+ const c = store[generalType] || { count: 0, examples: [], variants: [], questions: {}, first_seen: now, last_seen: now, proposed: false };
163
+ const isNewCall = callId && !c.examples.includes(callId);
164
+ if (isNewCall) { c.count += 1; c.examples.push(callId); }
165
+ if (variant && !c.variants.includes(variant)) c.variants.push(variant);
166
+ for (const g of gaps || []) {
167
+ const map = mapQuestionToField(g.question);
168
+ const fkey = map && map.key ? map.key : ("q:" + slug(g.question));
169
+ const why = map && map.why ? map.why : g.question;
170
+ const q = c.questions[fkey] || { count: 0, why, mapped: !!(map && map.key), examples: [] };
171
+ // only count once per call for a given field
172
+ if (isNewCall) q.count += 1;
173
+ if (q.examples.length < 3 && !q.examples.includes(g.question)) q.examples.push(g.question);
174
+ c.questions[fkey] = q;
175
+ }
176
+ c.last_seen = now;
177
+ store[generalType] = c;
178
+ return { candidate: c, ready: c.count >= N && !c.proposed };
179
+ }
180
+
181
+ function slug(s) {
182
+ return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
183
+ }
184
+
185
+ // Build a propose-and-confirm proposal from a ready candidate.
186
+ function buildProposal(generalType, candidate, N = 2) {
187
+ const recommended = {};
188
+ const weak = {};
189
+ for (const [fkey, q] of Object.entries((candidate && candidate.questions) || {})) {
190
+ if (fkey.startsWith("q:") || !q.mapped) { weak[fkey] = q.why; continue; } // unmapped -> weak suggestion
191
+ if (q.count >= N && !looksInstanceValued(fkey, q.why)) recommended[fkey] = q.why;
192
+ else weak[fkey] = q.why;
193
+ }
194
+ const aliases = ((candidate && candidate.variants) || []).filter((v) => v && v !== generalType);
195
+ return {
196
+ name: generalType,
197
+ aliases,
198
+ recommended_details: recommended,
199
+ weak_candidates: weak,
200
+ seen: (candidate && candidate.count) || 0,
201
+ examples: (candidate && candidate.examples) || [],
202
+ };
203
+ }
204
+
205
+ // #1 + collision: validate a proposed NEW profile before creation.
206
+ function validateNewProfile(name, profiles = {}, recommended_details = {}) {
207
+ const errors = [];
208
+ const n = String(name || "").toLowerCase().trim();
209
+ if (!/^[a-z][a-z0-9_]{1,40}$/.test(n)) errors.push(`Invalid profile name "${name}" (use snake_case, no spaces/values).`);
210
+ const gen = generalizeCallType(n);
211
+ if (gen.changed) errors.push(`"${name}" looks instance-shaped (embeds ${gen.stripped.join(", ")}). Use the general form "${gen.general}" and route the specific part into the per-call job.`);
212
+ // collision: name or alias already exists
213
+ let collision = null;
214
+ for (const [pname, p] of Object.entries(profiles || {})) {
215
+ const keys = [pname, ...((p && p.aliases) || [])].map((s) => s.toLowerCase());
216
+ if (keys.includes(n)) { collision = pname; break; }
217
+ }
218
+ for (const [k, why] of Object.entries(recommended_details || {})) {
219
+ if (looksInstanceValued(k, why)) errors.push(`recommended_details key "${k}" looks value-baked; use a generalized field name.`);
220
+ }
221
+ return { ok: errors.length === 0 && !collision, errors, collision };
222
+ }
223
+
224
+ // Shared orchestrator used by BOTH the MCP learn_from_call tool and the webhook
225
+ // auto-learn path. Mutates the passed profiles/candidates; the caller persists.
226
+ // callType comes from the arg or the call's round-tripped dynamic variable.
227
+ function applyLearning(call, { profiles, candidates, callType, minCount = 1, N = 2 } = {}) {
228
+ const ct = String(
229
+ callType || (call && call.retell_llm_dynamic_variables && call.retell_llm_dynamic_variables.call_type) || ""
230
+ ).toLowerCase();
231
+ const callId = call && call.call_id;
232
+ const gaps = detectGaps(call);
233
+ if (!ct) return { mode: "skip", reason: "no_call_type", gaps: gaps.map((g) => g.question) };
234
+ if (!gaps.length) return { mode: "skip", reason: "no_gaps", call_type: ct };
235
+
236
+ const profKey = matchProfile(profiles || {}, ct);
237
+ if (profKey) {
238
+ const r = enrichProfile(profiles[profKey], gaps, callId, { minCount });
239
+ return { mode: "enriched", profile: profKey, added: r.added || [], skipped: r.skipped || false, gaps: gaps.map((g) => g.question) };
240
+ }
241
+ const { general, changed, stripped } = generalizeCallType(ct);
242
+ const { candidate, ready } = stageCandidate(candidates || {}, general, { callId, variant: ct, gaps }, N);
243
+ const out = {
244
+ mode: "staged", scenario: general,
245
+ generalized_from: changed ? { original: ct, stripped } : undefined,
246
+ seen: candidate.count, ready_to_propose: ready, gaps: gaps.map((g) => g.question),
247
+ };
248
+ if (ready) out.proposal = buildProposal(general, candidate, N);
249
+ return out;
250
+ }
251
+
252
+ module.exports = {
253
+ DEFER_PATTERNS, QUESTION_TO_FIELD,
254
+ generalizeCallType, looksInstanceValued, mapQuestionToField,
255
+ parseTurns, detectGaps, matchProfile, enrichProfile,
256
+ stageCandidate, buildProposal, validateNewProfile, slug, applyLearning,
257
+ };
package/learn.js ADDED
@@ -0,0 +1,65 @@
1
+ // Learning loop CLI — enrich a scenario profile (or stage a candidate for a NEW
2
+ // profile) from a completed call. Thin wrapper over learn-core.js.
3
+ //
4
+ // node learn.js <call_id> <call_type>
5
+ //
6
+ // Env: RETELL_API_KEY
7
+
8
+ const fs = require("fs");
9
+ const { PROFILES_PATH, CANDIDATES_PATH } = require("./paths");
10
+ const L = require("./learn-core");
11
+
12
+ const API_KEY = process.env.RETELL_API_KEY;
13
+ if (!API_KEY) { console.error("Missing RETELL_API_KEY."); process.exit(1); }
14
+
15
+ const callId = process.argv[2];
16
+ const callType = (process.argv[3] || "").toLowerCase();
17
+ if (!callId || !callType) {
18
+ console.error("Usage: node learn.js <call_id> <call_type>");
19
+ process.exit(1);
20
+ }
21
+
22
+ function loadJson(p, fb) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fb; } }
23
+
24
+ async function getCall() {
25
+ const r = await fetch(`https://api.retellai.com/v2/get-call/${callId}`, {
26
+ headers: { Authorization: `Bearer ${API_KEY}` },
27
+ });
28
+ if (!r.ok) throw new Error(`get-call -> ${r.status}\n${await r.text()}`);
29
+ return r.json();
30
+ }
31
+
32
+ (async () => {
33
+ const call = await getCall();
34
+ const gaps = L.detectGaps(call);
35
+
36
+ if (!gaps.length) {
37
+ console.log("No unanswered-question gaps detected in this call. Nothing to learn.");
38
+ return;
39
+ }
40
+ console.log(`Detected ${gaps.length} gap(s) where the agent could not answer:`);
41
+ gaps.forEach((g, i) => console.log(` ${i + 1}. Business asked: "${g.question}"`));
42
+
43
+ const profiles = loadJson(PROFILES_PATH, {});
44
+ const profKey = L.matchProfile(profiles, callType);
45
+
46
+ if (profKey) {
47
+ const r = L.enrichProfile(profiles[profKey], gaps, callId);
48
+ fs.writeFileSync(PROFILES_PATH, JSON.stringify(profiles, null, 2) + "\n");
49
+ console.log(`\nProfile "${profKey}" enriched (${r.added.length} new field(s): ${r.added.join(", ") || "none"}).`);
50
+ return;
51
+ }
52
+
53
+ // No profile -> stage a candidate for possible NEW-profile creation (N>=2).
54
+ const { general } = L.generalizeCallType(callType);
55
+ const candidates = loadJson(CANDIDATES_PATH, {});
56
+ const { candidate, ready } = L.stageCandidate(candidates, general, { callId, variant: callType, gaps }, 2);
57
+ fs.writeFileSync(CANDIDATES_PATH, JSON.stringify(candidates, null, 2) + "\n");
58
+ console.log(`\nNo profile matches "${callType}". Staged candidate "${general}" (seen ${candidate.count}x).`);
59
+ if (ready) {
60
+ const proposal = L.buildProposal(general, candidate, 2);
61
+ console.log("READY for proposal:", JSON.stringify(proposal, null, 2));
62
+ } else {
63
+ console.log("Not yet promoted (needs to be seen >= 2 times).");
64
+ }
65
+ })().catch((e) => { console.error("\nFailed:\n" + e.message); process.exit(1); });
package/notify-core.js ADDED
@@ -0,0 +1,113 @@
1
+ // callwright — notification core. Per-call, opt-in SMS ("notify me when it's
2
+ // done") via Retell's outbound SMS (create-sms-chat). The DEFAULT remains
3
+ // pull-on-demand (get_call_outcome); SMS only fires when a call explicitly
4
+ // requested it (notify_sms round-tripped in the dynamic vars).
5
+ //
6
+ // Retell SMS requires a CHAT-mode agent; we provision a tiny "summary notifier"
7
+ // chat agent once and cache its id in config.notify.sms. The from_number must be
8
+ // a Retell number with SMS capability (KYC-gated).
9
+
10
+ const setup = require("./setup-core");
11
+
12
+ const SMS_AGENT_PROMPT =
13
+ "You are an SMS notifier for a personal assistant. Your ONLY job is to send a single " +
14
+ "text message containing exactly the notification provided in the variable {{summary}}. " +
15
+ "Send {{summary}} verbatim — no greeting, no sign-off, no extra words, and never ask a " +
16
+ "question. After sending that one message you are done; if the recipient replies, do not " +
17
+ "continue the conversation beyond a brief 'Thanks, noted.'";
18
+
19
+ const STATUS_EMOJI = { booked: "✅", failed: "❌", voicemail: "📭", callback_needed: "↩️", escalated: "⚠️", completed: "✅" };
20
+
21
+ // Build a concise one-line SMS summary of a finished call. Pure.
22
+ function buildCallSms(call) {
23
+ const v = (call && call.retell_llm_dynamic_variables) || {};
24
+ const a = (call && call.call_analysis) || {};
25
+ const c = a.custom_analysis_data || {};
26
+ const business = v.business_name || (call && call.to_number) || "the business";
27
+ const objective = v.objective || v.objective_detail || "";
28
+ const status = c.status || (a.call_successful === false ? "failed" : (a.call_successful ? "completed" : "completed"));
29
+ const emoji = STATUS_EMOJI[status] || "📞";
30
+
31
+ const phrase = {
32
+ booked: "booked", completed: "done", voicemail: "left a voicemail",
33
+ callback_needed: "callback needed", escalated: "escalated", failed: "not completed",
34
+ }[status] || status;
35
+
36
+ const details = [];
37
+ const when = `${c.booked_date || ""} ${c.booked_time || ""}`.trim();
38
+ if (when) details.push(when);
39
+ if (c.confirmation_ref) details.push(`conf ${c.confirmation_ref}`);
40
+
41
+ let msg = `${emoji} ${business}${objective ? ` (${objective})` : ""}: ${phrase}`;
42
+ if (details.length) msg += ` — ${details.join(", ")}`;
43
+ msg += ".";
44
+ return msg.slice(0, 320);
45
+ }
46
+
47
+ // Read the per-call notify intent off the round-tripped dynamic vars. Pure.
48
+ function shouldNotify(call) {
49
+ const v = (call && call.retell_llm_dynamic_variables) || {};
50
+ if (String(v.notify_sms || "") !== "1") return { notify: false };
51
+ return { notify: true, to: v.notify_to || "" };
52
+ }
53
+
54
+ const defaultApi = (key) => (m, p, b) => setup.apiCall(key, m, p, b);
55
+
56
+ // Provision (or reuse) the SMS summary chat agent. Mutates `smsCfg` with the ids
57
+ // and returns whether it created a new one.
58
+ async function ensureSmsAgent(api, smsCfg = {}) {
59
+ if (smsCfg.chat_agent_id) return { chat_agent_id: smsCfg.chat_agent_id, chat_llm_id: smsCfg.chat_llm_id, created: false };
60
+ const llm = await api("POST", "/create-retell-llm", { general_prompt: SMS_AGENT_PROMPT });
61
+ const agent = await api("POST", "/create-chat-agent", {
62
+ response_engine: { type: "retell-llm", llm_id: llm.llm_id },
63
+ agent_name: "callwright_sms_notifier",
64
+ });
65
+ smsCfg.chat_agent_id = agent.agent_id;
66
+ smsCfg.chat_llm_id = llm.llm_id;
67
+ return { chat_agent_id: agent.agent_id, chat_llm_id: llm.llm_id, created: true };
68
+ }
69
+
70
+ // Orchestrate a per-call SMS notification. Mutates `config` if it provisions the
71
+ // agent (caller persists when configChanged). Retell calls are injectable.
72
+ async function notifyCall(call, { config, api, key, to: forceTo, force = false } = {}) {
73
+ api = api || defaultApi(key);
74
+ const want = force ? { notify: true, to: forceTo } : shouldNotify(call);
75
+ if (!want.notify) return { sent: false, reason: "not_requested" };
76
+
77
+ const smsCfg = (config.notify && config.notify.sms) || {};
78
+ const to = want.to || forceTo || smsCfg.to || (config.principal && config.principal.callback_number) || "";
79
+ if (!to) return { sent: false, reason: "no_number" };
80
+ if (!force && call && call.to_number && call.to_number === to) return { sent: false, reason: "self_call" };
81
+
82
+ const fromNumber = smsCfg.from_number || (config.retell && config.retell.from_number);
83
+ if (!fromNumber) return { sent: false, reason: "no_from_number" };
84
+
85
+ let configChanged = false;
86
+ try {
87
+ config.notify = config.notify || {};
88
+ config.notify.sms = config.notify.sms || {};
89
+ const ens = await ensureSmsAgent(api, config.notify.sms);
90
+ if (ens.created) configChanged = true;
91
+
92
+ const summary = buildCallSms(call);
93
+ const resp = await api("POST", "/create-sms-chat", {
94
+ from_number: fromNumber,
95
+ to_number: to,
96
+ override_agent_id: config.notify.sms.chat_agent_id,
97
+ retell_llm_dynamic_variables: { summary },
98
+ metadata: { parent_call_id: call && call.call_id, kind: "call_notification" },
99
+ });
100
+ return { sent: true, to, from: fromNumber, chat_id: resp.chat_id || resp.chat_session_id || null, summary, configChanged };
101
+ } catch (e) {
102
+ const msg = e.message || String(e);
103
+ if (/a2p/i.test(msg)) {
104
+ return { sent: false, reason: "needs_a2p_registration", error: msg, configChanged,
105
+ hint: `The from-number ${fromNumber} is not registered for A2P 10DLC SMS. Complete A2P brand+campaign registration for this number in the Retell dashboard (or use configure_notifications from_number with an already SMS-registered number). This is a US carrier requirement, separate from voice; no code change is needed once registered.` };
106
+ }
107
+ return { sent: false, reason: "send_failed", error: msg, configChanged };
108
+ }
109
+ }
110
+
111
+ module.exports = {
112
+ SMS_AGENT_PROMPT, buildCallSms, shouldNotify, ensureSmsAgent, notifyCall,
113
+ };
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "callwright",
3
+ "version": "1.0.0",
4
+ "description": "A voice agent that places phone calls (reservations, appointments, inquiries) on your behalf, shaped by LLM direction + grounding. Runs fire-and-forget on Retell; an MCP server lets an LLM shape the grounding before each call.",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "callwright": "server.js",
8
+ "callwright-init": "init.js"
9
+ },
10
+ "files": [
11
+ "server.js",
12
+ "init.js",
13
+ "dispatch-core.js",
14
+ "dispatch.js",
15
+ "get-call.js",
16
+ "lang-core.js",
17
+ "learn-core.js",
18
+ "learn.js",
19
+ "notify-core.js",
20
+ "paths.js",
21
+ "retry-core.js",
22
+ "setup-agent-from.js",
23
+ "setup-core.js",
24
+ "update-prompt.js",
25
+ "generic-prompt.md",
26
+ "generic-prompt.ja.md",
27
+ "place_call.schema.json",
28
+ "scenario-profiles.json",
29
+ "lang-phrases.json",
30
+ "config.example.json",
31
+ "grounding.example.json",
32
+ ".env.example",
33
+ "Dockerfile",
34
+ "fly.toml",
35
+ ".dockerignore",
36
+ "providers/",
37
+ "webhook/",
38
+ "examples/",
39
+ "docs/",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "scripts": {
44
+ "start": "node server.js",
45
+ "init": "node init.js",
46
+ "status": "node init.js status",
47
+ "test": "node --test"
48
+ },
49
+ "keywords": [
50
+ "mcp",
51
+ "model-context-protocol",
52
+ "retell",
53
+ "voice-agent",
54
+ "phone-calls"
55
+ ],
56
+ "author": "topness-msft",
57
+ "license": "ISC",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/topness-msft/callwright.git"
61
+ },
62
+ "homepage": "https://github.com/topness-msft/callwright#readme",
63
+ "bugs": {
64
+ "url": "https://github.com/topness-msft/callwright/issues"
65
+ },
66
+ "engines": {
67
+ "node": ">=18"
68
+ },
69
+ "type": "commonjs",
70
+ "dependencies": {
71
+ "@modelcontextprotocol/sdk": "^1.0.0",
72
+ "ajv": "^8.20.0",
73
+ "ajv-formats": "^3.0.1",
74
+ "express": "^4.21.0",
75
+ "retell-sdk": "^4.66.0"
76
+ }
77
+ }
package/paths.js ADDED
@@ -0,0 +1,37 @@
1
+ // Centralized paths for the MUTABLE state files, so they can live on a
2
+ // persistent volume when hosted. Static assets (prompts, schema) stay in the
3
+ // app dir and are loaded relative to cwd as before.
4
+ //
5
+ // Set CALLWRIGHT_DATA_DIR (e.g. /data on a Fly volume) to relocate state.
6
+ // VIRTUPHIL_DATA_DIR is accepted as a legacy fallback. Defaults to "." so
7
+ // local/dev behavior is unchanged.
8
+
9
+ const path = require("path");
10
+ const fs = require("fs");
11
+
12
+ const DATA_DIR =
13
+ process.env.CALLWRIGHT_DATA_DIR || process.env.VIRTUPHIL_DATA_DIR || ".";
14
+
15
+ // Path to a file on the persistent volume (where runtime-added assets live).
16
+ function volumePath(file) { return path.join(DATA_DIR, file); }
17
+
18
+ // Volume-first asset resolution: prefer a volume copy (e.g. a runtime-added
19
+ // language's prompt) and fall back to the image-baked file. Used for assets
20
+ // that may be added at runtime but ship with seeds (prompts, lang-phrases).
21
+ function resolveAsset(file) {
22
+ if (DATA_DIR !== ".") {
23
+ try { const v = volumePath(file); if (fs.existsSync(v)) return v; } catch { /* ignore */ }
24
+ }
25
+ return file;
26
+ }
27
+
28
+ module.exports = {
29
+ DATA_DIR,
30
+ CONFIG_PATH: path.join(DATA_DIR, "config.json"),
31
+ AGENTS_PATH: path.join(DATA_DIR, "agents.json"),
32
+ PROFILES_PATH: path.join(DATA_DIR, "scenario-profiles.json"),
33
+ CANDIDATES_PATH: path.join(DATA_DIR, "scenario-candidates.json"),
34
+ RETRIES_PATH: path.join(DATA_DIR, "retries.json"),
35
+ volumePath,
36
+ resolveAsset,
37
+ };