jsfe 1.36.0 → 1.37.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.
@@ -1 +1 @@
1
- {"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../../src/forms/contracts.js"],"names":[],"mappings":"AAgBA;;;IAGC;AACD;;;;;EAGC;AACD,qGAAqG;AACrG;;QAwCC;AAqBD;;;QAEC;AAgBD;;;;;;;;;QASC;AACD;;;;QAGC;AACD;;;;;;;;QA8BC;AAED,mHAAmH;AACnH,kDAgBC;AAED,+GAA+G;AAC/G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4EC"}
1
+ {"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../../src/forms/contracts.js"],"names":[],"mappings":"AAgBA;;;IAGC;AACD;;;;;EAGC;AACD,qGAAqG;AACrG;;QAwCC;AAqBD;;;QAEC;AAgBD;;;;;;;;;QASC;AACD;;;;QAGC;AACD;;;;;;;;QA+BC;AAED,mHAAmH;AACnH,kDAgBC;AAED,+GAA+G;AAC/G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4EC"}
@@ -137,7 +137,7 @@ export function defineProvider({ provides, contract, parameters = [], variables
137
137
  { id: `${flow.id}_provide`, type: 'FLOW', value: 'provide-data', callType: 'call', parameters: { provides: '{{provides}}', asked: '{{asked}}', reply: '{{userInput}}', ...(gateParameters !== undefined ? { gate_params: '{{provider_gate_arguments}}' } : {}) } }] }, { role: 'provider', ...contract, catalog: 'provides' });
138
138
  }
139
139
  export function defineForm({ contract, provides, ...spec }) {
140
- const flow = formFlow({ ...spec, ...(provides ? { extraVars: { ...spec.extraVars, provides, questions: providerQuestions(provides) } } : {}) });
140
+ const flow = formFlow({ ...spec, confirm_soft: false, ...(provides ? { extraVars: { ...spec.extraVars, provides, questions: providerQuestions(provides) } } : {}) });
141
141
  return declared(flow, { role: 'collector', ...contract, form: { inputs: 'inputs' }, ...(provides ? { catalog: 'provides' } : {}) });
142
142
  }
