@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.
@@ -107,6 +107,18 @@ export interface BuyToolModelView {
107
107
  product_id: string | null;
108
108
  in_cart: boolean;
109
109
  } | null;
110
+ /**
111
+ * The choice the reply asks the shopper to make, absent when it asks none: each group's name and its values with their
112
+ * extra price, such as `Gluten-free bread (+$2.46)`. List them one per line, each starting with `• `, under the question.
113
+ */
114
+ choice?: {
115
+ question: string | null;
116
+ groups: Array<{
117
+ name: string;
118
+ required: boolean;
119
+ options: string[];
120
+ }>;
121
+ };
110
122
  /** The stores the reply presents to choose from, in the order it names them. */
111
123
  stores: BuyToolModelStore[];
112
124
  /** How many store pages of the stores above the shopper holds, 0 when none went out. */
@@ -116,6 +128,17 @@ export interface BuyToolModelView {
116
128
  store_id: string;
117
129
  selected: boolean;
118
130
  } | null;
131
+ /**
132
+ * Present when this answer went ahead on the yes the shopper already gave in the chat, after a step they finished
133
+ * outside it, or on their pick of what is in the cart (`picked`): the total they said yes to, or the chat last showed
134
+ * them, the cart's total now, and whether it changed. Never ask for their yes again.
135
+ */
136
+ yes_carried?: {
137
+ said_yes_to: string;
138
+ total: string;
139
+ total_changed: boolean;
140
+ picked?: true;
141
+ };
119
142
  }
120
143
  /**
121
144
  * The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
@@ -183,6 +206,16 @@ export declare function expiresIn(iso: string | null | undefined, now?: number):
183
206
  * reads as `needs_input`.
184
207
  */
185
208
  export declare function forModel(result: BuyToolResult): BuyToolModelView;
209
+ /**
210
+ * The fixed short line for an answer whose next step is a link the shopper already holds: `add your card to place the
211
+ * order`, `sign in to DoorDash to place your order`, or `approve the payment to place your order (up to $19.02)`, with
212
+ * the most the shopper can be charged in the parentheses when the cart names it. After the shopper's own words put the
213
+ * cart together, a line about the cart comes first, as its own message. After a yes the turn carried past a step outside
214
+ * the chat, a total that changed opens the ask: `it's $17.89 with fees and tax now. approve the payment ...`. Null for any
215
+ * other answer, when the link did not go out, when the shopper already read Agentcard's own ask through `onText`, when a
216
+ * card or approval step has no cart to place, and when the answer also showed products.
217
+ */
218
+ export declare function stepAsWritten(result: BuyToolResult): string | null;
186
219
  /**
187
220
  * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
188
221
  * `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
@@ -191,25 +224,36 @@ export declare function forModel(result: BuyToolResult): BuyToolModelView;
191
224
  * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
192
225
  * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
193
226
  * Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
227
+ * An answer whose next step is a link the call handed over reads as that step's fixed short line
228
+ * instead (`stepAsWritten`), whoever wrote the reply.
194
229
  *
195
230
  * ```ts
196
231
  * replyAsWritten(result); // 'found two at Amazon. want the first one?'
197
232
  * ```
198
233
  */
199
234
  export declare function replyAsWritten(result: BuyToolResult): string | null;
235
+ /** A card the shopper holds: a product's or a store's name and, for a product, its price as the model reads it (`$22.89`, `from $10.69`). */
236
+ export interface ShownCard {
237
+ name: string;
238
+ price?: string | null;
239
+ }
200
240
  /**
201
241
  * Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
202
242
  * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
203
- * points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
204
- * words, each further sentence that names another card. What is left is the reply around the list, such as `found
205
- * these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
243
+ * points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
244
+ * are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
245
+ * further sentence that names another card. What is left is the reply around the list, such as `found these at
246
+ * amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
247
+ *
248
+ * Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
249
+ * a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
206
250
  *
207
251
  * ```ts
208
252
  * withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
209
253
  * // 'Which one?'
210
254
  * ```
211
255
  */
