@agent-cards/commerce-sdk 0.14.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.
@@ -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']);
@@ -59,6 +60,24 @@ export function stripLinks(text) {
59
60
  .replace(/\n{3,}/g, '\n\n')
60
61
  .trim();
61
62
  }
63
+ /**
64
+ * A line that points at a link with a colon, the link after it on the same line or alone on the next: `here's the stool on
65
+ * Wayfair: https://www.wayfair.com/...`, one in angle brackets, or a bare address with a path or query (`vault.agentcard.sh?token=...`,
66
+ * never a bare `www.walmart.com`, which stripLinks keeps as the store's name). Every such
67
+ * link comes out of what the shopper reads (stripLinks), and the colon would then point at nothing. A Markdown link is not one:
68
+ * stripLinks keeps its label, which the shopper reads after the colon. A question mark that ends the sentence is not a query,
69
+ * as HOST_PATH reads it (`Buy from: glossier.com?`), and a page whose path ends in a bracket keeps it (`stool_(oak)`).
70
+ */
71
+ const POINTS_AT_LINK = /^([^\n]*?\S)[ \t]*:[ \t]*(?:\n[ \t]*)?<?(?:https?:\/\/|(?:www\.)?(?:[\p{L}\p{N}-]+\.)+\p{L}{2,}(?:[/#]|\?(?![.,;:!?]*(?:[\s<>"'`)\]]|$))))[^\s<>"'`\]]*>?[ \t]*[.!]?[ \t]*$/gimu;
72
+ /**
73
+ * Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
74
+ * one. A line that pointed at a link ends as a sentence when the answer sent the shopper what it pointed at, a product or a
75
+ * store card or a step's link, and goes when it sent none, so the reply never promises what the shopper does not get. 2026-10-27, chat.opencommerce.sh:
76
+ * "here's the stool on Wayfair:" reached the shopper with nothing after it.
77
+ */
78
+ export function replyWithoutLinks(reply, sent) {
79
+ return stripLinks(String(reply ?? '').replace(POINTS_AT_LINK, (_line, lead) => (sent ? `${lead}.` : '')));
80
+ }
62
81
  /** A currency's minor units as Intl knows them: 2 for USD and CAD, 0 for JPY. */
63
82
  function minorUnits(format) {
64
83
  return format.resolvedOptions().maximumFractionDigits ?? 0;
@@ -135,13 +154,14 @@ export function cardKey(product) {
135
154
  }
136
155
  /**
137
156
  * The key of the card a product gets, or null when it gets none: a product with a page at its store,
138
- * or a flight option, which has no page, with an id a pick takes. A flight's card is how the shopper
139
- * taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
157
+ * or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
158
+ * is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
159
+ * and a place's card is where the shopper reads whether it has room.
140
160
  */
141
161
  export function productCardKey(product) {
142
162
  if (typeof product.url === 'string' && product.url !== '')
143
163
  return cardKey(product);
144
- return product.flight ? pickId(product) : null;
164
+ return product.flight || product.availability ? pickId(product) : null;
145
165
  }
146
166
  function cartView(cart) {
147
167
  return {
@@ -218,7 +238,11 @@ export function forModel(result) {
218
238
  status,
219
239
  next_action: result.next_action ?? null,
220
240
  ...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
221
- reply: stripLinks(result.reply),
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
+ } } : {}),
245
+ reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
222
246
  cart: result.cart ? cartView(result.cart) : null,
223
247
  carts: (result.carts ?? []).map(cartView),
224
248
  products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
@@ -242,8 +266,73 @@ export function forModel(result) {
242
266
  store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
243
267
  ? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
244
268
  : null,
269
+ ...(yesCarriedView(result) ? { yes_carried: yesCarriedView(result) } : {}),
245
270
  };
246
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
+ }
247
336
  /**
248
337
  * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
249
338
  * `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
@@ -252,6 +341,8 @@ export function forModel(result) {
252
341
  * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
253
342
  * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
254
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.
255
346
  *
256
347
  * ```ts
257
348
  * replyAsWritten(result); // 'found two at Amazon. want the first one?'
@@ -261,13 +352,20 @@ export function replyAsWritten(result) {
261
352
  // The shopper already read every message of it through onText: handing it out again would say it twice.
262
353
  if (result.shopper_already_read_reply === true)
263
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;
264
359
  // The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
265
360
  const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
266
361
  const view = forModel(result);
267
362
  const reply = view.reply.trim();
268
363
  if (!agentcards || view.status !== 'needs_input' || reply === '')
269
364
  return null;
270
- 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
+ ];
271
369
  // A reply with nothing left once the cards' list comes out is your model's to word.
272
370
  return withoutCardList(reply, cards) || null;
273
371
  }
@@ -277,6 +375,15 @@ const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
277
375
  const PRICE = /[$£€]\s?\d/;
278
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. */
279
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']);
280
387
  /** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
281
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;
282
389
  /**
@@ -286,6 +393,8 @@ const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|m
286
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']]);
287
394
  /** A size or a count written as one word, such as `24pack` or `10ft`. */
288
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;
289
398
  /** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
290
399
  function wordsOf(text) {
291
400
  return text
@@ -295,29 +404,115 @@ function wordsOf(text) {
295
404
  .filter((word) => word !== '')
296
405
  .map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
297
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
+ }
298
415
  /**
299
- * 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
300
- * text. Null when it names none. A name of one word never counts in prose, where a generic word such as `soap` would.
301
- * 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.
302
419
  */
303
- function cardNamed(text, cards, inList) {
304
- const said = new Set(wordsOf(text));
305
- for (const [index, name] of cards.entries()) {
306
- const head = name.slice(0, 6);
307
- if (!head.every((word) => !/\d/.test(word) || said.has(word)))
308
- continue;
309
- const hits = head.filter((word) => said.has(word)).length;
310
- if (head.length === 1 ? inList && hits === 1 : hits >= Math.max(2, Math.ceil(head.length * 0.6)))
311
- 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));
312
426
  }
313
- 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);
314
505
  }
