jsfe 1.15.8 → 1.16.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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/docs/flow-docs.d.ts.map +1 -1
  3. package/dist/docs/flow-docs.js +4 -2
  4. package/dist/forms/address-flows.js +1 -1
  5. package/dist/forms/continuation-compat.d.ts +5 -0
  6. package/dist/forms/continuation-compat.d.ts.map +1 -0
  7. package/dist/forms/continuation-compat.js +45 -0
  8. package/dist/forms/contracts.d.ts.map +1 -1
  9. package/dist/forms/contracts.js +6 -4
  10. package/dist/forms/declarations.d.ts +3 -0
  11. package/dist/forms/declarations.d.ts.map +1 -1
  12. package/dist/forms/declarations.js +27 -5
  13. package/dist/forms/flows.d.ts +2 -1
  14. package/dist/forms/flows.d.ts.map +1 -1
  15. package/dist/forms/flows.js +32 -15
  16. package/dist/forms/identity-flows.d.ts +1 -0
  17. package/dist/forms/identity-flows.d.ts.map +1 -1
  18. package/dist/forms/identity-flows.js +20 -3
  19. package/dist/forms/index.d.ts +1 -0
  20. package/dist/forms/index.js +1 -0
  21. package/dist/helpers/address.d.ts +1 -0
  22. package/dist/helpers/address.d.ts.map +1 -1
  23. package/dist/helpers/address.js +4 -1
  24. package/dist/helpers/answer.d.ts +2 -0
  25. package/dist/helpers/answer.d.ts.map +1 -1
  26. package/dist/helpers/answer.js +5 -0
  27. package/dist/helpers/form-actions.js +1 -1
  28. package/dist/helpers/form-conversation.d.ts +3 -0
  29. package/dist/helpers/form-conversation.d.ts.map +1 -1
  30. package/dist/helpers/form-conversation.js +18 -0
  31. package/dist/helpers/form-state.d.ts +2 -1
  32. package/dist/helpers/form-state.d.ts.map +1 -1
  33. package/dist/helpers/form-state.js +6 -2
  34. package/dist/helpers/index.d.ts +3 -1
  35. package/dist/helpers/index.d.ts.map +1 -1
  36. package/dist/helpers/index.js +3 -3
  37. package/dist/helpers/match-inputs.d.ts.map +1 -1
  38. package/dist/helpers/match-inputs.js +44 -11
  39. package/dist/host/intent-stages.d.ts.map +1 -1
  40. package/dist/host/intent-stages.js +2 -0
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +7 -1
  44. package/dist/runtime-identity.js +1 -1
  45. package/docs/architecture-guide.md +366 -0
  46. package/docs/copilot-reference.md +5 -4
  47. package/docs/development-release.md +7 -1
  48. package/package.json +2 -1
package/README.md CHANGED
@@ -12,6 +12,7 @@ npm i jsfe
12
12
  - **[JavaScript Flow Engine User Guide](user-guide.md)**
13
13
  - Comprehensive tutorials, examples, and best practices
14
14
  - **[README.md](README.md)** - Technical API reference (this document)