212
- export declare function withoutCardList(reply: string, cardNames: readonly string[]): string;
256
+ export declare function withoutCardList(reply: string, cardNames: readonly (string | ShownCard)[]): string;
213
257
  /**
214
258
  * A thrown error as the model may read it: the same message with every link
215
259
  * taken out, and the original error on `cause` for your logs. A TypeError
@@ -1,3 +1,4 @@
1
+ import { SENTENCE_END, lastSentenceStart } from './sentences.js';
1
2
  /** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
2
3
  const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
3
4
  const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
@@ -237,6 +238,10 @@ export function forModel(result) {
237
238
  status,
238
239
  next_action: result.next_action ?? null,
239
240
  ...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
241
+ ...(result.choice ? { choice: {
242
+ question: result.choice.question === null ? null : stripLinks(result.choice.question),
243
+ groups: result.choice.groups.map((group) => ({ name: stripLinks(group.name), required: group.required, options: group.options.map((option) => `${stripLinks(option.label)}${option.price_delta_cents ? ` (+$${(option.price_delta_cents / 100).toFixed(2)})` : ''}`) })),
244
+ } } : {}),
240
245
  reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
241
246
  cart: result.cart ? cartView(result.cart) : null,
242
247
  carts: (result.carts ?? []).map(cartView),
@@ -261,8 +266,73 @@ export function forModel(result) {
261
266
  store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
262
267
  ? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
263
268
  : null,
269
+ ...(yesCarriedView(result) ? { yes_carried: yesCarriedView(result) } : {}),
264
270
  };
265
271
  }
272
+ /** The yes an answer went ahead on, as the model reads it, or null. */
273
+ function yesCarriedView(result) {
274
+ const yes = result.yes_carried;
275
+ if (!yes || typeof yes.said_yes_to_cents !== 'number' || typeof yes.total_cents !== 'number')
276
+ return null;
277
+ const said = money(yes.said_yes_to_cents, yes.currency);
278
+ const total = money(yes.total_cents, yes.currency);
279
+ return said && total ? { said_yes_to: said, total, total_changed: yes.said_yes_to_cents !== yes.total_cents, ...(yes.picked === true ? { picked: true } : {}) } : null;
280
+ }
281
+ /** The link each step's ask goes with. */
282
+ const STEP_LINK = { add_card: 'vault', approve: 'approval', store_login: 'store_login' };
283
+ /** The items of a cart in one line, such as `1 Chicken Salad` or `2 tacos and 1 horchata`. */
284
+ function itemsLine(cart) {
285
+ const lines = cart.items.map((line) => `${line.qty} ${line.name}`);
286
+ return lines.length <= 1 ? (lines[0] ?? '') : `${lines.slice(0, -1).join(', ')} and ${lines[lines.length - 1]}`;
287
+ }
288
+ /** A cart's total as the shopper reads it: `$17.43`, or `about $15.90 before tax and shipping or delivery` for an estimate. */
289
+ function totalLine(cart) {
290
+ if (!cart.total_is_estimate)
291
+ return cart.total;
292
+ const before = cart.total_leaves_out ? ` before ${cart.total_leaves_out.replace(/,.*$/, '')}` : '';
293
+ return `about ${cart.total}${before}`;
294
+ }
295
+ /**
296
+ * The fixed short line for an answer whose next step is a link the shopper already holds: `add your card to place the
297
+ * order`, `sign in to DoorDash to place your order`, or `approve the payment to place your order (up to $19.02)`, with
298
+ * the most the shopper can be charged in the parentheses when the cart names it. After the shopper's own words put the
299
+ * cart together, a line about the cart comes first, as its own message. After a yes the turn carried past a step outside
300
+ * the chat, a total that changed opens the ask: `it's $17.89 with fees and tax now. approve the payment ...`. Null for any
301
+ * other answer, when the link did not go out, when the shopper already read Agentcard's own ask through `onText`, when a
302
+ * card or approval step has no cart to place, and when the answer also showed products.
303
+ */
304
+ export function stepAsWritten(result) {
305
+ if (result.shopper_already_read_reply === true || result.link_sent !== true)
306
+ return null;
307
+ const next = result.next_action ?? '';
308
+ const kind = STEP_LINK[next];
309
+ const link = kind ? (result.links ?? []).find((l) => l.kind === kind && typeof l.url === 'string' && l.url !== '') : undefined;
310
+ if (!kind || !link)
311
+ return null;
312
+ const view = forModel(result);
313
+ const cart = view.cart;
314
+ // The line is about placing an order: an answer with no cart to place, such as the card link the shopper asked for while
315
+ // browsing, or one that also showed products the reply talks about, is the model's to word.
316
+ if ((!cart && next !== 'store_login') || view.products.length > 0)
317
+ return null;
318
+ const store = stripLinks(link.store ?? '') || cart?.store || 'the store';
319
+ const ask = next === 'add_card'
320
+ ? 'add your card to place the order'
321
+ : next === 'store_login'
322
+ ? `sign in to ${store} to place your order`
323
+ : `approve the payment to place your order${cart?.max_charge ? ` (up to ${cart.max_charge})` : ''}`;
324
+ const yes = view.yes_carried;
325
+ if (yes?.total_changed && cart) {
326
+ const total = cart.total_is_estimate ? `about ${cart.total}` : `${cart.total} with fees and tax`;
327
+ return `it's ${total} now. ${ask}`;
328
+ }
329
+ // Only the shopper's own words that built the cart get a line about it first; after a yes or a saved card, the ask is the whole message.
330
+ const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
331
+ if (agentcards && result.confirm_sent !== true && !yes && cart && cart.items.length > 0 && next !== 'approve') {
332
+ return `${itemsLine(cart)} from ${cart.store}, ${totalLine(cart)}\n\n${ask}`;
333
+ }
334
+ return ask;
335
+ }
266
336
  /**
267
337
  * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
268
338
  * `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
@@ -271,6 +341,8 @@ export function forModel(result) {
271
341
  * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
272
342
  * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
273
343
  * Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
344
+ * An answer whose next step is a link the call handed over reads as that step's fixed short line
345
+ * instead (`stepAsWritten`), whoever wrote the reply.
274
346
  *
275
347
  * ```ts
276
348
  * replyAsWritten(result); // 'found two at Amazon. want the first one?'
@@ -280,13 +352,20 @@ export function replyAsWritten(result) {
280
352
  // The shopper already read every message of it through onText: handing it out again would say it twice.
281
353
  if (result.shopper_already_read_reply === true)
282
354
  return null;
355
+ // A step whose link the shopper already holds is asked for in the same short line every time, with no model.
356
+ const step = stepAsWritten(result);
357
+ if (step !== null)
358
+ return step;
283
359
  // The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
284
360
  const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
285
361
  const view = forModel(result);
286
362
  const reply = view.reply.trim();
287
363
  if (!agentcards || view.status !== 'needs_input' || reply === '')
288
364
  return null;
289
- const cards = [...view.products.filter((product) => product.link_sent).map((product) => product.name), ...view.stores.filter((store) => store.link_sent).map((store) => store.name)];
365
+ const cards = [
366
+ ...view.products.filter((product) => product.link_sent).map((product) => ({ name: product.name, price: product.price })),
367
+ ...view.stores.filter((store) => store.link_sent).map((store) => ({ name: store.name })),
368
+ ];
290
369
  // A reply with nothing left once the cards' list comes out is your model's to word.
291
370
  return withoutCardList(reply, cards) || null;
292
371
  }
@@ -296,6 +375,15 @@ const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
296
375
  const PRICE = /[$£€]\s?\d/;
297
376
  /** The place a sentence points at in a numbered list, such as `#8` or `number 3`, where `a #2 Phillips head` names a kind of thing. */
