@agent-cards/commerce-sdk 0.15.0 → 0.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.
- package/README.md +52 -5
- package/dist/ack.d.ts +26 -16
- package/dist/ack.js +64 -2
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +2 -0
- package/dist/buy-tool.d.ts +45 -8
- package/dist/buy-tool.js +228 -27
- package/dist/client.d.ts +30 -0
- package/dist/client.js +11 -0
- package/dist/definition.js +2 -0
- package/dist/direct.d.ts +5 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/messages.js +4 -4
- package/dist/model-view.d.ts +48 -4
- package/dist/model-view.js +208 -27
- package/dist/order.d.ts +119 -0
- package/dist/order.js +155 -0
- package/dist/sentences.d.ts +3 -0
- package/dist/sentences.js +29 -0
- package/dist/shopper-message.d.ts +10 -0
- package/dist/shopper-message.js +51 -11
- package/dist/texting.d.ts +81 -0
- package/dist/texting.js +347 -0
- package/package.json +1 -1
package/dist/texting.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { forModel, replyAsWritten as replyWithoutCardList } from './model-view.js';
|
|
2
|
+
/** The mark every list line starts with. */
|
|
3
|
+
const BULLET = '• ';
|
|
4
|
+
/** A line that is a list item: `- `, `* `, `• `, `1. ` or `1) `, then text. The number, when it has one, is group 2. */
|
|
5
|
+
const MARKED = /^(\s*)(?:[-*•]|(\d{1,2})[.)])\s+(?=\S)/;
|
|
6
|
+
/** A line that leads into a list: it ends with a colon or a question mark, as `which bread do you want?` does. */
|
|
7
|
+
const LEAD = /[:?]["')\]]*$/;
|
|
8
|
+
/** A line indented under a list item, such as a description under its name: two spaces or a tab, then text. */
|
|
9
|
+
const CONTINUATION = /^(?: {2,}|\t)\S/;
|
|
10
|
+
/**
|
|
11
|
+
* A word whose period ends no sentence: a title or a short form such as `St.`, `Dr.`, `vs.` or `e.g.`, or initials
|
|
12
|
+
* such as `J.` or `U.S.`, so the word after it keeps its capital ("St. Louis", "Dr. Pepper").
|
|
13
|
+
*/
|
|
14
|
+
const ABBREVIATIONS = new Set(['st', 'dr', 'mr', 'mrs', 'ms', 'jr', 'sr', 'mt', 'ft', 'vs', 'e.g', 'i.e', 'etc']);
|
|
15
|
+
const INITIALS = /^(?:[A-Za-z]\.)*[A-Z]$/;
|
|
16
|
+
/** `No.` stands for a number before a digit, as in `No. 7`; anywhere else it is the answer "no" and ends its sentence. */
|
|
17
|
+
const NUMBER_SIGN = /^no$/i;
|
|
18
|
+
const DIGIT_NEXT = /^\s*\d/;
|
|
19
|
+
/**
|
|
20
|
+
* True when `word`, read up to the period after it and the text `after` that period, is an abbreviation or initials
|
|
21
|
+
* rather than a sentence's last word.
|
|
22
|
+
*/
|
|
23
|
+
function isAbbreviation(word, after) {
|
|
24
|
+
const bare = word.replace(/^[^\p{L}]+/u, '');
|
|
25
|
+
if (NUMBER_SIGN.test(bare))
|
|
26
|
+
return DIGIT_NEXT.test(after);
|
|
27
|
+
return ABBREVIATIONS.has(bare.toLowerCase()) || INITIALS.test(bare);
|
|
28
|
+
}
|
|
29
|
+
/** At most this many characters and words make a line short enough to be a choice written on its own, with no marker. */
|
|
30
|
+
const OPTION_CHARACTERS = 60;
|
|
31
|
+
const OPTION_WORDS = 8;
|
|
32
|
+
/**
|
|
33
|
+
* A line that is one choice written on its own, as a list of breads or times is written with no marker: short, and not
|
|
34
|
+
* the end of a sentence (`.`, `!`, `?`, `:` or `;`), so a line of prose never reads as one.
|
|
35
|
+
*/
|
|
36
|
+
function isOption(line) {
|
|
37
|
+
const text = line.trim();
|
|
38
|
+
return text !== '' && text.length <= OPTION_CHARACTERS && text.split(/\s+/).length <= OPTION_WORDS && !/[.!?:;]$/.test(text) && !MARKED.test(line);
|
|
39
|
+
}
|
|
40
|
+
/** The text with every list written as bullet lines, read by `options`. */
|
|
41
|
+
function bulletLists(text, options = {}) {
|
|
42
|
+
const lines = text.split('\n');
|
|
43
|
+
// A marked list: two or more item lines, with only blank lines and lines indented under an item between them. An
|
|
44
|
+
// indented line, such as ` the chef's pick` under `- Sourdough`, stays as written under its item's bullet.
|
|
45
|
+
for (let start = 0; start < lines.length; start += 1) {
|
|
46
|
+
if (!MARKED.test(lines[start]))
|
|
47
|
+
continue;
|
|
48
|
+
const items = [start];
|
|
49
|
+
let end = start + 1;
|
|
50
|
+
while (end < lines.length) {
|
|
51
|
+
if (MARKED.test(lines[end]))
|
|
52
|
+
items.push(end);
|
|
53
|
+
else if (lines[end].trim() !== '' && !CONTINUATION.test(lines[end]))
|
|
54
|
+
break;
|
|
55
|
+
end += 1;
|
|
56
|
+
}
|
|
57
|
+
start = items[items.length - 1];
|
|
58
|
+
// One marked line is not a list, so it stays as written.
|
|
59
|
+
if (items.length < 2)
|
|
60
|
+
continue;
|
|
61
|
+
const numbered = items.some((i) => MARKED.exec(lines[i])[2] !== undefined);
|
|
62
|
+
if (numbered && options.keepNumbers?.(items.map((i) => lines[i].replace(MARKED, ''))))
|
|
63
|
+
continue;
|
|
64
|
+
for (const i of items)
|
|
65
|
+
lines[i] = lines[i].replace(MARKED, (_, indent) => `${indent}${BULLET}`);
|
|
66
|
+
}
|
|
67
|
+
// Choices with no marker: two or more short lines in a row right after a line that leads into them, such as the
|
|
68
|
+
// breads after `which bread do you want?`. Blank lines may sit between the lead and the first choice.
|
|
69
|
+
const runFrom = (first) => {
|
|
70
|
+
let end = first;
|
|
71
|
+
while (end < lines.length && isOption(lines[end]))
|
|
72
|
+
end += 1;
|
|
73
|
+
if (end - first < 2)
|
|
74
|
+
return;
|
|
75
|
+
for (let i = first; i < end; i += 1)
|
|
76
|
+
lines[i] = BULLET + lines[i].trim();
|
|
77
|
+
};
|
|
78
|
+
const nextWithWords = (from) => {
|
|
79
|
+
let i = from;
|
|
80
|
+
while (i < lines.length && lines[i].trim() === '')
|
|
81
|
+
i += 1;
|
|
82
|
+
return i;
|
|
83
|
+
};
|
|
84
|
+
if (options.leadIn)
|
|
85
|
+
runFrom(nextWithWords(0));
|
|
86
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
87
|
+
if (LEAD.test(lines[i].trim()))
|
|
88
|
+
runFrom(nextWithWords(i + 1));
|
|
89
|
+
}
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The text with every list written as bullet lines that start with `• `: lines marked `- `, `* `, `• `, `1. ` or
|
|
94
|
+
* `1) `, and two or more short lines in a row that follow a line ending in `:` or `?`, such as the choices of bread
|
|
95
|
+
* after `which bread do you want?`. A list's lead-in line stays, and so does prose. One line on its own is no list.
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* asBullets('which bread do you want?\n\nSourdough\nWhole wheat'); // 'which bread do you want?\n\n• Sourdough\n• Whole wheat'
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function asBullets(text) {
|
|
102
|
+
return bulletLists(text);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The text with the capital that opens each sentence after its first lowercased. A capital after an abbreviation or
|
|
106
|
+
* initials, such as `St. Louis` or `J. Crew`, is a name and stays.
|
|
107
|
+
*/
|
|
108
|
+
function sentenceStarts(text) {
|
|
109
|
+
let out = '';
|
|
110
|
+
let from = 0;
|
|
111
|
+
// The capital is looked ahead, never consumed, so `vs. Mt. Bachelor` reads `Mt.` as a word of its own.
|
|
112
|
+
for (const match of text.matchAll(/(\S*)([.!?])\s+(?=[A-Z][a-z])/g)) {
|
|
113
|
+
const at = match.index + match[0].length;
|
|
114
|
+
if (match[2] === '.' && isAbbreviation(match[1], text.slice(match.index + match[1].length + 1)))
|
|
115
|
+
continue;
|
|
116
|
+
out += text.slice(from, at) + text[at].toLowerCase();
|
|
117
|
+
from = at + 1;
|
|
118
|
+
}
|
|
119
|
+
return out + text.slice(from);
|
|
120
|
+
}
|
|
121
|
+
/** Each line in the texting style: the work of `textingStyle` on text whose lists are bullets already. */
|
|
122
|
+
function styleLines(text) {
|
|
123
|
+
return text
|
|
124
|
+
.split('\n')
|
|
125
|
+
.map((raw) => {
|
|
126
|
+
const item = MARKED.test(raw);
|
|
127
|
+
const line = raw
|
|
128
|
+
.replace(/^\s*#{1,6}\s+/, '')
|
|
129
|
+
// A markdown marker on a line of its own goes; a bullet stays.
|
|
130
|
+
.replace(/^(\s*)[-*]\s+/, '$1')
|
|
131
|
+
.replace(/\*\*(.+?)\*\*|__(.+?)__/g, (_, a, b) => a ?? b ?? '')
|
|
132
|
+
.replace(/\s+[—–-]\s+/g, ', ')
|
|
133
|
+
.replace(/\s*—\s*/g, ', ')
|
|
134
|
+
.replace(/\bI(?=('(m|ll|ve|d))?\b)/g, 'i');
|
|
135
|
+
const marker = /^\s*(?:\d+[.)]\s+|•\s+)?/.exec(line)?.[0] ?? '';
|
|
136
|
+
const rest = sentenceStarts(line.slice(marker.length));
|
|
137
|
+
// A list line keeps its first word as written: it is usually a name, such as a product's or a store's. So does a
|
|
138
|
+
// line that opens with an abbreviation, such as `Mt. Hood`.
|
|
139
|
+
const opening = /^(\S*)\./.exec(rest);
|
|
140
|
+
return marker + (item || (opening !== null && isAbbreviation(opening[1], rest.slice(opening[0].length))) ? rest : rest.replace(/^([A-Z])(?=[a-z])/, (letter) => letter.toLowerCase()));
|
|
141
|
+
})
|
|
142
|
+
.join('\n')
|
|
143
|
+
.trim();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The text in a chat's texting style: every list as `• ` bullet lines (`asBullets`), no other markdown (headings, bold,
|
|
147
|
+
* a lone `- ` or `* ` marker), no spaced dashes, which become commas, and lowercase at the start of each sentence and
|
|
148
|
+
* for "I". A capital inside a sentence is a name and stays, and so does an all-caps word such as "USB". A list line
|
|
149
|
+
* keeps its first word as written, since it is usually a product's or a store's name.
|
|
150
|
+
*
|
|
151
|
+
* ```ts
|
|
152
|
+
* textingStyle('Here are two — both ship today. Which one?\n- Anker cable\n- Onn cable');
|
|
153
|
+
* // 'here are two, both ship today. which one?\n• Anker cable\n• Onn cable'
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export function textingStyle(text) {
|
|
157
|
+
return styleLines(bulletLists(text));
|
|
158
|
+
}
|
|
159
|
+
/** A text's words, lowercased, letters and digits only. */
|
|
160
|
+
function wordsOf(text) {
|
|
161
|
+
return text.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== '');
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* True when a list item names one of `names`, by the first four words of the name: two of them, and most of them, in
|
|
165
|
+
* the item, or the name's one word as the item's first.
|
|
166
|
+
*/
|
|
167
|
+
function namesOne(item, names) {
|
|
168
|
+
const said = wordsOf(item);
|
|
169
|
+
const set = new Set(said);
|
|
170
|
+
return names.some((name) => {
|
|
171
|
+
const head = name.slice(0, 4);
|
|
172
|
+
if (head.length === 1)
|
|
173
|
+
return said[0] === head[0];
|
|
174
|
+
const hits = head.filter((word) => set.has(word)).length;
|
|
175
|
+
return hits >= Math.max(2, Math.ceil(head.length * 0.6));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a `needs_input`
|
|
180
|
+
* answer with a reply, such as a size, a bread or a store to pick, or a cart's total and the ask for their yes. The
|
|
181
|
+
* reply comes with every link taken out, as the model reads it, without the list of products or stores whose cards
|
|
182
|
+
* went out (`withoutCardList`), and with every list left in it as `• ` bullet lines (`asBullets`). A numbered list
|
|
183
|
+
* that names the answer's products or stores keeps its numbers, because the shopper picks from it by number ("number
|
|
184
|
+
* 6"), and Agentcard and `directCall` read that number against the list. Null for any other answer, which your model
|
|
185
|
+
* puts in its own words, for a line the tool wrote itself for your model, and once the shopper read the reply through
|
|
186
|
+
* `onText` (`shopper_already_read_reply`). `{ textingStyle: true }` hands it over in the texting style (`textingStyle`).
|
|
187
|
+
*
|
|
188
|
+
* ```ts
|
|
189
|
+
* replyAsWritten(result); // 'which bread do you want?\n\n• Sourdough\n• Whole wheat'
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
export function replyAsWritten(result, options = {}) {
|
|
193
|
+
const reply = replyWithoutCardList(result);
|
|
194
|
+
if (reply === null)
|
|
195
|
+
return null;
|
|
196
|
+
const view = forModel(result);
|
|
197
|
+
const names = [...view.products.map((product) => product.name), ...view.stores.map((store) => store.name)].map(wordsOf).filter((name) => name.length > 0);
|
|
198
|
+
const bulleted = withChoice(bulletLists(reply, { keepNumbers: (items) => items.some((item) => namesOne(item, names)) }), result.choice ?? null);
|
|
199
|
+
return (options.textingStyle ? styleLines(bulleted) : bulleted) || null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* One message of Agentcard's, as `onText` hands it to the app: its lists as `• ` bullet lines and, with `texting`, in
|
|
203
|
+
* the texting style. A numbered list keeps its numbers, since a streamed message still lists the products its cards
|
|
204
|
+
* show and the shopper may pick one by its number. `leadIn` says the message before it ended in a colon or a question
|
|
205
|
+
* mark, so the choices that open this one are that question's list.
|
|
206
|
+
*/
|
|
207
|
+
export function messageForShopper(text, leadIn, texting) {
|
|
208
|
+
const bulleted = bulletLists(text, { leadIn, keepNumbers: () => true });
|
|
209
|
+
return texting ? styleLines(bulleted) : bulleted;
|
|
210
|
+
}
|
|
211
|
+
/** True when a message ends in a colon or a question mark, so a list that opens the next one belongs to it. */
|
|
212
|
+
export function leadsIntoList(text) {
|
|
213
|
+
return LEAD.test(text.trim());
|
|
214
|
+
}
|
|
215
|
+
/** A label as plain words: lowercase letters and digits, one space between words. */
|
|
216
|
+
const plainLabel = (text) => text.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
217
|
+
/** A value's extra price as a shopper reads it after the value: ` (+$2.46)`, nothing when it is free or not known. */
|
|
218
|
+
const extraPrice = (cents) => (cents && cents > 0 ? ` (+$${(cents / 100).toFixed(2)})` : '');
|
|
219
|
+
/**
|
|
220
|
+
* A choice's values as the shopper reads them, one `• ` line each with its extra price after it: `• Gluten-free bread
|
|
221
|
+
* (+$2.46)`. A choice of more than one group leads each group's lines with its name, as `Size:`.
|
|
222
|
+
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* choiceLines(result.choice!); // '• Ciabatta\n• Sourdough\n• Gluten-free bread (+$2.46)'
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
export function choiceLines(choice) {
|
|
228
|
+
const groups = choice.groups.filter((group) => group.options.length > 0);
|
|
229
|
+
return groups
|
|
230
|
+
.flatMap((group) => [
|
|
231
|
+
...(groups.length > 1 && group.name ? [`${group.name}:`] : []),
|
|
232
|
+
...group.options.map((option) => `${BULLET}${option.label}${extraPrice(option.price_delta_cents)}`),
|
|
233
|
+
])
|
|
234
|
+
.join('\n');
|
|
235
|
+
}
|
|
236
|
+
/** A value's extra price as its line names it: "2.46" for 246 cents; null when it is free or not known. */
|
|
237
|
+
const priceWords = (cents) => (cents && cents > 0 ? (cents / 100).toFixed(2) : null);
|
|
238
|
+
/**
|
|
239
|
+
* How `text` lists the choice's values: each value's own line (a list marker before it allowed), the values no line holds, and
|
|
240
|
+
* the lines of priced values that leave their extra price out. A line belongs to the one value whose label it opens with, the
|
|
241
|
+
* longest such label, so `• 7.5` is the line of 7.5 and never of 7.
|
|
242
|
+
*/
|
|
243
|
+
function choiceListing(text, choice) {
|
|
244
|
+
const raw = text.split('\n');
|
|
245
|
+
// Short lines in a row after a question are a list too (bulletLists), whether or not they carry a marker.
|
|
246
|
+
const read = bulletLists(text, { keepNumbers: () => true }).split('\n');
|
|
247
|
+
const lines = raw.map((line) => plainLabel(line.replace(MARKED, '')));
|
|
248
|
+
const options = choice.groups.flatMap((group, g) => group.options.map((option) => ({ group: g, option, label: plainLabel(option.label), price: priceWords(option.price_delta_cents) })));
|
|
249
|
+
const owner = lines.map((line, n) => {
|
|
250
|
+
const marked = MARKED.test(read[n] ?? raw[n]);
|
|
251
|
+
let best = -1;
|
|
252
|
+
options.forEach((option, i) => {
|
|
253
|
+
// A list line may say more after its value (a price, a note); a line of prose that only opens with a value is no list
|
|
254
|
+
// line. An unmarked line that is the value alone, or the value and its price, is one.
|
|
255
|
+
const listLine = marked || line === option.label || (!!option.price && line === `${option.label} ${plainLabel(option.price)}`);
|
|
256
|
+
if (option.label && listLine && (line === option.label || line.startsWith(`${option.label} `)) && (best < 0 || option.label.length > options[best].label.length))
|
|
257
|
+
best = i;
|
|
258
|
+
});
|
|
259
|
+
return best;
|
|
260
|
+
});
|
|
261
|
+
const unpriced = [];
|
|
262
|
+
options.forEach((option, i) => {
|
|
263
|
+
const line = owner.indexOf(i);
|
|
264
|
+
if (line >= 0 && option.price && !raw[line].includes(option.price))
|
|
265
|
+
unpriced.push({ line, price: option.price });
|
|
266
|
+
});
|
|
267
|
+
const lastLine = Math.max(-1, ...owner.map((o, line) => (o >= 0 ? line : -1)));
|
|
268
|
+
// Each value no line holds goes after the last listed value of its own group; a group none of whose values is listed follows
|
|
269
|
+
// the whole list, led by its name when the choice has more than one group.
|
|
270
|
+
const insertions = [];
|
|
271
|
+
let missing = 0;
|
|
272
|
+
choice.groups.forEach((group, g) => {
|
|
273
|
+
const absent = options.filter((option, i) => option.group === g && !owner.includes(i));
|
|
274
|
+
if (!absent.length)
|
|
275
|
+
return;
|
|
276
|
+
missing += absent.length;
|
|
277
|
+
const bullets = absent.map(({ option }) => `${BULLET}${option.label}${extraPrice(option.price_delta_cents)}`);
|
|
278
|
+
const own = Math.max(-1, ...owner.map((o, line) => (o >= 0 && options[o].group === g ? line : -1)));
|
|
279
|
+
insertions.push(own >= 0 ? { after: own, lines: bullets } : { after: lastLine, lines: [...(choice.groups.length > 1 && group.name ? [`${group.name}:`] : []), ...bullets] });
|
|
280
|
+
});
|
|
281
|
+
return { missing, lastLine, insertions, unpriced };
|
|
282
|
+
}
|
|
283
|
+
/** True when every value of the choice has a line of its own in `text`, each priced one naming its extra price. */
|
|
284
|
+
function listsChoiceWithPrices(text, choice) {
|
|
285
|
+
const listing = choiceListing(text, choice);
|
|
286
|
+
return listing.missing === 0 && listing.unpriced.length === 0;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The text less a bracket that holds nothing but the choice's values, such as a store's "(Size: 3 oz, 4.375 oz, 8.375 oz)":
|
|
290
|
+
* the values go under the question instead, so the shopper never reads them twice. A bracket whose values follow anything but a
|
|
291
|
+
* group's name, such as "(Choose up to 2: Garlic, Herb)", keeps what it says about the choice and stays.
|
|
292
|
+
*/
|
|
293
|
+
function withoutInlineValues(text, choice) {
|
|
294
|
+
const labels = new Set(choice.groups.flatMap((group) => group.options.map((option) => plainLabel(option.label))));
|
|
295
|
+
const groupNames = new Set(choice.groups.map((group) => plainLabel(group.name)));
|
|
296
|
+
return text.replace(/\s*\(([^()\n]*)\)/g, (whole, inside) => {
|
|
297
|
+
const parts = inside.split(/;\s*/).map((part) => /^([^:,]{1,40}):\s*(.*)$/.exec(part) ?? [part, null, part]);
|
|
298
|
+
if (parts.some(([, lead]) => lead != null && !groupNames.has(plainLabel(lead))))
|
|
299
|
+
return whole;
|
|
300
|
+
const values = parts.flatMap(([, , rest]) => (rest ?? '').split(/,\s*(?:or\s+|and\s+)?|\s+(?:or|and)\s+/))
|
|
301
|
+
.map((value) => value.trim()).filter((value) => value !== '' && value !== '…' && value !== '...');
|
|
302
|
+
return values.length >= 2 && values.every((value) => labels.has(plainLabel(value))) ? '' : whole;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The text with the choice's values listed one per line (choiceLines) under the line that asks the question, when the text
|
|
307
|
+
* does not list them already: Agentcard's reply may name them inside a sentence, as "i can also do sourdough, whole wheat,
|
|
308
|
+
* or gluten-free bread for $2.46 more" did. A bracket that holds only the values goes, since the list replaces it. A text
|
|
309
|
+
* that asks nothing gets the list at its end. When every value already opens a line, a priced one that leaves its price out
|
|
310
|
+
* gets it at the end of its line, and the text is otherwise as it is, as it is with no choice.
|
|
311
|
+
*
|
|
312
|
+
* ```ts
|
|
313
|
+
* withChoice('which bread do you want?', result.choice); // 'which bread do you want?\n• Ciabatta\n• Sourdough'
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
export function withChoice(text, choice) {
|
|
317
|
+
if (!choice || !choice.groups.some((group) => group.options.length > 0))
|
|
318
|
+
return text;
|
|
319
|
+
const listing = choiceListing(text, choice);
|
|
320
|
+
if (listing.lastLine >= 0) {
|
|
321
|
+
// Some values have their line already: a priced one that leaves its price out gets it at the line's end, and the values
|
|
322
|
+
// no line holds follow the last listed value of their own group, so the list is whole and none is read twice.
|
|
323
|
+
if (!listing.missing && !listing.unpriced.length)
|
|
324
|
+
return text;
|
|
325
|
+
const listed = text.split('\n');
|
|
326
|
+
for (const { line, price } of listing.unpriced)
|
|
327
|
+
listed[line] = `${listed[line].trimEnd()} (+$${price})`;
|
|
328
|
+
// From the bottom up, so each insertion leaves the lines above it where they were; at one place, in the groups' order.
|
|
329
|
+
for (const { after, lines } of [...listing.insertions].reverse().sort((x, y) => y.after - x.after))
|
|
330
|
+
listed.splice(after + 1, 0, ...lines);
|
|
331
|
+
return listed.join('\n');
|
|
332
|
+
}
|
|
333
|
+
const lines = withoutInlineValues(text, choice).split('\n');
|
|
334
|
+
const question = choice.question?.trim();
|
|
335
|
+
let at = question ? lines.findIndex((line) => line.includes(question)) : -1;
|
|
336
|
+
for (let i = lines.length - 1; at < 0 && i >= 0; i -= 1)
|
|
337
|
+
if (lines[i].includes('?'))
|
|
338
|
+
at = i;
|
|
339
|
+
if (at < 0)
|
|
340
|
+
at = lines.length - 1;
|
|
341
|
+
lines.splice(at + 1, 0, choiceLines(choice));
|
|
342
|
+
return lines.join('\n');
|
|
343
|
+
}
|
|
344
|
+
/** True when the shopper's messages already list every value of the choice one per line, each priced one with its extra price. */
|
|
345
|
+
export function messagesListChoice(messages, choice) {
|
|
346
|
+
return listsChoiceWithPrices(messages.map((message) => bulletLists(message, { leadIn: true, keepNumbers: () => true })).join('\n'), choice);
|
|
347
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/commerce-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|