jsfe 1.28.0 → 1.30.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":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAkBA;;;;;;;;;;;EAsRC"}
1
+ {"version":3,"file":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAkBA;;;;;;;;;;;EA+RC"}
@@ -410,7 +410,7 @@ export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log
410
410
  }
411
411
  else { /* none */ }
412
412
  if (flow.provides) {
413
- out.push('', '### Provider', '', ...(flow.provides.facts || []).map((f) => `- fact \`${f.key}\`: ${f.description || ''}${f.path ? ' (from `' + f.path + '`)' : ''}`), '', providerChart(flow.provides));
413
+ out.push('', '### Provider', '', ...(flow.provides.facts || []).map((f) => `- fact \`${f.key}\`: ${f.description || ''}${f.path ? ' (from `' + f.path + '`)' : ''}`), ...providerMenuNotes(flow.provides.menu), '', providerChart(flow.provides));
414
414
  }
415
415
  else { /* not a provider */ }
416
416
  if (flow.form) {
@@ -441,6 +441,20 @@ export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log
441
441
  else { /* no steps */ }
442
442
  return out.join('\n');
443
443
  }
444
+ // What a provider's menu opted into: both change what the caller hears, and neither shows in the chart.
445
+ function providerMenuNotes(menu) {
446
+ const notes = [];
447
+ if (menu && menu.whole_phrases) {
448
+ notes.push('- menu `whole_phrases`: a fact\'s phrase inside a sentence goes to the reply ranker; a fact pick also answers every fact that cleared 0.6');
449
+ }
450
+ else { /* not opted in */ }
451
+ const u = menu && menu.unanswered;
452
+ if (u) {
453
+ notes.push(`- menu \`unanswered\`: a question with no fact, back from this menu${u.asked ? ' (asked: ' + u.asked.join(', ') + ')' : ''}, says "${u.question}"${u.question_es ? ' / "' + u.question_es + '"' : ''}; yes runs menu choice \`${u.choice}\`, no returns to the menu`);
454
+ }
455
+ else { /* not opted in */ }
456
+ return notes;
457
+ }
444
458
  function upsert(file, title, generated, skeleton) {
445
459
  const block = `${START}\n${generated}\n${END}`;
446
460
  const old = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
@@ -134,7 +134,7 @@ export function defineProvider({ provides, contract, parameters = [], variables
134
134
  parameters: [...parameters, asked], variables: { ...variables, ...(gateParameters !== undefined ? { provider_gate_arguments: value(null) } : {}), asked: value(''), provides: value(provides) },
135
135
  steps: steps || [...before, ...(provides.default !== undefined ? [{ id: `${flow.id}_default`, type: 'SET', variable: 'asked', value: `asked || ${JSON.stringify(provides.default)}` }] : []),
136
136
  ...(gateParameters !== undefined ? [{ id: `${flow.id}_gate_arguments`, type: 'SET', variable: 'provider_gate_arguments', value: gateArgumentExpression(gateParameters) }] : []),
137
- { id: `${flow.id}_provide`, type: 'FLOW', value: 'provide-data', callType: 'call', parameters: { provides: '{{provides}}', asked: '{{asked}}', ...(gateParameters !== undefined ? { gate_params: '{{provider_gate_arguments}}' } : {}) } }] }, { role: 'provider', ...contract, catalog: 'provides' });
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
140
  const flow = formFlow({ ...spec, ...(provides ? { extraVars: { ...spec.extraVars, provides, questions: providerQuestions(provides) } } : {}) });
@@ -1 +1 @@
1
- {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AAqIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8DC;AACD;;;GAGG;AACH;;;;;;EAGC;AA4gBD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAltBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAi5B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
1
+ {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AAsIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8DC;AACD;;;GAGG;AACH;;;;;;EAGC;AA4gBD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AAntBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA46B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
@@ -84,7 +84,8 @@ export const FIELD_PRESETS = {
84
84
  card_expiration: { label: 'expiration date', label_es: 'fecha de vencimiento',
85
85
  // MM0YY: a zero said between month and year ("020 28", prod 2026-10-25 14:10, was taken for the billing ZIP).
86
86
  // Up to two words between month and year ("el mes 06 año 31", prod 2026-10-28 21:54, was asked again); a month by name.
87
- profile: { kind: 'pattern', regex: `\\b(0?[1-9]|1[0-2]|${MONTH_RE})\\.?(?:\\s*(?:/|-|del?|año|ano|year|of)(?=[\\s\\d])){0,2}\\s*(?:20)?(\\d{2})\\b|\\b0[1-9]0\\s+(?:20)?\\d{2}\\b` }, digits: { min: 3, max: 6 },
87
+ // A spoken separator with the digits apart: "0 4 guion 3 1" (owner 2026-11-02; prod 2026-11-01 15:19 was asked again).
88
+ profile: { kind: 'pattern', regex: `\\b(0?[1-9]|1[0-2]|${MONTH_RE})\\.?(?:\\s*(?:/|-|del?|año|ano|year|of)(?=[\\s\\d])){0,2}\\s*(?:20)?(\\d{2})\\b|\\b0[1-9]0\\s+(?:20)?\\d{2}\\b|\\b(?:0\\s?[1-9]|1\\s?[0-2]|[1-9])\\s*(?:gui[oó]n|dash|hyphen|slash|barra|diagonal)\\s*(?:2\\s?0\\s?)?\\d\\s?\\d\\b` }, digits: { min: 3, max: 6 },
88
89
  validator: 'validate-card-expiration',
89
90
  question: 'What is the expiration date, month and year?', question_es: '¿Cuál es la fecha de vencimiento, mes y año?',
90
91
  retry: 'Please say or enter the expiration as four digits, month then year, like 0 9 2 8.', retry_es: 'Por favor diga o ingrese el vencimiento en cuatro dígitos, mes y año, por ejemplo 0 9 2 8.',
@@ -776,11 +777,14 @@ export function finishProviderRequest(cargo, owner, provides, keys) {
776
777
  const provideData = {
777
778
  id: 'provide-data', name: 'ProvideData', version: '1.0.0', scope: 'isolated',
778
779
  description: "Worker: answer a question about a provider catalog. Resolves `asked` (a fact or set key; empty = ask which), passes the catalog's gate for non-public facts (gate.flow, called with gate_params; then every path in gate.verified must be truthy), loads the source (load-source), says the facts (answer-facts). Params: provides, asked.",
779
- parameters: [{ name: 'form', type: 'string', description: 'Originating FORM instance for task-owned deferred questions' }, { name: 'provides', type: 'object', description: 'The provider catalog' }, { name: 'asked', type: 'string', description: 'Fact or set key' }, { name: 'asked_keys', type: 'array', description: 'Independent requested facts or sets' }, { name: 'no_menu', type: 'string', description: "'true': answer only (a question asked inside a form)" }, { name: 'gate_params', type: 'object', description: 'Identifiers the caller already gave, passed to the gate flow as gate_params' }, { name: 'no_gate', type: 'string', description: "'true': the caller is inside a task on the verified account; the gate flow is not run (the verified paths are still checked)" }],
780
- variables: vars({ form: '', pd_request: null, pd_deferred: null, pd_ready: [], asked_keys: [], no_gate: '', pd_close: false, pd_who: '', no_menu: '', gate_params: null, pd_keys: [], pd_menu: [], pd_mkeys: [], pd_q: '', pd_q_es: '', pd_gated: false, pd_ok: false }),
780
+ parameters: [{ name: 'form', type: 'string', description: 'Originating FORM instance for task-owned deferred questions' }, { name: 'provides', type: 'object', description: 'The provider catalog' }, { name: 'asked', type: 'string', description: 'Fact or set key' }, { name: 'reply', type: 'string', description: 'The words that started the provider, this turn: tells a question back from its own menu from a later one' }, { name: 'asked_keys', type: 'array', description: 'Independent requested facts or sets' }, { name: 'no_menu', type: 'string', description: "'true': answer only (a question asked inside a form)" }, { name: 'gate_params', type: 'object', description: 'Identifiers the caller already gave, passed to the gate flow as gate_params' }, { name: 'no_gate', type: 'string', description: "'true': the caller is inside a task on the verified account; the gate flow is not run (the verified paths are still checked)" }],
781
+ variables: vars({ reply: '', pd_unanswered: false, form: '', pd_request: null, pd_deferred: null, pd_ready: [], asked_keys: [], no_gate: '', pd_close: false, pd_who: '', no_menu: '', gate_params: null, pd_keys: [], pd_menu: [], pd_mkeys: [], pd_q: '', pd_q_es: '', pd_gated: false, pd_ok: false }),
781
782
  steps: [
782
783
  WHEN('pd_no_catalog', "!provides || typeof provides !== 'object' || !Array.isArray(provides.facts)", END('pd_no_catalog_end', { outcome: 'unresolved', reason: 'provide_data_catalog', recovery: { version: 1, mode: 'none', reason: 'provide_data_catalog' } })),
783
784
  SET('pd_resolve', 'pd_keys', "(function () { var requested=Array.isArray(asked_keys)&&asked_keys.length?asked_keys:[typeof asked!=='undefined'?String(asked||''):''];return requested.reduce(function(keys,a){var set=(provides.sets||[]).filter(function(x){return x.key===a;})[0];var facts=set?set.facts:provides.facts.some(function(f){return f.key===a;})?[a]:[];return keys.concat(facts.filter(function(key){return keys.indexOf(key)<0;}));},[]); })()"),
785
+ // Back from its own menu with nothing it can answer: the ranker found no fact, or intent detection named a catch-all (unanswered.asked).
786
+ SET('pd_unanswered_check', 'pd_unanswered', "(function () { var u = provides.menu && provides.menu.unanswered, m = cargo.providerAsked; delete cargo.providerAsked; var same = function (x) { return String(x == null ? '' : x).replace(/\\\\/g, '').trim(); }; return !!u && !!m && m.subject === String(provides.subject || '') && typeof reply !== 'undefined' && same(m.input) === same(reply) && (m.ranked === true || (u.asked || []).indexOf(typeof asked !== 'undefined' ? String(asked || '') : '') > -1); })()"),
787
+ WHEN('pd_unanswered_go', 'pd_unanswered', FLOW('pd_unanswered_flow', 'provider-unanswered', 'replace', { provides: '{{provides}}' })),
784
788
  // Nothing specific asked: offer the sets and facts as a menu (keys 1..n, never 8; at most 7 options shown).
785
789
  SET('pd_menu_items', 'pd_menu', "(provides.sets || []).concat(provides.facts).slice(0, 7)"),
786
790
  SET('pd_menu_keys', 'pd_mkeys', `${KEYS_JS}(pd_menu.length)`),
@@ -825,6 +829,26 @@ const provideQueuedData = {
825
829
  provideData.variables.pd_menu_choices = { type: 'array', value: [] };
826
830
  provideData.steps.splice(provideData.steps.findIndex((s) => s.id === 'pd_menu_q'), 0, SET('pd_menu_build', 'pd_menu_choices', "pd_menu.map(function (m, i) { return { key: m.key, phrases: [pd_mkeys[i], m.label || m.key, m.label_es || ''].filter(Boolean) }; })"));
827
831
  // provider-menu: what next, after an answer — the catalog's actions, another fact, or done (owner MENU-1016 shape)
832
+ // The menu's own question came back to its provider and no fact answers it (owner 2026-11-02, prod-shaped "por qué subió mi saldo",
833
+ // which re-read the summary): say so, offer the action the catalog names (provides.menu.unanswered), and otherwise return to the menu.
834
+ const providerUnanswered = {
835
+ id: 'provider-unanswered', name: 'ProviderUnanswered', version: '1.0.0', scope: 'isolated',
836
+ description: "Worker: a question the provider has no fact for, sent back to it from its own menu: says provides.menu.unanswered.question(_es) and offers that menu's choice unanswered.choice. YES runs it, NO returns to the menu, anything else is DISPATCHed. Param: provides.",
837
+ parameters: [{ name: 'provides', type: 'object', description: 'The provider catalog' }],
838
+ variables: vars({ pu_choice: null, pu_yes: false, pu_no: false, pu_flow: '' }),
839
+ steps: [
840
+ SET('pu_find', 'pu_choice', "(provides.menu.choices || []).filter(function (c) { return c.key === provides.menu.unanswered.choice; })[0] || null"),
841
+ FLOW('pu_ask', 'ask-choice', 'call', { question: '{{provides.menu.unanswered.question}}', question_es: '{{provides.menu.unanswered.question_es}}', choices: [{ key: 'yes', phrases: ['1'] }, { key: 'no', phrases: ['2', 'no', 'no thanks', 'no thank you', 'not now', 'no gracias', 'ahora no'] }], accept_other: true, digits: { min: 1, max: 1 } }),
842
+ SET('pu_read_yes', 'pu_yes', "String(cargo.choice) === 'yes' || isAnswer(cargo.choiceText, 'yes')"),
843
+ SET('pu_read_no', 'pu_no', "!pu_yes && String(cargo.choice) === 'no'"),
844
+ SET('pu_take', 'pu_flow', "pu_yes && pu_choice ? (function (c) { if (c.set) { Object.keys(c.set).forEach(function (k) { cargo[k] = c.set[k]; }); } else { /* nothing to set */ } return c.flow; })(pu_choice) : ''"),
845
+ { id: 'pu_route', type: 'CASE', branches: {
846
+ 'condition: pu_flow': FLOW('pu_act', '{{pu_flow}}', 'replace'),
847
+ 'condition: pu_no': FLOW('pu_menu', 'provider-menu', 'replace', { provides: '{{provides}}' }),
848
+ default: { id: 'pu_elsewhere', type: 'DISPATCH' }
849
+ } },
850
+ ],
851
+ };
828
852
  const providerMenu = {
829
853
  id: 'provider-menu', name: 'ProviderMenu', version: '1.1.0', scope: 'isolated',
830
854
  description: "Worker: after a provider answered, offer its menu (provides.menu: question(_voice)(_es) with {verb} / {name}; choices [{ key, phrases, flow, exact, set }]; done { phrases, say(_es) }). A fact or set named by its words is answered and the menu comes back (menu.whole_phrases: only when the phrase is the whole reply; a phrase inside a sentence goes to the reply ranker, whose no-pick is the host's); an action runs its flow (replace) after setting its `set` cargo fields; done says goodbye. Other phrases: DISPATCH. No phrases: asked once more, then 'anything else?'. Params: provides, retry.",
@@ -841,6 +865,8 @@ const providerMenu = {
841
865
  // existing reserved done fallback retain their normal behavior.
842
866
  SET('pm_take', 'pm_key', "(function(){var key=String(cargo.choice || '');if(key.indexOf('fact:')===0)return pickProviderQuestion(cargo.choiceText,pm_choices.filter(function(c){return c.key.indexOf('fact:')===0;}));if(key.indexOf('act:')===0 && provides.menu.done && pickChoice(cargo.choiceText,[{key:'terminal:done',phrases:provides.menu.done.phrases ?? provides.menu.done.words,exact:true}],true)==='terminal:done')return 'done';return key;})()"),
843
867
  SET('pm_contained_check', 'pm_contained', "provides.menu.whole_phrases === true && pm_key.indexOf('fact:') === 0 && pickChoice(cargo.choiceText, pm_choices.filter(function (c) { return c.key === pm_key; }).map(function (c) { return { key: c.key, phrases: c.phrases, exact: true }; }), false) !== pm_key"),
868
+ // Who asked: provide-data knows the reply came back from this menu (same words, this turn), and whether the ranker read it.
869
+ SIDE('pm_mark_reply', "(provides.menu.unanswered ? cargo.providerAsked = { subject: String(provides.subject || ''), input: String(cargo.choiceText || ''), ranked: pm_contained } : null, true)"),
844
870
  WHEN('pm_rank', 'pm_contained', FLOW('pm_read_reply', 'read-provider-reply', 'call', { question: '{{pm_q}}', question_es: '{{pm_q_es}}', choices: '{{pm_choices}}' })),
845
871
  SET('pm_ranked_also', 'pm_also', "pm_contained ? ((cargo.providerReply || {}).also || []) : []"),
846
872
  SET('pm_ranked_key', 'pm_key', "pm_contained ? String((cargo.providerReply || {}).key || '') : pm_key"),
@@ -935,7 +961,7 @@ const resumeForm = { id: 'resume-form', name: 'ResumeForm', version: '1.0.0', sc
935
961
  SET('resume_definition', 'target', 'cargo.forms[form].definition'), SET('resume_parent', 'parent', "cargo.forms[form].parent || ''"), SET('resume_slot', 'slot', "cargo.forms[form].result_input || ''"),
936
962
  FLOW('resume_owned_form', '{{target}}', 'replace', { form_instance: '{{form}}', parent_form: '{{parent}}', result_input: '{{slot}}' })
937
963
  ] };
938
- const LIBRARY_FLOWS = [readProviderReply, readFormReply, readFormChoice, readConfirmReply, confirmInputProposal, proposalDeclined, resumeForm, performAction, sendTextAction, prepareInput, runForm, collectInput, validateBatch, requestCancelled, readFormResponse, collectContextualAnswer, accept, acceptedEffects, disambiguate, confirmForm, formDone, providerMenu, ...validators, ...fieldCollectors, ...subForms, provideData, provideQueuedData, loadSource, answerFacts];
964
+ const LIBRARY_FLOWS = [providerUnanswered, readProviderReply, readFormReply, readFormChoice, readConfirmReply, confirmInputProposal, proposalDeclined, resumeForm, performAction, sendTextAction, prepareInput, runForm, collectInput, validateBatch, requestCancelled, readFormResponse, collectContextualAnswer, accept, acceptedEffects, disambiguate, confirmForm, formDone, providerMenu, ...validators, ...fieldCollectors, ...subForms, provideData, provideQueuedData, loadSource, answerFacts];
939
965
  const LIBRARY_CONTRACTS = {
940
966
  'confirm-input-proposal': { role: 'collector', requires: [INPUT_PROPOSALS_REQUIREMENT] }, 'confirm-input-proposal-declined': { role: 'worker', requires: [INPUT_PROPOSALS_REQUIREMENT] },
941
967
  'resume-form': { role: 'worker' },
@@ -949,7 +975,7 @@ const LIBRARY_CONTRACTS = {
949
975
  'validate-input-batch': { role: 'worker', result: { path: 'cargo.inputBatch', type: 'object' } },
950
976
  'collect-input-accept': { role: 'worker' }, 'input-accepted-effects': { role: 'worker' },
951
977
  'disambiguate-input': { role: 'worker' }, 'confirm-form': { role: 'worker' }, 'form-done': { role: 'worker' },
952
- 'provider-menu': { role: 'worker' }, 'provide-data': { role: 'worker' }, 'provide-queued-data': { role: 'worker' },
978
+ 'provider-menu': { role: 'worker' }, 'provider-unanswered': { role: 'worker' }, 'provide-data': { role: 'worker' }, 'provide-queued-data': { role: 'worker' },
953
979
  'load-source': { role: 'worker' }, 'answer-facts': { role: 'worker' },
954
980
  'validate-card-number': { role: 'worker', result: { path: 'cargo.inputCheck', type: 'object' } },
955
981
  'validate-card-expiration': { role: 'worker', result: { path: 'cargo.inputCheck', type: 'object' } },
@@ -1 +1 @@
1
- {"version":3,"file":"form-state.d.ts","sourceRoot":"","sources":["../../src/helpers/form-state.js"],"names":[],"mappings":"AAQA,qDAKC;AAED,oDAEC;AAED,qFAAqF;AACrF,oEA8BC;AA0CD,2FAEC;AACD,8FAcC;AACD,2DAOC;AACD,uEA8CC;AAED,kGAAkG;AAClG,2GAOC;AACD,0HAA0H;AAC1H,uEAwBC;AACD,8HAA8H;AAC9H;;;;;EAWC;AACD,uGAAuG;AACvG;;;;;;;;;;;;;;QAuCC;AAED,0FAA0F;AAC1F;;;SAIC;AACD,gGAAgG;AAChG;;;;;;;;;;;EAQC;AAED,sGAAsG;AACtG,2DAIC;AACD;;;GAGG;AACH;;;;;;;SAkBC;AAED,2FAA2F;AAC3F,oGAIC;AACD,gFA4BC;AAED,2GAA2G;AAC3G;;;EASC;AACD,mEAoBC;AACD,mEAGC;AACD,sEAUC;AACD,+EAEC;AAoCD,qFAYC;AACD,oGAqCC;AACD;;;SAKC;AACD,qEAGC;AACD,gEAgBC;AACD;;uGAEuG;AACvG,sEAGC;AACD,2DAQC;AACD,oGAUC;AAED,uEAAuE;AACvE,wHA+BC;AAED,uHAAuH;AACvH,6EAaC;AACD,mHAAmH;AACnH,iGAsDC;AACD;;;GAGG;AACH;;;SAcC;AACD,+EASC;AACD,8EAeC;AAzpBM,8CAAmE"}
1
+ {"version":3,"file":"form-state.d.ts","sourceRoot":"","sources":["../../src/helpers/form-state.js"],"names":[],"mappings":"AAQA,qDAKC;AAED,oDAEC;AAED,qFAAqF;AACrF,oEA8BC;AA0CD,2FAEC;AACD,8FAcC;AACD,2DAOC;AACD,uEA8CC;AAED,kGAAkG;AAClG,2GAOC;AACD,0HAA0H;AAC1H,uEAwBC;AACD,8HAA8H;AAC9H;;;;;EAeC;AACD,uGAAuG;AACvG;;;;;;;;;;;;;;QAuCC;AAED,0FAA0F;AAC1F;;;SAIC;AACD,gGAAgG;AAChG;;;;;;;;;;;EAQC;AAED,sGAAsG;AACtG,2DAIC;AACD;;;GAGG;AACH;;;;;;;SAkBC;AAED,2FAA2F;AAC3F,oGAIC;AACD,gFA4BC;AAED,2GAA2G;AAC3G;;;EASC;AACD,mEAoBC;AACD,mEAGC;AACD,sEAUC;AACD,+EAEC;AAoCD,qFAYC;AACD,oGAqCC;AACD;;;SAKC;AACD,qEAGC;AACD,gEAgBC;AACD;;uGAEuG;AACvG,sEAGC;AACD,2DAQC;AACD,oGAUC;AAED,uEAAuE;AACvE,wHA+BC;AAED,uHAAuH;AACvH,6EAaC;AACD,mHAAmH;AACnH,iGAsDC;AACD;;;GAGG;AACH;;;SAcC;AACD,+EASC;AACD,8EAeC;AA7pBM,8CAAmE"}
@@ -219,7 +219,7 @@ export function enrichFormMatches(matches, chain, form, names = [], asked = '')
219
219
  const enrich = h => {
220
220
  const spec = chain.find(c => c.form === h.form && c.name === h.name) || {};
221
221
  return { ...h, label: h.option_label || spec.label || h.name, label_es: h.option_label_es || spec.label_es || spec.label || h.name, ...(h.option_label ? { option: true } : {}),
222
- validator: spec.validator || '', ...(spec.response ? { response: spec.response } : {}), after: spec.after || '', validate_always: spec.validate_always === true, ...(spec.activation ? { activation: spec.activation } : {}), ...(spec.silent === true ? { silent: true } : {}), pii: spec.pii === true, current: h.form === form && names.includes(h.name), asked };
222
+ validator: spec.validator || '', ...(!(h.form === form && names.includes(h.name)) && spec.profile && !['choice', 'amount', 'text'].includes(spec.profile.kind) && /\d/.test(String(h.value)) ? { out_of_order_number: true } : {}), ...(spec.response ? { response: spec.response } : {}), after: spec.after || '', validate_always: spec.validate_always === true, ...(spec.activation ? { activation: spec.activation } : {}), ...(spec.silent === true ? { silent: true } : {}), pii: spec.pii === true, current: h.form === form && names.includes(h.name), asked };
223
223
  };
224
224
  return { ...matches, hits: matches.hits.map(enrich), ambiguous: matches.ambiguous.map(enrich), remaining: (matches.remaining || []).map(group => group.map(enrich)) };
225
225
  }
@@ -276,6 +276,10 @@ export function expandFormActivations(cargo, hits = []) {
276
276
  }
277
277
  /** No partial activation: validation failure suppresses inferred changes; pending prerequisites retain the complete group. */
278
278
  export function settleFormActivations(batch = {}, cargo = null) {
279
+ // A number read out of order is taken only when it is valid (owner 2026-11-02): one its validator rejects fills nothing and
280
+ // says nothing, and the current question is asked again.
281
+ const invalidNumbers = (batch.rejected || []).filter(h => h.out_of_order_number === true);
282
+ batch = { ...batch, rejected: (batch.rejected || []).filter(h => h.out_of_order_number !== true), ignored: [...(batch.ignored || []), ...invalidNumbers] };
279
283
  const accepted = [...(batch.accepted || [])], rejected = [...(batch.rejected || [])], pending = [...(batch.pending || [])];
280
284
  const all = accepted.concat(rejected, pending), keys = new Set(all.map(h => h.activation_group).filter(Boolean));
281
285
  const held = new Set(), discarded = new Set();
@@ -22,6 +22,7 @@ export function readInputProfile(profile: any, answer: any, isCurrent?: boolean)
22
22
  * 2. else the one input of the current form that fits takes it, and parent forms are not considered;
23
23
  * 3. else the same test one form up, and so on;
24
24
  * 4. two inputs at the nearest form that fits are ambiguous: asked, never ranked.
25
+ * Exception: a number that fits two inputs out of order is asked about wherever they are (owner 2026-11-02).
25
26
  * Exception: an input that declares `confirm_against: [names]` is asked about together with those inputs whenever both
26
27
  * fit the same words, wherever scope would have put it ('62' at the payment offer: $62 or sub-account 62, prod
27
28
  * 2026-10-19 07:38).
@@ -1 +1 @@
1
- {"version":3,"file":"match-inputs.d.ts","sourceRoot":"","sources":["../../src/helpers/match-inputs.js"],"names":[],"mappings":"AAiBA;;;;GAIG;AACH,sEAQC;AAuFD;;;GAGG;AACH,sFA8GC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH;mHACmH;AACnH;;;;;;;;;;;;;;;;;;;;;EAeC;AACD;;;;;;;;;;;;;;;;;;;;;;EA+PC"}
1
+ {"version":3,"file":"match-inputs.d.ts","sourceRoot":"","sources":["../../src/helpers/match-inputs.js"],"names":[],"mappings":"AAiBA;;;;GAIG;AACH,sEAQC;AAwFD;;;GAGG;AACH,sFA8GC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH;mHACmH;AACnH;;;;;;;;;;;;;;;;;;;;;EAeC;AACD;;;;;;;;;;;;;;;;;;;;;;EA6QC"}
@@ -56,7 +56,8 @@ function amountMasked(raw) {
56
56
  }
57
57
  /** Source intervals let different readers recognize the same evidence without agreeing on its type. */
58
58
  function numberSpans(raw, value, amount = false) {
59
- const runs = [...raw.matchAll(/\d(?:[\s.,-]?\d)*/g)];
59
+ // A field's number is located as its reader found it: DIGIT_RUN joins "31, 0 31". An unlocated number escaped every span rule.
60
+ const runs = [...raw.matchAll(amount ? /\d(?:[\s.,-]?\d)*/g : DIGIT_RUN)];
60
61
  if (!amount) {
61
62
  const wanted = String(value).replace(/\D/g, '');
62
63
  return runs.filter(m => { const d = m[0].replace(/\D/g, ''); return d === wanted || d === '1' + wanted; })
@@ -271,6 +272,7 @@ export function readInputProfile(profile, answer, isCurrent = true) {
271
272
  * 2. else the one input of the current form that fits takes it, and parent forms are not considered;
272
273
  * 3. else the same test one form up, and so on;
273
274
  * 4. two inputs at the nearest form that fits are ambiguous: asked, never ranked.
275
+ * Exception: a number that fits two inputs out of order is asked about wherever they are (owner 2026-11-02).
274
276
  * Exception: an input that declares `confirm_against: [names]` is asked about together with those inputs whenever both
275
277
  * fit the same words, wherever scope would have put it ('62' at the payment offer: $62 or sub-account 62, prod
276
278
  * 2026-10-19 07:38).
@@ -368,7 +370,7 @@ export function matchInputs(answer, candidates, current, choiceKey) {
368
370
  // A declared confirm_against pair with the current question is relevant to it as well: the pair is asked about.
369
371
  const pairedWithCurrent = (reading) => list.some((c) => isCur(c) && c.form === reading.form && ((c.confirm_against || []).includes(reading.name) || (fieldOf(reading).confirm_against || []).includes(c.name)));
370
372
  const inOrder = (reading) => reading.current || invited(reading) || pairedWithCurrent(reading);
371
- // Rule 3: a number read out of order must be whole: every digit run its evidence touches lies entirely inside it,
373
+ // Rule 3, the whole number: every digit run a number read out of order touches lies entirely inside it,
372
374
  // so the last four of "88 91 7112" fill nothing. A written decimal is a run of its own ("51 72.45": 51, then 72.45).
373
375
  const decimalRuns = [...raw.matchAll(DECIMAL_AMOUNT)].map((m) => [m.index, m.index + m[0].length]);
374
376
  const undecimal = decimalRuns.reduce((text, [a, b]) => text.slice(0, a) + ' '.repeat(b - a) + text.slice(b), raw);
@@ -450,6 +452,20 @@ export function matchInputs(answer, candidates, current, choiceKey) {
450
452
  }
451
453
  // Rule 4: a single digit the current question offers as a key is that key (the keypad reading wins its group below);
452
454
  // one it does not offer reaches the invited amount, whose own validation decides ("3 dollars?").
455
+ // Rule 3, a perfect match (owner 2026-11-02): a number read out of order accounts for every digit of the answer. "0 4 guion
456
+ // 31, 0 31" at the expiration is not a ZIP 31031 and "31st. 9 3 1." not a code 931. Several numbers are a compound answer only
457
+ // when each is read, and a free-text answer owns its own digits. An amount has rule 4, a choice its declared phrases.
458
+ const readsDigits = (reading) => !['choice', 'amount', 'text'].includes(fieldOf(reading).profile.kind) && /\d/.test(spanText(reading) + String(reading.value));
459
+ const outOfOrderDigits = readings.filter((reading) => reading.rule === 'out_of_order' && readsDigits(reading));
460
+ const unread = unclaimedSpans(raw, readings.filter((reading) => reading.unlocated !== true)).map(([a, b]) => raw.slice(a, b)).join(' ');
461
+ if (outOfOrderDigits.length && /\d/.test(unread)) {
462
+ for (const reading of outOfOrderDigits)
463
+ refused.push({ form: reading.form, name: reading.name, value: reading.value, rule: 'not_whole_answer', spans: reading.spans });
464
+ readings = readings.filter((reading) => !outOfOrderDigits.includes(reading));
465
+ }
466
+ else {
467
+ // no number read out of order, or nothing of the answer's digits is left over
468
+ }
453
469
  // profile.whole_phrases (a choice, or a pattern that reads words): a phrase picks only when the answer is accounted
454
470
  // for: its phrases, the arguments read beside them (numbers, amounts, a card), courtesy or connecting words
455
471
  // (isReaderFiller) and the field's own profile.template_words. A template such as "pay 50 with my Visa card" stays;
@@ -537,8 +553,9 @@ export function matchInputs(answer, candidates, current, choiceKey) {
537
553
  winners = members.filter((m) => m.current);
538
554
  }
539
555
  else {
556
+ // A number that fits two inputs out of order is asked about wherever they are (owner 2026-11-02); scope settles words.
540
557
  const nearest = Math.min(...members.map((m) => m.level));
541
- winners = members.filter((m) => m.level === nearest);
558
+ winners = members.every(readsDigits) ? members : members.filter((m) => m.level === nearest);
542
559
  }
543
560
  // a winner declared to be confirmed against another member is asked about together with it
544
561
  const confirmWith = winners.length === 1 ? members.filter((m) => m !== winners[0] && paired(m, winners[0])) : [];
@@ -1 +1 @@
1
- export const JSFE_RUNTIME_FINGERPRINT = "sha256:80c880b71fd6c48f39f31a723104940b23f2a66e4cab17b95ebb48d334f7b54f";
1
+ export const JSFE_RUNTIME_FINGERPRINT = "sha256:2bf72b6928fba83b16952cd064e11de4ecb2619129b5e8b8020ade6ecc3e9137";
@@ -278,7 +278,7 @@ does this reply do with the question the flow asked? The model picks from a clos
278
278
 
279
279
  **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.
280
280
 
281
- **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.
281
+ **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.
282
282
 
283
283
  Some options are **never offered**, so the ranker cannot pick them: the YES of a firm confirmation or of a form's
284
284
  confirmation, a `current_only` choice while another question is asked, and text. `exact` on a choice says how words
@@ -232,16 +232,21 @@ Rules, in order:
232
232
  `depends_on` prerequisites are not filled yet (the sub-account before the account is known); a named answer ("50
233
233
  dollars") still waits as a pending value. **The whole signature:** A digit field takes a number only when the whole number fits its
234
234
  length; a pattern only when it covers the whole number; a choice only its declared or prepared values. A fragment of
235
- a longer number (the last four of "88 91 7112") fills nothing.
235
+ a longer number (the last four of "88 91 7112") fills nothing. **A perfect match:** a number read out of order accounts for every digit
236
+ of the answer, alone or with the other fields a compound answer fills. "0 4 guion 31, 0 31" at the expiration fills no ZIP;
237
+ "the line is 8185551234" fills the phone. Its validation decides too: a number its validator rejects fills nothing and says nothing, and the question
238
+ is asked again.
236
239
  4. **A bare number** (no money mark, no word naming a field) is an amount only at a question that is, invites or is
237
240
  declared `confirm_against` an amount. A single digit the question offers as a key is that key; one it does not offer reaches the invited amount,
238
- whose validation decides (Curacao asks for it in dollars). A bare number that does not fit the current question is
241
+ whose validation decides. The engine holds no amounts, so "only a known amount" is the amount validator's rule: Curacao's
242
+ takes a bare number at the amount question, and at a question that only invites an amount only when it is an amount the
243
+ caller can pay (else it asks for it in dollars). A bare number that does not fit the current question is
239
244
  read out of order under rule 3.
240
245
  5. **Compound answers.** Several fields fill from one answer only through the form's compound parser, or when every
241
246
  part of the answer fits exactly one field. **One part of an answer never fills two fields**; the engine refuses that
242
247
  reading and records the refusal.
243
248
  6. **Scope.** Across forms the nearest form wins; two fields of the same form that fit the same part are asked about,
244
- never ranked.
249
+ never ranked. A number that fits two fields out of order is asked about wherever they are.
245
250
 
246
251
  Every result records why: `decisions` lists each field filled other than as the current answer with its rule (`invited`, `out_of_order`, `compound`), and every
247
252
  refused reading is listed with its rule, so hosts can log what was filled out of order and what was refused.
@@ -301,7 +306,7 @@ A fact has unique `key`, `description`, optional `phrases`, and a `path` within
301
306
 
302
307
  Supported simple `format` names include `money`, `money_nonzero`, `date`, `date_no_year`, `last4`, `digits`. An object format maps result properties to format names. List format uses `{type:"list",item,item_es?,fields?,join?,join_es?}` with `{items}` / `{count}` in the outer template; optional `say_one` / `say_one_es` handles one item. Templates use `{value}`, named result properties and supplied `{name}` / `{label}`. Bracketed optional segments can provide alternatives separated by `|`.
303
308
 
304
- `menu` supports `question`, `question_es`, optional voice variants, and `choices:[{key,phrases,exact?,flow?,set?}]`. `set` applies declared cargo assignments before an action route. `menu.whole_phrases: true` (off by default): a fact's phrase answers only as the whole reply; a phrase inside a longer sentence goes to the reply ranker, offered every fact, action and `done` (each by its `label` or a fact's `description`, else its key and phrases) and a person. A pick is taken, and every other fact the reply clears at 0.6 is answered with it. A reply that picks nothing goes to the host, without intent detection, so the Guru answers from the account in its user context; `new_request` goes to intent detection. A reply with no phrase is a plain DISPATCH, as before. `done` supports `phrases`, `say`, `say_es`. Keep whole-answer terminal phrases such as “no thanks” separate from a neutral “no” that offers further help. Facts take priority; a declared terminal answer must not be shortened into a different action. Test following-turn choices and facts, not just direct `asked` activations.
309
+ `menu` supports `question`, `question_es`, optional voice variants, and `choices:[{key,phrases,exact?,flow?,set?}]`. `set` applies declared cargo assignments before an action route. `menu.unanswered: {asked:[catch-all keys],choice,question,question_es}` (off by default): a reply from this menu that intent detection sends back to this provider, when the ranker found no fact or `asked` is a listed catch-all, gets that question instead of the facts again; YES runs the named menu choice, NO returns to the menu, anything else is DISPATCHed. `menu.whole_phrases: true` (off by default): a fact's phrase answers only as the whole reply; a phrase inside a longer sentence goes to the reply ranker, offered every fact, action and `done` (each by its `label` or a fact's `description`, else its key and phrases) and a person. A pick is taken, and every other fact the reply clears at 0.6 is answered with it. A reply that picks nothing goes to the host, without intent detection, so the Guru answers from the account in its user context; `new_request` goes to intent detection. A reply with no phrase is a plain DISPATCH, as before. `done` supports `phrases`, `say`, `say_es`. Keep whole-answer terminal phrases such as “no thanks” separate from a neutral “no” that offers further help. Facts take priority; a declared terminal answer must not be shortened into a different action. Test following-turn choices and facts, not just direct `asked` activations.
305
310
 
306
311
  The shared `provide-data`, `load-source`, `answer-facts` and provider-menu workers gate, load and answer only the requested facts. Top-level `gateParameters` may pass literal data or whole-expression templates to the gate. Refresh clears old results before loading and records freshness only on success. `no_gate` may avoid rerunning a gate only while all declared verification paths still hold. It cannot grant access. Mid-form provider questions preserve the owner and pending verification instead of stacking repeated authentication tasks.
307
312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsfe",
3
- "version": "1.28.0",
3
+ "version": "1.30.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",