298
377
  const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
378
+ /**
379
+ * A sentence that goes on to more of the list rather than recommending: `other 6-packs are ...`, `other options include ...`,
380
+ * `there's also ...`, `you could also ...`. One that names a card is the list, whatever the reply recommended before it.
381
+ */
382
+ const MORE_OF_LIST = /^[\s*_"'(]*(?:(?:and|plus|but|or)\s+)?(?:others?\b[^.!?:]{0,40}?\b(?:are|is|include|includes|would be)\b|there(?:'s|’s|\s+is|\s+are)\s+also\b|(?:you|we)\s+(?:could|can|might)\s+also\b)/i;
383
+ /** An amount a text gives after a currency sign, such as `$22.89` or `¥1,331`. */
384
+ const AMOUNT = /[$£€¥]\s?(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/g;
385
+ /** Words that tell no product from another: a card's brand or its distinctive words are never one of these. */
386
+ const FILLER = new Set(['the', 'and', 'with', 'for', 'from', 'of', 'by', 'in', 'on', 'to', 'or', 'at', 'an', 'new', 'pack', 'count', 'set', 'each', 'size']);
299
387
  /** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
300
388
  const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|mb|w|mah|v|cm|mm|m|pack|pk|ct|count|pcs|pieces)\b/gi;
301
389
  /**
@@ -305,6 +393,8 @@ const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|m
305
393
  const SAME_UNIT = new Map([['feet', 'ft'], ['foot', 'ft'], ['in', ''], ['inch', ''], ['inches', ''], ['pk', 'pack'], ['ct', 'pack'], ['count', 'pack'], ['pcs', 'pack'], ['pieces', 'pack'], ['lbs', 'lb']]);
306
394
  /** A size or a count written as one word, such as `24pack` or `10ft`. */
307
395
  const SIZE = /^(\d+)(\p{L}+)$/u;
396
+ /** A size in inches, however a text writes it: `10 inch`, `10-inches`, `10in`, `10 in`. */
397
+ const INCHES = /(\d+)\s*-?\s*(?:inch(?:es)?|in)\b/gi;
308
398
  /** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
309
399
  function wordsOf(text) {
310
400
  return text
@@ -314,29 +404,115 @@ function wordsOf(text) {
314
404
  .filter((word) => word !== '')
315
405
  .map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
316
406
  }
407
+ /** An amount in hundredths, so `$26.46`, `from $26.46` and `26.46` all read 2646, and `¥1,331` reads 133100. */
408
+ function hundredths(whole, fraction) {
409
+ return Math.round(Number(`${whole.replace(/,/g, '')}${fraction ?? ''}`) * 100);
410
+ }
411
+ /** The amounts a text gives after a currency sign, in hundredths. */
412
+ function amountsIn(text) {
413
+ return new Set([...text.matchAll(AMOUNT)].map((match) => hundredths(match[1], match[2])));
414
+ }
317
415
  /**
318
- * The card a text names, by the first six words of the card's name: at least two of them, and most of them, in the
319
- * text. Null when it names none. A name of one word never counts in prose, where a generic word such as `soap` would.
320
- * A size or a count among those words has to be in the text too, so the 3ft cable's line never reads as the 10ft card.
416
+ * The sizes among a text's words, by unit: `10ft` and `3ft` are two sizes in feet, `24pack` a count. Inches come from
417
+ * the text itself, because `wordsOf` leaves `10 inch` as the bare `10`, so the 10 inch skillet reads as one size in
418
+ * inches and the 12 inch one as another.
321
419
  */
322
- function cardNamed(text, cards, inList) {
323
- const said = new Set(wordsOf(text));
324
- for (const [index, name] of cards.entries()) {
325
- const head = name.slice(0, 6);
326
- if (!head.every((word) => !/\d/.test(word) || said.has(word)))
327
- continue;
328
- const hits = head.filter((word) => said.has(word)).length;
329
- if (head.length === 1 ? inList && hits === 1 : hits >= Math.max(2, Math.ceil(head.length * 0.6)))
330
- return index;
420
+ function sizesOf(words, text) {
421
+ const sizes = new Map();
422
+ for (const word of words) {
423
+ const [, count, unit] = /^(\d+)(\p{L}+)$/u.exec(word) ?? [];
424
+ if (count && unit)
425
+ sizes.set(unit, (sizes.get(unit) ?? new Set()).add(count));
331
426
  }
332
- return null;
427
+ for (const [, count] of text.matchAll(INCHES))
428
+ sizes.set('inch', (sizes.get('inch') ?? new Set()).add(count));
429
+ return sizes;
430
+ }
431
+ function readCards(shown) {
432
+ const named = shown
433
+ .map((card) => (typeof card === 'string' ? { name: card, price: null } : { name: String(card?.name ?? ''), price: card?.price ?? null }))
434
+ .map((card) => ({ name: card.name, words: wordsOf(card.name), price: card.price }))
435
+ .filter((card) => card.words.length > 0);
436
+ const holders = new Map();
437
+ for (const card of named)
438
+ for (const word of new Set(card.words))
439
+ holders.set(word, (holders.get(word) ?? 0) + 1);
440
+ const telling = (word) => word.length >= 3 && !/\d/.test(word) && !FILLER.has(word);
441
+ return named.map(({ name, words, price }) => {
442
+ const brand = words.find((word) => /\p{L}/u.test(word) && word.length >= 2 && !FILLER.has(word)) ?? null;
443
+ const amount = typeof price === 'string' ? /(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/.exec(price) : null;
444
+ return {
445
+ words,
446
+ head: words.slice(0, 6),
447
+ brand,
448
+ marks: new Set([...(brand ? [brand] : []), ...words.filter((word) => telling(word) && (holders.get(word) ?? 0) <= Math.max(1, named.length / 2))]),
449
+ price: amount ? hundredths(amount[1], amount[2]) : null,
450
+ sizes: sizesOf(words, name),
451
+ };
452
+ });
453
+ }
454
+ /**
455
+ * The cards a text names, by their indexes; empty when it names none. A text names a card in one of three ways:
456
+ *
457
+ * - By the first six words of the card's name: at least two of them, and most of them, in the text. A name of one word
458
+ * never counts in prose, where a generic word such as `soap` would. A size or a count among those words has to be in
459
+ * the text too, so the 3ft cable's line never reads as the 10ft card.
460
+ * - By the card's exact price with its brand or another word that tells it apart: `Planet Oat Extra Creamy ($22.89)`
461
+ * names the card `Planet Oat Extra Creamy Oatmilk, 32 Fl Oz (Pack of 6)` at $22.89. A price alone, as on a line of
462
+ * sizes, names nothing.
463
+ * - By its brand and two more of its first six words, when the text gives no price or gives the card's.
464
+ *
465
+ * The last two take a short name a reply writes for a long title, and neither counts when the text gives a size the card
466
+ * has in another number, such as 3ft for its 10ft. A text that gives prices, none of them the card's, names it only by
467
+ * the first six words, so a line for a product no card shows keeps its place beside the card of its brand.
468
+ * 2026-10-27, "Oat milk, shelf stable, a 6 pack": "Planet Oat Extra Creamy ($22.89)" shares too few of its title's first
469
+ * six words to match by them alone.
470
+ */
471
+ function cardsNamed(text, cards, inList) {
472
+ const words = wordsOf(text);
473
+ // Each word with how often the text says it, so a name of one word twice over, `Miu Miu`, counts twice.
474
+ const said = new Map();
475
+ for (const word of words)
476
+ said.set(word, (said.get(word) ?? 0) + 1);
477
+ const amounts = amountsIn(text);
478
+ const sizes = sizesOf(words, text);
479
+ const found = [...cards.entries()].flatMap(([index, card]) => {
480
+ const { head } = card;
481
+ const shared = head.filter((word) => said.has(word));
482
+ const hit = new Set(shared);
483
+ if (head.every((word) => !/\d/.test(word) || said.has(word))) {
484
+ if (head.length === 1 ? inList && shared.length === 1 : shared.length >= Math.max(2, Math.ceil(head.length * 0.6)))
485
+ return [{ index, hit }];
486
+ }
487
+ const otherSize = [...card.sizes].some(([unit, counts]) => sizes.has(unit) && ![...counts].some((count) => sizes.get(unit).has(count)));
488
+ if (otherSize)
489
+ return [];
490
+ const ownPrice = card.price !== null && amounts.has(card.price);
491
+ if (ownPrice && [...card.marks].some((word) => said.has(word)))
492
+ return [{ index, hit }];
493
+ const otherPrice = amounts.size > 0 && !ownPrice;
494
+ const more = new Set(head.filter((word) => word !== card.brand && word.length >= 2 && !FILLER.has(word) && said.has(word)));
495
+ return !otherPrice && card.brand !== null && said.has(card.brand) && more.size >= 2 ? [{ index, hit }] : [];
496
+ });
497
+ // Cards whose names share their words read as several products where the text names one, as three Kirkland Signature
498
+ // Organic milks do for `Kirkland Signature Organic Almond Milk`: a card the text names in nothing but another card's
499
+ // words, each said once, is that card's mention, and counts for no product of its own. A text that says those words
500
+ // again names both products, as `Diamond Walnut Pieces ($5.99) and Diamond Walnut Pieces Halves ($7.99)` does.
501
+ const saidOnce = (hit) => [...hit].every((word) => said.get(word) === 1);
502
+ return found
503
+ .filter(({ hit }) => hit.size === 0 || !saidOnce(hit) || !found.some((other) => other.hit.size > hit.size && [...hit].every((word) => other.hit.has(word))))
504
+ .map(({ index }) => index);
333
505
  }
334
506
  /**
335
507
  * Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
336
508
  * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
337
- * points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
338
- * words, each further sentence that names another card. What is left is the reply around the list, such as `found
339
- * these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
509
+ * points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
510
+ * are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
511
+ * further sentence that names another card. What is left is the reply around the list, such as `found these at
512
+ * amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
513
+ *
514
+ * Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
515
+ * a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
340
516
  *
341
517
  * ```ts
342
518
  * withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
@@ -344,18 +520,18 @@ function cardNamed(text, cards, inList) {
344
520
  * ```
345
521
  */
346
522
  export function withoutCardList(reply, cardNames) {
347
- const cards = cardNames.map(wordsOf).filter((name) => name.length > 0);
523
+ const cards = readCards(cardNames);
348
524
  if (cards.length === 0)
349
525
  return reply.trim();
350
526
  // A text that opens with a card's name and gives its price is a list line, as a reply writes one without a bullet.
351
527
  const opensCard = (text) => {
352
528
  const words = wordsOf(text);
353
- return PRICE.test(text) && cards.some((name) => name.length >= 2 && name.slice(0, 3).every((word, i) => words[i] === word));
529
+ return PRICE.test(text) && cards.some((card) => card.words.length >= 2 && card.words.slice(0, 3).every((word, i) => words[i] === word));
354
530
  };
355
531
  const lines = reply.split('\n');
356
532
  const kind = lines.map((line) => {
357
533
  if (LIST_ITEM.test(line))
358
- return cardNamed(line.replace(LIST_ITEM, ''), cards, true) !== null ? 'item' : 'kept';
534
+ return cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept';
359
535
  return opensCard(line) ? 'named' : 'kept';
360
536
  });
361
537
  const dropped = kind.map((of) => of !== 'kept');
@@ -382,21 +558,26 @@ export function withoutCardList(reply, cardNames) {
382
558
  // `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
383
559
  const list = listAfter(i);
384
560
  if (text.trim().endsWith(':') && list.length > 0 && list.every(Boolean))
385
- text = text.trim().replace(/(?:^|(?<=[.!?])\s+)[^.!?]*:$/, '');
386
- const sentences = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
561
+ text = text.trim().slice(0, lastSentenceStart(text.trim())).trimEnd();
562
+ const sentences = text.split(SENTENCE_END).filter((sentence) => {
387
563
  const place = LIST_NUMBER.exec(sentence);
388
564
  if (place && numbers.has(Number(place[1] ?? place[2])))
389
565
  return false;
390
- const card = cardNamed(sentence, cards, false);
566
+ const found = cardsNamed(sentence, cards, false);
391
567
  // A list line written without a bullet keeps the sentences its card does not say, such as the question after it.
392
568
  if (kind[i] === 'named')
393
- return card === null && !opensCard(sentence);
394
- if (card === null || named.has(card))
569
+ return found.length === 0 && !opensCard(sentence);
570
+ if (found.length === 0)
395
571
  return true;
572
+ // `other 6-packs are ...` and `there's also ...` recommend nothing: they are the list again, in a sentence.
573
+ if (MORE_OF_LIST.test(sentence))
574
+ return false;
575
+ const fresh = found.filter((card) => !named.has(card));
396
576
  // A reply recommends one or two products in words; a sentence that names a third is the list again.
397
- if (named.size >= 2)
577
+ if (named.size + fresh.length > 2)
398
578
  return false;
399
- named.add(card);
579
+ for (const card of fresh)
580
+ named.add(card);
400
581
  return true;
401
582
  });
402
583
  if (kind[i] === 'named' && sentences.length === 0)
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Where a shopper's order is, read from the messages Agentcard sends about it, for an app that draws
3
+ * an order tracker instead of a line of text. `readShopperMessage` puts it on `message.order`;
4
+ * `updateOrder` folds the next message about the same order into the one a tracker shows, and
5
+ * `arrivalText` writes the arrival line. No import reaches outside the package, so the browser entry
6
+ * carries this file too.
7
+ */
8
+ /** The steps a tracker draws, left to right. An order that ends early reads `canceled` or `failed`. */
9
+ export declare const ORDER_STEPS: readonly ['placed', 'confirmed', 'preparing', 'on_the_way', 'delivered'];
10
+ /** One step a tracker draws. */
11
+ export type OrderPathStep = (typeof ORDER_STEPS)[number];
12
+ /** Where an order is: one of `ORDER_STEPS`, or `canceled` or `failed`, which end it before `delivered`. */
13
+ export type OrderStep = OrderPathStep | 'canceled' | 'failed';
14
+ /** One step of the tracker: the order went past it (`done`), is at it (`current`), or has not reached it (`upcoming`). */
15
+ export interface OrderStepState {
16
+ step: OrderPathStep;
17
+ /** The step's name as the tracker writes it, such as "Preparing". */
18
+ label: string;
19
+ state: 'done' | 'current' | 'upcoming';
20
+ }
21
+ /**
22
+ * An order as a tracker shows it. Every message about one order carries the same `orderId`, so an app
23
+ * keeps one tracker per `orderId` and passes each new message's order through `updateOrder`. A value
24
+ * Agentcard did not send is null; the package never guesses one.
25
+ */
26
+ export interface ShopperOrder {
27
+ /** The store's id for the order, the same on every message about it. Null on a failure that placed no order. */
28
+ orderId: string | null;
29
+ /** The service the order went through, such as "DoorDash" or "Walmart". Null for an order across several retailers. */
30
+ merchantName: string | null;
31
+ /** The place the order comes from, such as the restaurant on DoorDash, when Agentcard names it. Show `merchantName` without it. */
32
+ storeName: string | null;
33
+ step: OrderStep;
34
+ /** The step's name as the tracker writes it: "Placed", "Accepted", "Preparing", "On the way", "Delivered", "Canceled" or "Failed". */
35
+ label: string;
36
+ /** The steps of `ORDER_STEPS` in order, each `done`, `current` or `upcoming`. After `canceled` or `failed` none is `current`. */
37
+ steps: OrderStepState[];
38
+ /** The store's own line for where the order is, such as "Preparing your order". */
39
+ title: string | null;
40
+ /** The store's own sentence under it, such as "Mendocino Farms is preparing your order." */
41
+ detail: string | null;
42
+ /** The earliest and the latest arrival the store estimates, as ISO 8601 timestamps. */
43
+ arrival: {
44
+ earliest: string | null;
45
+ latest: string | null;
46
+ } | null;
47
+ /** The delivery window the retailer quoted, in its own words, such as "Sat, Sep 6 - Tue, Sep 9". */
48
+ deliveryWindow: string | null;
49
+ /** The total in the currency's minor unit: 1789 is $17.89. */
50
+ totalCents: number | null;
51
+ /** The three-letter currency code, lowercase. */
52
+ currency: string | null;
53
+ /** The retailer's own order number, as on its confirmation, when it differs from `orderId`. */
54
+ retailerOrderId: string | null;
55
+ /** When the order was delivered, as an ISO 8601 timestamp. */
56
+ deliveredAt: string | null;
57
+ /** Why the order was canceled or failed, in the store's or Agentcard's words. */
58
+ reason: string | null;
59
+ /** When the order reached this step, as an ISO 8601 timestamp. */
60
+ updatedAt: string;
61
+ }
62
+ /** The step an `order.updated` stage reads as, or null for a stage the package does not know. */
63
+ export declare function orderStepOf(stage: string | null | undefined): OrderStep | null;
64
+ /** The values a message names about its order, as the package reads them. */
65
+ export interface OrderFields {
66
+ orderId?: string;
67
+ merchantName?: string;
68
+ storeName?: string;
69
+ step: OrderStep;
70
+ /** For a canceled order, the step it was at before. */
71
+ reached?: OrderStep | null;
72
+ title?: string;
73
+ detail?: string;
74
+ arrivalEarliest?: string;
75
+ arrivalLatest?: string;
76
+ deliveryWindow?: string;
77
+ totalCents?: number;
78
+ currency?: string;
79
+ retailerOrderId?: string;
80
+ deliveredAt?: string;
81
+ reason?: string;
82
+ updatedAt: string;
83
+ }
84
+ /** The order a message describes, from the values it names. */
85
+ export declare function orderFrom(fields: OrderFields): ShopperOrder;
86
+ /**
87
+ * The order a tracker shows once `next` arrives, for two messages about the same order. The order
88
+ * never moves back a step: Agentcard delivers each message at least once and in no set order, so a
89
+ * late `order.confirmed` after the driver picked the order up leaves the tracker at `on_the_way`. At
90
+ * the same step, the message with the later `updatedAt` wins. A value the shown message lacks, such
91
+ * as the total `order_placed` named or the restaurant `order.updated` named, comes from the other,
92
+ * and so does the store's line when both are at the same step. An arrival estimate holds until the
93
+ * order is delivered, canceled or failed.
94
+ *
95
+ * ```ts
96
+ * trackers.set(order.orderId, updateOrder(trackers.get(order.orderId), order));
97
+ * ```
98
+ */
99
+ export declare function updateOrder(previous: ShopperOrder | null | undefined, next: ShopperOrder): ShopperOrder;
100
+ export interface ArrivalTextOptions {
101
+ /** The shopper's IANA time zone, such as `America/Los_Angeles`. Default: the runtime's own. */
102
+ timeZone?: string;
103
+ /** The locale the times are written in. Default: `en-US`. */
104
+ locale?: string;
105
+ /** Unix milliseconds that count as now, to tell a time today from one on another day. Default: the system clock. */
106
+ now?: number;
107
+ }
108
+ /**
109
+ * The line a tracker writes under the steps about when the order arrives, in the shopper's time zone,
110
+ * or null when Agentcard sent no time for it:
111
+ *
112
+ * - `Arriving 12:04 – 12:22 PM` from the store's estimate, with the weekday when it is not today;
113
+ * - `Arriving by 12:22 PM` when the estimate names only its latest time;
114
+ * - `Expected Sat, Sep 6 - Tue, Sep 9` from a retailer's delivery window;
115
+ * - `Delivered at 12:20 PM` once delivered.
116
+ *
117
+ * A canceled or failed order has no arrival line.
118
+ */
119
+ export declare function arrivalText(order: Pick<ShopperOrder, 'step' | 'arrival' | 'deliveryWindow' | 'deliveredAt'>, options?: ArrivalTextOptions): string | null;