315
506
  /**
316
507
  * Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
317
508
  * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
318
- * points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
319
- * words, each further sentence that names another card. What is left is the reply around the list, such as `found
320
- * 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).
321
516
  *
322
517
  * ```ts
323
518
  * withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
@@ -325,18 +520,18 @@ function cardNamed(text, cards, inList) {
325
520
  * ```
326
521
  */
327
522
  export function withoutCardList(reply, cardNames) {
328
- const cards = cardNames.map(wordsOf).filter((name) => name.length > 0);
523
+ const cards = readCards(cardNames);
329
524
  if (cards.length === 0)
330
525
  return reply.trim();
331
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.
332
527
  const opensCard = (text) => {
333
528
  const words = wordsOf(text);
334
- 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));
335
530
  };
336
531
  const lines = reply.split('\n');
337
532
  const kind = lines.map((line) => {
338
533
  if (LIST_ITEM.test(line))
339
- return cardNamed(line.replace(LIST_ITEM, ''), cards, true) !== null ? 'item' : 'kept';
534
+ return cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept';
340
535
  return opensCard(line) ? 'named' : 'kept';
341
536
  });
342
537
  const dropped = kind.map((of) => of !== 'kept');
@@ -363,21 +558,26 @@ export function withoutCardList(reply, cardNames) {
363
558
  // `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
364
559
  const list = listAfter(i);
365
560
  if (text.trim().endsWith(':') && list.length > 0 && list.every(Boolean))
366
- text = text.trim().replace(/(?:^|(?<=[.!?])\s+)[^.!?]*:$/, '');
367
- 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) => {
368
563
  const place = LIST_NUMBER.exec(sentence);
369
564
  if (place && numbers.has(Number(place[1] ?? place[2])))
370
565
  return false;
371
- const card = cardNamed(sentence, cards, false);
566
+ const found = cardsNamed(sentence, cards, false);
372
567
  // A list line written without a bullet keeps the sentences its card does not say, such as the question after it.
373
568
  if (kind[i] === 'named')
374
- return card === null && !opensCard(sentence);
375
- if (card === null || named.has(card))
569
+ return found.length === 0 && !opensCard(sentence);
570
+ if (found.length === 0)
376
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));
377
576
  // A reply recommends one or two products in words; a sentence that names a third is the list again.
