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.
@@ -0,0 +1,173 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "place_call",
4
+ "description": "Place an outbound phone call on the principal's behalf to accomplish a goal (e.g. a reservation or appointment). The assistant MUST collect every required field from the user before calling this tool. If a required field is missing or ambiguous, ASK the user — do not guess. DATA MINIMIZATION: in `principal.facts`, include ONLY the personal data plausibly needed for THIS specific call_type — never the user's whole profile. Choose the minimal subset (e.g. a repair-confirmation may need service_address; a dinner reservation needs none). PII CONVENTION: put personal/identifying data (address, member id, DOB, account number, etc.) in `principal.facts` (disclosed only if the business asks), and keep `request.scenario_details` for NON-PII call specifics (service_type, party_size, occasion). After assembling the arguments, read them back to the user in plain language — INCLUDING exactly which personal data types will be available to the agent (the names of the keys in principal.facts, plus name/callback_number) — and get confirmation before invoking. This tool is fire-and-forget: it returns a call_id immediately; the result is retrieved on demand via get_call_outcome (Retell is the system of record).",
5
+ "type": "object",
6
+ "required": ["call_type", "target", "principal", "request"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+
10
+ "call_type": {
11
+ "type": "string",
12
+ "description": "Open-ended scenario id, lowercase snake/kebab (e.g. 'haircut', 'restaurant_reservation', 'dentist_cleaning', 'general_inquiry'). If a matching scenario profile exists, it supplies recommended fields, default flexibility, direction hints, and confirm items. Unknown types fall back to generic behavior."
13
+ },
14
+
15
+ "target": {
16
+ "type": "object",
17
+ "description": "The business being called.",
18
+ "required": ["business_name", "phone_number"],
19
+ "additionalProperties": false,
20
+ "properties": {
21
+ "business_name": { "type": "string", "description": "Name of the business, used by the agent when speaking." },
22
+ "phone_number": {
23
+ "type": "string",
24
+ "description": "E.164 format, e.g. +15555550199. If the user gives a local format, normalize it and confirm.",
25
+ "pattern": "^\\+[1-9]\\d{6,14}$"
26
+ },
27
+ "timezone": { "type": "string", "description": "IANA tz of the business, e.g. America/New_York. Infer from area code if not given, then confirm." }
28
+ }
29
+ },
30
+
31
+ "principal": {
32
+ "type": "object",
33
+ "description": "The person the call is placed on behalf of. Stable identity + an OPEN grounding bag. This tool does not source grounding itself — the host LLM populates `facts` from the user's standing profile and/or other connected MCPs (WorkIQ, Gmail, calendar, CRM, etc.).",
34
+ "required": [],
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "name": { "type": "string", "description": "Name to give for the reservation/appointment. OPTIONAL — omit for general inquiries where no booking/identity is involved, so the agent does not attach the principal's name to the call." },
38
+ "anonymous": { "type": "boolean", "description": "Set true for a truly nameless call (general inquiries). Suppresses backfill of name/callback_number from config and places the call with NO principal identity. Default false (basics are backfilled for convenience)." },
39
+ "callback_number": {
40
+ "type": "string",
41
+ "description": "E.164. The agent only shares this if the business asks. Mark in do_not_volunteer if it should never be offered proactively.",
42
+ "pattern": "^\\+[1-9]\\d{6,14}$"
43
+ },
44
+ "facts": {
45
+ "type": "object",
46
+ "description": "MINIMAL per-call subset of standing facts about the principal that the agent may share ONLY IF the business asks (never volunteer). Keys are snake_case labels the agent speaks humanized (e.g. service_address, account_number, date_of_birth, member_id, vehicle, insurance_provider, loyalty_number). Values are strings/numbers/booleans. DATA MINIMIZATION: include ONLY what THIS call_type plausibly needs — do NOT dump the user's full profile. The host LLM selects this subset from the user's standing profile and/or connected context MCPs (WorkIQ, Gmail, calendar, CRM). This is the right home for PII: anything identifying belongs here (only-if-asked + surfaced in the read-back), NOT in scenario_details. Distinct from request.scenario_details (non-PII call specifics).",
47
+ "additionalProperties": { "type": ["string", "number", "boolean"] }
48
+ }
49
+ }
50
+ },
51
+
52
+ "request": {
53
+ "type": "object",
54
+ "description": "What to actually accomplish. Shape these from the user's intent; ask for anything missing.",
55
+ "required": ["summary", "preferred"],
56
+ "additionalProperties": false,
57
+ "properties": {
58
+ "summary": {
59
+ "type": "string",
60
+ "description": "One sentence the agent can act on, e.g. 'Dinner reservation for 4'. For appointments, include the service, e.g. 'Routine dental cleaning'."
61
+ },
62
+ "opening_ask": {
63
+ "type": "string",
64
+ "description": "OPTIONAL. The first thing the agent says after disclosing it's an AI — leads with the goal/need, e.g. \"I'm calling to confirm an appointment. Could you help me with that?\". If omitted, one is derived from `summary`. Do NOT phrase as 'is now a good time?'."
65
+ },
66
+ "party_size": { "type": "integer", "minimum": 1, "description": "Required for reservations. Number of people." },
67
+ "max_party_size": { "type": "integer", "minimum": 1, "description": "Negotiation ceiling; agent will not accept a larger booking. Defaults to party_size if omitted." },
68
+ "preferred": {
69
+ "type": "object",
70
+ "description": "The first-choice date/time. The agent aims for this before falling back. If acceptable_windows is omitted, a single window is derived from this plus the flexibility minutes.",
71
+ "required": ["date", "time"],
72
+ "additionalProperties": false,
73
+ "properties": {
74
+ "date": { "type": "string", "format": "date", "description": "YYYY-MM-DD. Resolve relative dates ('Saturday') against the business timezone and confirm the absolute date." },
75
+ "time": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", "description": "24h HH:MM." }
76
+ }
77
+ },
78
+ "acceptable_windows": {
79
+ "type": "array",
80
+ "description": "OPTIONAL ordered fallback ladder. The agent negotiates DOWN this list and accepts nothing outside it. If omitted, one window is derived from `preferred` +/- the flexibility minutes (default 45). Provide explicitly when the user names multiple fallback dates/times.",
81
+ "minItems": 1,
82
+ "items": {
83
+ "type": "object",
84
+ "required": ["date", "earliest", "latest"],
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "date": { "type": "string", "format": "date" },
88
+ "earliest": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" },
89
+ "latest": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ },
95
+
96
+ "scenario_details": {
97
+ "type": "object",
98
+ "description": "NON-PII scenario specifics keyed by the matching profile (e.g. for 'haircut': stylist, service_type, hair_length; for a reservation: occasion). These are spoken PROACTIVELY by the agent during the call, so DO NOT put personal/identifying data here (address, member id, DOB, phone, account number) — that belongs in principal.facts (only-if-asked). The profile lists which keys are recommended; the dispatcher warns when recommended keys are missing and speaks the provided ones during the call. Open-ended bag — any string/number/boolean values allowed.",
99
+ "additionalProperties": { "type": ["string", "number", "boolean"] }
100
+ },
101
+
102
+ "preferences": {
103
+ "type": "array",
104
+ "description": "Nice-to-haves, ranked. Never blockers. e.g. 'Quieter table', 'Patio over bar'.",
105
+ "items": { "type": "string" }
106
+ },
107
+
108
+ "constraints": {
109
+ "type": "array",
110
+ "description": "Things the agent must proactively raise/verify, e.g. 'Nut allergy — confirm kitchen can accommodate', 'Wheelchair access needed'.",
111
+ "items": { "type": "string" }
112
+ },
113
+
114
+ "must_confirm": {
115
+ "type": "array",
116
+ "description": "Fields the agent reads back to the business before ending the call. Defaults to date, time, party_size, name.",
117
+ "items": { "type": "string" }
118
+ },
119
+
120
+ "do_not_volunteer": {
121
+ "type": "array",
122
+ "description": "Facts the agent may USE if asked but must NOT offer proactively (PII minimization). e.g. 'callback_number'.",
123
+ "items": { "type": "string" }
124
+ },
125
+
126
+ "anticipated_qa": {
127
+ "type": "array",
128
+ "description": "Pre-answers to likely business questions, to avoid awkward pauses. Optional but improves call quality.",
129
+ "items": {
130
+ "type": "object",
131
+ "required": ["q", "a"],
132
+ "additionalProperties": false,
133
+ "properties": {
134
+ "q": { "type": "string" },
135
+ "a": { "type": "string" }
136
+ }
137
+ }
138
+ },
139
+
140
+ "overrides": {
141
+ "type": "object",
142
+ "description": "Optional per-call overrides of the direction template defaults. Omit to use template defaults.",
143
+ "additionalProperties": false,
144
+ "properties": {
145
+ "time_flexibility_minutes": { "type": "integer", "minimum": 0 },
146
+ "disclose_ai": { "type": "boolean", "description": "Default true. Do not set false without a legal reason." },
147
+ "record_audio": { "type": "boolean", "description": "Default false. If true, agent must speak a consent line." },
148
+ "max_call_duration_seconds": { "type": "integer", "minimum": 30 },
149
+ "on_voicemail": { "type": "string", "enum": ["leave_message", "hang_up"] },
150
+ "retry": {
151
+ "type": "object",
152
+ "description": "Retry-on-no-answer policy. Defaults to exactly 1 retry; never raise max_retries above 1 without an explicit user request (repeated calls are spammy).",
153
+ "additionalProperties": false,
154
+ "properties": {
155
+ "max_retries": { "type": "integer", "minimum": 0, "maximum": 5, "description": "Number of re-dials on no-answer. Default 1. >1 only on explicit user request." },
156
+ "on": { "type": "array", "items": { "type": "string", "enum": ["no_answer", "busy", "failed"] } },
157
+ "delay_seconds": { "type": "integer", "minimum": 0, "maximum": 600 }
158
+ }
159
+ }
160
+ }
161
+ },
162
+
163
+ "notify": {
164
+ "type": "object",
165
+ "description": "Optional per-call 'text me when it's done'. Off by default — results are pulled on demand via get_call_outcome. Only set sms:true if the user asks to be notified.",
166
+ "additionalProperties": false,
167
+ "properties": {
168
+ "sms": { "type": "boolean", "description": "True = send the user an SMS when this call completes." },
169
+ "to": { "type": "string", "description": "The user's own number to text (E.164). If omitted, uses the saved notify number; if none, ask the user." }
170
+ }
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,116 @@
1
+ # Provider abstraction (evaluated — Retell retained)
2
+
3
+ > **Decision (2026-06-23): stay on Retell.** This document is kept as (a) a record of the
4
+ > ElevenLabs evaluation and (b) a ready blueprint if we ever add a second provider. The live
5
+ > code is intentionally **not** refactored behind this interface — Retell is a direct,
6
+ > well-contained dependency.
7
+ >
8
+ > **Why Retell over ElevenLabs**, despite ElevenLabs' stronger voice/multilingual:
9
+ > 1. **Adoptability** — Retell is one self-contained platform ("paste API key, buy a number,
10
+ > done"). ElevenLabs Agents needs a separate **BYO Twilio** account/number/billing, which is
11
+ > real friction for an OSS tool meant for others to clone and run.
12
+ > 2. **Pay-as-you-go, no monthly floor** — Retell bills per-minute with no commitment, ideal for
13
+ > a personal tool placing a handful of calls/month. ElevenLabs Agents pricing is
14
+ > plan/subscription-oriented (a monthly floor).
15
+ > 3. **No capability gap** — Retell does everything we need (incl. IVR/`press_digit`); ElevenLabs'
16
+ > edges (voice warmth, multilingual STT, `language_detection`) are quality improvements, not
17
+ > blockers. They're documented below as a **fallback** if those ever become the dominant pain.
18
+
19
+ Callwright's "brain" (job schema, scenario profiles, grounding/direction composition,
20
+ disclosure & negotiation rules in the prompts, the 7 MCP tools, the learning loop) is
21
+ **platform-agnostic**. Only the *actuator* — the part that actually provisions agents and
22
+ dials phones — is platform-specific.
23
+
24
+ Today that actuator is **Retell**, reached via plain REST. This document defines the small
25
+ interface a provider must implement so the actuator becomes pluggable (Retell **or**
26
+ ElevenLabs, selected by a `CALLWRIGHT_PROVIDER` env var).
27
+
28
+ The actuator surface is tiny — **5 methods**. Everything else is reused unchanged.
29
+
30
+ ## The interface
31
+
32
+ ```js
33
+ // providers/<name>.js
34
+ module.exports = {
35
+ // 1. Verify credentials; return the list of existing agents.
36
+ async verifyKey(key) -> Agent[],
37
+
38
+ // 2. List/resolve the outbound caller number.
39
+ async resolveFromNumber(key, preferred) -> { from_number, numbers, needsChoice?, reason? },
40
+
41
+ // 3. Reuse-or-create the generic agent from a prompt file + voice + language.
42
+ // Bakes in the call behavior (wait-for-greeting, IVR/DTMF tool, voicemail,
43
+ // hold tolerance, post-call analysis fields).
44
+ async ensureGenericAgent(key, { agents, defaultId, promptFile, voiceId, lang }) -> { agent_id, created, reused },
45
+
46
+ // 4. Place one outbound call, injecting per-call grounding as {{variables}}.
47
+ async placeCall({ key, from, to, agentId, vars }) -> { call_id },
48
+
49
+ // 5. Fetch a completed call's outcome + transcript.
50
+ async getOutcome(key, callId) -> { status, disconnect, duration_s, analysis, transcript },
51
+ };
52
+ ```
53
+
54
+ ## Method mapping: Retell (today) vs ElevenLabs
55
+
56
+ | # | Method | **Retell** (current code) | **ElevenLabs** (verified equivalent) |
57
+ |---|---|---|---|
58
+ | 1 | `verifyKey` | `POST /v2/list-agents` (voice channel filter; paginated) | `GET /v1/convai/agents` (list agents) |
59
+ | 2 | `resolveFromNumber` | `GET /v2/list-phone-numbers` (buy/list in-platform) | List imported numbers; **BYO Twilio/SIP** |
60
+ | 3 | `ensureGenericAgent` | `POST /create-retell-llm` + `POST /create-agent` (voice_id, language, stt_mode, interruption_sensitivity, `press_digit` + `end_call` tools, voicemail_option, post_call_analysis_data) | `POST /v1/convai/agents/create` with `conversation_config` (prompt, first_message, voice, system tools: `play_keypad_touch_tone`, `voicemail_detection`, `language_detection`, `end_call`, `skip_turn`) |
61
+ | 4 | `placeCall` | `POST /v2/create-phone-call` with `override_agent_id` + `retell_llm_dynamic_variables` | Outbound call API / batch-calls with per-call **dynamic variables** + overrides (`language`, `first_message`, `system_prompt`, `voice_id`) |
62
+ | 5 | `getOutcome` | `GET /v2/get-call/{id}` → `call_analysis.custom_analysis_data` | Conversation analysis API → analysis fields |
63
+
64
+ ## What ports unchanged (the brain — ~70% of the code)
65
+
66
+ - `place_call.schema.json` — the job contract
67
+ - `scenario-profiles.json` + `learn.js` — profiles & learning loop
68
+ - `dispatch-core.composeCall()` — grounding → `{{variables}}` (BOTH platforms use identical
69
+ `{{var}}` double-curly templating, so the composed output is portable verbatim)
70
+ - `generic-prompt.md` / `.ja.md` — the agent prompts (same `{{var}}` syntax)
71
+ - `server.js` — all 7 MCP tools, stateless HTTP, auth
72
+ - `setup-core` config/status/guard helpers
73
+
74
+ ## What changes (the actuator — ~30%)
75
+
76
+ - Current Retell calls in `dispatch-core.dispatchCall`, `setup-core.{verifyKey,listNumbers,
77
+ ensureGenericAgent}`, and `server.retell()`/`get-call.js` move behind `providers/<name>.js`.
78
+ - `dispatch-core`/`setup-core` call `provider.X()` chosen by `CALLWRIGHT_PROVIDER`
79
+ (default `retell`).
80
+ - `providers/retell.js` is a straight extraction of today's code (no behavior change).
81
+ - `providers/elevenlabs.js` is the new implementation (~200–300 lines).
82
+
83
+ ## Behavior parity notes (ElevenLabs has NATIVE solutions to our hand-tuned pain points)
84
+
85
+ | Callwright behavior (hand-tuned in Retell) | ElevenLabs native equivalent |
86
+ |---|---|
87
+ | `press_digit` for IVR menus | `play_keypad_touch_tone` (0-9,*,#, **pause control**, **out-of-band RFC 4733** — more robust) |
88
+ | Wait for callee greeting (`start_speaker:user` + 8s) | `skip_turn` system tool + turn-taking config |
89
+ | Voicemail handling (`voicemail_option`) | `voicemail_detection` system tool |
90
+ | Bilingual JA/EN STT garbling (our worst issue) | `language_detection` system tool (auto-switch mid-call) |
91
+ | `end_call` tool | `end_call` system tool |
92
+ | post_call_analysis_data | conversation analysis |
93
+
94
+ ## Cost (rough, all-in per minute)
95
+
96
+ | | Retell | ElevenLabs |
97
+ |---|---|---|
98
+ | Voice stack | ~$0.07–0.08/min (bundled) | $0.08/min flat (paid tier) |
99
+ | LLM | passthrough | separate (credits) |
100
+ | Telephony US | $0.015/min (bundled) | BYO Twilio ~$0.014/min |
101
+ | Telephony Japan | $0.28/min (markup) | BYO Twilio ~$0.10–0.20/min raw |
102
+ | **US call all-in** | ~$0.10–0.15/min | ~$0.10–0.14/min (≈ parity) |
103
+ | **Japan call all-in** | ~$0.37–0.44/min | ~$0.20–0.30/min (cheaper) |
104
+
105
+ Tradeoff: ElevenLabs is ≈ parity (US) / cheaper (intl) per-minute, but adds **BYO Twilio
106
+ setup** + a small monthly plan floor. At personal volume, per-minute cost is pennies either
107
+ way — choose on capability (voice warmth, multilingual), not price.
108
+
109
+ ## Status
110
+
111
+ This is a **design contract only** — the live code is NOT yet refactored behind it (the
112
+ production Retell path is untouched). Implementing it is a bounded follow-up:
113
+ 1. Extract `providers/retell.js` from current code (no behavior change) + verify/deploy.
114
+ 2. Add `CALLWRIGHT_PROVIDER` switch in `dispatch-core`/`setup-core`.
115
+ 3. Implement `providers/elevenlabs.js` + a Twilio number.
116
+ 4. A/B one real call per platform (esp. a bilingual Japanese call) before switching default.
package/retry-core.js ADDED
@@ -0,0 +1,121 @@
1
+ // callwright — retry-on-no-answer core. Pure functions: classify a Retell
2
+ // disconnection, parse a retry policy (DEFAULT = exactly 1 retry; never more
3
+ // without an explicit per-call request — anti-spam), round-trip the policy +
4
+ // attempt counter through the call's dynamic variables, and decide whether a
5
+ // finished call should be re-dialed.
6
+ //
7
+ // The policy travels IN the call's dynamic vars (retry_max/retry_on/
8
+ // retry_attempt/retry_delay), so the webhook can decide from the call_ended
9
+ // payload alone — no job lookup — and the incrementing attempt counter makes
10
+ // the retry chain self-terminating.
11
+
12
+ const MAX_RETRY_CAP = 5; // absolute ceiling, even on explicit request
13
+ const MAX_DELAY_SECONDS = 600; // 10 min (best-effort while the machine is warm)
14
+ const DEFAULT_MAX_RETRIES = 1; // <-- the anti-spam default: one retry, no more
15
+ const DEFAULT_DELAY_SECONDS = 60;
16
+
17
+ // Retell disconnection_reason -> coarse category.
18
+ const REASON_CATEGORY = {
19
+ dial_no_answer: "no_answer",
20
+ no_answer: "no_answer",
21
+ dial_busy: "busy",
22
+ busy: "busy",
23
+ dial_failed: "failed",
24
+ failed: "failed",
25
+ voicemail_reached: "voicemail",
26
+ machine_detected: "voicemail",
27
+ voicemail: "voicemail",
28
+ user_hangup: "answered",
29
+ agent_hangup: "answered",
30
+ call_transfer: "answered",
31
+ inactivity: "answered",
32
+ max_duration_reached: "answered",
33
+ scam_detected: "blocked",
34
+ no_valid_payment: "blocked",
35
+ concurrency_limit_reached: "blocked",
36
+ };
37
+
38
+ function classifyDisconnect(reason) {
39
+ const r = String(reason || "").toLowerCase();
40
+ if (REASON_CATEGORY[r]) return REASON_CATEGORY[r];
41
+ if (r.startsWith("error")) return "error";
42
+ return "other";
43
+ }
44
+
45
+ // Categories that retry can act on (no-answer / busy / transient failure).
46
+ const DEFAULT_ON = ["no_answer", "busy", "failed"];
47
+
48
+ function normalizeCategory(token) {
49
+ const t = String(token || "").toLowerCase();
50
+ return REASON_CATEGORY[t] || (DEFAULT_ON.includes(t) ? t : (t === "voicemail" ? "voicemail" : t));
51
+ }
52
+
53
+ function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
54
+
55
+ function parseRetryPolicy(overridesRetry) {
56
+ const o = overridesRetry || {};
57
+ const rawMax = (o.max_retries === undefined || o.max_retries === null) ? DEFAULT_MAX_RETRIES : Number(o.max_retries);
58
+ const maxRetries = Number.isFinite(rawMax) ? clamp(Math.trunc(rawMax), 0, MAX_RETRY_CAP) : DEFAULT_MAX_RETRIES;
59
+ const rawDelay = (o.delay_seconds === undefined || o.delay_seconds === null) ? DEFAULT_DELAY_SECONDS : Number(o.delay_seconds);
60
+ const delaySeconds = Number.isFinite(rawDelay) ? clamp(Math.trunc(rawDelay), 0, MAX_DELAY_SECONDS) : DEFAULT_DELAY_SECONDS;
61
+ const onList = Array.isArray(o.on) && o.on.length ? o.on.map(normalizeCategory) : DEFAULT_ON.slice();
62
+ return { maxRetries, delaySeconds, on: new Set(onList) };
63
+ }
64
+
65
+ function policyToVars(policy, attempt = 0) {
66
+ return {
67
+ retry_max: String(policy.maxRetries),
68
+ retry_on: [...policy.on].join(","),
69
+ retry_delay: String(policy.delaySeconds),
70
+ retry_attempt: String(attempt),
71
+ };
72
+ }
73
+
74
+ function readPolicyVars(vars) {
75
+ const v = vars || {};
76
+ if (v.retry_max === undefined || v.retry_max === null || v.retry_max === "") return null; // no policy present
77
+ const on = new Set(String(v.retry_on || "").split(",").map((s) => s.trim()).filter(Boolean).map(normalizeCategory));
78
+ return {
79
+ maxRetries: clamp(parseInt(v.retry_max, 10) || 0, 0, MAX_RETRY_CAP),
80
+ delaySeconds: clamp(parseInt(v.retry_delay, 10) || 0, 0, MAX_DELAY_SECONDS),
81
+ attempt: parseInt(v.retry_attempt, 10) || 0,
82
+ on,
83
+ };
84
+ }
85
+
86
+ // Decide whether a finished call should be retried. Pure: reads the call's
87
+ // disconnection_reason + retry_* dynamic vars. Returns the NEXT attempt number.
88
+ function decideRetry(call) {
89
+ const reason = call && call.disconnection_reason;
90
+ const category = classifyDisconnect(reason);
91
+ const policy = readPolicyVars(call && call.retell_llm_dynamic_variables);
92
+ if (!policy) return { retry: false, reason: "no_policy", category };
93
+ if (policy.maxRetries <= 0) return { retry: false, reason: "max_retries_reached", category, maxRetries: policy.maxRetries };
94
+ if (!policy.on.has(category)) return { retry: false, reason: "not_retryable", category };
95
+ if (policy.attempt >= policy.maxRetries) return { retry: false, reason: "max_retries_reached", category, attempt: policy.attempt, maxRetries: policy.maxRetries };
96
+ return {
97
+ retry: true,
98
+ attempt: policy.attempt + 1,
99
+ maxRetries: policy.maxRetries,
100
+ delaySeconds: policy.delaySeconds,
101
+ category,
102
+ };
103
+ }
104
+
105
+ // Vars for the re-dial: same vars, attempt bumped so the chain self-terminates.
106
+ function buildRetryVars(vars, attempt) {
107
+ return { ...(vars || {}), retry_attempt: String(attempt) };
108
+ }
109
+
110
+ // A one-line human summary for the read-back.
111
+ function describePolicy(policy) {
112
+ if (!policy || policy.maxRetries <= 0) return "off (no automatic retry)";
113
+ const on = [...policy.on].join("/");
114
+ return `up to ${policy.maxRetries} on ${on} (after ${policy.delaySeconds}s, best-effort)`;
115
+ }
116
+
117
+ module.exports = {
118
+ MAX_RETRY_CAP, MAX_DELAY_SECONDS, DEFAULT_MAX_RETRIES, DEFAULT_DELAY_SECONDS, DEFAULT_ON,
119
+ classifyDisconnect, parseRetryPolicy, policyToVars, readPolicyVars,
120
+ decideRetry, buildRetryVars, describePolicy,
121
+ };
@@ -0,0 +1,112 @@
1
+ {
2
+ "haircut": {
3
+ "aliases": [
4
+ "haircut",
5
+ "hair_cut",
6
+ "salon",
7
+ "barber",
8
+ "hair"
9
+ ],
10
+ "default_flex_minutes": 60,
11
+ "recommended_details": {
12
+ "stylist": "Preferred stylist name, or 'first available'",
13
+ "service_type": "Type of service requested"
14
+ },
15
+ "must_confirm": [
16
+ "the date",
17
+ "the time",
18
+ "the service",
19
+ "the stylist if specified"
20
+ ],
21
+ "learned": []
22
+ },
23
+ "restaurant_reservation": {
24
+ "aliases": [
25
+ "restaurant",
26
+ "reservation",
27
+ "dinner",
28
+ "table"
29
+ ],
30
+ "default_flex_minutes": 45,
31
+ "recommended_details": {
32
+ "party_size": "Number of people",
33
+ "occasion": "Special occasion, if any (else none)"
34
+ },
35
+ "must_confirm": [
36
+ "the party size",
37
+ "the date",
38
+ "the time"
39
+ ],
40
+ "learned": []
41
+ },
42
+ "dentist": {
43
+ "aliases": [
44
+ "dentist",
45
+ "dental",
46
+ "cleaning",
47
+ "orthodontist"
48
+ ],
49
+ "default_flex_minutes": 120,
50
+ "recommended_details": {
51
+ "service_type": "e.g. routine cleaning, filling, checkup",
52
+ "new_or_existing": "New patient or existing patient",
53
+ "insurance": "Insurance provider, if they may ask"
54
+ },
55
+ "must_confirm": [
56
+ "the date",
57
+ "the time",
58
+ "the service",
59
+ "the patient name"
60
+ ],
61
+ "learned": []
62
+ },
63
+ "appointment_confirmation": {
64
+ "aliases": [
65
+ "appointment_confirmation",
66
+ "confirm_appointment",
67
+ "confirmation",
68
+ "service_appointment",
69
+ "repair",
70
+ "service_call"
71
+ ],
72
+ "default_flex_minutes": 0,
73
+ "recommended_details": {
74
+ "service_address": "Service/site address used to look up the appointment",
75
+ "service_type": "What the appointment is for (e.g. stove repair)"
76
+ },
77
+ "must_confirm": [
78
+ "the date",
79
+ "the appointment window",
80
+ "the name"
81
+ ],
82
+ "learned": []
83
+ },
84
+ "hotel_transport_inquiry": {
85
+ "aliases": [
86
+ "hotel_transport_inquiry",
87
+ "hotel_transport",
88
+ "hire_car",
89
+ "hired_car",
90
+ "car_service",
91
+ "airport_transfer",
92
+ "limousine",
93
+ "haiya",
94
+ "shuttle"
95
+ ],
96
+ "default_flex_minutes": 0,
97
+ "recommended_details": {
98
+ "destination": "Where the car is going (e.g. the airport name), or pickup+dropoff",
99
+ "pickup_time": "Desired pickup date/time, if booking; omit for a pure price inquiry",
100
+ "passengers": "Number of passengers",
101
+ "luggage": "Number/size of bags, if relevant to vehicle choice",
102
+ "vehicle_preference": "Sedan / van / luxury, if the caller has a preference"
103
+ },
104
+ "must_confirm": [
105
+ "the destination",
106
+ "the price",
107
+ "what the price includes (tax, tolls)",
108
+ "the vehicle type"
109
+ ],
110
+ "learned": []
111
+ }
112
+ }