15
+ - **[Flows architecture guide](docs/architecture-guide.md)** - start here: the concepts, the building blocks, what the caller experiences, and how conversational AI and exact transactional flows work together (the reply ranker and DISPATCH)
15
16
  - **[Flow author reference](docs/copilot-reference.md)** - the authoritative engine and JSON authoring specification for flow authors and AI flow generators (shipped in the package; the Flow Designer's CoPilot reads it from the published version, so change it with the engine)
16
17
  - **[Development candidate acceptance](docs/development-release.md)** - reproducible package and cross-repository verification
17
18
  - **[System Flows](docs/flows/system/README.md)** and **[Shopify Flows](docs/flows/shopify/README.md)** - the bundled flow and tool libraries in `flows/`: a page and a chart per flow, generated from the JSON (`node scripts/flow-docs.mjs`), plus the conventions the system flows share
@@ -1 +1 @@
1
- {"version":3,"file":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAiBA;;;;;;;;;;;EAqRC"}
1
+ {"version":3,"file":"flow-docs.d.ts","sourceRoot":"","sources":["../../src/docs/flow-docs.js"],"names":[],"mappings":"AAkBA;;;;;;;;;;;EAsRC"}
@@ -14,8 +14,10 @@
14
14
  */
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
+ import { resolveSetDataReferences } from '../forms/declarations.js';
17
18
  export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log = console.log }) {
18
- const SETS = sets;
19
+ // A catalog or value written as {"$data": path} is documented as compilation resolves it.
20
+ const SETS = sets.map((s) => ({ ...s, flows: resolveSetDataReferences(s.flows) }));
19
21
  // Which set holds a flow id; a page links to a flow in its own set, or in another set's folder.
20
22
  const setOf = new Map(), flowOf = new Map();
21
23
  for (const s of SETS) {
@@ -422,7 +424,7 @@ export function generateFlowDocs({ sets, root, check = false, hostRun = {}, log
422
424
  }
423
425
  else { /* not a compiled form */ }
424
426
  if (flow.performs) {
425
- out.push('', '### Performs', '', ...flow.performs.map((a) => `- \`${a.action}\` (${a.effect}): ${a.reconcile && a.reconcile.policy ? cut(a.reconcile.policy, 300) : ''}`), '', performsChart(flow));
427
+ out.push('', '### Performs', '', ...flow.performs.map((a) => `- \`${a.action}\` (${a.effect})`), '', performsChart(flow));
426
428
  }
427
429
  else { /* not a performer */ }
428
430
  if (flow.call) {
@@ -28,7 +28,7 @@ const street = C('address-street-validate', 'Validate a proposed street against
28
28
  W('address_street_malformed', "!/^\\d{1,6}\\s+[A-Za-z\\xC0-\\xFF]/.test(String(value))", S('address_street_invalid', 'noop', "(cargo.inputCheck={ok:false,message:'Please give the street number and street name.',message_es:'Indique el número y nombre de la calle.'},true)")), W('address_street_stop', "!/^\\d{1,6}\\s+[A-Za-z\\xC0-\\xFF]/.test(String(value))", { id: 'address_street_invalid_end', type: 'END' }),
29
29
  S('address_street_components', 'components', "'postal_code:'+values.zip+'|country:US'"), { id: 'address_street_tool', type: 'CALL-TOOL', tool: 'geocode-street', variable: 'result', args: { address: '{{value}}', components: '{{components}}' }, onFail: S('address_street_failed', 'result', 'null') },
30
30
  S('address_street_projection', 'location', E(streetLocation, 'result', 'value', 'values.zip')),
31
- S('address_street_result', 'noop', "(cargo.forms[form].data.street=location, cargo.inputCheck={ok:true,value:String(value)}, true)")
31
+ S('address_street_result', 'noop', "(cargo.forms[form].data.street=location, location.city&&cargo.forms[form].accepted&&cargo.forms[form].accepted.city&&cargo.forms[form].accepted.city.origin==='derived'&&cargo.forms[form].values.city!==location.city?(cargo.forms[form].values.city=location.city):null, cargo.inputCheck={ok:true,value:String(value)}, true)")
32
32
  ], { parameters: [P('form'), P('value'), P('values', 'object')], variables: V({ components: '', result: null, location: null }), contract: { tools: ['geocode-street'], result: { path: 'cargo.inputCheck', type: 'object' } } });
33
33
  const zipPrompt = C('address-zip-prompt', 'Render the ZIP retry wording from the owned address instance.', [S('address_zip_prompt', 'noop', "(cargo.forms[form].data.zip_question=(cargo.forms[form].data.retry ? \"Let's try again. \" : '')+'What is the 5-digit zip code of your new address?',cargo.forms[form].data.zip_question_es=(cargo.forms[form].data.retry?'Intentémoslo otra vez. ':'')+'¿Cuál es el código postal de 5 dígitos de su nueva dirección?',true)")], { parameters: [P('form')], contract: { result: { path: 'cargo.forms[form].data', type: 'object' } } });
34
34
  const streetPrompt = C('address-street-prompt', 'Include the ZIP-derived location when asking for the street.', [S('address_street_prompt', 'noop', "(cargo.forms[form].data.street_question=(cargo.forms[form].data.location?'Got it, '+(cargo.forms[form].values.city?cargo.forms[form].values.city+', '+cargo.forms[form].data.location.state:cargo.forms[form].data.location.line)+'. ':'')+'What is the street address?',cargo.forms[form].data.street_question_es=(cargo.forms[form].data.location?'Entendido, '+(cargo.forms[form].values.city?cargo.forms[form].values.city+', '+cargo.forms[form].data.location.state:cargo.forms[form].data.location.line)+'. ':'')+'¿Cuál es la dirección?',true)")], { parameters: [P('form')], contract: { result: { path: 'cargo.forms[form].data', type: 'object' } } });
@@ -0,0 +1,5 @@
1
+ export function continuationBreaks(previousFlows: any, nextFlows: any): {
2
+ id: any;
3
+ removed: {};
4
+ }[];
5
+ //# sourceMappingURL=continuation-compat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"continuation-compat.d.ts","sourceRoot":"","sources":["../../src/forms/continuation-compat.js"],"names":[],"mappings":"AAgBA;;;IAgBC"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A deploy check: a change that can break a saved session must bump its declaration's major version. A saved session
3
+ * continues across a minor or patch change, so an entry argument or a form input it may still hold must not disappear
4
+ * without a major bump; neither may a shared data or function name without its entry's major bump. Removing a flow needs
5
+ * no bump: a session standing in a flow that no longer exists is retired anyway.
6
+ *
7
+ * continuationBreaks(previousFlows, nextFlows) → [{ id, removed: { parameters?, inputs?, data?, functions? } }]
8
+ * both arrays are the authored declarations (a set's .flows file); ordinary executable flows are not compared.
9
+ * It reads names only: a change of meaning under the same name is still the author's to declare with a major bump.
10
+ */
11
+ import { majorOf } from './declarations.js';
12
+ const parameterNames = parameters => Array.isArray(parameters) ? parameters.map(p => p?.name).filter(Boolean) : Object.keys(parameters || {});
13
+ const inputNames = inputs => (inputs || []).flatMap(input => [input?.name, ...inputNames(input?.any_of)]).filter(Boolean);
14
+ const missing = (before, after) => { const keep = new Set(after); return [...new Set(before)].filter(name => !keep.has(name)); };
15
+ export function continuationBreaks(previousFlows, nextFlows) {
16
+ const next = new Map((nextFlows || []).filter(flow => flow && flow.id).map(flow => [flow.id, flow]));
17
+ const breaks = [];
18
+ for (const before of previousFlows || []) {
19
+ const after = before && next.get(before.id);
20
+ if (!after || before.declarationVersion === undefined || majorOf(before.version) !== majorOf(after.version))
21
+ continue;
22
+ const removed = {};
23
+ if (before.library === 'data') {
24
+ const names = missing(Object.keys(before.data || {}), Object.keys(after.data || {}));
25
+ if (names.length)
26
+ removed.data = names;
27
+ }
28
+ else if (before.library === 'functions') {
29
+ const names = missing(Object.keys(before.functions || {}), Object.keys(after.functions || {}));
30
+ if (names.length)
31
+ removed.functions = names;
32
+ }
33
+ else {
34
+ const parameters = missing(parameterNames(before.parameters), parameterNames(after.parameters));
35
+ if (parameters.length)
36
+ removed.parameters = parameters;
37
+ const inputs = missing(inputNames(before.form?.inputs), inputNames(after.form?.inputs));
38
+ if (inputs.length)
39
+ removed.inputs = inputs;
40
+ }
41
+ if (Object.keys(removed).length)
42
+ breaks.push({ id: before.id, removed });
43
+ }
44
+ return breaks;
45
+ }
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2EC"}
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"}
@@ -138,7 +138,7 @@ export function defineProvider({ provides, contract, parameters = [], variables
138
138
  }
139
139
  export function defineForm({ contract, provides, ...spec }) {
140
140
  const flow = formFlow({ ...spec, ...(provides ? { extraVars: { ...spec.extraVars, provides, questions: providerQuestions(provides) } } : {}) });
141
- return declared(flow, { role: 'collector', ...contract, form: { inputs: 'inputs' } });
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 }) {
144
144
  validateActionProjection(project, spec.id);
@@ -170,7 +170,7 @@ export function definePerformer({ performs, contract, prepare, present, project
170
170
  { ...set('receipt_applied', 'noop', 'pf_receipt ? consumeActionReceipt(cargo,pf_receipt) : true'), requireTrue: true },
171
171
  call('present', present, { form: '{{form_instance}}', request: '{{pf_request}}', result: '{{pf_result}}' }),
172
172
  ...(spec.after || [])] });
173
- return declared(flow, { role: 'performer', ...contract, requires: ['guardedActions', ...normalizeHostRequirements(contract?.requires, spec.id)], actions: 'performs', form: { inputs: 'inputs' }, prepares: prepare, presents: present, projects: 'projection' });
173
+ return declared(flow, { role: 'performer', ...contract, requires: ['guardedActions', ...normalizeHostRequirements(contract?.requires, spec.id)], actions: 'performs', form: { inputs: 'inputs' }, prepares: prepare, presents: present, projects: 'projection', ...(provides ? { catalog: 'provides' } : {}) });
174
174
  }
175
175
  /** A registered tool gets one generated adapter with explicit args; no dynamic tool-name interpreter is needed. */
176
176
  export function operationWorkers(tools) {
@@ -278,6 +278,10 @@ export function validateFlowContracts(flows, tools = [], { externalFlows = [], r
278
278
  for (const reference of [provides.gate?.flow, provides.source?.flow])
279
279
  if (reference && !ids.has(reference))
280
280
  fail(flow.id, `unknown provider worker ${reference}`);
281
+ // a FLOW to a name that does not exist is skipped silently at run time, so a menu choice's target is checked here
282
+ for (const choice of provides.menu?.choices || [])
283
+ if (choice?.flow && !ids.has(choice.flow))
284
+ fail(flow.id, `unknown menu worker ${choice.flow}`);
281
285
  }
282
286
  for (const action of contract.performs || flow.variables?.[contract.actions]?.value || []) {
283
287
  if (action.retry?.unknown && !(action.effect === 'send' && action.retry.unknown === 'explicit'))
@@ -292,8 +296,6 @@ export function validateFlowContracts(flows, tools = [], { externalFlows = [], r
292
296
  fail(flow.id, `unknown action worker ${action.flow}`);
293
297
  if (!action.outcomes || !Object.keys(action.outcomes).length)
294
298
  fail(flow.id, `action ${action.action} needs outcomes`);
295
- if (['charge', 'write', 'send', 'cancel'].includes(action.effect) && !action.idempotency && !action.reconcile)
296
- fail(flow.id, `action ${action.action} needs idempotency or reconciliation`);
297
299
  if ((action.effect === 'charge' || action.effect === 'cancel' || action.reversible === false) && action.confirm?.exact !== true)
298
300
  fail(flow.id, `action ${action.action} needs exact confirmation`);
299
301
  }
@@ -1,6 +1,7 @@
1
1
  /** The set's shared data for expressions, read as $data.<name>: references resolved, deep-frozen (one engine serves many
2
2
  * sessions), never copied into session variables. Every $data.<name> an expression reads must exist, checked at load. */
3
3
  export function flowSetData(flows: any, compiledFlows?: any[]): any;
4
+ export function resolveSetDataReferences(flows: any): any;
4
5
  /** Compile only explicitly versioned declarations. Ordinary executable JSON is unchanged.
5
6
  * @param {any[]} flows
6
7
  * @param {{tools?: any[], externalFlows?: any[], validate?: boolean}} options
@@ -20,4 +21,6 @@ export function captureOriginalDeclaration(flow: any): {
20
21
  export function serializeFlowDeclaration(flow: any, { resolvedFlow }?: {
21
22
  resolvedFlow?: any;
22
23
  }): any;
24
+ /** The major of a declared version ("2.4.1" → "2"; absent or unreadable → "0"). */
25
+ export function majorOf(version: any): string;
23
26
  //# sourceMappingURL=declarations.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"declarations.d.ts","sourceRoot":"","sources":["../../src/forms/declarations.js"],"names":[],"mappings":"AAgDA;yHACyH;AACzH,oEAaC;AAmFD;;;;GAIG;AACH,+CAJW,GAAG,EAAE,uCACL;IAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,aAAa,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAC,GACxD,GAAG,EAAE,CAmEjB;AAED,sGAAsG;AACtG;;;SAAqK;AAuBrK,6FAA6F;AAC7F;;QAuCC"}
1
+ {"version":3,"file":"declarations.d.ts","sourceRoot":"","sources":["../../src/forms/declarations.js"],"names":[],"mappings":"AAyDA;yHACyH;AACzH,oEAaC;AAID,0DAIC;AAmFD;;;;GAIG;AACH,+CAJW,GAAG,EAAE,uCACL;IAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,aAAa,CAAC,EAAE,GAAG,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAC,GACxD,GAAG,EAAE,CAuEjB;AAED,sGAAsG;AACtG;;;SAAqK;AAuBrK,6FAA6F;AAC7F;;QAuCC;AAnSD,mFAAmF;AACnF,8CAA4G"}
@@ -4,7 +4,7 @@ import { linkFormSchemas, validateFlowContracts } from './contracts.js';
4
4
  import { compileFormPrompts } from './prompt-compiler.js';
5
5
  import { withSafetyRevision } from './continuation-safety.js';
6
6
  import { capturedAuthorSpec, copyAuthorSpec, authoredExpressions } from './declaration-capture.js';
7
- const formKeys = new Set(['inputs', 'before', 'after', 'confirm', 'before_confirm', 'declined', 'stop', 'close_on_stop', 'resume_on_parent_change', 'done', 'validator', 'confirmation_condition', 'prompt', 'prompt_es', 'incomplete_outcome', 'on_exhausted', 'must_complete']);
7
+ const formKeys = new Set(['inputs', 'before', 'after', 'confirm', 'before_confirm', 'declined', 'stop', 'close_on_stop', 'resume_on_parent_change', 'done', 'validator', 'confirmation_condition', 'prompt', 'prompt_es', 'incomplete_outcome', 'on_exhausted', 'on_no', 'must_complete']);
8
8
  const sharedKeys = ['id', 'name', 'description', 'version', 'scope', 'primary', 'parameters', 'contract', 'parameterInstructions'];
9
9
  const safe = /^[A-Za-z_$][\w$]*$/;
10
10
  const fail = message => { throw new Error('Flow declaration: ' + message); };
@@ -12,6 +12,15 @@ const fail = message => { throw new Error('Flow declaration: ' + message); };
12
12
  function sourceRevision(source) { const text = JSON.stringify(source); const hashes = [2166136261, 2246822507, 3266489909, 668265263]; for (let i = 0; i < text.length; i++)
13
13
  for (let lane = 0; lane < hashes.length; lane++)
14
14
  hashes[lane] = Math.imul(hashes[lane] ^ text.charCodeAt(i), 16777619 + lane * 2) >>> 0; return hashes.map(n => n.toString(16).padStart(8, '0')).join(''); }
15
+ /** The major of a declared version ("2.4.1" → "2"; absent or unreadable → "0"). */
16
+ const majorOf = version => { const m = /^\s*v?(\d+)/i.exec(String(version ?? '')); return m ? String(Number(m[1])) : '0'; };
17
+ /** A declaration's continuation revision: its major version, plus the major of each shared entry it uses ($data, fn.).
18
+ * A minor or patch change keeps the revision, so a saved session continues; a breaking change bumps the major. */
19
+ function majorRevision(declarationVersion, source, libraries) {
20
+ const text = JSON.stringify(source), uses = { data: /\$data\b/.test(text), functions: /(?<![\w$])fn\./.test(text) };
21
+ const shared = libraries.filter(library => uses[library.library]).map(library => library.library + '@' + library.id + ':' + majorOf(library.version)).sort();
22
+ return 'declaration-v' + declarationVersion + ':major:' + majorOf(source.version) + (shared.length ? '/' + shared.join('/') : '');
23
+ }
15
24
  const variableDeclaration = value => value && typeof value === 'object' && !Array.isArray(value) && ['string', 'number', 'integer', 'boolean', 'object', 'array'].includes(value.type) ? value : { type: Array.isArray(value) ? 'array' : value === null || typeof value === 'object' ? 'object' : typeof value, value };
16
25
  const DECLARATION_VERSIONS = new Set([1, 2]);
17
26
  /** A flow set's shared entries: {id, library:'functions', declarationVersion:2, functions} and
@@ -29,7 +38,7 @@ function sharedFunctions(libraries) {
29
38
  fail('a shared functions entry needs an id');
30
39
  if (library.declarationVersion !== 2)
31
40
  fail('shared functions ' + library.id + ' require declarationVersion 2');
32
- const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'functions'].includes(key));
41
+ const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'version', 'functions'].includes(key));
33
42
  if (extra.length)
34
43
  fail('shared functions ' + library.id + ' cannot declare ' + extra.join(', '));
35
44
  if (!library.functions || typeof library.functions !== 'object' || Array.isArray(library.functions) || !Object.keys(library.functions).length)
@@ -51,7 +60,7 @@ function sharedData(libraries) {
51
60
  fail('a shared data entry needs an id');
52
61
  if (library.declarationVersion !== 2)
53
62
  fail('shared data ' + library.id + ' requires declarationVersion 2');
54
- const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'data'].includes(key));
63
+ const extra = Object.keys(library).filter(key => !['id', 'name', 'library', 'declarationVersion', 'description', 'version', 'data'].includes(key));
55
64
  if (extra.length)
56
65
  fail('shared data ' + library.id + ' cannot declare ' + extra.join(', '));
57
66
  if (!library.data || typeof library.data !== 'object' || Array.isArray(library.data) || !Object.keys(library.data).length)
@@ -88,6 +97,15 @@ export function flowSetData(flows, compiledFlows = []) {
88
97
  }
89
98
  return Object.keys(resolved).length ? resolved : undefined;
90
99
  }
100
+ /** A set's version 2 declarations with every {"$data":"path"} replaced by its value, as compilation sees them; the shared
101
+ * data entries are kept. For readers of the declarations (the flow docs); compilation resolves them itself. */
102
+ export { majorOf };
103
+ export function resolveSetDataReferences(flows) {
104
+ const data = sharedData((flows || []).filter(isLibrary));
105
+ if (!Object.keys(data).length)
106
+ return flows;
107
+ return flows.map(flow => flow?.declarationVersion === 2 && !isLibrary(flow) ? resolveDataReferences(flow, data, flow.id) : flow);
108
+ }
91
109
  const isDataReference = value => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.$data === 'string';
92
110
  /** Replace every {"$data":"path"} with a copy of the shared value at that path; an unknown path fails compilation. */
93
111
  function resolveDataReferences(value, data, where, active = new Set()) {
@@ -266,7 +284,11 @@ export function compileFlowDeclarations(flows, { tools, externalFlows = [], vali
266
284
  return false; parameterNames.add(parameter.name); return true; }), ...(rest.parameterInstructions !== undefined ? { parameterInstructions: rest.parameterInstructions } : {}), ...(functions ? { functions } : {}), metadata: { ...flow.metadata, declaration: { version: declarationVersion, source: source.id } } };
267
285
  // Revisions are engine bookkeeping; a stale authored token cannot override source change detection.
268
286
  const { safetyRevision: ignoredRevision, ...revisionSource } = source;
269
- return withSafetyRevision(flow, 'declaration-v' + declarationVersion + ':' + sourceRevision(declarationVersion === 2 && Object.keys(shared).length ? { ...revisionSource, sharedFunctions: shared } : revisionSource));
287
+ const revised = withSafetyRevision(flow, majorRevision(declarationVersion, original, libraries));
288
+ // The earlier revision, a fingerprint of the whole declaration: a session saved under it continues while the declaration
289
+ // is unchanged (and takes the major revision), so moving to major revisions retires no session.
290
+ revised.safetyRevisionCompat = 'declaration-v' + declarationVersion + ':' + sourceRevision(declarationVersion === 2 && Object.keys(shared).length ? { ...revisionSource, sharedFunctions: shared } : revisionSource);
291
+ return revised;
270
292
  });
271
293
  const externalObjects = externalFlows.filter(flow => flow && typeof flow === 'object');
272
294
  const formIds = new Set(flows.filter(flow => flow.declarationVersion !== undefined && flow.form).map(flow => flow.id));
@@ -472,7 +494,7 @@ export function serializeFlowDeclaration(flow, { resolvedFlow = flow } = {}) {
472
494
  else if (spec.variables)
473
495
  result.variables = spec.variables;
474
496
  for (const [key, value] of Object.entries(spec))
475
- if (!sharedKeys.includes(key) && !['extraVars', 'variables', 'helpers', 'safetyRevision'].includes(key) && !(isForm && formKeys.has(key)))
497
+ if (!sharedKeys.includes(key) && !['extraVars', 'variables', 'helpers', 'safetyRevision', 'safetyRevisionCompat'].includes(key) && !(isForm && formKeys.has(key)))
476
498
  result[key] = value;
477
499
  const out = rewrite(result);
478
500
  // Helpers and bodies can reference other explicitly authored expressions as well.
@@ -1,4 +1,4 @@
1
- export function formFlow({ id, name, version, description, inputs, after, extraVars, parameters, confirm, before_confirm, declined, stop, close_on_stop, resume_on_parent_change, before, done, primary, scope, validator, confirmation_condition, prompt, prompt_es, incomplete_outcome, on_exhausted, must_complete }: {
1
+ 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 }: {
2
2
  id: any;
3
3
  name: any;
4
4
  version: any;
@@ -12,6 +12,7 @@ export function formFlow({ id, name, version, description, inputs, after, extraV
12
12
  declined?: never[] | undefined;
13
13
  stop?: string | undefined;
14
14
  close_on_stop?: boolean | undefined;
15
+ on_no?: string | undefined;
15
16
  resume_on_parent_change?: boolean | undefined;
16
17
  before?: never[] | undefined;
17
18
  done?: boolean | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AAqIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAueD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AA3qBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAo2B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
1
+ {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../../src/forms/flows.js"],"names":[],"mappings":"AAqIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6DC;AACD;;;GAGG;AACH;;;;;;EAGC;AAsfD,wHAAwH;AACxH,wEAaC;AACD,4FAA4F;AAC5F;;;EAYC;AACD,sGAAsG;AACtG;;;EAOC;AA3rBM,6CAAuG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAo3B9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAOG"}
@@ -127,8 +127,10 @@ function validateAcceptanceDeclarations(id, inputs) {
127
127
  else { /* no invites, or a list of names */ }
128
128
  }
129
129
  }
130
- export function formFlow({ id, name, version, description, inputs, after = [], extraVars = {}, parameters = [], confirm = null, before_confirm = [], declined = [], stop = '', close_on_stop = false, 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 }) {
130
+ 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 }) {
131
131
  validateFormExhaustion(on_exhausted);
132
+ if (on_no !== '' && (typeof on_no !== 'string' || !/^[\w-]+$/.test(on_no)))
133
+ throw new Error(`FORM ${id}: on_no must be a flow id`);
132
134
  if (typeof must_complete !== 'boolean')
133
135
  throw new Error(`FORM ${id}: must_complete must be true or false`);
134
136
  validateAcceptanceDeclarations(id, inputs);
@@ -154,13 +156,13 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
154
156
  ] : [];
155
157
  return {
156
158
  id, name, version, description, scope, metadata: { formProtocol: 2 }, parameters: [...parameters, { name: 'form_instance', type: 'string', internal: true, description: 'Internal instance to resume' }], ...(primary ? { primary: true } : {}), ...(prompt ? { prompt, prompt_es } : {}),
157
- variables: vars({ form_on_exhausted: on_exhausted, form_must_complete: must_complete, form_incomplete_outcome: incompleteFrom ? null : incomplete_outcome, form_instance: '', form_internal_resume: false, form_resume_owner: '', form_arguments: {}, form_exhaustion: null, form_values: {}, form_validation: '', initial_readings: [], inputs: resolveFormInputs(inputs, FIELD_PRESETS), parent_form: '', result_input: '', questions: [], provides: null, exit_context: '', exit_context_es: '', form_confirm_q: '', form_confirm_q_es: '', form_confirmation: confirm || {}, form_should_confirm: false, ...extraVars }),
159
+ variables: vars({ form_on_exhausted: on_exhausted, form_on_no: on_no, form_must_complete: must_complete, form_incomplete_outcome: incompleteFrom ? null : incomplete_outcome, form_instance: '', form_internal_resume: false, form_resume_owner: '', form_arguments: {}, form_exhaustion: null, form_values: {}, form_validation: '', initial_readings: [], inputs: resolveFormInputs(inputs, FIELD_PRESETS), parent_form: '', result_input: '', questions: [], provides: null, exit_context: '', exit_context_es: '', form_confirm_q: '', form_confirm_q_es: '', form_confirmation: confirm || {}, form_should_confirm: false, ...extraVars }),
158
160
  steps: [
159
161
  ...contextualResponseRuntimeSteps(id, [], hasFormResponse(inputs) ? [CONTEXTUAL_RESPONSES_REQUIREMENT] : []),
160
162
  ...(hasFormResponse(inputs) ? [{ ...SET(`${id}_response_reader_required`, 'noop', "typeof validateFormResponse === 'function'"), requireTrue: true }] : []),
161
163
  ...proposalRuntimeSteps(id, [], hasInputProposals(inputs) ? [INPUT_PROPOSALS_REQUIREMENT] : []),
162
164
  SET(`${id}_resume_kind`, 'form_internal_resume', '!!form_instance'),
163
- SET(`${id}_open`, 'form_instance', `openFormInstance(cargo, ${JSON.stringify(id)}, inputs, { parent: parent_form || null, input: result_input || '', context:!!parent_form && !result_input, resume_on_parent_change: ${resume_on_parent_change === true}, resume: form_instance || (!parent_form ? (cargo.formTasks || {})[${JSON.stringify(id)}] || '' : ''), providers: provides, questions: questions, incomplete_outcome: ${incompleteExpression}, on_exhausted: form_on_exhausted, must_complete: form_must_complete })`),
165
+ SET(`${id}_open`, 'form_instance', `openFormInstance(cargo, ${JSON.stringify(id)}, inputs, { parent: parent_form || null, input: result_input || '', context:!!parent_form && !result_input, resume_on_parent_change: ${resume_on_parent_change === true}, resume: form_instance || (!parent_form ? (cargo.formTasks || {})[${JSON.stringify(id)}] || '' : ''), providers: provides, questions: questions, incomplete_outcome: ${incompleteExpression}, on_exhausted: form_on_exhausted, on_no: form_on_no, must_complete: form_must_complete })`),
164
166
  SIDE(`${id}_task`, `(!parent_form ? (cargo.formTasks = cargo.formTasks || {}, cargo.formTasks[${JSON.stringify(id)}] = form_instance) : true, true)`),
165
167
  SIDE(`${id}_provider_reset`, '(delete cargo.forms[form_instance].providerRequests, delete cargo.forms[form_instance].activeGates, delete cargo.forms[form_instance].providerGateActive, true)'),
166
168
  SIDE(`${id}_pass_reset`, '(cargo.forms[form_instance].replays = 0, form_internal_resume ? true : (cargo.forms[form_instance].attempts = {},cargo.forms[form_instance].misses={},cargo.forms[form_instance].feedback={}), true)'),
@@ -250,7 +252,7 @@ const collectInput = {
250
252
  id: 'collect-input', name: 'CollectInput', version: '1.0.0', scope: 'isolated',
251
253
  description: "Worker: ask the form's current input once and file the answer wherever it belongs in the form chain (matchInputs, decided by scope; a declared confirm_against pair is asked). One reading → collect-input-accept; ambiguous → disambiguate-input; a question about the form's data → provide-data; a short miss → counted, the loop re-asks; 3+ words that fit nothing → DISPATCH. Params: form, current, questions, provides, exit_context(_es).",
252
254
  parameters: ['form', 'current', 'questions', 'provides', 'exit_context', 'exit_context_es'].map((n) => ({ name: n, type: ['questions'].includes(n) ? 'array' : n === 'provides' ? 'object' : 'string', description: n })),
253
- variables: vars({ ci_partial: '', ci_reply_reader: '', ci_exhaustion: null, ci_d: {}, ci_pre: '', ci_val: '', ci_routed: '', ci_names: [], ci_chain: [], ci_spec: null, ci_choices: [], ci_q: '', ci_q_es: '', ci_missed: 0, ci_max: 3, ci_misses: 0, ci_miss_limit: 2, ci_must: false, ci_m: null, ci_qkey: '', ci_skip_gate: '', ci_words: 0, ci_digits: null, ci_amount: false, ci_as_spoken: false, ci_reader: {}, ci_pii: false, ci_ctx: '', ci_ctx_es: '' }),
255
+ variables: vars({ ci_partial: '', ci_no_owner: '', ci_no_flow: '', ci_no_up: false, ci_reply_reader: '', ci_exhaustion: null, ci_d: {}, ci_pre: '', ci_val: '', ci_routed: '', ci_names: [], ci_chain: [], ci_spec: null, ci_choices: [], ci_q: '', ci_q_es: '', ci_missed: 0, ci_max: 3, ci_misses: 0, ci_miss_limit: 2, ci_must: false, ci_m: null, ci_qkey: '', ci_skip_gate: '', ci_words: 0, ci_digits: null, ci_amount: false, ci_as_spoken: false, ci_reader: {}, ci_pii: false, ci_ctx: '', ci_ctx_es: '' }),
254
256
  steps: [
255
257
  SET('ci_find_spec', 'ci_spec', "(function () { var s = ((cargo.forms[form] || {}).specs || []).filter(function (s) { return s.name === current; })[0]; return s ? (Array.isArray(s.any_of) ? Object.assign({}, s, { profile: { kind: 'group' } }) : s) : null; })()"),
256
258
  SET('ci_current_names', 'ci_names', "Array.isArray(ci_spec && ci_spec.any_of) ? ci_spec.any_of.map(function (m) { return m.name; }) : [current]"),
@@ -299,6 +301,15 @@ const collectInput = {
299
301
  // short of the input's minimum the next question is "Go on." Anything that is not digits drops the partial.
300
302
  SET('ci_partial_join', 'ci_partial', "(function () { var state = cargo.forms[form]; if (!ci_spec || ci_spec.accumulate !== true || !ci_spec.digits) return ''; var said = String(cargo.choiceText || '').trim(); var data = state.data = state.data || {}; var key = '__partial_' + current; if (!/^[0-9][0-9\\s.,-]*$/.test(said)) { delete data[key]; return ''; } var joined = String(data[key] || '') + said.replace(/[^0-9]/g, ''); var starts = ci_spec.accumulate_first ? new RegExp(ci_spec.accumulate_first) : null; if (!data[key] && starts && !starts.test(joined)) return ''; if (joined.length < Number(ci_spec.digits.min || 0)) { data[key] = joined; var fb = state.feedback || {}; fb[current] = 'partial'; state.feedback = fb; return 'hold'; } delete data[key]; cargo.choiceText = joined; cargo.choice = joined; return 'joined'; })()"),
301
303
  WHEN('ci_partial_hold', "ci_partial === 'hold'", END('ci_partial_wait')),
304
+ // A whole NO at a question that does not read it, in a form (or a sub-form of one) that declares `on_no`: that
305
+ // form's worker answers it (a payment starts its selection again), instead of any field or the reply ranker; the
306
+ // owning form then continues, a sub-form's owner included.
307
+ SET('ci_no_owner', 'ci_no_owner', "formNoOwner(cargo, form, ci_spec, cargo.choiceText)"),
308
+ SET('ci_no_flow', 'ci_no_flow', "ci_no_owner ? cargo.forms[ci_no_owner].on_no : ''"),
309
+ SET('ci_no_up', 'ci_no_up', "!!ci_no_owner && ci_no_owner !== form"), // before the call: a shared-scope worker may set `form`
310
+ WHEN('ci_no_run', 'ci_no_flow', FLOW('ci_no_answer', '{{ci_no_flow}}', 'call', { form: '{{ci_no_owner}}' })),
311
+ WHEN('ci_no_owner_resume', 'ci_no_flow && ci_no_up', FLOW('ci_no_resume_owner', 'resume-form', 'reboot', { form: '{{ci_no_owner}}' })),
312
+ WHEN('ci_no_end', 'ci_no_flow', END('ci_no_done')),
302
313
  SET('ci_build_chain', 'ci_chain', CHAIN_JS),
303
314
  SET('ci_match', 'ci_m', "readFormAnswer(cargo.choiceText, ci_chain, Array.isArray(ci_spec.any_of) ? { form: form, names: ci_names } : { form: form, name: current }, cargo.choice, provides && Array.isArray(questions) ? questions : [])"),
304
315
  SIDE('ci_acceptance_trace', "(cargo.formAcceptance = formAcceptanceTrace(cargo.formAcceptance, ci_m), true)"),
@@ -407,9 +418,9 @@ const rankedChoices = (id, from) => SET(id, 'ranked', `(${from}).concat(${ESCAPE
407
418
  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));}";
408
419
  // The choices of this form's OTHER inputs that the answer could also set (the word reader reads them all: 'LO VENCIDO' at
409
420
  // the pay confirmation): the ranker is offered every choice the reader would accept (owner 2026-10-28). A confirmation
410
- // input is answered only when asked, and a key already offered is not offered twice. Each carries its input, so a pick is
411
- // read as that input's answer.
412
- 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}};}));},[]);})()`;
421
+ // input is answered only when asked, a current_only choice only at its own question (as the reader reads it), and a key
422
+ // already offered is not offered twice. Each carries its input, so a pick is read as that input's answer.
423
+ 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(c.current_only===true||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}};}));},[]);})()`;
413
424
  const escapeRoutes = (id) => [WHEN(`${id}_agent`, "reply && reply.key === '__agent'", FLOW(`${id}_agent_flow`, 'live-agent-requested', 'reboot'))];
414
425
  const rankedDispatch = (id, reason) => ({ ...yieldDispatch(id, 'form', `'${reason}'`), onResponse: 'continue', responseContext: 'Current question: {{asked}}', responseChoices: 'ranked', responseVariable: 'reply' });
415
426
  const readFormReply = {
@@ -448,20 +459,26 @@ const readFormChoice = {
448
459
  // form's other choices (a correction) and a person, never YES. Result: cargo.confirmRanked = 'no' | 'changed' | 'none'
449
460
  // (ConfirmForm reads it); `abort` ends every flow; `new_request` goes to intent detection.
450
461
  const readConfirmReply = {
451
- 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.',
452
- parameters: [{ name: 'form', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }],
453
- variables: vars({ question: '', question_es: '', asked: '', ranked: [], reply: null, chain: null, others: [], picked: null, reading: null }),
462
+ id: 'read-confirm-reply', name: 'ReadConfirmReply', version: '1.3.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.',
463
+ parameters: [{ name: 'form', type: 'string' }, { name: 'question', type: 'string' }, { name: 'question_es', type: 'string' }, { name: 'named_fields', type: 'boolean' }],
464
+ variables: vars({ question: '', question_es: '', asked: '', ranked: [], reply: null, chain: null, others: [], fields: [], field: null, picked: null, reading: null, named_fields: false }),
454
465
  steps: [SET('confirm_asked', 'asked', "language === 'es' && question_es ? question_es : question"),
455
466
  SET('confirm_chain', 'chain', 'formInputChain(cargo.forms, form, cargo)'),
456
467
  SET('confirm_others', 'others', otherChoicesJS("''", '[]')),
457
- 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};}))"),
468
+ // confirm.named_fields: a field read back may be corrected in the caller's own words ('es Long Beach' at an address
469
+ // read back with the ZIP code's city): each named input of this form that holds a value is offered, and a pick asks
470
+ // that input again, so the caller's value replaces the one read back
471
+ 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}};}) : []"),
472
+ 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};})).concat(fields.map(function(c){return {key:c.key,label:c.label};}))"),
458
473
  rankedDispatch('confirm_dispatch', 'confirmation_not_matched'),
459
474
  ...escapeRoutes('confirm_escape'),
460
475
  // a correction picked by the ranker is read as its input's answer: the form changes and is confirmed again, never confirmed
461
476
  SET('confirm_picked', 'picked', "others.filter(function(c){return reply && c.key===reply.key;})[0] || null"),
462
477
  SET('confirm_read', 'reading', "picked ? enrichFormMatches(readFormAnswer(picked.key, chain, picked.owner, picked.key, []), chain, form, [picked.owner.name], picked.owner.name) : null"),
463
478
  WHEN('confirm_change', 'reading && reading.hits.length', FLOW('confirm_change_accept', 'collect-input-accept', 'call', { hits: '{{reading.hits}}' })),
464
- SIDE('confirm_ranked', "(cargo.confirmRanked=reading && reading.hits.length ? 'changed' : reply && reply.key==='no' ? 'no' : 'none',true)")]
479
+ SET('confirm_field', 'field', "fields.filter(function(c){return reply && c.key===reply.key;})[0] || null"),
480
+ WHEN('confirm_field_reset', 'field', SIDE('confirm_field_ask', '(resetFormInputs(cargo, field.owner.form, [field.owner.name]), true)')),
481
+ SIDE('confirm_ranked', "(cargo.confirmRanked=reading && reading.hits.length || field ? 'changed' : reply && reply.key==='no' ? 'no' : 'none',true)")]
465
482
  };
466
483
  const collectContextualAnswer = formFlow({ id: 'collect-contextual-answer', name: 'CollectContextualAnswer', description: 'Collect one open-ended answer using contextual DISPATCH and preserve the caller wording.',
467
484
  parameters: [...['question', 'question_es', 'description', 'context', 'context_es'].map(name => ({ name, type: 'string' })), { name: 'choices', type: 'array' }, { name: 'digits', type: 'object' }],
@@ -502,8 +519,8 @@ const acceptedEffects = {
502
519
  WHEN('ie_none', '!ca_h', END('ie_end')),
503
520
  SET('ie_rest', 'ca_rest', 'readings.slice(1)'),
504
521
  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)")),
505
- 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 ? '' : ca_h.outcome === 'known' && !ca_h.current ? ('Thank you, I already have your ' + ca_h.label + '.') : ca_h.outcome === 'changed' ? ('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 + '.') : ''"),
506
- 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 ? '' : ca_h.outcome === 'known' && !ca_h.current ? ('Gracias, ya tengo su ' + ca_h.label_es + '.') : ca_h.outcome === 'changed' ? ('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 + '.') : ''"),
522
+ 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 ? '' : 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 + '.') : ''"),
523
+ 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 ? '' : 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 + '.') : ''"),
507
524
  WHEN('ie_say', 'ca_ack', SAY('ie_ack', '{{ca_ack}}', '{{ca_ack_es}}')),
508
525
  WHEN('ie_callback', "ca_h.after && ca_h.outcome !== 'known' && ca_h.outcome !== 'rejected' && ca_h.outcome !== 'pending'", FLOW('ie_after', '{{ca_h.after}}', 'call', { form: '{{ca_h.form}}', input: '{{ca_h.name}}', value: '{{ca_h.value}}' })),
509
526
  WHEN('ie_more', 'ca_rest.length > 0', FLOW('ie_next', 'input-accepted-effects', 'replace', { readings: '{{ca_rest}}' })),
@@ -586,7 +603,7 @@ const confirmForm = {
586
603
  'condition: cf_named': SET('cf_named_reset', 'cf_result', "(resetFormInputs(cargo, cf_named.form, [cf_named.name]), 'changed')"),
587
604
  "condition: cargo.choice === 'no'": SET('cf_declined', 'cf_result', "'no'"),
588
605
  "condition: Array.isArray(clarifications) && pickChoice(String(cargo.choiceText || '').replace(/[?¿]/g,''),[{key:'clarify',phrases:clarifications,exact:true}],false)==='clarify'": SET('cf_clarification', 'cf_result', "'clarification'"),
589
- 'condition: !cf_must && cf_words > 0': FLOW('cf_elsewhere', 'read-confirm-reply', 'call', { form: '{{form}}', question: '{{cf_prompt}}', question_es: '{{cf_prompt_es}}' }),
606
+ 'condition: !cf_must && cf_words > 0': FLOW('cf_elsewhere', 'read-confirm-reply', 'call', { form: '{{form}}', question: '{{cf_prompt}}', question_es: '{{cf_prompt_es}}', named_fields: '{{named_fields}}' }),
590
607
  default: SET('cf_unclear', 'cf_result', "'unclear'")
591
608
  } },
592
609
  // the ranker's reading of an unread answer: a decline is the NO; an answer that declines nothing is asked again
@@ -5,6 +5,7 @@ export function serviceDigits(text: any): string;
5
5
  export function otpDigits(text: any): string;
6
6
  export function contactPrompt(cargo: any, options: any, callerAllowed: any, partial?: boolean, es?: boolean): string;
7
7
  export function contactError(kind: any, options: any, es?: boolean): string;
8
+ export function otpWhere(cargo: any, es?: boolean): string;
8
9
  export function otpPrompt(cargo: any, es?: boolean): string;
9
10
  export function emailReadback(address: any, voice: any, es?: boolean): string;
10
11
  /** Reenter the existing owning FORM or its gate without making the owner its own parent. */
@@ -1 +1 @@
1
- {"version":3,"file":"identity-flows.d.ts","sourceRoot":"","sources":["../../src/forms/identity-flows.js"],"names":[],"mappings":"AA2BA,+GAA+G;AAC/G,oFAOC;AAQD,wFAAwF;AACxF,iDAGC;AACD,6CAMC;AACD,qHAQC;AAMD,4EAKC;AAoFD,4DAKC;AAmHD,8EAGC;AA4BD,4FAA4F;AAC5F;;;;EAOC;AAQD,0BAAmI"}
1
+ {"version":3,"file":"identity-flows.d.ts","sourceRoot":"","sources":["../../src/forms/identity-flows.js"],"names":[],"mappings":"AA2BA,+GAA+G;AAC/G,oFAOC;AAQD,wFAAwF;AACxF,iDAGC;AACD,6CAMC;AACD,qHAQC;AAMD,4EAKC;AAwFD,2DAKC;AACD,4DAMC;AAuHD,8EAGC;AA4BD,4FAA4F;AAC5F;;;;EAOC;AAQD,0BAAmI"}
@@ -95,6 +95,9 @@ const contact = worker('get-cell-or-email', 'GetCellOrEmail', 'Identify a suppli
95
95
  when('identity_contact_partial_reset', '!contact_partial', effect('identity_contact_partial_forget', "(cargo.gcePartial='',true)")),
96
96
  set('identity_contact_allowed', 'contact_allowed', "!!cargo.callerId && !callerIsInternational(cargo) && !cargo.callerDeclined && contact_options.caller_on_file !== 'no'"),
97
97
  set('identity_contact_prefill', 'contact_prefill', "String(acct_number || cell_number || email || (!contact_partial && contact_allowed && contact_options.caller_on_file === 'yes' ? cargo.callerId : ''))"),
98
+ // a question answered from what is already known (the caller ID, a contact given) is never spoken: its intro (why the
99
+ // account is needed) leads the verification code question instead (otpPrompt). A spoken question said it (or had none).
100
+ effect('identity_contact_intro_unsaid', "(!contact_prefill ? (cargo.otpIntro='',cargo.otpIntro_es='') : contact_options.intro && !cargo.contactIntroSkip && !contact_options.fromMenu ? (cargo.otpIntro=String(contact_options.intro),cargo.otpIntro_es=String(contact_options.intro_es || contact_options.intro)) : true,true)"),
98
101
  effect('identity_contact_preselected', "(acct_number || cell_number || email ? true : (cargo.callerPreselected=!!(contact_prefill && contact_options.caller_on_file === 'yes')),true)"),
99
102
  set('identity_contact_profiles', 'contact_profiles', "contact_profiles_base.map(function (field) {return field.name === 'account' ? Object.assign({},field,{profile:{kind:'identifier',pattern:contact_options.account_pattern || '(?!)'}}) : field;}).concat(contact_allowed ? [{name:'caller_decision',label:'caller ID choice',label_es:'opción del identificador de llamadas',profile:{kind:'choice',choices:[{key:'yes',phrases:['1','yes','si','sí'],exact:true},{key:'no',phrases:['no','nope','nah'],exact:true}]}}] : [])"),
100
103
  set('identity_contact_keypad', 'contact_keypad', "(function () {var shapes=[{first:'23456789',lengths:[10]},{first:'1',lengths:[11]}].concat(contact_options.account_shape || []);if(contact_partial){var left=[];(contact_options.account_shape || []).forEach(function(s){(s.lengths || []).forEach(function(n){var rest=n-String(cargo.gcePartial).length;if(rest>0 && left.indexOf(rest)<0)left.push(rest);});});return {min:1,max:12,autoSubmitMs:3000,complete:[{first:'0123456789',lengths:left.sort(function(a,b){return a-b;})}]};}return {min:7,max:12,autoSubmitMs:3000,autoSubmitChars:contact_allowed?['1']:[],complete:shapes};})()"),
@@ -171,16 +174,28 @@ const auth = worker('authenticate-user', 'AuthenticateUser', 'Authenticate a con
171
174
  cases('auth_complete', { 'condition: cargo.otpVerified === true': set('auth_ok', 'auth_result', 'true'), 'condition: cargo.authRetry': end('auth_return_retry'), default: otpMenu('auth_retry_code', "I couldn't verify your account. You can try again or use another contact.", 'No pude verificar su cuenta. Puede intentarlo de nuevo o usar otro contacto.', true) }),
172
175
  call('auth_gate_end', 'task-gate', { form: '{{parent_form}}', gate: 'authenticate-user', active: false })
173
176
  ] });
177
+ // Where the code went, for a caller who asks ('¿dónde está?'): the channel and the masked destination, never the code.
178
+ export function otpWhere(cargo, es = false) {
179
+ const number = String(cargo.otp_cell_number || '').replace(/\D/g, '').slice(-4), tail = cargo.voice ? number.split('').join(', ') : number;
180
+ if (cargo.otpSentOn === 'whatsapp')
181
+ return es ? 'Se lo acabo de enviar aquí en este chat.' : 'I just sent it to you here in this chat.';
182
+ if (cargo.otp_cell_number)
183
+ return es ? 'Se lo envié por mensaje de texto al celular que termina con ' + tail + '. Revise sus mensajes de texto.' : 'I sent it by text message to the cell ending with ' + tail + '. Check your text messages.';
184
+ return es ? 'Se lo envié por correo electrónico a ' + String(cargo.otp_email || '') + '. Revise su correo.' : 'I sent it by email to ' + String(cargo.otp_email || '') + '. Check your email.';
185
+ }
174
186
  export function otpPrompt(cargo, es = false) {
175
187
  const verb = es ? cargo.verb_es : cargo.verb, number = String(cargo.otp_cell_number || '').replace(/\D/g, '').slice(-4), tail = cargo.voice ? number.split('').join(', ') : number;
176
188
  const where = cargo.otpSentOn === 'whatsapp' ? (es ? 'que le acabo de enviar aquí en este chat' : 'I just sent you here in this chat') : (es ? 'que recibió en ' : 'you received at ') + (cargo.otp_cell_number ? (es ? 'celular que termina con ' : 'cell ending with ') + tail : (es ? 'correo electrónico: ' : 'email: ') + String(cargo.otp_email || ''));
177
189
  const name = cargo.callerPreselected && cargo.callerNamed && cargo.displayName ? cargo.displayName : '';
178
- return (name ? name + (es ? ', por favor' : ', please') : es ? 'Por favor' : 'Please') + ' ' + verb + ' ' + (cargo.voice ? es ? 'o ingrese ' : 'or enter ' : '') + (es ? 'el código de verificación de 6 dígitos ' : 'the 6-digit verification code ') + where + '. ' + (es ? `También puede ${cargo.voice ? 'presionar * o decir' : 'escribir'} SALIR para cancelar.` : `You can also ${cargo.voice ? 'press * or ' : ''}${verb} EXIT to cancel.`) + (cargo.callerPreselected ? (es ? ` Para usar otra cuenta, ${verb} OTRA CUENTA.` : ` To use another account, ${verb} ANOTHER ACCOUNT.`) : '');
190
+ const lead = String((es ? cargo.otpIntro_es : cargo.otpIntro) || '');
191
+ return (lead ? lead + ' ' : '') + (name ? name + (es ? ', por favor' : ', please') : es ? 'Por favor' : 'Please') + ' ' + verb + ' ' + (cargo.voice ? es ? 'o ingrese ' : 'or enter ' : '') + (es ? 'el código de verificación de 6 dígitos ' : 'the 6-digit verification code ') + where + '. ' + (es ? `También puede ${cargo.voice ? 'presionar * o decir' : 'escribir'} SALIR para cancelar.` : `You can also ${cargo.voice ? 'press * or ' : ''}${verb} EXIT to cancel.`) + (cargo.callerPreselected ? (es ? ` Para usar otra cuenta, ${verb} OTRA CUENTA.` : ` To use another account, ${verb} ANOTHER ACCOUNT.`) : '');
179
192
  }
180
193
  const OTP_PROFILES = [{ name: 'code', label: 'verification code', label_es: 'código de verificación', profile: { kind: 'digits', min: 6, max: 6 } }, raw];
181
194
  const code = worker('get-and-validate-otp-code', 'GetAndValidateOtpCode', 'Collect a one-time-code answer through the owning FORM ancestry, preserving protocol commands for validation.', { variables: vars({ otp_code: '', user_choice: '', normalized_otp_code: '', otp_question: '', otp_question_es: '', otp_profiles: OTP_PROFILES }), contract: { role: 'collector', summary: 'One-time-code answer collector using shared FORM matching' }, steps: [
182
195
  when('otp_already_verified', 'cargo.otpVerified === true', end('otp_existing_verification')),
183
196
  set('otp_question', 'otp_question', expression(otpPrompt, 'cargo')), set('otp_question_es', 'otp_question_es', expression(otpPrompt, 'cargo', 'true')),
197
+ // a contact question's unsaid intro (identity_contact_intro_unsaid) leads the first code question only
198
+ effect('otp_intro_said', "(cargo.otpIntro='',cargo.otpIntro_es='',true)"),
184
199
  call('otp_collect', 'collect-service-answer', { profiles: '{{otp_profiles}}', question: '{{otp_question}}', question_es: '{{otp_question_es}}', keypad: { min: 6, max: 6 }, parent_form: '{{cargo.authOptions.parent_form}}', stop_path: 'cargo.otpVerified', context: 'account verification', context_es: 'verificación de cuenta' }),
185
200
  when('otp_verified_by_join', 'cargo.otpVerified === true', end('otp_join_finished')),
186
201
  set('otp_answer', 'otp_code', "String(cargo.serviceAnswer.values.code || cargo.serviceAnswer.raw || '')"), set('otp_choice', 'user_choice', 'otp_code.trim().toLowerCase()'), set('otp_digits', 'normalized_otp_code', expression(otpDigits, 'otp_code')),
@@ -206,8 +221,8 @@ function otpPhoneUnavailable(value) {
206
221
  // What the reply ranker may take at the code question beside the code: the step's offers. Email and another account are
207
222
  // offered only where the step offers them (a code sent by text; a caller-ID preselection).
208
223
  const OTP_REPLY_CHOICES = [{ key: 'email', label: 'use the email on the account instead of the phone' }, { key: 'another_account', label: 'use another account' },
209
- { key: 'resend', label: 'the code did not arrive; send it again' }, { key: 'no_phone', label: 'no longer has that phone or number' }, { key: '__agent', label: 'speak with a person (a live agent)' }];
210
- const validateCode = worker('validate-otp-code', 'ValidateOtpCode', 'Validate a challenge response with explicit bounded retries, alternate-contact handling and tenant callbacks.', { variables: vars({ otp_phrases: OTP_WORDS, no_code_given: false, wants_other_account: false, other_account_given: false, otp_validation_result: false, whatsapp_invite_sent: false, invite_text: '', otp_cancel: 'contact-support', answer_key: '', answer_value: '', otp_reply_choices: OTP_REPLY_CHOICES, otp_offers: [], otp_reply: {} }), contract: { role: 'gate', summary: 'Validate the active one-time challenge', tools: ['validate-otp', 'send-whatsapp-invite'], result: { path: 'cargo.otpVerified', type: 'boolean' } }, steps: [
224
+ { key: 'where', label: 'asks where the code is or where it was sent, or cannot find it' }, { key: 'resend', label: 'the code did not arrive; send it again' }, { key: 'no_phone', label: 'no longer has that phone or number' }, { key: '__agent', label: 'speak with a person (a live agent)' }];
225
+ const validateCode = worker('validate-otp-code', 'ValidateOtpCode', 'Validate a challenge response with explicit bounded retries, alternate-contact handling and tenant callbacks.', { variables: vars({ otp_phrases: OTP_WORDS, no_code_given: false, wants_other_account: false, other_account_given: false, otp_validation_result: false, whatsapp_invite_sent: false, invite_text: '', otp_cancel: 'contact-support', answer_key: '', answer_value: '', otp_reply_choices: OTP_REPLY_CHOICES, otp_offers: [], otp_reply: {}, otp_where: '', otp_where_es: '' }), contract: { role: 'gate', summary: 'Validate the active one-time challenge', tools: ['validate-otp', 'send-whatsapp-invite'], result: { path: 'cargo.otpVerified', type: 'boolean' } }, steps: [
211
226
  set('otp_cancel', 'otp_cancel', "cargo.authCancelFlow || 'contact-support'"),
212
227
  set('otp_reset_no_code', 'no_code_given', 'false'), set('otp_reset_invite', 'whatsapp_invite_sent', 'false'), set('otp_reset_callback', 'answer_key', "''"),
213
228
  when('otp_tenant_code', 'cargo.authOptions && cargo.authOptions.code_validator && normalized_otp_code.length !== 6 && /[0-9]/.test(user_choice)', call('otp_tenant_code_call', '{{cargo.authOptions.code_validator}}', { answer: '{{user_choice}}' })),
@@ -243,10 +258,12 @@ const validateCode = worker('validate-otp-code', 'ValidateOtpCode', 'Validate a
243
258
  + ".concat(((typeof $callers !== 'undefined' && $callers && $callers['authenticate-user']) || []).map(function(c){return {key:'usecase:'+c.id,label:'states the reason for the call: '+c.label};}))"
244
259
  + ".concat(((typeof $gateExits !== 'undefined' && $gateExits && $gateExits['authenticate-user']) || []).map(function(a){return {key:'exit:'+a.key,label:a.label,route:'new_request'};}))"),
245
260
  when('otp_off_topic', 'no_code_given', { id: 'otp_reply_rank', type: 'DISPATCH', onResponse: 'continue', responseContext: 'Please say the 6-digit verification code I sent you. (Por favor diga el código de verificación de 6 dígitos que le envié.)', responseChoices: 'otp_offers', responseVariable: 'otp_reply', onNoIntent: 'stay' }),
261
+ set('otp_where', 'otp_where', expression(otpWhere, 'cargo')), set('otp_where_es', 'otp_where_es', expression(otpWhere, 'cargo', 'true')),
246
262
  set('otp_reply_picked', 'no_code_given', "no_code_given && !(otp_reply && otp_reply.key && String(otp_reply.key).indexOf('usecase:') !== 0)"),
247
263
  cases('otp_reply_route', {
248
264
  "condition: !no_code_given && otp_reply && otp_reply.key === 'email'": call('otp_ranked_email', 'authenticate-user', { cell_number: '', email: '', retrying: true, declined_caller: true }, 'replace'),
249
265
  "condition: !no_code_given && otp_reply && otp_reply.key === 'another_account'": call('otp_ranked_account', 'authenticate-user', { cell_number: '', email: '', retrying: true, declined_caller: true }, 'replace'),
266
+ "condition: !no_code_given && otp_reply && otp_reply.key === 'where'": say('otp_ranked_where', '{{otp_where}}', '{{otp_where_es}}'),
250
267
  "condition: !no_code_given && otp_reply && otp_reply.key === 'resend'": call('otp_ranked_resend', 'resend-otp-code', { resend_reason: 'requested' }),
251
268
  "condition: !no_code_given && otp_reply && otp_reply.key === 'no_phone'": otpMenu('otp_ranked_no_phone', "No problem. Then the code can't reach you there. You can use the email on your account instead, or {{cargo.verb}} AGENT to reach a person.", 'No hay problema. Entonces el código no le puede llegar ahí. Puede usar el correo electrónico de su cuenta, o {{cargo.verb_es}} AGENTE para hablar con una persona.', false),
252
269
  "condition: !no_code_given && otp_reply && otp_reply.key === '__agent'": call('otp_ranked_agent', 'live-agent-requested', undefined, 'reboot'),
@@ -2,6 +2,7 @@ export const FLOWS: any[];
2
2
  export { FLOWS as IDENTITY_FLOWS } from "./identity-flows.js";
3
3
  export { compileFormPrompts } from "./prompt-compiler.js";
4
4
  export { explainSession } from "./inspection.js";
5
+ export { continuationBreaks } from "./continuation-compat.js";
5
6
  export { inheritFlowDeclaration } from "./declaration-capture.js";
6
7
  export { withSafetyRevision, CONTINUATION_SAFETY_REQUIREMENT } from "./continuation-safety.js";
7
8
  export { FIELD_PRESETS, formFlow, declinedFlow, textValue } from "./flows.js";
@@ -13,4 +13,5 @@ export { addressDraft, zipLocation, streetLocation, resolvedAddress } from '../h
13
13
  export { explainSession } from './inspection.js';
14
14
  export { Form, Provider, Performer, Worker, compileExpression } from './authoring.js';
15
15
  export { compileFlowDeclarations, serializeFlowDeclaration, captureOriginalDeclaration } from './declarations.js';
16
+ export { continuationBreaks } from './continuation-compat.js';
16
17
  export { inheritFlowDeclaration } from './declaration-capture.js';
@@ -16,6 +16,7 @@ export function streetLocation(result: any, said: any, zip: any): {
16
16
  street: string;
17
17
  said: string;
18
18
  verified: boolean;
19
+ city: any;
19
20
  };
20
21
  export function resolvedAddress(values: any, data: any): {
21
22
  street: any;
@@ -1 +1 @@
1
- {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../../src/helpers/address.js"],"names":[],"mappings":"AAAA,uFAAuF;AACvF;;;;;EAQC;AACD;;;;;SASC;AACD,gDAEC;AACD;;;;EAMC;AACD;;;;;;;;EAIC"}
1
+ {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../../src/helpers/address.js"],"names":[],"mappings":"AAAA,uFAAuF;AACvF;;;;;EAQC;AACD;;;;;SASC;AACD,gDAEC;AACD;;;;;EASC;AACD;;;;;;;;EAIC"}
@@ -27,7 +27,10 @@ export function streetLocation(result, said, zip) {
27
27
  const part = kind => components.find(c => (c.types || []).includes(kind))?.long_name || '';
28
28
  const types = row.types || [], locationType = row.location_type || row.geometry?.location_type || '';
29
29
  const exact = result?.status === 'OK' && row.partial_match !== true && types.includes('street_address') && locationType === 'ROOFTOP' && part('street_number') && part('route') && (!part('postal_code') || part('postal_code') === zip);
30
- return { street: exact ? part('street_number') + ' ' + part('route') : String(said).trim(), said: String(said).trim(), verified: !!exact };
30
+ // An exact street match names the place its mail carries; it corrects a city read from the ZIP code alone (90805 is
31
+ // Lakewood to the ZIP lookup, while its streets are Long Beach and Lakewood)
32
+ const city = exact ? part('locality') || part('postal_town') || part('sublocality') : '';
33
+ return { street: exact ? part('street_number') + ' ' + part('route') : String(said).trim(), said: String(said).trim(), verified: !!exact, city };
31
34
  }
32
35
  export function resolvedAddress(values, data) {
33
36
  const unit = /^(none|no|ninguno|ninguna|sin|no hay)$/i.test(String(values.unit || '')) ? '' : String(values.unit || '').replace(/^(apt\.?|unit|#)\s*/i, '');
@@ -3,6 +3,8 @@
3
3
  * YES, AGENT and TEXT never match a negated answer ("not sure", "I don't want an agent"); 0 is AGENT, * is EXIT.
4
4
  */
5
5
  export function isAnswer(input: any, kind: any, extra?: any[]): boolean;
6
+ /** Whether the WHOLE answer is a NO ("No.", "no gracias", "nope, thanks"), never a sentence that starts with one. */
7
+ export function isWholeNo(input: any): boolean;
6
8
  /**
7
9
  * Whether the WHOLE answer is an exit word ("exit", "Cancelar.", "salir por favor", "*"), never a sentence that contains
8
10
  * one: "Cancelar el pedido" asks for something; it does not withdraw (prod 2026-10-26, at a verification-code prompt).