378
- if (named.size >= 2)
577
+ if (named.size + fresh.length > 2)
379
578
  return false;
380
- named.add(card);
579
+ for (const card of fresh)
580
+ named.add(card);
381
581
  return true;
382
582
  });
383
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;
package/dist/order.js ADDED
@@ -0,0 +1,155 @@
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 const ORDER_STEPS = ['placed', 'confirmed', 'preparing', 'on_the_way', 'delivered'];
10
+ const LABELS = {
11
+ placed: 'Placed',
12
+ confirmed: 'Accepted',
13
+ preparing: 'Preparing',
14
+ on_the_way: 'On the way',
15
+ delivered: 'Delivered',
16
+ canceled: 'Canceled',
17
+ failed: 'Failed',
18
+ };
19
+ /**
20
+ * The step each delivery stage of `order.updated` reads as. The store accepting the order is
21
+ * `confirmed`; a driver being found, waiting at the store or the order being ready is `preparing`;
22
+ * the order picked up or nearby is `on_the_way`.
23
+ */
24
+ const STAGE_STEPS = {
25
+ placed: 'placed',
26
+ confirmed: 'confirmed',
27
+ courier_assigned: 'preparing',
28
+ courier_at_store: 'preparing',
29
+ ready_for_pickup: 'preparing',
30
+ picked_up: 'on_the_way',
31
+ arriving: 'on_the_way',
32
+ delivered: 'delivered',
33
+ canceled: 'canceled',
34
+ };
35
+ /** The step an `order.updated` stage reads as, or null for a stage the package does not know. */
36
+ export function orderStepOf(stage) {
37
+ return (stage && STAGE_STEPS[stage]) || null;
38
+ }
39
+ const RANK = { placed: 0, confirmed: 1, preparing: 2, on_the_way: 3, delivered: 4, canceled: 5, failed: 5 };
40
+ /**
41
+ * The tracker's steps. An order at a step has passed every step before it. A canceled order has
42
+ * passed the step it was canceled at, `reached`, and a failed one passed none.
43
+ */
44
+ function stepsOf(step, reached) {
45
+ const ended = step === 'canceled' || step === 'failed';
46
+ const at = ended ? (reached && reached !== 'canceled' && reached !== 'failed' ? RANK[reached] : step === 'canceled' ? 0 : -1) : RANK[step];
47
+ return ORDER_STEPS.map((s) => ({
48
+ step: s,
49
+ label: LABELS[s],
50
+ state: RANK[s] < at || (ended && RANK[s] === at) ? 'done' : RANK[s] === at ? 'current' : 'upcoming',
51
+ }));
52
+ }
53
+ /** The order a message describes, from the values it names. */
54
+ export function orderFrom(fields) {
55
+ const earliest = fields.arrivalEarliest ?? null;
56
+ const latest = fields.arrivalLatest ?? null;
57
+ return {
58
+ orderId: fields.orderId ?? null,
59
+ merchantName: fields.merchantName ?? null,
60
+ storeName: fields.storeName ?? null,
61
+ step: fields.step,
62
+ label: LABELS[fields.step],
63
+ steps: stepsOf(fields.step, fields.reached ?? null),
64
+ title: fields.title ?? null,
65
+ detail: fields.detail ?? null,
66
+ arrival: earliest || latest ? { earliest, latest } : null,
67
+ deliveryWindow: fields.deliveryWindow ?? null,
68
+ totalCents: fields.totalCents ?? null,
69
+ currency: fields.currency ?? null,
70
+ retailerOrderId: fields.retailerOrderId ?? null,
71
+ deliveredAt: fields.deliveredAt ?? null,
72
+ reason: fields.reason ?? null,
73
+ updatedAt: fields.updatedAt,
74
+ };
75
+ }
76
+ /** Values that stay true of an order whatever step it reaches. */
77
+ const LASTING = ['orderId', 'merchantName', 'storeName', 'totalCents', 'currency', 'retailerOrderId', 'deliveryWindow'];
78
+ /**
79
+ * The order a tracker shows once `next` arrives, for two messages about the same order. The order
80
+ * never moves back a step: Agentcard delivers each message at least once and in no set order, so a
81
+ * late `order.confirmed` after the driver picked the order up leaves the tracker at `on_the_way`. At
82
+ * the same step, the message with the later `updatedAt` wins. A value the shown message lacks, such
83
+ * as the total `order_placed` named or the restaurant `order.updated` named, comes from the other,
84
+ * and so does the store's line when both are at the same step. An arrival estimate holds until the
85
+ * order is delivered, canceled or failed.
86
+ *
87
+ * ```ts
88
+ * trackers.set(order.orderId, updateOrder(trackers.get(order.orderId), order));
89
+ * ```
90
+ */
91
+ export function updateOrder(previous, next) {
92
+ if (!previous)
93
+ return next;
94
+ const diff = RANK[next.step] - RANK[previous.step];
95
+ const newer = diff !== 0 ? diff > 0 : !(Date.parse(next.updatedAt) < Date.parse(previous.updatedAt));
96
+ const [shown, other] = newer ? [next, previous] : [previous, next];
97
+ const merged = { ...shown };
98
+ for (const key of LASTING)
99
+ if (merged[key] === null)
100
+ merged[key] = other[key];
101
+ if (shown.step === other.step) {
102
+ merged.title ??= other.title;
103
+ merged.detail ??= other.detail;
104
+ }
105
+ // An arrival estimate holds until the order arrives, whichever step quoted it.
106
+ if (shown.step !== 'delivered' && shown.step !== 'canceled' && shown.step !== 'failed')
107
+ merged.arrival ??= other.arrival;
108
+ return merged;
109
+ }
110
+ function validDate(value) {
111
+ if (!value)
112
+ return null;
113
+ const date = new Date(value);
114
+ return Number.isNaN(date.getTime()) ? null : date;
115
+ }
116
+ /**
117
+ * The line a tracker writes under the steps about when the order arrives, in the shopper's time zone,
118
+ * or null when Agentcard sent no time for it:
119
+ *
120
+ * - `Arriving 12:04 – 12:22 PM` from the store's estimate, with the weekday when it is not today;
121
+ * - `Arriving by 12:22 PM` when the estimate names only its latest time;
122
+ * - `Expected Sat, Sep 6 - Tue, Sep 9` from a retailer's delivery window;
123
+ * - `Delivered at 12:20 PM` once delivered.
124
+ *
125
+ * A canceled or failed order has no arrival line.
126
+ */
127
+ export function arrivalText(order, options = {}) {
128
+ const locale = options.locale ?? 'en-US';
129
+ const now = new Date(options.now ?? Date.now());
130
+ const time = (date) => {
131
+ const day = (d) => new Intl.DateTimeFormat(locale, { timeZone: options.timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }).format(d);
132
+ const today = day(date) === day(now);
133
+ return new Intl.DateTimeFormat(locale, { timeZone: options.timeZone, hour: 'numeric', minute: '2-digit', ...(today ? {} : { weekday: 'short' }) });
134
+ };
135
+ if (order.step === 'canceled' || order.step === 'failed')
136
+ return null;
137
+ if (order.step === 'delivered') {
138
+ const at = validDate(order.deliveredAt);
139
+ return at ? `Delivered at ${time(at).format(at)}` : 'Delivered';
140
+ }
141
+ const earliest = validDate(order.arrival?.earliest);
142
+ const latest = validDate(order.arrival?.latest);
143
+ if (earliest && latest && earliest.getTime() < latest.getTime()) {
144
+ const format = time(earliest);
145
+ const range = typeof format.formatRange === 'function' ? format.formatRange(earliest, latest) : `${format.format(earliest)} – ${format.format(latest)}`;
146
+ return `Arriving ${range}`;
147
+ }
148
+ if (latest)
149
+ return `Arriving by ${time(latest).format(latest)}`;
150
+ if (earliest)
151
+ return `Arriving around ${time(earliest).format(earliest)}`;
152
+ if (order.deliveryWindow)
153
+ return `Expected ${order.deliveryWindow}`;
154
+ return null;
155
+ }