jsfe 1.4.2 → 1.5.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 (41) hide show
  1. package/README.md +25 -0
  2. package/dist/helpers/amount-to-speech.d.ts +6 -0
  3. package/dist/helpers/amount-to-speech.d.ts.map +1 -0
  4. package/dist/helpers/amount-to-speech.js +195 -0
  5. package/dist/helpers/answer.d.ts +50 -0
  6. package/dist/helpers/answer.d.ts.map +1 -0
  7. package/dist/helpers/answer.js +409 -0
  8. package/dist/helpers/capture.d.ts +5 -0
  9. package/dist/helpers/capture.d.ts.map +1 -0
  10. package/dist/helpers/capture.js +50 -0
  11. package/dist/helpers/card-checks.d.ts +8 -0
  12. package/dist/helpers/card-checks.d.ts.map +1 -0
  13. package/dist/helpers/card-checks.js +46 -0
  14. package/dist/helpers/contact.d.ts +50 -0
  15. package/dist/helpers/contact.d.ts.map +1 -0
  16. package/dist/helpers/contact.js +178 -0
  17. package/dist/helpers/date-words.d.ts +2 -0
  18. package/dist/helpers/date-words.d.ts.map +1 -0
  19. package/dist/helpers/date-words.js +29 -0
  20. package/dist/helpers/index.d.ts +72 -0
  21. package/dist/helpers/index.d.ts.map +1 -0
  22. package/dist/helpers/index.js +34 -0
  23. package/dist/helpers/match-inputs.d.ts +28 -0
  24. package/dist/helpers/match-inputs.d.ts.map +1 -0
  25. package/dist/helpers/match-inputs.js +223 -0
  26. package/dist/helpers/matches-choice.d.ts +26 -0
  27. package/dist/helpers/matches-choice.d.ts.map +1 -0
  28. package/dist/helpers/matches-choice.js +61 -0
  29. package/dist/helpers/otp-check.d.ts +36 -0
  30. package/dist/helpers/otp-check.d.ts.map +1 -0
  31. package/dist/helpers/otp-check.js +75 -0
  32. package/dist/helpers/phrases.d.ts +12 -0
  33. package/dist/helpers/phrases.d.ts.map +1 -0
  34. package/dist/helpers/phrases.js +31 -0
  35. package/dist/helpers/url-speech.d.ts +8 -0
  36. package/dist/helpers/url-speech.d.ts.map +1 -0
  37. package/dist/helpers/url-speech.js +28 -0
  38. package/dist/helpers/validators.d.ts +13 -0
  39. package/dist/helpers/validators.d.ts.map +1 -0
  40. package/dist/helpers/validators.js +32 -0
  41. package/package.json +5 -1
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Reading a contact out of a customer's answer, ONE way for every flow set (APPROVED_FUNCTIONS readEmail, readPhone,
3
+ * readIdentifier), saying it back masked (last4, maskEmail), and where a text can reach the customer
4
+ * (callerTextNumber, textDestination). Before it, an email was read four different ways: only ask-email understood a spoken or spelled
5
+ * address ("juan at gmail dot com", "j u a n arroba gmail punto com"), so the same answer worked for a statement and
6
+ * failed at the identity question (flow design review 2026-10-13).
7
+ *
8
+ * Part of jsfe/helpers; zero imports.
9
+ */
10
+ // Words that introduce an address and are never part of it ("my email is", "mi correo es", "send it to").
11
+ const EMAIL_LEAD_IN = new Set(['my', 'mi', 'su', 'your', 'email', 'e-mail', 'mail', 'correo', 'electronico', 'electrónico',
12
+ 'address', 'dirección', 'direccion', 'de', 'is', 'es', "it's", 'its', 'it', 'the', 'el', 'la', 'to', 'para', 'send',
13
+ 'sent', 'envíalo', 'envialo', 'envía', 'envia', 'mandalo', 'mándalo', 'use', 'usa', 'please', 'por', 'favor', 'and', 'y',
14
+ 'yes', 'yeah', 'yep', 'si', 'sí', 'ok', 'okay', 'sure', 'claro', 'hi', 'hello', 'hola', 'um', 'uh', 'eh', 'this', 'thats',
15
+ "that's", 'here', 'aqui', 'aquí', 'with', 'con', 'try', 'now', 'ahora']);
16
+ /**
17
+ * The email address in the answer, lowercased, or ''. At / arroba and dot / punto may be spoken, and the name before
18
+ * the @ may come in several pieces ("J. A Garcia at example.com", "j u a n", "Maria jose@example.com"): every piece
19
+ * back to the first lead-in word ("my email is", "mi correo es") is kept and joined. On a call the address is read
20
+ * back for a YES / NO, so a misheard piece can be corrected; a dropped one could not. Accents are dropped from the
21
+ * name (an address cannot carry them), and only the last @ counts.
22
+ */
23
+ export function readEmail(text) {
24
+ const spoken = String(text ?? '')
25
+ .replace(/\s+(?:at|arroba)\s+/gi, ' @ ')
26
+ .replace(/\s+(?:dot|punto)\s+/gi, '.')
27
+ .replace(/\s+(?:underscore|gui[oó]n bajo)[.,]?(?:\s+|$)/gi, '_')
28
+ .replace(/\s+(?:dash|hyphen|gui[oó]n)[.,]?(?:\s+|$)/gi, '-');
29
+ const at = spoken.lastIndexOf('@'); // a speech engine may write an @ inside the name too: the last one is the address's
30
+ if (at < 0) {
31
+ return '';
32
+ }
33
+ else {
34
+ const domain = (spoken.slice(at + 1).trim().match(/^[^\s@]+/) || [''])[0].replace(/[.,;:!?]+$/, '');
35
+ const pieces = spoken.slice(0, at).replace(/@/g, ' ').normalize('NFD').replace(/[\u0300-\u036f]/g, '').split(/[\s,;:!?]+/).map((w) => w.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')).filter(Boolean);
36
+ const local = [];
37
+ for (let k = pieces.length - 1; k >= 0 && local.join('').length < 64; k--) {
38
+ // a one-letter lead-in ("y" = and) beside another single letter is a spelled letter ("s o n n y")
39
+ if (EMAIL_LEAD_IN.has(pieces[k].toLowerCase()) && !(pieces[k].length === 1 && k > 0 && pieces[k - 1].length === 1)) {
40
+ break;
41
+ }
42
+ else {
43
+ local.unshift(pieces[k]);
44
+ }
45
+ }
46
+ const email = (local.join('') + '@' + domain).toLowerCase();
47
+ return /^[a-z0-9][a-z0-9._%+-]*@[a-z0-9.-]+\.[a-z]{2,}$/.test(email) ? email : '';
48
+ }
49
+ }
50
+ /** The 10-digit North American number in the answer (a leading 1 dropped), or ''. */
51
+ export function readPhone(text) {
52
+ const digits = String(text ?? '').replace(/[^0-9]/g, '');
53
+ if (digits.length === 11 && digits.startsWith('1')) {
54
+ return digits.slice(1);
55
+ }
56
+ else if (digits.length === 10) {
57
+ return digits;
58
+ }
59
+ else {
60
+ return '';
61
+ }
62
+ }
63
+ /**
64
+ * A phone number from outside the US / Canada in the answer, as its digits with the country code, or ''. With an
65
+ * explicit prefix ("+", "plus", "más", "00") 8-15 digits; without one 11-13 digits not starting with 1 (a card or an
66
+ * account number is not mistaken for one). US / Canada numbers are readPhone's. The flows say what such a number
67
+ * cannot be used for instead of "I didn't get that" (owner, 2026-10-14).
68
+ */
69
+ export function readInternationalPhone(text) {
70
+ const t = String(text ?? '');
71
+ const prefixed = /(^|[\s(])(\+|00|plus\b|m[aá]s\b)\s*\d/i.test(t);
72
+ let d = t.replace(/[^0-9]/g, '');
73
+ if (prefixed && d.startsWith('00')) {
74
+ d = d.slice(2);
75
+ }
76
+ else {
77
+ // no international call prefix
78
+ }
79
+ if (readEmail(t) || readPhone(t) || d.startsWith('1')) {
80
+ return '';
81
+ }
82
+ else if (prefixed) {
83
+ return d.length >= 8 && d.length <= 15 ? d : '';
84
+ }
85
+ else {
86
+ return d.length >= 11 && d.length <= 13 ? d : '';
87
+ }
88
+ }
89
+ /** Whether the caller's own number (cargo.callerId) is from outside the US / Canada: on a call, a real caller ID. */
90
+ export function callerIsInternational(cargo) {
91
+ const c = cargo || {};
92
+ const d = String(c.callerId ?? '').replace(/\D/g, '');
93
+ return !!c.voice && d.length >= 8 && !(d.length === 10 || (d.length === 11 && d[0] === '1'));
94
+ }
95
+ /**
96
+ * What the customer identified themselves with: { kind: 'account' | 'phone' | 'international_phone' | 'email', value } or
97
+ * null.
98
+ * accountPattern (a regex source, e.g. '^5\\d{6,7}$') is the tenant's account-number shape; its digits win first.
99
+ */
100
+ export function readIdentifier(text, accountPattern = '') {
101
+ const digits = String(text ?? '').replace(/[^0-9]/g, '');
102
+ let account = false;
103
+ try {
104
+ account = !!accountPattern && new RegExp(accountPattern).test(digits);
105
+ }
106
+ catch {
107
+ account = false; // an invalid tenant pattern identifies no account
108
+ }
109
+ if (account) {
110
+ return { kind: 'account', value: digits };
111
+ }
112
+ else if (readPhone(text)) {
113
+ return { kind: 'phone', value: readPhone(text) };
114
+ }
115
+ else if (readInternationalPhone(text)) {
116
+ return { kind: 'international_phone', value: readInternationalPhone(text) };
117
+ }
118
+ else if (readEmail(text)) {
119
+ return { kind: 'email', value: readEmail(text) };
120
+ }
121
+ else {
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * The last four characters of a number (card, account, phone, tracking) as said to the customer: spaced one by one on a
127
+ * call ("5, 4, 7, 5"), as written otherwise ("5475"). Written ~25 times inline across the flow sets before it.
128
+ */
129
+ export function last4(value, voice) {
130
+ const tail = String(value ?? '').slice(-4);
131
+ if (voice) {
132
+ return tail.split('').join(', ');
133
+ }
134
+ else {
135
+ return tail;
136
+ }
137
+ }
138
+ /**
139
+ * An email said back to the customer without reading all of it out. On a call: its first two characters spaced
140
+ * one by one and its domain ("starting with r, o, at gmail.com" / "que empieza con r, o, arroba gmail.com"): "ending
141
+ * in @gmail.com" alone is ambiguous on a call, and "ro***" makes the speech engine read the asterisks. Written:
142
+ * "ro***@gmail.com". '' for anything that is not an email.
143
+ */
144
+ export function maskEmail(email, voice, lang = 'en') {
145
+ const m = /^([^@\s]+)@([^@\s]+)$/.exec(String(email ?? '').trim().toLowerCase());
146
+ if (!m) {
147
+ return '';
148
+ }
149
+ else if (voice) {
150
+ const head = m[1].slice(0, 2).split('').join(', ');
151
+ return lang === 'es' ? `que empieza con ${head}, arroba ${m[2]}` : `starting with ${head}, at ${m[2]}`;
152
+ }
153
+ else {
154
+ return `${m[1].slice(0, 2)}***@${m[2]}`;
155
+ }
156
+ }
157
+ /** A US / Canada number as 10 digits (a leading 1 dropped), or '' for anything else: we text only those. */
158
+ function nanp(value) {
159
+ const d = String(value ?? '').replace(/\D/g, '');
160
+ return d.length === 10 ? d : (d.length === 11 && d[0] === '1' ? d.slice(1) : '');
161
+ }
162
+ /**
163
+ * The number we can text the caller at, 10 digits, or '': ONE rule for offering TEXT and for sending one. Only on a
164
+ * call with a sender (cargo.twilioNumber), a US / Canada caller ID, and not the number a code could not be texted to
165
+ * (cargo.otpUndeliverableTo). A number from another country used to be cut to its last 10 digits and offered TEXT,
166
+ * which then failed (dev calls from +52, 2026-10-13).
167
+ */
168
+ export function callerTextNumber(cargo) {
169
+ const c = cargo || {};
170
+ const n = c.voice && c.twilioNumber ? nanp(c.callerId) : '';
171
+ return n && n !== String(c.otpUndeliverableTo || '') ? n : '';
172
+ }
173
+ /** Where a text to the customer can go, 10 digits or '': the verified cell, else the caller (callerTextNumber). */
174
+ export function textDestination(cargo) {
175
+ const c = cargo || {};
176
+ const cell = c.twilioNumber ? nanp(c.otp_cell_number) : '';
177
+ return cell && cell !== String(c.otpUndeliverableTo || '') ? cell : callerTextNumber(c);
178
+ }
@@ -0,0 +1,2 @@
1
+ export function dateToWords(date: any, lang?: string, withYear?: boolean): string;
2
+ //# sourceMappingURL=date-words.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"date-words.d.ts","sourceRoot":"","sources":["../../src/helpers/date-words.js"],"names":[],"mappings":"AAaA,kFAcC"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * dateToWords (APPROVED_FUNCTIONS): an ISO date ('2026-11-05', or a timestamp starting with one) as words —
3
+ * 'November 5, 2026' / '5 de noviembre de 2026', or without the year 'November 5' / '5 de noviembre'.
4
+ * ONE formatter for every flow: three ai-ar flows wrote their own month tables, and one read the raw '2026-12-31'
5
+ * aloud (flow design review 2026-10-13). Anything that is not a date comes back unchanged.
6
+ *
7
+ * Part of jsfe/helpers; zero imports.
8
+ */
9
+ const MONTHS = {
10
+ en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
11
+ es: ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'],
12
+ };
13
+ export function dateToWords(date, lang = 'en', withYear = true) {
14
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(date ?? '').trim());
15
+ const month = m ? MONTHS[lang === 'es' ? 'es' : 'en'][parseInt(m[2], 10) - 1] : '';
16
+ if (!m || !month) {
17
+ return String(date ?? '');
18
+ }
19
+ else {
20
+ const day = parseInt(m[3], 10);
21
+ const year = parseInt(m[1], 10);
22
+ if (lang === 'es') {
23
+ return withYear ? `${day} de ${month} de ${year}` : `${day} de ${month}`;
24
+ }
25
+ else {
26
+ return withYear ? `${month} ${day}, ${year}` : `${month} ${day}`;
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,72 @@
1
+ export namespace STANDARD_FUNCTIONS {
2
+ export { matchesChoice };
3
+ export { isAnswer };
4
+ export { isAgentRequest };
5
+ export { isOtherRequest };
6
+ export { isSpelledFragment };
7
+ export { isAmountAnswer };
8
+ export { amountInAnswer };
9
+ export { pickChoice };
10
+ export { readEmail };
11
+ export { readPhone };
12
+ export { readInternationalPhone };
13
+ export { readIdentifier };
14
+ export { last4 };
15
+ export { maskEmail };
16
+ export { callerTextNumber };
17
+ export { textDestination };
18
+ export { callerIsInternational };
19
+ export { normalizeAndFindCapture };
20
+ export { anythingElse };
21
+ export { goodbye };
22
+ export { dateToWords };
23
+ export { amountToSpeech };
24
+ export { validateCardNumber };
25
+ export { validateExpiration };
26
+ export { matchInputs };
27
+ export { validateOTP };
28
+ export { validateDigits };
29
+ export { validatePhone };
30
+ export { validateEmail };
31
+ export { urlToSpeech };
32
+ export { textWithUrlToSpeech };
33
+ }
34
+ import { matchesChoice } from './matches-choice.js';
35
+ import { ANSWER_WORDS } from './answer.js';
36
+ import { CONFIRM_WORDS } from './answer.js';
37
+ import { isAnswer } from './answer.js';
38
+ import { isAgentRequest } from './answer.js';
39
+ import { isOtherRequest } from './answer.js';
40
+ import { isSpelledFragment } from './answer.js';
41
+ import { isAmountAnswer } from './answer.js';
42
+ import { amountInAnswer } from './answer.js';
43
+ import { pickChoice } from './answer.js';
44
+ import { readEmail } from './contact.js';
45
+ import { readPhone } from './contact.js';
46
+ import { readInternationalPhone } from './contact.js';
47
+ import { readIdentifier } from './contact.js';
48
+ import { last4 } from './contact.js';
49
+ import { maskEmail } from './contact.js';
50
+ import { callerTextNumber } from './contact.js';
51
+ import { textDestination } from './contact.js';
52
+ import { callerIsInternational } from './contact.js';
53
+ import { normalizeAndFindCapture } from './capture.js';
54
+ import { anythingElse } from './phrases.js';
55
+ import { goodbye } from './phrases.js';
56
+ import { sayName } from './phrases.js';
57
+ import { dateToWords } from './date-words.js';
58
+ import { amountToSpeech } from './amount-to-speech.js';
59
+ import { validateCardNumber } from './card-checks.js';
60
+ import { validateExpiration } from './card-checks.js';
61
+ import { matchInputs } from './match-inputs.js';
62
+ import { checkOtp } from './otp-check.js';
63
+ import { maskDestination } from './otp-check.js';
64
+ import { validateOTP } from './otp-check.js';
65
+ import { OTP_EXPIRY_MS } from './otp-check.js';
66
+ import { validateDigits } from './validators.js';
67
+ import { validatePhone } from './validators.js';
68
+ import { validateEmail } from './validators.js';
69
+ import { urlToSpeech } from './url-speech.js';
70
+ import { textWithUrlToSpeech } from './url-speech.js';
71
+ export { matchesChoice, ANSWER_WORDS, CONFIRM_WORDS, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice, readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational, normalizeAndFindCapture, anythingElse, goodbye, sayName, dateToWords, amountToSpeech, validateCardNumber, validateExpiration, matchInputs, checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS, validateDigits, validatePhone, validateEmail, urlToSpeech, textWithUrlToSpeech };
72
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/helpers/index.js"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BAc8B,qBAAqB;6BACkG,aAAa;8BAAb,aAAa;yBAAb,aAAa;+BAAb,aAAa;+BAAb,aAAa;kCAAb,aAAa;+BAAb,aAAa;+BAAb,aAAa;2BAAb,aAAa;0BACT,cAAc;0BAAd,cAAc;uCAAd,cAAc;+BAAd,cAAc;sBAAd,cAAc;0BAAd,cAAc;iCAAd,cAAc;gCAAd,cAAc;sCAAd,cAAc;wCAC/H,cAAc;6BACP,cAAc;wBAAd,cAAc;wBAAd,cAAc;4BACjC,iBAAiB;+BACd,uBAAuB;mCACC,kBAAkB;mCAAlB,kBAAkB;4BAC7C,mBAAmB;yBACuB,gBAAgB;gCAAhB,gBAAgB;4BAAhB,gBAAgB;8BAAhB,gBAAgB;+BACzB,iBAAiB;8BAAjB,iBAAiB;8BAAjB,iBAAiB;4BAC7B,iBAAiB;oCAAjB,iBAAiB"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * jsfe/helpers — the functions JSFE's system flows call from expressions, in ONE place for every host: reading an
3
+ * answer (matchesChoice, pickChoice, isAnswer, readEmail, readIdentifier, amountInAnswer, matchInputs …), saying
4
+ * values (amountToSpeech, dateToWords, last4, urlToSpeech …), checking shapes and one-time codes. Pure: no credentials,
5
+ * no network, no host state beyond the container a caller passes in.
6
+ *
7
+ * The engine does not register them by itself. A host lists them among its approved functions, usually all at once:
8
+ *
9
+ * import { STANDARD_FUNCTIONS } from 'jsfe/helpers';
10
+ * new WorkflowEngine(logger, aiCallback, flows, tools, { ...STANDARD_FUNCTIONS, ...hostFunctions }, …);
11
+ *
12
+ * Functions that need credentials or the network (sending a text or an email, geocoding, a store's API) belong to the
13
+ * host.
14
+ */
15
+ import { matchesChoice } from './matches-choice.js';
16
+ import { ANSWER_WORDS, CONFIRM_WORDS, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice } from './answer.js';
17
+ import { readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational } from './contact.js';
18
+ import { normalizeAndFindCapture } from './capture.js';
19
+ import { anythingElse, goodbye, sayName } from './phrases.js';
20
+ import { dateToWords } from './date-words.js';
21
+ import { amountToSpeech } from './amount-to-speech.js';
22
+ import { validateCardNumber, validateExpiration } from './card-checks.js';
23
+ import { matchInputs } from './match-inputs.js';
24
+ import { checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS } from './otp-check.js';
25
+ import { validateDigits, validatePhone, validateEmail } from './validators.js';
26
+ import { urlToSpeech, textWithUrlToSpeech } from './url-speech.js';
27
+ export { matchesChoice, ANSWER_WORDS, CONFIRM_WORDS, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice, readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational, normalizeAndFindCapture, anythingElse, goodbye, sayName, dateToWords, amountToSpeech, validateCardNumber, validateExpiration, matchInputs, checkOtp, maskDestination, validateOTP, OTP_EXPIRY_MS, validateDigits, validatePhone, validateEmail, urlToSpeech, textWithUrlToSpeech, };
28
+ /** The functions flows call by name, ready to spread into a host's approved functions. */
29
+ export const STANDARD_FUNCTIONS = {
30
+ matchesChoice, isAnswer, isAgentRequest, isOtherRequest, isSpelledFragment, isAmountAnswer, amountInAnswer, pickChoice,
31
+ readEmail, readPhone, readInternationalPhone, readIdentifier, last4, maskEmail, callerTextNumber, textDestination, callerIsInternational,
32
+ normalizeAndFindCapture, anythingElse, goodbye, dateToWords, amountToSpeech, validateCardNumber, validateExpiration, matchInputs,
33
+ validateOTP, validateDigits, validatePhone, validateEmail, urlToSpeech, textWithUrlToSpeech,
34
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * candidates: [{ form, name, profile, value?, confirm_against? }] — the inputs of the form chain that apply now (`when`
3
+ * holds): the current form's first, then its parent's, and so on up. `value` is what is already stored for it.
4
+ * current: { form, name } of the input that was asked, or { form, names } when the asked input is one of several
5
+ * (an any_of group: phone, email or account number); every member counts as the current input. choiceKey: the key
6
+ * ask-choice picked from the current input's own choices, if any.
7
+ *
8
+ * Readings that claim DIFFERENT parts of the answer are all taken (a compound answer fills several inputs). Readings
9
+ * that claim the SAME part are settled by scope (owner decision 2026-10-19, A):
10
+ * 1. the current input takes it;
11
+ * 2. else the one input of the current form that fits takes it, and parent forms are not considered;
12
+ * 3. else the same test one form up, and so on;
13
+ * 4. two inputs at the nearest form that fits are ambiguous: asked, never ranked.
14
+ * Exception: an input that declares `confirm_against: [names]` is asked about together with those inputs whenever both
15
+ * fit the same words, wherever scope would have put it ('62' at the payment offer: $62 or sub-account 62, prod
16
+ * 2026-10-19 07:38).
17
+ *
18
+ * Returns { hits, ambiguous }:
19
+ * hits — what the answer fills, each { form, name, value, known } (`known`: equals the stored value);
20
+ * ambiguous — the readings to ask about (the first such group), or empty.
21
+ */
22
+ export function matchInputs(answer: any, candidates: any, current: any, choiceKey: any): {
23
+ hits: {
24
+ [x: string]: any;
25
+ }[];
26
+ ambiguous: any;
27
+ };
28
+ //# sourceMappingURL=match-inputs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"match-inputs.d.ts","sourceRoot":"","sources":["../../src/helpers/match-inputs.js"],"names":[],"mappings":"AA0GA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH;;;;;EAuEC"}
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Which of a FORM's inputs an answer fills (APPROVED_FUNCTIONS matchInputs), for the system collect-input worker
3
+ * (owner direction 2026-10-19, the FORM convention in the jsfe-flow-designer skill). A caller may answer out of order,
4
+ * fill several inputs at once, or say something that fits two inputs; this reads the answer against every candidate
5
+ * input's declared shape and says which, never guessing between two: an answer whose words fit two inputs is
6
+ * returned as ambiguous, and the flow asks.
7
+ *
8
+ * Tenant-agnostic shape reading only (the same class as pickChoice / readIdentifier). Conversation behaviour stays in
9
+ * the flows. Part of jsfe/helpers; imports only siblings.
10
+ */
11
+ import { matchesChoice } from './matches-choice.js';
12
+ import { pickChoice, amountInAnswer, isAmountAnswer } from './answer.js';
13
+ import { readEmail, readPhone, readInternationalPhone, readIdentifier } from './contact.js';
14
+ /** The answer as said: jsfe stores user input escaped (don\'t). */
15
+ function unescape(answer) {
16
+ return String(answer ?? '').replace(/\\(['"\\])/g, '$1');
17
+ }
18
+ /** Digit runs as said: spoken digits come apart ("4 3 2 1", "555-123-4567"), so single spaces / dashes / dots inside a run join it. */
19
+ function digitRuns(raw) {
20
+ return (raw.match(/\d(?:[\s.-]?\d)*/g) || []).map((r) => r.replace(/\D/g, ''));
21
+ }
22
+ /**
23
+ * One input's reading of the answer: { value, evidence } or null. `evidence` is what the answer spent on it (digits
24
+ * or words), so two inputs that claim the same part of the answer are told apart from a compound answer.
25
+ */
26
+ function readProfile(profile, raw, isCurrent) {
27
+ const kind = profile && profile.kind;
28
+ if (kind === 'choice') {
29
+ const choices = Array.isArray(profile.choices) ? profile.choices : [];
30
+ const key = pickChoice(raw, choices, false);
31
+ if (key && key.indexOf('__') !== 0) {
32
+ const choice = choices.filter((c) => c && c.key === key)[0] || { words: [] };
33
+ const words = (choice.words || []).filter((w) => matchesChoice(raw, [w]));
34
+ // a number word is the same evidence as a number read by another input ('62' as a sub-account and as $62)
35
+ return { value: key, evidence: words.map((w) => (/^[\d\s.-]+$/.test(String(w)) ? `d:${String(w).replace(/\D/g, '')}` : `w:${String(w).toLowerCase()}`)) };
36
+ }
37
+ else {
38
+ return null; // no choice, or an AGENT / EXIT request (ask-choice owns those)
39
+ }
40
+ }
41
+ else if (kind === 'amount') {
42
+ const n = profile.marked ? (isAmountAnswer(raw) ? amountInAnswer(raw) : NaN) : amountInAnswer(raw);
43
+ const inRange = !isNaN(n) && (profile.min === undefined || n >= Number(profile.min)) && (profile.max === undefined || n <= Number(profile.max));
44
+ if (inRange) {
45
+ // the digits it was read from: the runs that start with its whole-dollar part ("82.50" said as "82 50" → 8250)
46
+ const runs = digitRuns(raw).filter((d) => d.indexOf(String(Math.trunc(n))) === 0);
47
+ return { value: String(n), evidence: runs.length ? runs.map((d) => `d:${d}`) : ['amount'] };
48
+ }
49
+ else {
50
+ return null;
51
+ }
52
+ }
53
+ else if (kind === 'email') {
54
+ const e = readEmail(raw);
55
+ if (e) {
56
+ return { value: e, evidence: [`e:${e}`] };
57
+ }
58
+ else {
59
+ return null;
60
+ }
61
+ }
62
+ else if (kind === 'phone') {
63
+ const p = readPhone(raw) || readInternationalPhone(raw);
64
+ if (p) {
65
+ return { value: p, evidence: [`d:${String(p).replace(/\D/g, '')}`] };
66
+ }
67
+ else {
68
+ return null;
69
+ }
70
+ }
71
+ else if (kind === 'identifier') {
72
+ const id = readIdentifier(raw, profile.pattern || '');
73
+ if (id && id.kind === 'account') {
74
+ return { value: id.value, evidence: [`d:${id.value}`] };
75
+ }
76
+ else {
77
+ return null;
78
+ }
79
+ }
80
+ else if (kind === 'digits') {
81
+ const min = Number(profile.min || 1);
82
+ const max = Number(profile.max || min);
83
+ const fits = [...new Set(digitRuns(raw).filter((d) => d.length >= min && d.length <= max))];
84
+ if (fits.length === 1) {
85
+ return { value: fits[0], evidence: [`d:${fits[0]}`] };
86
+ }
87
+ else {
88
+ return null; // none, or two different numbers of that length: not this input's answer by shape alone
89
+ }
90
+ }
91
+ else if (kind === 'pattern') {
92
+ let m = null;
93
+ try {
94
+ m = new RegExp(profile.regex, profile.flags || 'i').exec(raw);
95
+ }
96
+ catch {
97
+ m = null; // an invalid pattern in a spec reads nothing
98
+ }
99
+ if (m) {
100
+ const v = m[profile.group || 0] || m[0];
101
+ return { value: v, evidence: [`p:${String(v).toLowerCase()}`] };
102
+ }
103
+ else {
104
+ return null;
105
+ }
106
+ }
107
+ else if (kind === 'text') {
108
+ const t = raw.trim();
109
+ if (isCurrent && t) {
110
+ return { value: t, evidence: ['text'] };
111
+ }
112
+ else {
113
+ return null; // free text fills only the input that was asked, or every answer would fit it
114
+ }
115
+ }
116
+ else {
117
+ return null; // an unknown kind (e.g. 'date' before its reader exists) reads nothing
118
+ }
119
+ }
120
+ /**
121
+ * candidates: [{ form, name, profile, value?, confirm_against? }] — the inputs of the form chain that apply now (`when`
122
+ * holds): the current form's first, then its parent's, and so on up. `value` is what is already stored for it.
123
+ * current: { form, name } of the input that was asked, or { form, names } when the asked input is one of several
124
+ * (an any_of group: phone, email or account number); every member counts as the current input. choiceKey: the key
125
+ * ask-choice picked from the current input's own choices, if any.
126
+ *
127
+ * Readings that claim DIFFERENT parts of the answer are all taken (a compound answer fills several inputs). Readings
128
+ * that claim the SAME part are settled by scope (owner decision 2026-10-19, A):
129
+ * 1. the current input takes it;
130
+ * 2. else the one input of the current form that fits takes it, and parent forms are not considered;
131
+ * 3. else the same test one form up, and so on;
132
+ * 4. two inputs at the nearest form that fits are ambiguous: asked, never ranked.
133
+ * Exception: an input that declares `confirm_against: [names]` is asked about together with those inputs whenever both
134
+ * fit the same words, wherever scope would have put it ('62' at the payment offer: $62 or sub-account 62, prod
135
+ * 2026-10-19 07:38).
136
+ *
137
+ * Returns { hits, ambiguous }:
138
+ * hits — what the answer fills, each { form, name, value, known } (`known`: equals the stored value);
139
+ * ambiguous — the readings to ask about (the first such group), or empty.
140
+ */
141
+ export function matchInputs(answer, candidates, current, choiceKey) {
142
+ const raw = unescape(answer);
143
+ const list = Array.isArray(candidates) ? candidates.filter((c) => c && c.name && c.profile) : [];
144
+ const cur = current && typeof current === 'object' ? current : {};
145
+ const isCur = (c) => (Array.isArray(cur.names) ? cur.names.indexOf(c.name) > -1 : c.name === cur.name) && (cur.form === undefined || c.form === cur.form);
146
+ const own = Array.isArray(cur.names) ? undefined : list.filter(isCur)[0]; // a group has no single own choice list
147
+ // A declared confirm_against pair with the current input is read in full below, even when its own choice was picked.
148
+ const pairedWithOwn = !!own && list.some((c) => c !== own && ((Array.isArray(c.confirm_against) && c.confirm_against.indexOf(own.name) > -1) || (Array.isArray(own.confirm_against) && own.confirm_against.indexOf(c.name) > -1)));
149
+ if (own && choiceKey && !pairedWithOwn && own.profile.kind === 'choice' && (own.profile.choices || []).some((c) => c && c.key === choiceKey)) {
150
+ return { hits: [{ form: own.form, name: own.name, value: choiceKey, known: own.value === choiceKey }], ambiguous: [] };
151
+ }
152
+ else {
153
+ // no direct pick of the current input's own choice: read every candidate's shape
154
+ }
155
+ // A form's scope level: 0 for the current input's form, then each further form in the order the chain lists them.
156
+ const forms = [];
157
+ if (own) {
158
+ forms.push(own.form);
159
+ }
160
+ else {
161
+ // the current input is not among the candidates (it no longer applies): levels follow the list order
162
+ }
163
+ list.forEach((c) => { if (forms.indexOf(c.form) === -1) {
164
+ forms.push(c.form);
165
+ }
166
+ else { /* already ranked */ } });
167
+ const readings = [];
168
+ for (const c of list) {
169
+ const r = readProfile(c.profile, raw, isCur(c));
170
+ if (r) {
171
+ readings.push({ form: c.form, name: c.name, value: r.value, known: c.value !== undefined && c.value !== null && String(c.value) === String(r.value), evidence: r.evidence, level: forms.indexOf(c.form), current: isCur(c), confirm: Array.isArray(c.confirm_against) ? c.confirm_against : [] });
172
+ }
173
+ else {
174
+ // this input's shape is not in the answer
175
+ }
176
+ }
177
+ // Readings that spent the same evidence form a group (connected components).
178
+ const conflicts = (a, b) => a.evidence.some((e) => b.evidence.indexOf(e) > -1);
179
+ const parent = readings.map((_, i) => i);
180
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
181
+ for (let i = 0; i < readings.length; i++) {
182
+ for (let j = i + 1; j < readings.length; j++) {
183
+ if (conflicts(readings[i], readings[j])) {
184
+ parent[find(i)] = find(j);
185
+ }
186
+ else {
187
+ // distinct parts of the answer
188
+ }
189
+ }
190
+ }
191
+ const groups = {};
192
+ readings.forEach((r, i) => { (groups[find(i)] = groups[find(i)] || []).push(r); });
193
+ const strip = ({ evidence, level, current: c, confirm, ...r }) => r;
194
+ const paired = (a, b) => a.confirm.indexOf(b.name) > -1 || b.confirm.indexOf(a.name) > -1;
195
+ const hits = [];
196
+ let ambiguous = [];
197
+ for (const members of Object.values(groups)) {
198
+ let winners;
199
+ if (members.length === 1) {
200
+ winners = members;
201
+ }
202
+ else if (members.some((m) => m.current)) {
203
+ winners = members.filter((m) => m.current);
204
+ }
205
+ else {
206
+ const nearest = Math.min(...members.map((m) => m.level));
207
+ winners = members.filter((m) => m.level === nearest);
208
+ }
209
+ // a winner declared to be confirmed against another member is asked about together with it
210
+ const confirmWith = winners.length === 1 ? members.filter((m) => m !== winners[0] && paired(m, winners[0])) : [];
211
+ const asked = confirmWith.length ? winners.concat(confirmWith) : winners;
212
+ if (asked.length === 1) {
213
+ hits.push(strip(asked[0]));
214
+ }
215
+ else if (ambiguous.length === 0) {
216
+ ambiguous = asked.map(strip);
217
+ }
218
+ else {
219
+ // a second ambiguous group in one answer is left for the next question
220
+ }
221
+ }
222
+ return { hits, ambiguous };
223
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Strict word-aware option matching for flow CASE conditions (APPROVED_FUNCTIONS.matchesChoice) —
3
+ * replaces the substring `.some(choice => input.includes(choice))` idiom, whose false positives broke
4
+ * branch routing ('si' inside "imposible"/"así" matched YES, prod-observed 2026-08-09).
5
+ *
6
+ * Rules (deliberately NO fuzzy/prefix matching):
7
+ * - single-word choice: exact word membership
8
+ * - multi-word choice: exact word-sequence containment
9
+ * - plurals/inflections: enumerate them explicitly in the choice list
10
+ * - a keypad key (one digit, * or #) matches ONLY an answer that is exactly that key, never a key
11
+ * inside a longer answer (owner rule): "I made payments by 1," at "press 1 to pay" charged the full
12
+ * amount due on dev, 2026-10-14
13
+ * - an ELONGATED word the user typed is forgiven (2026-10-01): a repeated vowel or any letter typed
14
+ * 3+ times collapses — "Sii" matches 'si', "nadaaaa" 'nada', "yesss" 'yes'. A typed "Sii" at the
15
+ * authentication retry menu ended the conversation in contact-support. Only the user's word is
16
+ * collapsed, and only against an option word with no doubled letter of its own. Measured on a
17
+ * week of prod prompts (scratch replay, 98 option lists × 23,692 prompts):
18
+ * · collapsing BOTH sides: rejected — 'all' → 'al' made "servicio al cliente" (138 turns) pick
19
+ * the "all" option, and 'mm' → 'm' made "I'm" a filler;
20
+ * · collapsing every doubled letter of the user's word: rejected — "mess" matched Spanish
21
+ * 'mes' and "Mall" 'mal'. A doubled consonant is real spelling, so "okk" stays unmatched.
22
+ *
23
+ * Zero imports, so scripts/tests/matches-choice.mjs runs the real implementation.
24
+ */
25
+ export function matchesChoice(input: any, choices: any): boolean;
26
+ //# sourceMappingURL=matches-choice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"matches-choice.d.ts","sourceRoot":"","sources":["../../src/helpers/matches-choice.js"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,iEAiCC"}