jsfe 1.14.0 → 1.14.2

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.
@@ -452,7 +452,7 @@ export const FLOWS = [
452
452
  "id": "ac_switch_to_text_key",
453
453
  "type": "SET",
454
454
  "variable": "ac_key",
455
- "value": "ac_key === '' && !ac_other && !ac_amount && cargo.voice && String(ac_answer || '').trim() === '9' && !!callerTextNumber(cargo) ? '__text' : ac_key"
455
+ "value": "ac_key === '' && !ac_other && !ac_amount && cargo.voice && String(ac_answer || '').trim().replace(/[.,!?;:]+$/, '') === '9' && !!callerTextNumber(cargo) ? '__text' : ac_key"
456
456
  },
457
457
  {
458
458
  "id": "ac_keypad_again",
@@ -629,7 +629,7 @@ export const FLOWS = [
629
629
  "id": "ac_switch_to_text_key_again",
630
630
  "type": "SET",
631
631
  "variable": "ac_key",
632
- "value": "ac_key === '' && !ac_other && !ac_amount && cargo.voice && String(ac_answer || '').trim() === '9' && !!callerTextNumber(cargo) ? '__text' : ac_key"
632
+ "value": "ac_key === '' && !ac_other && !ac_amount && cargo.voice && String(ac_answer || '').trim().replace(/[.,!?;:]+$/, '') === '9' && !!callerTextNumber(cargo) ? '__text' : ac_key"
633
633
  },
634
634
  {
635
635
  "id": "ac_keypad_done",
@@ -1 +1 @@
1
- {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AA6HA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAodD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAhpBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAy0B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
1
+ {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AA6HA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAueD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAnqBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA41B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
@@ -269,7 +269,9 @@ const collectInput = {
269
269
  SET('ci_question', 'ci_q', "formInputPrompt(cargo,form,current,ci_spec,ci_d,'en')"),
270
270
  SET('ci_question_es', 'ci_q_es', "formInputPrompt(cargo,form,current,ci_spec,ci_d,'es')"),
271
271
  SIDE('ci_feedback_consumed', 'consumeFormFeedback(cargo,form,current)'),
272
- SET('ci_own_choices', 'ci_choices', "ci_spec.choices_from && Array.isArray(ci_d[ci_spec.choices_from]) ? ci_d[ci_spec.choices_from] : (ci_spec.profile.kind === 'choice' && Array.isArray(ci_spec.profile.choices) ? ci_spec.profile.choices : [])"),
272
+ // A grouped input (any_of: a plan option, an amount...) offers the ranker every choice its members declare (prod 2026-10-28:
273
+ // the pay offer offered none, so a caller's own words for a listed option were not understood).
274
+ SET('ci_own_choices', 'ci_choices', "ci_spec.choices_from && Array.isArray(ci_d[ci_spec.choices_from]) ? ci_d[ci_spec.choices_from] : (ci_spec.profile.kind === 'choice' && Array.isArray(ci_spec.profile.choices) ? ci_spec.profile.choices : (Array.isArray(ci_spec.any_of) ? ci_spec.any_of.reduce(function(all,m){return all.concat(m && m.profile && m.profile.kind === 'choice' && Array.isArray(m.profile.choices) ? m.profile.choices.filter(function(c,i,own){return !all.some(function(a){return String(a.key)===String(c.key);}) && own.findIndex(function(o){return String(o.key)===String(c.key);})===i;}) : []);},[]) : []))"),
273
275
  SET('ci_keypad', 'ci_digits', 'ci_spec.digits || null'),
274
276
  SET('ci_is_amount', 'ci_amount', "ci_spec.amount === 'marked' ? 'marked' : (ci_spec.amount === true || ci_spec.profile.kind === 'amount')"),
275
277
  // FORM profile readers own normalization and compound matching. Never discard other
@@ -383,15 +385,24 @@ const readFormResponse = {
383
385
  };
384
386
  // An answer the field's reader could not read goes to the reply ranker, never straight to the host (owner 2026-10-26: a form
385
387
  // gives up only for a real new intent). The ranker is offered the question's declared choices and the two escapes every
386
- // question supports (owner 2026-10-28): a person, and on a call with a textable number, text. A choice that only its
387
- // exact words may take (exact, or the YES of a firm confirmation) is never offered; a NO is (declining takes no action).
388
+ // question supports (owner 2026-10-28): a person, and on a call with a textable number, text. Every declared choice is
389
+ // offered, `exact` ones too (exact is how WORDS match a choice, not a confirmation); only the YES of a firm (hard)
390
+ // confirmation never is. A choice without a label is described by its key and its worded phrases (keys are digits).
388
391
  // `answers` (picks none): asked again ("Sorry, I didn't catch that."), counted as an attempt so max_attempts still bounds
389
392
  // it; `abort` ends every flow; `new_request` goes to intent detection.
390
393
  const rankedMiss = (id) => SIDE(id, "(cargo.forms[form].attempts=cargo.forms[form].attempts || {},cargo.forms[form].attempts[input]=Number(cargo.forms[form].attempts[input] || 0)+1,cargo.forms[form].feedback=cargo.forms[form].feedback || {},cargo.forms[form].feedback[input]='miss',true)");
391
- const ESCAPES_JS = "[{key:'__agent',label:'speak with a person (a live agent)'}].concat(cargo.voice && callerTextNumber(cargo) ? [{key:'__text',label:'continue by text message'}] : [])";
394
+ // The ranker is offered only a person. Text is never offered: its label matched "continuar", "texto" and "enlace", and a
395
+ // call moved to text hangs up (prod 2026-10-28: 7 of 7 such picks were wrong). TEXTO and keypad 9 are read before it.
396
+ const ESCAPES_JS = "[{key:'__agent',label:'speak with a person (a live agent)'}]";
392
397
  const rankedChoices = (id, from) => SET(id, 'ranked', `(${from}).concat(${ESCAPES_JS})`);
393
- const escapeRoutes = (id) => [WHEN(`${id}_agent`, "reply && reply.key === '__agent'", FLOW(`${id}_agent_flow`, 'live-agent-requested', 'reboot')),
394
- WHEN(`${id}_text`, "reply && reply.key === '__text'", FLOW(`${id}_text_flow`, 'switch-to-text', 'reboot'))];
398
+ // A choice as the ranker is offered it: its key, and its label or its worded phrases.
399
+ const RANK_LABEL_JS = "function(c){var said=(Array.isArray(c.phrases) ? c.phrases : []).map(String).filter(function(p){return /[a-zà-ÿ]/i.test(p) && p!==String(c.key);}).slice(0,6);return String(c.label || (said.length ? c.key+' ('+said.join(', ')+')' : c.key));}";
400
+ // The choices of this form's OTHER inputs that the answer could also set (the word reader reads them all: 'LO VENCIDO' at
401
+ // the pay confirmation): the ranker is offered every choice the reader would accept (owner 2026-10-28). A confirmation
402
+ // input is answered only when asked, and a key already offered is not offered twice. Each carries its input, so a pick is
403
+ // read as that input's answer.
404
+ const otherChoicesJS = (asked, taken) => `(function(){var seen=(${taken}).map(function(c){return String(c.key);}).concat(['yes','no']);return (chain || []).filter(function(i){return i.form===form && i.name!==${asked} && !i.confirmation && i.profile && i.profile.kind==='choice' && Array.isArray(i.profile.choices);}).reduce(function(all,i){return all.concat(i.profile.choices.filter(function(c){var k=String(c.key);if(seen.indexOf(k)>=0)return false;seen.push(k);return true;}).map(function(c){return {key:String(c.key),label:(${RANK_LABEL_JS})(c),owner:{form:i.form,name:i.name}};}));},[]);})()`;
405
+ const escapeRoutes = (id) => [WHEN(`${id}_agent`, "reply && reply.key === '__agent'", FLOW(`${id}_agent_flow`, 'live-agent-requested', 'reboot'))];
395
406
  const rankedDispatch = (id, reason) => ({ ...yieldDispatch(id, 'form', `'${reason}'`), onResponse: 'continue', responseContext: 'Current question: {{asked}}', responseChoices: 'ranked', responseVariable: 'reply' });
396
407
  const readFormReply = {
397
408
  id: 'read-form-reply', name: 'ReadFormReply', version: '1.1.0', scope: 'isolated', description: 'Rank an unreadable answer against its question: a request for a person or for text is taken; an answer is asked again; a withdrawal or a new request leaves with saved form progress.',
@@ -404,37 +415,45 @@ const readFormReply = {
404
415
  rankedMiss('reply_miss')]
405
416
  };
406
417
  const readFormChoice = {
407
- id: 'read-form-choice', name: 'ReadFormChoice', version: '1.1.0', scope: 'isolated', description: 'Rank an unreadable answer to a choice question and take the offered option it picks, or a request for a person or for text; otherwise ask again. A withdrawal or a new request leaves with saved form progress.',
418
+ id: 'read-form-choice', name: 'ReadFormChoice', version: '1.3.0', scope: 'isolated', description: 'Rank an unreadable answer to a choice question and take the offered option it picks, or a request for a person or for text; otherwise ask again. A withdrawal or a new request leaves with saved form progress.',
408
419
  parameters: [{ name: 'form', type: 'string' }, { name: 'input', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }, { name: 'choices', type: 'array' }, { name: 'firm', type: 'string' }],
409
- variables: vars({ question: '', question_es: '', choices: [], firm: '', asked: '', ranked: [], reply: null, picked: '', chain: null, reading: null }),
420
+ variables: vars({ question: '', question_es: '', choices: [], firm: '', asked: '', ranked: [], reply: null, picked: '', chain: null, reading: null, others: [], owner: null }),
410
421
  steps: [SET('choice_asked', 'asked', "language === 'es' && question_es ? question_es : question"),
411
- rankedChoices('choice_ranked', "(choices || []).filter(function(c){return String(c.key)==='no' || c.exact!==true && !(firm==='firm' && String(c.key)==='yes');}).map(function(c){return {key:String(c.key),label:String(c.label || (Array.isArray(c.phrases) && c.phrases.filter(function(p){return String(p)!==String(c.key);})[0]) || c.key)};})"),
422
+ SET('choice_chain', 'chain', 'formInputChain(cargo.forms, form, cargo)'),
423
+ SET('choice_others', 'others', otherChoicesJS('input', 'choices || []')),
424
+ rankedChoices('choice_ranked', `(choices || []).filter(function(c,i,all){return !(firm==='firm' && String(c.key)==='yes') && all.findIndex(function(o){return String(o.key)===String(c.key);})===i;}).map(function(c){return {key:String(c.key),label:(${RANK_LABEL_JS})(c)};}).concat(others.map(function(c){return {key:c.key,label:c.label};}))`),
412
425
  rankedDispatch('choice_dispatch', 'input_not_matched'),
413
426
  ...escapeRoutes('choice_escape'),
414
- SET('choice_picked', 'picked', "(function(){var k=String(reply && reply.key || '');var c=(choices || []).filter(function(c){return String(c.key)===k;})[0];return c ? String(c.key) : '';})()"),
427
+ SET('choice_picked', 'picked', "(function(){var k=String(reply && reply.key || '');var c=(choices || []).filter(function(c){return String(c.key)===k;})[0] || others.filter(function(c){return c.key===k;})[0];return c ? String(c.key) : '';})()"),
428
+ SET('choice_owner', 'owner', "(others.filter(function(c){return c.key===picked && !(choices || []).some(function(own){return String(own.key)===picked;});})[0] || {}).owner || null"),
415
429
  WHEN('choice_none', '!picked', rankedMiss('choice_miss')),
416
430
  WHEN('choice_none_end', '!picked', END('choice_none_done')),
417
431
  SIDE('choice_take', "(cargo.choice=picked,cargo.choiceText=picked,true)"),
418
- SET('choice_chain', 'chain', 'formInputChain(cargo.forms, form, cargo)'),
419
- SET('choice_read', 'reading', "enrichFormMatches(readFormAnswer(picked, chain, { form: form, name: input }, picked, []), chain, form, [input], input)"),
432
+ SET('choice_read', 'reading', "owner ? enrichFormMatches(readFormAnswer(picked, chain, owner, picked, []), chain, form, [owner.name], owner.name) : enrichFormMatches(readFormAnswer(picked, chain, { form: form, name: input }, picked, []), chain, form, [input], input)"),
420
433
  WHEN('choice_unread', '!reading.hits.length', rankedMiss('choice_unread_miss')),
421
434
  WHEN('choice_unread_end', '!reading.hits.length', END('choice_unread_done')),
422
435
  SIDE('choice_clear', 'clearFormMisses(cargo,form,input)'),
423
436
  FLOW('choice_accept', 'collect-input-accept', 'call', { hits: '{{reading.hits}}' })]
424
437
  };
425
438
  // A confirmation's unread answer goes to the reply ranker with the confirmation question (owner 2026-10-27), never
426
- // straight to the host. A form's confirmation is exact (it authorizes the form's action), so the ranker is offered NO
427
- // and the escapes, never YES. Result: cargo.confirmRanked = 'no' | 'none' (ConfirmForm reads it); `abort` ends every
428
- // flow; `new_request` goes to intent detection.
439
+ // straight to the host. A form's confirmation is exact (it authorizes the form's action), so the ranker is offered NO, the
440
+ // form's other choices (a correction) and a person, never YES. Result: cargo.confirmRanked = 'no' | 'changed' | 'none'
441
+ // (ConfirmForm reads it); `abort` ends every flow; `new_request` goes to intent detection.
429
442
  const readConfirmReply = {
430
- id: 'read-confirm-reply', name: 'ReadConfirmReply', version: '1.1.0', scope: 'isolated', description: 'Rank an unreadable answer to a confirmation: it declines (NO), asks for a person or for text, or is asked again; a withdrawal or a new request leaves with saved form progress. Never confirms.',
443
+ id: 'read-confirm-reply', name: 'ReadConfirmReply', version: '1.2.0', scope: 'isolated', description: 'Rank an unreadable answer to a confirmation: it declines (NO), picks another of the form\'s choices (a correction, read as that input\'s answer), asks for a person, or is asked again; a withdrawal or a new request leaves with saved form progress. Never confirms.',
431
444
  parameters: [{ name: 'form', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }],
432
- variables: vars({ question: '', question_es: '', asked: '', ranked: [], reply: null }),
445
+ variables: vars({ question: '', question_es: '', asked: '', ranked: [], reply: null, chain: null, others: [], picked: null, reading: null }),
433
446
  steps: [SET('confirm_asked', 'asked', "language === 'es' && question_es ? question_es : question"),
434
- rankedChoices('confirm_ranked_choices', "[{key:'no',label:'no: declines or refuses what the question asks to confirm'}]"),
447
+ SET('confirm_chain', 'chain', 'formInputChain(cargo.forms, form, cargo)'),
448
+ SET('confirm_others', 'others', otherChoicesJS("''", '[]')),
449
+ rankedChoices('confirm_ranked_choices', "[{key:'no',label:'no: declines or refuses what the question asks to confirm'}].concat(others.map(function(c){return {key:c.key,label:c.label};}))"),
435
450
  rankedDispatch('confirm_dispatch', 'confirmation_not_matched'),
436
451
  ...escapeRoutes('confirm_escape'),
437
- SIDE('confirm_ranked', "(cargo.confirmRanked=reply && reply.key==='no' ? 'no' : 'none',true)")]
452
+ // a correction picked by the ranker is read as its input's answer: the form changes and is confirmed again, never confirmed
453
+ SET('confirm_picked', 'picked', "others.filter(function(c){return reply && c.key===reply.key;})[0] || null"),
454
+ SET('confirm_read', 'reading', "picked ? enrichFormMatches(readFormAnswer(picked.key, chain, picked.owner, picked.key, []), chain, form, [picked.owner.name], picked.owner.name) : null"),
455
+ WHEN('confirm_change', 'reading && reading.hits.length', FLOW('confirm_change_accept', 'collect-input-accept', 'call', { hits: '{{reading.hits}}' })),
456
+ SIDE('confirm_ranked', "(cargo.confirmRanked=reading && reading.hits.length ? 'changed' : reply && reply.key==='no' ? 'no' : 'none',true)")]
438
457
  };
439
458
  const collectContextualAnswer = formFlow({ id: 'collect-contextual-answer', name: 'CollectContextualAnswer', description: 'Collect one open-ended answer using contextual DISPATCH and preserve the caller wording.',
440
459
  parameters: [...['question', 'question_es', 'description', 'context', 'context_es'].map(name => ({ name, type: 'string' })), { name: 'choices', type: 'array' }, { name: 'digits', type: 'object' }],
@@ -257,12 +257,12 @@ const retry = worker('generic-retry-with-options', 'GenericRetryWithOptions', 'C
257
257
  set('retry_question', 'retry_question', "error_message+' '+(question || 'Would you like to try again?')+' To retry '+(cargo.voice?'Press 1 or ':'')+cargo.verb+' YES. '+(callerTextNumber(cargo)?'To switch to text press 9 or '+cargo.verb+' TEXT. ':'')+'To abort '+(cargo.voice?'press the asterisk key or ':'')+cargo.verb+' EXIT.'"),
258
258
  set('retry_question_es', 'retry_question_es', "error_message_es+' '+(question_es || '¿Le gustaría intentar nuevamente?')+' Para reintentar '+(cargo.voice?'Presione 1 o ':'')+cargo.verb_es+' SÍ. '+(callerTextNumber(cargo)?'Para cambiar a texto presione 9 o '+cargo.verb_es+' TEXTO. ':'')+'Para salir '+(cargo.voice?'presione la tecla de asterisco o ':'')+cargo.verb_es+' SALIR.'"),
259
259
  call('retry_collect', 'collect-service-answer', { profiles: '{{retry_profiles}}', question: '{{retry_question}}', question_es: '{{retry_question_es}}', keypad: { min: 1, max: 1 }, parent_form: '{{parent_form}}' }),
260
- set('retry_answer', 'user_choice', 'String(cargo.serviceAnswer.raw || "")'),
260
+ set('retry_answer', 'user_choice', 'String(cargo.serviceAnswer.raw || "").trim().replace(/[.,!?;:]+$/,"")'),
261
261
  set('retry_default_cancel', 'cancel_flow', "cancel_flow || 'contact-support'"),
262
262
  when('retry_unknown_key_question', '/^\\s*[2-8]\\s*$/.test(user_choice)', set('retry_unknown_question', 'retry_question', "'Sorry, that is not an option. To retry '+(cargo.voice?'Press 1 or ':'')+cargo.verb+' YES. '+(callerTextNumber(cargo)?'To switch to text press 9 or '+cargo.verb+' TEXT. ':'')+(cargo.voice?'For an agent press 0. ':'')+'To abort '+(cargo.voice?'press the asterisk key or ':'')+cargo.verb+' EXIT.'")),
263
263
  when('retry_unknown_key_question_es', '/^\\s*[2-8]\\s*$/.test(user_choice)', set('retry_unknown_question_es', 'retry_question_es', "'Lo siento, esa no es una opción. Para reintentar '+(cargo.voice?'Presione 1 o ':'')+cargo.verb_es+' SÍ. '+(callerTextNumber(cargo)?'Para cambiar a texto presione 9 o '+cargo.verb_es+' TEXTO. ':'')+(cargo.voice?'Para un agente presione 0. ':'')+'Para salir '+(cargo.voice?'presione la tecla de asterisco o ':'')+cargo.verb_es+' SALIR.'")),
264
264
  when('retry_unknown_key', '/^\\s*[2-8]\\s*$/.test(user_choice)', call('retry_bad_key_collect', 'collect-service-answer', { profiles: '{{retry_profiles}}', question: '{{retry_question}}', question_es: '{{retry_question_es}}', keypad: { min: 1, max: 1 }, parent_form: '{{parent_form}}' })),
265
- set('retry_latest_answer', 'user_choice', 'String(cargo.serviceAnswer.raw || "")'), set('retry_capture', 'smart_capture_result', 'capture_patterns && capture_patterns.length ? normalizeAndFindCapture(user_choice,capture_patterns) : null'),
265
+ set('retry_latest_answer', 'user_choice', 'String(cargo.serviceAnswer.raw || "").trim().replace(/[.,!?;:]+$/,"")'), set('retry_capture', 'smart_capture_result', 'capture_patterns && capture_patterns.length ? normalizeAndFindCapture(user_choice,capture_patterns) : null'),
266
266
  cases('retry_capture_route', { 'condition: smart_capture_result && retry_call_type === "replace"': call('retry_captured_replace', '{{retry_flow}}', { cell_number: '', email: '', '{{smart_capture_result.variable}}': '{{smart_capture_result.value}}', retry_flow: '{{retry_flow}}', cancel_flow: '{{cancel_flow}}', retrying: true, contact_options: null, parent_form: '{{parent_form}}' }, 'replace'), 'condition: smart_capture_result': call('retry_captured_reboot', '{{retry_flow}}', { '{{smart_capture_result.variable}}': '{{smart_capture_result.value}}', retry_flow: '{{retry_flow}}', cancel_flow: '{{cancel_flow}}', retrying: true, contact_options: null, parent_form: '{{parent_form}}' }, 'reboot'), default: effect('retry_no_capture', 'true') }),
267
267
  set('retry_typed_code', 'normalized_otp_code', "allow_code_entry && user_choice.replace(/\\D/g,'').length === 6 ? user_choice.replace(/\\D/g,'') : ''"), when('retry_validate_code', '!!normalized_otp_code', call('retry_code_validate', 'validate-otp-code')),
268
268
  cases('retry_decision', { 'condition: normalized_otp_code && cargo.otpVerified === true': end('retry_verified'), "condition: (user_choice.trim() === '1' || isAnswer(user_choice,'yes',retry_yes_words)) && retry_call_type === 'replace'": call('retry_yes_replace', '{{retry_flow}}', { retrying: true, cell_number: '', email: '', contact_options: null, parent_form: '{{parent_form}}' }, 'replace'), "condition: user_choice.trim() === '1' || isAnswer(user_choice,'yes',retry_yes_words)": call('retry_yes_reboot', '{{retry_flow}}', { retrying: true, contact_options: null, parent_form: '{{parent_form}}' }, 'reboot'), "condition: user_choice.trim() === '9' || isAnswer(user_choice,'text')": call('retry_text', 'switch-to-text', undefined, 'reboot'), "condition: isAgentRequest(user_choice)": call('retry_agent', 'live-agent-requested', undefined, 'reboot'), "condition: !user_choice.trim() || /^\\d$/.test(user_choice.trim()) || isExitRequest(user_choice)": call('retry_cancel', '{{cancel_flow}}', undefined, 'reboot'), default: { id: 'retry_dispatch', type: 'DISPATCH', outcome: '{{caller_outcome}}', reason: '{{caller_reason}}' } })
@@ -1 +1 @@
1
- {"version":3,"file":"contact.d.ts","sourceRoot":"","sources":["../../src/helpers/contact.js"],"names":[],"mappings":"AAiBA;;;;;;;;GAQG;AACH;;WA8BC;AAID;;;;;GAKG;AACH,0CAWC;AAED;;;;;GAKG;AACH,0DAgBC;AAED,qHAAqH;AACrH,2DAIC;AAED;;;;GAIG;AACH;;;SAmBC;AAED;;;GAGG;AACH,sDAOC;AAED;;;;;GAKG;AACH,yEAUC;AAQD;;;;;GAKG;AACH,qDAIC;AAED,mHAAmH;AACnH,oDAIC"}
1
+ {"version":3,"file":"contact.d.ts","sourceRoot":"","sources":["../../src/helpers/contact.js"],"names":[],"mappings":"AAiBA;;;;;;;;GAQG;AACH;;WAiCC;AAID;;;;;GAKG;AACH,0CAWC;AAED;;;;;GAKG;AACH,0DAgBC;AAED,qHAAqH;AACrH,2DAIC;AAED;;;;GAIG;AACH;;;SAmBC;AAED;;;GAGG;AACH,sDAOC;AAED;;;;;GAKG;AACH,yEAUC;AAQD;;;;;GAKG;AACH,qDAIC;AAED,mHAAmH;AACnH,oDAIC"}
@@ -40,7 +40,10 @@ export function readEmail(text, { literal = false } = {}) {
40
40
  return '';
41
41
  }
42
42
  else {
43
- const domain = (spoken.slice(at + 1).trim().match(/^[^\s@]+/) || [''])[0].replace(/[.,;:!?]+$/, '');
43
+ // a spelled domain arrives in pieces ("gm a i l.com", "y a h o o . c o m"): join them up to its top-level domain
44
+ const after = spoken.slice(at + 1).trim();
45
+ const spelled = after.match(/^[a-z0-9-]+(?:\s+[a-z0-9-]+){1,12}?(?:\s*\.\s*(?:[a-z]{2,}|[a-z](?:\s+[a-z]){1,2})\b){1,2}/i);
46
+ const domain = (spelled ? spelled[0].replace(/\s+/g, '') : (after.match(/^[^\s@]+/) || [''])[0]).replace(/[.,;:!?]+$/, '');
44
47
  const pieces = spoken.slice(0, at).replace(/@/g, ' ').normalize('NFD').replace(/[\u0300-\u036f]/g, '').split(/[\s,;:!?]+/).map((w) => w.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')).filter(Boolean);
45
48
  const local = [];
46
49
  for (let k = pieces.length - 1; k >= 0 && local.join('').length < 64; k--) {
@@ -26,6 +26,11 @@ export declare const CHOICE_PREFIX = "choice:";
26
26
  export declare const REPLY_DEFAULT: ReplyOption;
27
27
  /** A noul at or above this is a yes. */
28
28
  export declare const REPLY_YES_THRESHOLD = 0.5;
29
+ /**
30
+ * The cut an option must clear, by what a wrong pick costs: an option not listed here takes REPLY_YES_THRESHOLD. Set
31
+ * from labelled prod replies (owner 2026-10-28), never guessed.
32
+ */
33
+ export declare const REPLY_CUTS: Partial<Record<ReplyOption, number>>;
29
34
  export interface ReplyTurn {
30
35
  role: 'user' | 'assistant';
31
36
  content: string;
@@ -60,13 +65,15 @@ export declare function decodeReplyRankLlm(text: string, request: ReplyRankReque
60
65
  option: ReplyOption;
61
66
  };
62
67
  /**
63
- * System One's answers as the canonical reply: the highest-rated option when it clears the cut, else the default
64
- * (`answers`), with every option's probability (for logs). Throws if any question is unanswered.
68
+ * System One's answers as the canonical reply: the highest-rated option that clears its own cut (REPLY_CUTS, else the
69
+ * threshold), a cleared choice before `answers` and `new_request`; when none does, the default (`answers`: asked again at a choice question, the answer at an open one), with
70
+ * every option's probability (for logs). Throws if any question is unanswered.
65
71
  */
66
72
  export declare function decodeReplyRankSystemOne(answers: Record<string, {
67
73
  noul?: unknown;
68
- } | undefined> | undefined, request: ReplyRankRequest, { threshold }?: {
69
- threshold?: number | undefined;
74
+ } | undefined> | undefined, request: ReplyRankRequest, { threshold, cuts }?: {
75
+ threshold?: number;
76
+ cuts?: Partial<Record<ReplyOption, number>>;
70
77
  }): {
71
78
  option: ReplyOption;
72
79
  scores: Record<string, number>;
@@ -1 +1 @@
1
- {"version":3,"file":"reply-rank.d.ts","sourceRoot":"","sources":["../../src/host/reply-rank.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,eAAO,MAAM,aAAa,8CAA+C,CAAC;AAC1E,kEAAkE;AAClE,MAAM,MAAM,WAAW,GAAG,OAAO,aAAa,CAAC,MAAM,CAAC,GAAG,UAAU,MAAM,EAAE,CAAC;AAC5E,0FAA0F;AAC1F,MAAM,WAAW,WAAW;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAC3D,eAAO,MAAM,aAAa,YAAY,CAAC;AACvC,gGAAgG;AAChG,eAAO,MAAM,aAAa,EAAE,WAAuB,CAAC;AACpD,wCAAwC;AACxC,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,MAAM,WAAW,SAAS;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAC1E,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,SAAS,EAAE,CAAC;IAC1B,yFAAyF;IACzF,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB,GAAG,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;IACvE,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAAC,KAAK,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,WAAW,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;CACjI;AAwCD,0DAA0D;AAC1D,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,SAAS,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,WAAW,EAAO,GAAG,gBAAgB,CA0CxI;AAED,4FAA4F;AAC5F,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,CAMnG;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAAC,GAAG,SAAS,EACnE,OAAO,EAAE,gBAAgB,EACzB,EAAE,SAA+B,EAAE;;CAAK,GACvC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAa5E;AAED,+FAA+F;AAC/F,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAGhF"}
1
+ {"version":3,"file":"reply-rank.d.ts","sourceRoot":"","sources":["../../src/host/reply-rank.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,eAAO,MAAM,aAAa,8CAA+C,CAAC;AAC1E,kEAAkE;AAClE,MAAM,MAAM,WAAW,GAAG,OAAO,aAAa,CAAC,MAAM,CAAC,GAAG,UAAU,MAAM,EAAE,CAAC;AAC5E,0FAA0F;AAC1F,MAAM,WAAW,WAAW;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAC3D,eAAO,MAAM,aAAa,YAAY,CAAC;AACvC,gGAAgG;AAChG,eAAO,MAAM,aAAa,EAAE,WAAuB,CAAC;AACpD,wCAAwC;AACxC,eAAO,MAAM,mBAAmB,MAAM,CAAC;AACvC;;;GAGG;AACH,eAAO,MAAM,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAO3D,CAAC;AAEF,MAAM,WAAW,SAAS;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAC1E,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,SAAS,EAAE,CAAC;IAC1B,yFAAyF;IACzF,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB,GAAG,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;IACvE,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAAC,KAAK,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,WAAW,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;CACjI;AAwCD,0DAA0D;AAC1D,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,SAAS,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,WAAW,EAAO,GAAG,gBAAgB,CA+CxI;AAED,4FAA4F;AAC5F,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,CAMnG;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAAC,GAAG,SAAS,EACnE,OAAO,EAAE,gBAAgB,EACzB,EAAE,SAA+B,EAAE,IAAiB,EAAE,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAA;CAAO,GAC/H;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAiB5E;AAED,+FAA+F;AAC/F,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAGhF"}
@@ -19,6 +19,18 @@ export const CHOICE_PREFIX = 'choice:';
19
19
  export const REPLY_DEFAULT = 'answers';
20
20
  /** A noul at or above this is a yes. */
21
21
  export const REPLY_YES_THRESHOLD = 0.5;
22
+ /**
23
+ * The cut an option must clear, by what a wrong pick costs: an option not listed here takes REPLY_YES_THRESHOLD. Set
24
+ * from labelled prod replies (owner 2026-10-28), never guessed.
25
+ */
26
+ export const REPLY_CUTS = {
27
+ // a NO cancels what the caller was confirming. Measured 2026-10-28: replies that are not a NO ("imposible", "no es un")
28
+ // rated 0.51-0.63, real NOs ("No me interesa", "No de verdad", "mejor no") 0.67-0.95
29
+ 'choice:no': 0.65,
30
+ // a new request leaves the question unanswered. Unclear replies at a question ("la curazán" at a confirmation, "Es que
31
+ // mi tarjeta se cambió" at the pay menu) rated 0.47-0.58 and vary ±0.04 between calls; they are asked again
32
+ new_request: 0.6,
33
+ };
22
34
  // ── the criteria: written once, rendered for both executors ─────────────────────────────────────────
23
35
  const CRITERIA = {
24
36
  answers: {
@@ -81,7 +93,12 @@ export function buildReplyRank(input, conversation, question, choices = []) {
81
93
  ].join('\n');
82
94
  const questions = {};
83
95
  const index = [];
84
- options.forEach((o, i) => {
96
+ // With declared choices, `answers` ("picks none of them") is not a question System One answers: it competes with the
97
+ // choices it excludes. It is what is left when no option clears its cut (owner 2026-10-28). A text model still has it.
98
+ // An escape (`__agent`) is not the question's own choice: a question that offers only escapes is an open question,
99
+ // where `answers` is the reply (prod 2026-10-28: a city named at "check nearby stores" went to intent detection).
100
+ const ownChoices = choices.some(c => !c.key.startsWith('__'));
101
+ options.filter(o => !(o === 'answers' && ownChoices)).forEach((o, i) => {
85
102
  const id = `r${i}`;
86
103
  questions[id] = { type: 'noul', criteria: criteriaOf(o, choices), instructions: { option: o, question: `Does the latest message do what \`${o}\` describes?` } };
87
104
  index.push({ id, option: o });
@@ -110,10 +127,11 @@ export function decodeReplyRankLlm(text, request) {
110
127
  return { option };
111
128
  }
112
129
  /**
113
- * System One's answers as the canonical reply: the highest-rated option when it clears the cut, else the default
114
- * (`answers`), with every option's probability (for logs). Throws if any question is unanswered.
130
+ * System One's answers as the canonical reply: the highest-rated option that clears its own cut (REPLY_CUTS, else the
131
+ * threshold), a cleared choice before `answers` and `new_request`; when none does, the default (`answers`: asked again at a choice question, the answer at an open one), with
132
+ * every option's probability (for logs). Throws if any question is unanswered.
115
133
  */
116
- export function decodeReplyRankSystemOne(answers, request, { threshold = REPLY_YES_THRESHOLD } = {}) {
134
+ export function decodeReplyRankSystemOne(answers, request, { threshold = REPLY_YES_THRESHOLD, cuts = REPLY_CUTS } = {}) {
117
135
  const scores = {};
118
136
  for (const q of request.systemOne.index) {
119
137
  const noul = Number(answers?.[q.id]?.noul);
@@ -121,8 +139,12 @@ export function decodeReplyRankSystemOne(answers, request, { threshold = REPLY_Y
121
139
  throw new Error(`System One: question ${q.id} unanswered`);
122
140
  scores[q.option] = noul;
123
141
  }
124
- const [best] = request.systemOne.index.map(q => q.option).sort((a, b) => scores[b] - scores[a]);
125
- if (best && scores[best] >= threshold) {
142
+ const cleared = request.systemOne.index.map(q => q.option).filter(o => scores[o] >= (cuts[o] ?? threshold)).sort((a, b) => scores[b] - scores[a]);
143
+ // A cleared choice is taken over `answers` and `new_request` (owner 2026-10-28: a request that names an offered option
144
+ // is that option, "Representative." is the agent); only `abort` keeps its rank.
145
+ const choice = cleared.find(o => o.startsWith(CHOICE_PREFIX));
146
+ const best = choice && (cleared[0] === 'answers' || cleared[0] === 'new_request') ? choice : cleared[0];
147
+ if (best) {
126
148
  return { option: best, scores, fallback: false };
127
149
  }
128
150
  else {
@@ -1 +1 @@
1
- export const JSFE_RUNTIME_FINGERPRINT = "sha256:1be74f9057dfbf4d79cd04f72532eb6245a6f68d306196e8966ec830133dc7b2";
1
+ export const JSFE_RUNTIME_FINGERPRINT = "sha256:9fca8fe719c5610d1260ece2cc393107d549667095f71bcaa62427a894f0ba7b";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsfe",
3
- "version": "1.14.0",
3
+ "version": "1.14.2",
4
4
  "description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
5
5
  "license": "MIT",
6
6
  "author": "Ron Pinkas",