143
143
  export function definePerformer({ performs, contract, prepare, present, project = [], provides, ...spec }) {
@@ -158,7 +158,8 @@ export function definePerformer({ performs, contract, prepare, present, project
158
158
  when('bad_request', "!(pf_action === null || pf_action && typeof pf_action === 'object' && pf_action.action) || pf_action !== null && (!pf_request || typeof pf_request !== 'object')", { id: `${spec.id}_prepare_failed`, type: 'FLOW', value: 'give-up', callType: 'reboot', recovery: { version: 1, mode: 'none', reason: 'performer_request_invalid' }, parameters: { context: '{{exit_context}}', context_es: '{{exit_context_es}}' } }),
159
159
  set('proposal', 'pf_record', "pf_action ? prepareFormAction(cargo, form_instance, pf_action, pf_request) : null"),
160
160
  ];
161
- const flow = formFlow({ ...spec, confirm, confirmation_condition: "pf_action !== null && pf_action && typeof pf_action === 'object'",
161
+ // its confirmation is as firm as what it authorizes: firm when an action is exact (charge, cancel, irreversible), else soft
162
+ const flow = formFlow({ ...spec, confirm, confirm_soft: !performs.some(action => action.confirm?.exact), confirmation_condition: "pf_action !== null && pf_action && typeof pf_action === 'object'",
162
163
  extraVars: { ...spec.extraVars, ...(provides ? { provides, questions: providerQuestions(provides) } : {}), performs, projection: project, pf_action: null, pf_request: null, pf_record: null, pf_result: null, pf_receipt: null },
163
164
  before_confirm: confirm ? [...prepareSteps, ...(spec.before_confirm || [])] : spec.before_confirm || [],
164
165
  after: [...(confirm ? [] : prepareSteps),
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * The form shell: declare inputs, run the loop, hand the result on. `after` = the form's own steps once complete.
3
3
  * `confirm` ({ question, question_es } — SAY-style templates that may read cargo.forms[<id>].values; build them in
4
- * `before_confirm` SET steps when they need computing): the exact confirmation, asked every time after the loop and
5
- * never an input. YES runs `after`; NO runs `declined`; a correction given there goes through the form's own matching
4
+ * `before_confirm` SET steps when they need computing): the confirmation, asked every time after the loop and
5
+ * never an input. It is firm unless `confirm_soft` (definePerformer: no action it authorizes declares `confirm.exact`): a soft
6
+ * confirmation's YES is read as a soft input confirmation's and is offered to the reply ranker; a firm one's is neither. YES runs `after`; NO runs `declined`; a correction given there goes through the form's own matching
6
7
  * and the form runs again (so whatever the correction made missing is asked).
7
8
  */
8
9
  /**
@@ -12,7 +13,7 @@
12
13
  */
13
14
  /** rank_parameters and a choice's parameters: [{ name, description, type?, reads? }] (reads: the input the value answers). */
14
15
  export function validateRankParameters(id: any, name: any, what: any, list: any): void;
15
- export function formFlow({ id, name, version, description, inputs, after, extraVars, parameters, confirm, before_confirm, declined, stop, close_on_stop, on_no, resume_on_parent_change, before, done, primary, scope, validator, confirmation_condition, prompt, prompt_es, incomplete_outcome, on_exhausted, must_complete, first_task, first_task_es, acknowledge }: {
16
+ export function formFlow({ id, name, version, description, inputs, after, extraVars, parameters, confirm, confirm_soft, before_confirm, declined, stop, close_on_stop, on_no, resume_on_parent_change, before, done, primary, scope, validator, confirmation_condition, prompt, prompt_es, incomplete_outcome, on_exhausted, must_complete, first_task, first_task_es, acknowledge }: {
16
17
  id: any;
17
18
  name: any;
18
19
  version: any;
@@ -22,6 +23,7 @@ export function formFlow({ id, name, version, description, inputs, after, extraV
22
23
  extraVars?: {} | undefined;
23
24
  parameters?: never[] | undefined;
24
25
  confirm?: null | undefined;
26
+ confirm_soft?: boolean | undefined;
25
27
  before_confirm?: never[] | undefined;
26
28
  declined?: never[] | undefined;
27
29
  stop?: string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AA6GA;;;;;;GAMG;AACH;;;;GAIG;AACH,8HAA8H;AAC9H,uFAIC;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAglBD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAjyBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0/B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
1
+ {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AA6GA;;;;;;;GAOG;AACH;;;;GAIG;AACH,8HAA8H;AAC9H,uFAIC;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAolBD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAtyBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+/B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
@@ -104,8 +104,9 @@ const FIELD_COLLECTORS = { 'collect-phone': 'phone', 'collect-email': 'email', '
104
104
  /**
105
105
  * The form shell: declare inputs, run the loop, hand the result on. `after` = the form's own steps once complete.
106
106
  * `confirm` ({ question, question_es } — SAY-style templates that may read cargo.forms[<id>].values; build them in
107
- * `before_confirm` SET steps when they need computing): the exact confirmation, asked every time after the loop and
108
- * never an input. YES runs `after`; NO runs `declined`; a correction given there goes through the form's own matching
107
+ * `before_confirm` SET steps when they need computing): the confirmation, asked every time after the loop and
108
+ * never an input. It is firm unless `confirm_soft` (definePerformer: no action it authorizes declares `confirm.exact`): a soft
109
+ * confirmation's YES is read as a soft input confirmation's and is offered to the reply ranker; a firm one's is neither. YES runs `after`; NO runs `declined`; a correction given there goes through the form's own matching
109
110
  * and the form runs again (so whatever the correction made missing is asked).
110
111
  */
111
112
  /**
@@ -144,7 +145,7 @@ function validateAcceptanceDeclarations(id, inputs) {
144
145
  else { /* no invites, or a list of names */ }
145
146
  }
146
147
  }
147
- export function formFlow({ id, name, version, description, inputs, after = [], extraVars = {}, parameters = [], confirm = null, before_confirm = [], declined = [], stop = '', close_on_stop = false, on_no = '', resume_on_parent_change = false, before = [], done = true, primary = false, scope = 'isolated', validator = '', confirmation_condition = 'true', prompt = undefined, prompt_es = undefined, incomplete_outcome = null, on_exhausted = null, must_complete = false, first_task = '', first_task_es = '', acknowledge = 'all' }) {
148
+ export function formFlow({ id, name, version, description, inputs, after = [], extraVars = {}, parameters = [], confirm = null, confirm_soft = false, before_confirm = [], declined = [], stop = '', close_on_stop = false, on_no = '', resume_on_parent_change = false, before = [], done = true, primary = false, scope = 'isolated', validator = '', confirmation_condition = 'true', prompt = undefined, prompt_es = undefined, incomplete_outcome = null, on_exhausted = null, must_complete = false, first_task = '', first_task_es = '', acknowledge = 'all' }) {
148
149
  validateFormExhaustion(on_exhausted);
149
150
  if (acknowledge !== 'all' && acknowledge !== 'data')
150
151
  throw new Error(`FORM ${id}: acknowledge must be 'all' or 'data'`);
@@ -168,7 +169,7 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
168
169
  // FLOW parameters are paths only: the question is built here, as a SAY-style template, then passed by name
169
170
  SET(`${id}_confirm_q`, 'form_confirm_q', textValue(confirm.question)),
170
171
  SET(`${id}_confirm_q_es`, 'form_confirm_q_es', textValue(confirm.question_es || confirm.question)),
171
- FLOW(`${id}_confirm`, 'confirm-form', 'call', { form: '{{form_instance}}', question: '{{form_confirm_q}}', question_es: '{{form_confirm_q_es}}', phrases: '{{form_confirmation.phrases}}', words: '{{form_confirmation.words}}', clarifications: '{{form_confirmation.clarifications}}', no_keys: '{{form_confirmation.no_keys}}', interrupts: '{{form_confirmation.interrupts}}', invites: '{{form_confirmation.invites}}', named_fields: '{{form_confirmation.named_fields}}', reply_context: '{{form_confirmation.reply_context}}', rank_parameters: '{{form_confirmation.rank_parameters}}', exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' }),
172
+ FLOW(`${id}_confirm`, 'confirm-form', 'call', { form: '{{form_instance}}', confirmation: confirm_soft === true ? 'soft' : 'firm', question: '{{form_confirm_q}}', question_es: '{{form_confirm_q_es}}', phrases: '{{form_confirmation.phrases}}', words: '{{form_confirmation.words}}', clarifications: '{{form_confirmation.clarifications}}', no_keys: '{{form_confirmation.no_keys}}', interrupts: '{{form_confirmation.interrupts}}', invites: '{{form_confirmation.invites}}', named_fields: '{{form_confirmation.named_fields}}', reply_context: '{{form_confirmation.reply_context}}', rank_parameters: '{{form_confirmation.rank_parameters}}', exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' }),
172
173
  { id: `${id}_confirmed`, type: 'CASE', branches: {
173
174
  [`condition: (cargo.formConfirm || {})[form_instance] === 'changed'`]: FLOW(`${id}_again`, id, 'replace', { parent_form: '{{parent_form}}', result_input: '{{result_input}}', form_instance: '{{form_instance}}' }),
174
175
  // NO: the declined flow takes the form's place, so the action below never runs
@@ -536,13 +537,14 @@ const readFormChoice = {
536
537
  FLOW('choice_accept', 'collect-input-accept', 'call', { hits: '{{reading.hits}}' })]
537
538
  };
538
539
  // A confirmation's unread answer goes to the reply ranker with the confirmation question (owner 2026-10-27), never
539
- // straight to the host. A form's confirmation is exact (it authorizes the form's action), so the ranker is offered NO, the
540
- // form's other choices (a correction) and a person, never YES. Result: cargo.confirmRanked = 'no' | 'changed' | 'none'
540
+ // straight to the host. A firm confirmation authorizes the form's action, so the ranker is offered NO, the form's other
541
+ // choices (a correction) and a person, never YES; a soft one (no exact action) is offered YES too (owner 2026-11-08:
542
+ // prod 11:07 "Sí, para enviar." at a ticket's confirmation). Result: cargo.confirmRanked = 'yes' | 'no' | 'changed' | 'none'
541
543
  // (ConfirmForm reads it); `abort` ends every flow; `new_request` goes to intent detection.
542
544
  const readConfirmReply = {
543
545
  id: 'read-confirm-reply', name: 'ReadConfirmReply', version: '1.4.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), says a named field read back is wrong (confirm.named_fields: that input is asked again), asks for a person, or is asked again; a withdrawal or a new request leaves with saved form progress. Never confirms.',
544
- parameters: [{ name: 'form', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }, { name: 'named_fields', type: 'boolean' }, { name: 'reply_context', type: 'string' }, { name: 'rank_parameters', type: 'array' }, { name: 'invites', type: 'array' }],
545
- variables: vars({ question: '', question_es: '', asked: '', ranked: [], reply: null, chain: null, others: [], fields: [], field: null, picked: null, reading: null, named_fields: false, prompt: null, field_arguments: [], corrections: [], reply_context: '', stay: false, rank_parameters: [], invites: [], rank_params: [], args: null }),
546
+ parameters: [{ name: 'form', type: 'string' }, { name: 'confirmation', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }, { name: 'named_fields', type: 'boolean' }, { name: 'reply_context', type: 'string' }, { name: 'rank_parameters', type: 'array' }, { name: 'invites', type: 'array' }],
547
+ variables: vars({ confirmation: '', question: '', question_es: '', asked: '', ranked: [], reply: null, chain: null, others: [], fields: [], field: null, picked: null, reading: null, named_fields: false, prompt: null, field_arguments: [], corrections: [], reply_context: '', stay: false, rank_parameters: [], invites: [], rank_params: [], args: null }),
546
548
  steps: [
547
549
  SET('confirm_asked', 'asked', "(language === 'es' && question_es ? question_es : question)+(reply_context ? ' '+reply_context : '')"),
548
550
  SET('confirm_stay', 'stay', "formMustComplete(cargo,form,'__confirm')"),
@@ -556,7 +558,7 @@ const readConfirmReply = {
556
558
  SET('confirm_fields', 'fields', "named_fields === true || named_fields === 'true' ? (chain || []).filter(function(i){var v=cargo.forms[form] && cargo.forms[form].values;return i.form===form && !i.confirmation && Array.isArray(i.names) && i.names.length && v && v[i.name]!==undefined && v[i.name]!==null && v[i.name]!=='';}).map(function(i){var l=String(i.label || i.names[0]);return {key:'field:'+i.name,label:'says the '+l+' read back is wrong, or gives a different '+l,owner:{form:i.form,name:i.name}};}) : []"),
557
559
  // A correction may name several fields ('it is Long Beach, apartment 4'): any field's pick extracts every named field's new value.
558
560
  SET('confirm_field_arguments', 'field_arguments', "fields.map(function(c){var i=(chain || []).filter(function(i){return i.form===c.owner.form && i.name===c.owner.name;})[0];var l=String(i.label || i.names[0]);return {name:c.owner.name,type:'string',description:'The correct '+(i.argument_description || l)+' the caller gives now. Leave it empty when they give none.'};})"),
559
- rankedChoices('confirm_ranked_choices', "[{key:'no',label:'no: declines or refuses what the question asks to confirm'}].concat(others.map(function(c){return Object.assign({key:c.key,label:c.label},c.parameters ? {parameters:c.parameters.map(function(p){return {name:p.name,type:p.type || 'string',description:p.description};})} : {});})).concat(fields.map(function(c){return {key:c.key,label:c.label,parameters:field_arguments};}))"),
561
+ rankedChoices('confirm_ranked_choices', "(confirmation==='soft' ? [{key:'yes',label:'yes: agrees to what the question asks to confirm'}] : []).concat([{key:'no',label:'no: declines or refuses what the question asks to confirm'}]).concat(others.map(function(c){return Object.assign({key:c.key,label:c.label},c.parameters ? {parameters:c.parameters.map(function(p){return {name:p.name,type:p.type || 'string',description:p.description};})} : {});})).concat(fields.map(function(c){return {key:c.key,label:c.label,parameters:field_arguments};}))"),
560
562
  // confirm.rank_parameters: values extracted when the ranker says the reply answers (owner 2026-11-06), read as RANK_ARGUMENTS_JS says
561
563
  SET('confirm_rank_params', 'rank_params', "(Array.isArray(rank_parameters) ? rank_parameters : []).map(function(p){return {name:p.name,type:p.type || 'string',description:p.description};})"),
562
564
  rankedDispatch('confirm_dispatch', 'confirmation_not_matched', true, 'rank_params'),
@@ -584,7 +586,7 @@ const readConfirmReply = {
584
586
  WHEN('confirm_correct', 'corrections.length', FLOW('confirm_correct_accept', 'collect-input-accept', 'call', { hits: '{{corrections}}' })),
585
587
  WHEN('confirm_field_reset', 'field && !corrections.length', SIDE('confirm_field_ask', '(resetFormInputs(cargo, field.owner.form, [field.owner.name]), true)')),
586
588
  WHEN('confirm_args_accept', '!picked && args.hits.length', FLOW('confirm_args_accept_flow', 'collect-input-accept', 'call', { hits: '{{args.hits}}' })),
587
- SIDE('confirm_ranked', "(cargo.confirmRanked=reading && reading.hits.length || field || args.hits.length ? 'changed' : reply && reply.key==='no' ? 'no' : 'none',true)")
589
+ SIDE('confirm_ranked', "(cargo.confirmRanked=reading && reading.hits.length || field || args.hits.length ? 'changed' : reply && (reply.key==='yes' || reply.key==='no') ? reply.key : 'none',true)")
588
590
  ]
589
591
  };
590
592
  const collectContextualAnswer = formFlow({ id: 'collect-contextual-answer', name: 'CollectContextualAnswer', description: 'Collect one open-ended answer using contextual DISPATCH and preserve the caller wording.',
@@ -627,7 +629,10 @@ const acceptedEffects = {
627
629
  SET('ie_rest', 'ca_rest', 'readings.slice(1)'),
628
630
  WHEN('ie_reject', "ca_h.outcome === 'rejected'", SIDE('ie_attempt', "(cargo.forms[ca_h.form].attempts[ca_h.asked || ca_h.name] = Number(cargo.forms[ca_h.form].attempts[ca_h.asked || ca_h.name] || 0) + 1, cargo.forms[ca_h.form].feedback=cargo.forms[ca_h.form].feedback || {}, cargo.forms[ca_h.form].feedback[ca_h.asked || ca_h.name]='invalid', true)")),
629
631
  SET('ie_say_en', 'ca_ack', "ca_h.outcome === 'rejected' && ca_h.quiet ? '' : ca_h.outcome === 'rejected' ? (ca_h.message || ('That ' + (ca_h.label || ca_h.name) + \" doesn't look right.\")) : ca_h.silent === true || ca_h.fromPending || !formAcknowledges(cargo, ca_h) ? '' : ca_h.outcome === 'known' && !ca_h.current ? ('Thank you, I already have your ' + ca_h.label + '.') : ca_h.outcome === 'changed' && ca_h.origin !== 'activation' ? ('Okay, I noted your ' + ca_h.label + '.') : (ca_h.outcome === 'taken' || ca_h.outcome === 'pending') && ca_h.origin !== 'activation' ? ('Got it, your ' + ca_h.label + '.') : ''"),
630
- SET('ie_say_es', 'ca_ack_es', "ca_h.outcome === 'rejected' && ca_h.quiet ? '' : ca_h.outcome === 'rejected' ? (ca_h.message_es || ca_h.message || ('Ese ' + (ca_h.label_es || ca_h.label || ca_h.name) + ' no parece correcto.')) : ca_h.silent === true || ca_h.fromPending || !formAcknowledges(cargo, ca_h) ? '' : ca_h.outcome === 'known' && !ca_h.current ? ('Gracias, ya tengo su ' + ca_h.label_es + '.') : ca_h.outcome === 'changed' && ca_h.origin !== 'activation' ? ('Listo, anoté su ' + ca_h.label_es + '.') : (ca_h.outcome === 'taken' || ca_h.outcome === 'pending') && ca_h.origin !== 'activation' ? ('Entendido, su ' + ca_h.label_es + '.') : ''"),
632
+ // The Spanish rejection names the label without an article or adjective ("No pude usar eso como ciudad o código postal."): labels are
633
+ // feminine and masculine, and some carry their own article; 'Ese ' + label + ' no parece correcto' was wrong for every feminine one
634
+ // (owner 2026-11-07; prod "Ese ciudad o código postal…").
635
+ SET('ie_say_es', 'ca_ack_es', "ca_h.outcome === 'rejected' && ca_h.quiet ? '' : ca_h.outcome === 'rejected' ? (ca_h.message_es || ca_h.message || ('No pude usar eso como ' + (ca_h.label_es || ca_h.label || ca_h.name) + '.')) : ca_h.silent === true || ca_h.fromPending || !formAcknowledges(cargo, ca_h) ? '' : ca_h.outcome === 'known' && !ca_h.current ? ('Gracias, ya tengo su ' + ca_h.label_es + '.') : ca_h.outcome === 'changed' && ca_h.origin !== 'activation' ? ('Listo, anoté su ' + ca_h.label_es + '.') : (ca_h.outcome === 'taken' || ca_h.outcome === 'pending') && ca_h.origin !== 'activation' ? ('Entendido, su ' + ca_h.label_es + '.') : ''"),
631
636
  WHEN('ie_say', 'ca_ack', SAY('ie_ack', '{{ca_ack}}', '{{ca_ack_es}}')),
632
637
  // While a field's callback runs, a reply cannot fill the form's callback fields, so a question in it cannot run them again (formInputChain).
633
638
  SET('ie_after_mark', 'noop', "(ca_h.after && ca_h.outcome !== 'known' && ca_h.outcome !== 'rejected' && ca_h.outcome !== 'pending' && cargo.forms[ca_h.form] ? (cargo.forms[ca_h.form].afterRunning = true) : true, true)"),
@@ -674,12 +679,12 @@ const disambiguate = {
674
679
  // ── confirm-form: the exact confirmation of a finished form; a correction is matched against the form chain ───────
675
680
  const confirmForm = {
676
681
  id: 'confirm-form', name: 'ConfirmForm', version: '1.0.0', scope: 'isolated',
677
- description: "Worker: ask a form's confirmation (exact YES / NO; keys 1 and 2). A correction instead ('no, make it $40') is read against the form chain and stored, and the result is 'changed' so the form runs again; an answer with words it cannot read goes to the reply ranker (read-confirm-reply); an unclear one is asked again, and after two the give-up worker takes over. Result: cargo.formConfirm[form] = 'yes' | 'no' | 'changed'. Params: form, question(_es), exit_context(_es).",
678
- parameters: ['form', 'question', 'question_es', 'exit_context', 'exit_context_es', 'phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'invites', 'rank_parameters'].map((n) => ({ name: n, type: ['phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'invites', 'rank_parameters'].includes(n) ? 'array' : 'string', description: n })).concat([{ name: 'named_fields', type: 'boolean', description: "true: an answer that names one of the form's inputs (its names) without a value re-asks only that input" }]),
679
- variables: vars({ cf_clarify: null, named_fields: false, reply_context: '', rank_parameters: [], cf_must: false, cf_presets: FIELD_PRESETS, cf_chain: [], cf_m: null, cf_current: null, cf_result: '', cf_prompt: '', cf_prompt_es: '', cf_words: 0, cf_tries: 0, phrases: null, words: [], clarifications: [], no_keys: ['2'], interrupts: [], invites: [], cf_choices: [], cf_event: '', cf_amount: false, cf_questions: [], cf_provides: null, cf_skip_gate: '' }),
682
+ description: "Worker: ask a form's confirmation (YES / NO, keys 1 and 2; firm when an action it authorizes is exact; otherwise soft: YES is read as a soft confirmation's and offered to the reply ranker). A correction instead ('no, make it $40') is read against the form chain and stored, and the result is 'changed' so the form runs again; an answer with words it cannot read goes to the reply ranker (read-confirm-reply); an unclear one is asked again, and after two the give-up worker takes over. Result: cargo.formConfirm[form] = 'yes' | 'no' | 'changed'. Params: form, question(_es), exit_context(_es).",
683
+ parameters: ['form', 'confirmation', 'question', 'question_es', 'exit_context', 'exit_context_es', 'phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'invites', 'rank_parameters'].map((n) => ({ name: n, type: ['phrases', 'words', 'interrupts', 'no_keys', 'clarifications', 'invites', 'rank_parameters'].includes(n) ? 'array' : 'string', description: n })).concat([{ name: 'named_fields', type: 'boolean', description: "true: an answer that names one of the form's inputs (its names) without a value re-asks only that input" }]),
684
+ variables: vars({ confirmation: '', cf_clarify: null, named_fields: false, reply_context: '', rank_parameters: [], cf_must: false, cf_presets: FIELD_PRESETS, cf_chain: [], cf_m: null, cf_current: null, cf_result: '', cf_prompt: '', cf_prompt_es: '', cf_words: 0, cf_tries: 0, phrases: null, words: [], clarifications: [], no_keys: ['2'], interrupts: [], invites: [], cf_choices: [], cf_event: '', cf_amount: false, cf_questions: [], cf_provides: null, cf_skip_gate: '' }),
680
685
  steps: [
681
686
  SIDE('cf_reset', "(cargo.formConfirm = cargo.formConfirm || {}, cargo.formConfirm[form] = '', cargo.confirmRanked = '', true)"),
682
- SET('cf_choices', 'cf_choices', `[{key:'yes',phrases:['1'].concat(Array.isArray(phrases)?phrases:Array.isArray(words)?words:[]),exact:true},{key:'no',phrases:(Array.isArray(no_keys)?no_keys:['2']).concat(${JSON.stringify(ANSWER_WORDS.no)}),exact:true}].concat((Array.isArray(interrupts)?interrupts:[]).map(function(event){return {key:'event:'+event.key,phrases:event.phrases ?? event.words,exact:event.exact!==false};}))`),
687
+ SET('cf_choices', 'cf_choices', `[{key:'yes',phrases:['1'].concat(Array.isArray(phrases)?phrases:Array.isArray(words)?words:[]).concat(confirmation==='soft'?${JSON.stringify(ANSWER_WORDS.yes)}:[]),exact:confirmation!=='soft'},{key:'no',phrases:(Array.isArray(no_keys)?no_keys:['2']).concat(${JSON.stringify(ANSWER_WORDS.no)}),exact:true}].concat((Array.isArray(interrupts)?interrupts:[]).map(function(event){return {key:'event:'+event.key,phrases:event.phrases ?? event.words,exact:event.exact!==false};}))`),
683
688
  SET('cf_initial_chain', 'cf_chain', CHAIN_JS),
684
689
  SET('cf_amount_context', 'cf_amount', "cf_chain.some(function(input){return input.profile && input.profile.kind==='amount';})"),
685
690
  SET('cf_prompt', 'cf_prompt', "formInputPrompt(cargo,form,'__confirm',{question:question,question_es:question_es},{},'en')"),
@@ -713,11 +718,11 @@ const confirmForm = {
713
718
  'condition: cf_m.hits.length > 0': FLOW('cf_accept', 'collect-input-accept', 'call', { hits: '{{cf_m.hits}}' }),
714
719
  "condition: cargo.choice === 'no'": SET('cf_declined', 'cf_result', "'no'"),
715
720
  "condition: Array.isArray(clarifications) && pickChoice(String(cargo.choiceText || '').replace(/[?¿]/g,''),[{key:'clarify',phrases:clarifications,exact:true}],false)==='clarify'": SET('cf_clarification', 'cf_result', "'clarification'"),
716
- 'condition: cf_words > 0': FLOW('cf_elsewhere', 'read-confirm-reply', 'call', { form: '{{form}}', question: '{{cf_prompt}}', question_es: '{{cf_prompt_es}}', named_fields: '{{named_fields}}', reply_context: '{{reply_context}}', rank_parameters: '{{rank_parameters}}', invites: '{{invites}}' }),
721
+ 'condition: cf_words > 0': FLOW('cf_elsewhere', 'read-confirm-reply', 'call', { form: '{{form}}', confirmation: '{{confirmation}}', question: '{{cf_prompt}}', question_es: '{{cf_prompt_es}}', named_fields: '{{named_fields}}', reply_context: '{{reply_context}}', rank_parameters: '{{rank_parameters}}', invites: '{{invites}}' }),
717
722
  default: SET('cf_unclear', 'cf_result', "'unclear'")
718
723
  } },
719
724
  // the ranker's reading of an unread answer: a decline is the NO; an answer that declines nothing is asked again
720
- SET('cf_ranked', 'cf_result', "cf_result ? cf_result : cargo.confirmRanked === 'no' ? 'no' : cargo.confirmRanked === 'none' ? 'unclear' : ''"),
725
+ SET('cf_ranked', 'cf_result', "cf_result ? cf_result : cargo.confirmRanked === 'yes' ? 'yes' : cargo.confirmRanked === 'no' ? 'no' : cargo.confirmRanked === 'none' ? 'unclear' : ''"),
721
726
  SET('cf_changed', 'cf_result', "cf_result ? cf_result : 'changed'"),
722
727
  // an unclear answer is asked again (the form runs again and reaches this question), at most twice
723
728
  SET('cf_tries_count', 'cf_tries', "cf_result === 'unclear' ? missFormInput(cargo,form,'__confirm') : (clearFormMisses(cargo,form,'__confirm'),0)"),
@@ -1 +1 @@
1
- export const JSFE_RUNTIME_FINGERPRINT = "sha256:a0b5ea3d7166c78be29265f91ee6767856453dffec1f0e8486d98923f9cf90a9";
1
+ export const JSFE_RUNTIME_FINGERPRINT = "sha256:f97519aef9f9c5c878c5ef549068d5aa78e092c5ba2a85b99e864841aebe31e8";
@@ -274,21 +274,74 @@ does this reply do with the question the flow asked? The model picks from a clos
274
274
  | `new_request` | the caller clearly asks for something else | DISPATCH: the message goes to intent detection |
275
275
  | `irrelevant` | the reply means nothing here: a stray word, an unfinished sentence, speech to someone else | the question is asked again; no flow moves |
276
276
 
277
- **Arguments in an unread form answer** (jsfe 1.36, opt-in): an input's `rank_parameters` are extracted on `answers` and a profile choice's `parameters` on its pick; the form's own reader reads each value as if said alone (`reads`: the input it answers, else the asked question). The model only finds the words; validation is unchanged.
278
-
279
277
  **Options with arguments.** A declared choice may carry `parameters` (`{name,type,description}`): a pick then extracts them from the reply with the same stage as a flow's parameters, and the flow reads `{ key, arguments }`. The pick stands when extraction finds nothing or fails. The ranker is generic, so the flow supplies the context: a form's `confirm.reply_context` says what a reply may contain. The address read-back uses this: "It's Long Beach, apartment 4" changes the city and the apartment and reads the address back, with no further question.
280
278
 
281
- **Word readers and the ranker.** An input with `profile.whole_phrases` reads a choice from words only when the whole reply is accounted for: its phrase, another input's argument, courtesy, connecting words and the input's `template_words`. A reply with any other word goes to the ranker, so a word inside a sentence ("Quiero hacer una nueva compra" at a pay menu) never picks an option. Compound answers that carry arguments stay with the reader ("pay $50 with my Visa card"), because a picked option extracts no arguments. A proposal's confirmation offers the ranker its input's choices, so a correction ("yes, it was stolen") picks the changed candidate.
279
+ ### Arguments in free-form answers
280
+
281
+ A caller rarely answers in the shape a reader expects: "Voy a hacer un pago de 100 dólares", "I'd like to fly to
282
+ Cancún the week after Easter". The readers cannot read such a sentence, so it goes to the reply ranker. A flow can
283
+ declare the values to look for in it (jsfe 1.36, opt-in). The ranker then decides what the reply does, the parameter
284
+ stage (the same one that extracts a flow's arguments in intent detection) finds the values, and **the form's own
285
+ readers decide what each value is.** The model only finds the words; every reader rule and validator still applies.
286
+
287
+ **Where to declare them.** Each is a list of `{ name, description, type?, reads? }`:
288
+
289
+ | Declared on | Extracted when | Example |
290
+ | --- | --- | --- |
291
+ | an input: `rank_parameters` | the ranker says the reply `answers` the question | the amount question, the travel search question |
292
+ | a profile choice: `parameters` | the ranker picks that choice: the asked question's own, or another input's option offered with it | "different amount" carries the amount |
293
+ | a form's confirmation: `confirm.rank_parameters` | the ranker says the reply `answers` the confirmation | "No, make it 200 dollars" at "I'll charge $119.77…" |
294
+
295
+ **`reads` decides which question a value answers.** With `reads: '<input>'` the value is read as that input's
296
+ answer, wherever the question was: it can only become that input. Without `reads` it is read as the answer to the
297
+ question asked, exactly as if the caller had said only that value, so that question's rules decide where it goes (a
298
+ key, a sub account number, an invited amount). At a confirmation, a value without `reads` is read as a correction there.
299
+ Use `reads` for a value the caller clearly meant for one field; leave it out for a value whose field the question's
300
+ rules should decide.
301
+
302
+ **How the values are taken:**
303
+
304
+ - Each value is read by the readers as if said alone, then validated and stored like a typed answer, with the same
305
+ acknowledgement, attempts and confirmation. A value read at a confirmation changes the form, which is confirmed again.
306
+ - Nothing read: the question is asked again, as without the declaration. Extraction failing behaves the same.
307
+ - Two readings of one input that differ take nothing: the question is asked again.
308
+ - A picked choice is applied first, then its arguments. An argument that answers the choice's own exclusive group
309
+ replaces it, being the more specific answer ("other amount" with $100 is the amount $100).
310
+ - Each reading carries `origin: 'rank_argument'` and `argument: '<name>'`, so the input's validator can tell an
311
+ extracted value from a typed one (the Curacao amount check treats an extracted definite amount as an amount, never a
312
+ key or a sub account number).
313
+
314
+ **Writing the descriptions.** The description is the whole instruction to the model, so it states what counts and what
315
+ does not: "The amount the caller definitely wants to pay now … Empty when no number is meant as the amount to pay now:
316
+ an amount already paid, owed or asked about is not one." Ask for the value as the caller said it ("as the digits they
317
+ said, with cents only if they said cents"): the reader applies the rules, and a model that normalises a value can change
318
+ which rule applies. Use `type: 'string'` (the default) for anything a reader reads.
319
+
320
+ **Cost.** One extraction call, only on replies that already went to the ranker; a reply the readers read never reaches
321
+ it.
322
+
323
+ ```js
324
+ { name: 'selection', exclusive: true,
325
+ rank_parameters: [
326
+ { name: 'amount', reads: 'amount', description: 'The amount the caller definitely wants to pay now, …' },
327
+ { name: 'amount_candidate', description: 'A number that may be the amount to pay now but could answer another option of this question, …' }],
328
+ any_of: [ { name: 'option', profile: { kind: 'choice', choices: [ /* … */
329
+ { key: 'amount', phrases: ['other amount', 'otro monto'], parameters: [{ name: 'amount', reads: 'amount', description: '…' }] } ] } },
330
+ { name: 'amount', profile: { kind: 'amount', min: 0.01 } } ] }
331
+ ```
332
+
333
+ **Word readers and the ranker.** An input with `profile.whole_phrases` reads a choice from words only when the whole reply is accounted for: its phrase, another input's argument, courtesy, connecting words and the input's `template_words`. A reply with any other word goes to the ranker, so a word inside a sentence ("Quiero hacer una nueva compra" at a pay menu) never picks an option. Compound answers that carry arguments stay with the reader ("pay $50 with my Visa card"), because a picked option extracts only the arguments it declares. A proposal's confirmation offers the ranker its input's choices, so a correction ("yes, it was stolen") picks the changed candidate.
282
334
 
283
335
  **A number that must name itself.** `input.confirm_out_of_order: {word, word_es, message, message_es}` (default off): out of order a number fills this input only beside its word, in either language ("account 5373850"). Alone it may be something else, a code said with an extra digit, so nothing is filled: the caller hears `message` (`{value}` the number, digit by digit on a call; `{word}` the word) and the current question again, which counts as an attempt at that question. It works at `collect-input` and `confirm-form` questions. At its own question the number needs no word. `authenticate-user`'s `code_confirm` takes the same declaration for a `code_validator`, whose flow reads it with the helpers `saysConfirmWord(answer, spec)` and `confirmOutOfOrderLine(spec, value, language, voice)`.
284
336
 
285
337
  **A provider's menu.** `provides.menu.whole_phrases: true` (default off) applies the same rule to a menu: a fact's phrase answers only as the whole reply, and a phrase inside a longer sentence goes to the reply ranker, offered every fact with phrases, every action, `done` and a person. A fact pick is answered with every other fact that cleared `REPLY_ALSO_CUT` (0.6), so one sentence that asks two things hears both; an action pick runs alone. `answers` and `irrelevant` fall to the menu's plain DISPATCH, which hands the turn to the host without intent detection (a Guru answers from the user context the flow set); `new_request` runs intent detection. A reply with no phrase is unchanged. `menu.unanswered` handles a question the provider cannot answer: when a reply from its own menu comes back to it through intent detection with no fact to say, the caller hears the declared apology and offer (`provider-unanswered`) instead of the same facts again.
286
338
 
287
- Some options are **never offered**, so the ranker cannot pick them: the YES of a firm confirmation or of a form's
288
- confirmation, a `current_only` choice while another question is asked, and text. `exact` on a choice says how words
339
+ Some options are **never offered**, so the ranker cannot pick them: the YES of a firm confirmation (an input's, or a form's
340
+ confirmation of an exact action), a `current_only` choice while another question is asked, and text. `exact` on a choice says how words
289
341
  match it, not what it does, so exact choices are offered. A charge or a cancellation is guarded by `confirm: {exact: true}`
290
342
  on its action, whose form confirmation offers the ranker NO, corrections and a person: a ranker can understand "no thanks,
291
- maybe later" as NO; it can never produce a YES to a payment. Options that cost more when wrong need a higher score (a NO that would cancel what the
343
+ maybe later" as NO; it can never produce a YES to a payment. A form whose actions are none of them exact (filing a request)
344
+ confirms softly: its YES is read as a soft confirmation's and is offered to the ranker too. Options that cost more when wrong need a higher score (a NO that would cancel what the
292
345
  caller was confirming, or a new request that leaves the question unanswered). A reply that clears no option is
293
346
  `answers`, and the question is asked again if nothing usable was in it.
294
347
 
@@ -357,7 +410,7 @@ Cancellation and Concurrency" explains each rule and the production incident beh
357
410
 
358
411
  - **Choices.** A choice has a key, phrases in each language and, on a call, keypad keys. A key matches only as the
359
412
  whole answer. Sensitive choices are `exact`.
360
- - **Confirmations.** `confirmation: "firm"` for consent (a charge, a cancellation, storing a card, filing a request):
413
+ - **Confirmations.** `confirmation: "firm"` for consent (a charge, a cancellation, storing a card):
361
414
  only confirmation words. `"soft"` for an offer ("Would you like a link?"): the wider YES vocabulary.
362
415
  - **Proposals.** When the flow infers a value ("Is this the card ending in 4242?"), a proposal asks the caller to
363
416
  approve that one value. It never authorizes the final action.
@@ -214,7 +214,7 @@ Declarations:
214
214
 
215
215
  | Declaration | Meaning |
216
216
  | --- | --- |
217
- | input `confirmation:"firm"` / `"soft"` | A yes/no decision. Firm (consent to a charge, a cancellation, storing a card, filing): YES only by `CONFIRM_WORDS` or the option's own exact phrases. Soft (an offer): the wider YES vocabulary. NO by the NO vocabulary or the option's own phrases. |
217
+ | input `confirmation:"firm"` / `"soft"` | A yes/no decision. Firm (consent to a charge, a cancellation, storing a card): YES only by `CONFIRM_WORDS` or the option's own exact phrases. Soft (an offer): the wider YES vocabulary. NO by the NO vocabulary or the option's own phrases. |
218
218
  | input `invites:[names]`, `form.confirm.invites` | The other inputs this question (or the read-back) also asks for ("NEW CARD, LINK, or a different amount" invites `amount`), in its form or an enclosing one; read as in order while it is asked. |
219
219
  | profile `out_of_order:{…}` | A narrower signature for out-of-order reading: the payment card takes a bare last four when asked, but out of order only a card word or "ending in 4242". |
220
220
  | `authenticate-user` `other_whole_answer:true` | At the code question "another account" switches only as the whole answer; inside a sentence the reply ranker decides, offered `another_account` and `no_other_account` ("I understand. We still need to secure access to your account.", then the question). Default off. |
@@ -290,6 +290,8 @@ Ordinary DISPATCH is the default uncertainty escape. An explicit contextual DISP
290
290
 
291
291
  Declare `response` as a local object variable. A single open string parameter whose words are the value (a reason, a description) may add `"verbatim":true`: a confident `answers` then keeps the reply as said when extraction finds nothing. Never mark a field that names something to look up (a product, a city). The reply ranker answers `answers`, `abort`, `new_request` or `irrelevant` (a cleared `new_request` outranks it; nothing clear is `answers`; `irrelevant` extracts nothing and the question is asked again). `responseChoices` names a flow variable holding `[{key,label,route?,parameters?}]`: the ranker is also offered `choice:<key>`. A pick sets the response variable to `{key}`, plus `also` (other declared choices the reply clears at 0.6, by score) when there are any, plus `arguments` when the choice declares `parameters:[{name,type,description}]`. `route:"new_request"` makes a pick leave like a new request. `onNoIntent:"stay"`: a `new_request` that intent detection routes to no flow is an answer to this question, and the flow continues. `answers` retains the live continuation and runs the following recovery steps; an optional typed response writes a cloned normalized object before continuing. `new_request` routes the input through intent detection; `abort` ends every flow as a RETURN with the reply `cmd_request_aborted` and outcome `user_aborted` (no recovery stamp). Neither writes that response object. Typed contextual v1 permits primitive string/boolean/number/integer fields, not object/array/jsonSchema. Missing fields remain missing. Errors preserve the pending step and prior object. Context is supplied to that decision without overwriting saved conversation history. This is existing configured intent/extraction, not authorization and not a snapshot restore.
292
292
 
293
+ **A form question's values in a free-form sentence** (jsfe 1.36): declare `rank_parameters` on the input (extracted when the ranker says the reply answers), `parameters` on a choice (extracted when it is picked) or `confirm.rank_parameters` (at the form's confirmation), each `[{ name, description, type?, reads? }]`. Each value is read by the form's readers as if said alone: `reads` names the input it answers, else it answers the asked question. Never a DISPATCH of your own for this. Details and rules: architecture-guide.md, "Arguments in free-form answers".
294
+
293
295
  ## 7. Providers
294
296
 
295
297
  Declare a catalog once in top-level `provides`, including on a Form/Performer that answers related questions. JSFE derives the public `asked` parameter and mid-form question catalog. Do not maintain a second vocabulary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsfe",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
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",