@agent-cards/commerce-sdk 0.18.5 → 0.18.7

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.
@@ -164,7 +164,12 @@ export function productCardKey(product) {
164
164
  return cardKey(product);
165
165
  return product.flight || product.availability ? pickId(product) : null;
166
166
  }
167
- function cartView(cart) {
167
+ /** True when `store`, the domain an answer's `cart_building` names, is the store `cart` is at. */
168
+ function buildingAt(cart, store) {
169
+ const building = store?.trim().toLowerCase();
170
+ return !!building && [cart.merchant, cart.store?.id].some((name) => typeof name === 'string' && name.toLowerCase() === building);
171
+ }
172
+ function cartView(cart, building) {
168
173
  return {
169
174
  store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
170
175
  items: cart.items.map((line) => ({
@@ -179,10 +184,14 @@ function cartView(cart) {
179
184
  line_total: money(line.lineTotalCents ?? (typeof line.priceCents === 'number' ? line.priceCents * line.qty : null), cart.currency),
180
185
  options: (line.options ?? []).map(stripLinks).filter((o) => o !== ''),
181
186
  variant: line.variant ? stripLinks(line.variant) || null : null,
187
+ ...(line.fulfilled_by ? { fulfilled_by: stripLinks(line.fulfilled_by.line) } : {}),
182
188
  })),
183
189
  fees: cart.serviceFeesCents !== null && cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
184
190
  fees_status: cart.serviceFeesStatus,
185
191
  fee_lines: (cart.merchantFeeLines ?? []).map((line) => ({ label: stripLinks(line.label), amount: money(line.amountCents, cart.currency) })),
192
+ ...(cart.discounts?.length ? { discounts: cart.discounts.map((d) => ({ label: stripLinks(d.label), amount: money(d.amountCents, cart.currency), amount_cents: d.amountCents, kind: d.kind, code: d.code })) } : {}),
193
+ ...(cart.pricing === true || buildingAt(cart, building) ? { pricing: true } : {}),
194
+ ...(cart.hold ? { hold: { amount: money(cart.hold.amountCents, cart.currency), amount_cents: cart.hold.amountCents, reason: cart.hold.reason } } : {}),
186
195
  tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
187
196
  total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
188
197
  total_is_estimate: cart.totalIsEstimate === true,
@@ -196,12 +205,42 @@ function cartView(cart) {
196
205
  /** What an estimated total leaves out: tax, and shipping or delivery, less what the store's own fee lines already carry
197
206
  * (a 1-800-Flowers estimate holds its Florist Delivery and Sales Tax). Null when the lines carry both. */
198
207
  function leftOut(lines) {
208
+ return leftOutWords(leftOutParts(lines), 1);
209
+ }
210
+ function leftOutParts(lines) {
199
211
  const parts = [];
200
212
  if (!lines.some((l) => /\btax\b/i.test(l.label)))
201
213
  parts.push('tax');
202
214
  if (!lines.some((l) => /\b(?:deliver(?:y|ies)?|shipping)\b/i.test(l.label)))
203
215
  parts.push('shipping or delivery');
204
- return parts.length ? `${parts.join(' and ')}, which the store adds at checkout` : null;
216
+ return parts;
217
+ }
218
+ function leftOutWords(parts, stores) {
219
+ return parts.length ? `${parts.join(' and ')}, which the ${stores === 1 ? 'store adds' : 'stores add'} at checkout` : null;
220
+ }
221
+ /**
222
+ * The carts' grand totals, one per currency, summed from each cart's `totalCents`, which already holds its tip, fees and
223
+ * tax: a display string is never parsed. Carts in two currencies are never added together.
224
+ */
225
+ export function grandTotals(carts) {
226
+ const groups = new Map();
227
+ for (const cart of carts) {
228
+ const code = typeof cart.currency === 'string' && cart.currency.trim() !== '' ? cart.currency.trim().toUpperCase() : null;
229
+ groups.set(code, [...(groups.get(code) ?? []), cart]);
230
+ }
231
+ return [...groups].map(([currency, group]) => {
232
+ const cents = group.reduce((sum, cart) => sum + cart.totalCents, 0);
233
+ const estimates = group.filter((cart) => cart.totalIsEstimate === true);
234
+ const parts = new Set(estimates.flatMap((cart) => leftOutParts(cart.merchantFeeLines ?? [])));
235
+ return {
236
+ amount: money(cents, currency),
237
+ amount_cents: cents,
238
+ currency,
239
+ is_estimate: estimates.length > 0,
240
+ leaves_out: leftOutWords(['tax', 'shipping or delivery'].filter((part) => parts.has(part)), estimates.length),
241
+ stores: group.length,
242
+ };
243
+ });
205
244
  }
206
245
  /** How long until `iso`, from `now`, as the shopper reads it: `about 24 hours`, `about 15 minutes`, `no longer`, or null. */
207
246
  export function expiresIn(iso, now = Date.now()) {
@@ -244,8 +283,8 @@ export function forModel(result) {
244
283
  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)})` : ''}`) })),
245
284
  } } : {}),
246
285
  reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
247
- cart: result.cart ? cartView(result.cart) : null,
248
- carts: (result.carts ?? []).map(cartView),
286
+ cart: result.cart ? cartView(result.cart, result.cart_building?.store) : null,
287
+ carts: (result.carts ?? []).map((cart) => cartView(cart, result.cart_building?.store)),
249
288
  products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
250
289
  approval_expires_at: typeof result.approval_expires_at === 'string' ? stripLinks(result.approval_expires_at) || null : null,
251
290
  link_expires_in: expiresIn(linkExpiry(result)),
@@ -267,6 +306,7 @@ export function forModel(result) {
267
306
  store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
268
307
  ? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
269
308
  : null,
309
+ grand_totals: grandTotals(result.carts ?? []),
270
310
  ...(yesCarriedView(result) ? { yes_carried: yesCarriedView(result) } : {}),
271
311
  };
272
312
  }
@@ -393,6 +433,17 @@ const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
393
433
  * `there's also ...`, `you could also ...`. One that names a card is the list, whatever the reply recommended before it.
394
434
  */
395
435
  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;
436
+ /**
437
+ * A sentence whose subject is the list's items, named by nothing but a pointer back at them and followed by its verb:
438
+ * `both are ...`, `these ship ...`, `the two cost ...`, `all three come ...`, `they're ...`. A pointer that leads a
439
+ * noun, `Both Visa and Mastercard are accepted.`, `Both taste and price matter.` or `Either way, ...`, points at no item and never
440
+ * matches: a word read as the verb but joined to another by `and` or `or` is a noun.
441
+ */
442
+ const POINTS_BACK = /^[\s*_"'(]*(?:(?:and|but|so|plus)\s+)?(?:both|these|those|either|neither|they|the (?:two|three|four|five)|all (?:two|three|four|five|of (?:them|these|those))|each of (?:them|these|those))(?:\s+of\s+(?:them|these|those))?(?:'re|’re|\s+(?:(?:also|all|only|each|usually|both)\s+)?(?:are|is|were|was|have|has|had|come|comes|ship|ships|cost|costs|run|runs|sell|sells|look|looks|work|works|seem|seems|can|could|will|would|should|might|do|does|offer|offers|include|includes|take|takes|use|uses|weigh|weighs|arrive|arrives|carry|carries|fit|fits|need|needs|sit|sits|taste|tastes|smell|smells|feel|feels|make|makes|go|goes|pair|pairs|brew|brews|roast|roasts|stay|stays|last|lasts|hold|holds|don['’]t|doesn['’]t|didn['’]t|aren['’]t|isn['’]t|won['’]t|can['’]t|wouldn['’]t)\b(?!\s+(?:and|or)\b))/i;
443
+ /** How far a line is set in, a tab counting as four spaces, so a nested list line reads as deeper than its item. */
444
+ function indentOf(line) {
445
+ return (/^[ \t]*/.exec(line)?.[0] ?? '').replace(/\t/g, ' ').length;
446
+ }
396
447
  /** An amount a text gives after a currency sign, such as `$22.89` or `¥1,331`. */
397
448
  const AMOUNT = /[$£€¥]\s?(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/g;
398
449
  /** Words that tell no product from another: a card's brand or its distinctive words are never one of these. */
@@ -629,9 +680,11 @@ function inlineList(line, cards) {
629
680
  * points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
630
681
  * are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
631
682
  * further sentence that names another card. A list written on one line goes the same way, item by item (inlineList),
632
- * and so does a list line written without a bullet, whole, up to the question on it. What is left is the reply around
633
- * the list, such as `found these at amazon.` and `which one do you want?`. A reply whose cards went out names none of
634
- * them twice over. A list that keeps an item for a product no card shows keeps its lead-in, and the two read as one
683
+ * and so does a list line written without a bullet, whole, up to the question on it. The lines set in under a list
684
+ * line that goes, its details such as ` - ships from London`, go with it, and once the list went, a sentence that only
685
+ * points back at it, `both are priced in foreign currency`, goes when the cards show another number than it counts.
686
+ * What is left is the reply around the list, such as `found these at amazon.` and `which one do you want?`. A reply
687
+ * whose cards went out names none of them twice over. A list that keeps an item for a product no card shows keeps its lead-in, and the two read as one
635
688
  * message (toMessages), so the shopper never reads `two options came in under $20:` on its own.
636
689
  *
637
690
  * Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
@@ -653,10 +706,14 @@ export function withoutCardList(reply, cardNames) {
653
706
  };
654
707
  // The numbers the dropped list gave, so a number of a product's own, `a #2 Phillips head`, keeps its sentence.
655
708
  const numbers = new Set();
709
+ // How many items of a list written inline went: with the list lines that went, the count a pointer such as `these` means.
710
+ let inlineGone = 0;
711
+ // The lines whose inline list went whole: the lines set in under one describe its items, and go with them.
712
+ const inlineEmptied = new Set();
656
713
  // A list a line writes inline goes the way a list of lines does: each item that names a card, and the sentence that
657
714
  // led into the list once every item went. An item for a product no card shows stays, with the lead, and so does a
658
715
  // list that opens the line, `1) a mug; 2) a candle; 3) a soap box`, item by item rather than as one list line.
659
- const unlisted = (line) => {
716
+ const unlisted = (line, index) => {
660
717
  const list = inlineList(line, cards);
661
718
  const gone = list?.items.map((item) => cardsNamed(item.body, cards, true).length > 0) ?? [];
662
719
  if (!list || !gone.some(Boolean))
@@ -665,6 +722,9 @@ export function withoutCardList(reply, cardNames) {
665
722
  if (gone[k] && item.number !== null)
666
723
  numbers.add(item.number);
667
724
  });
725
+ inlineGone += gone.filter(Boolean).length;
726
+ if (gone.every(Boolean))
727
+ inlineEmptied.add(index);
668
728
  const after = line.slice(list.end).trimStart();
669
729
  if (gone.every(Boolean))
670
730
  return `${line.slice(0, list.lead).trimEnd()} ${after}`.trim();
@@ -672,22 +732,37 @@ export function withoutCardList(reply, cardNames) {
672
732
  const close = /[.!?]$/.test(left[left.length - 1]) ? '' : '.';
673
733
  return [line.slice(0, list.items[0].start).trimEnd(), `${left.join('; ')}${close}`, after].filter((part) => part !== '').join(' ');
674
734
  };
675
- const lines = reply.split('\n').map(unlisted);
676
- const kind = lines.map((line) => {
677
- if (LIST_ITEM.test(line))
678
- return cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept';
679
- return opensCard(line) ? 'named' : 'kept';
735
+ const written = reply.split('\n');
736
+ const lines = written.map(unlisted);
737
+ // A line set in deeper than a list line that names a card is that item's detail, such as ` - ships from London`
738
+ // under `- Square Mile Red Brick, £13.50`: it goes with its item (`under`), since what it describes is the card.
739
+ let item = null;
740
+ const kind = lines.map((line, i) => {
741
+ if (inlineEmptied.has(i)) {
742
+ // A list nested under an item already dropped keeps that item's depth, so the lines after it still go with their item.
743
+ const at = indentOf(written[i]);
744
+ if (item === null || at <= item)
745
+ item = at;
746
+ return 'kept';
747
+ }
748
+ if (line.trim() === '')
749
+ return 'kept';
750
+ if (item !== null && indentOf(line) > item)
751
+ return 'under';
752
+ const of = LIST_ITEM.test(line) ? (cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept') : opensCard(line) ? 'named' : 'kept';
753
+ item = of === 'kept' ? null : indentOf(line);
754
+ return of;
680
755
  });
681
756
  const dropped = kind.map((of) => of !== 'kept');
682
757
  lines.forEach((line, i) => {
683
- const number = dropped[i] ? Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1]) : NaN;
758
+ const number = dropped[i] && kind[i] !== 'under' ? Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1]) : NaN;
684
759
  if (Number.isInteger(number))
685
760
  numbers.add(number);
686
761
  });
687
762
  // The list a line leads into: the lines from the next one with words on it to the first that is not a list line, so a
688
763
  // question on the line after the last item, `Which one?`, is no part of the list. A blank line between two list lines
689
764
  // is no end of it, as toMessages reads one, so a list that keeps an item past the blank keeps its lead.
690
- const listLine = (j) => LIST_ITEM.test(lines[j]) || kind[j] === 'named';
765
+ const listLine = (j) => LIST_ITEM.test(lines[j]) || kind[j] === 'named' || kind[j] === 'under';
691
766
  const listAfter = (line) => {
692
767
  const start = lines.findIndex((after, j) => j > line && after.trim() !== '');
693
768
  if (start === -1)
@@ -709,8 +784,20 @@ export function withoutCardList(reply, cardNames) {
709
784
  };
710
785
  const named = new Set();
711
786
  const kept = [];
787
+ // Once a list went, a sentence that only points back at its items, `both are priced in foreign currency` or `these ship
788
+ // free`, reads against the cards. It stays when it counts what the cards show, `both` beside two cards, and goes when the
789
+ // cards show another number, as eight cards beside `both` do: the shopper cannot tell which two it means.
790
+ const listed = kind.filter((of) => of === 'item' || of === 'named').length + inlineGone;
791
+ const pointsElsewhere = (sentence) => {
792
+ const pointer = POINTS_BACK.exec(sentence);
793
+ if (!pointer || listed === 0)
794
+ return false;
795
+ const counted = /\b(both|either|neither|two|three|four|five)\b/i.exec(pointer[0])?.[1]?.toLowerCase();
796
+ const count = counted ? { both: 2, either: 2, neither: 2, two: 2, three: 3, four: 4, five: 5 }[counted] : listed;
797
+ return count !== cards.length;
798
+ };
712
799
  lines.forEach((line, i) => {
713
- if (kind[i] === 'item')
800
+ if (kind[i] === 'item' || kind[i] === 'under')
714
801
  return;
715
802
  let text = line;
716
803
  // The line that leads into a list whose every line went loses the sentence that leads in: `Two I'd suggest:` or
@@ -727,6 +814,8 @@ export function withoutCardList(reply, cardNames) {
727
814
  if (place && numbers.has(Number(place[1] ?? place[2])))
728
815
  return [];
729
816
  const found = cardsNamed(sentence, cards, false);
817
+ if (found.length === 0 && !ASKS.test(sentence) && !/:\s*$/.test(sentence) && pointsElsewhere(sentence))
818
+ return [];
730
819
  if (kind[i] === 'named') {
731
820
  const card = found.length > 0 || opensCard(sentence);
732
821
  asked ||= ASKS.test(sentence);
package/dist/plan.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A checkout plan on a purchase answer: the items a shopper chose to book together, booked in the order they depend on each
3
+ * other on one approval, such as a trip's flight first, then its hotel and car. The same object rides `plan.updated`, so a
4
+ * tracker redraws from either. Null on an answer with no plan, and on an older API.
5
+ */
6
+ export type BuyPlanStatus = 'preparing' | 'running' | 'done' | 'partly_done' | 'stopped';
7
+ export type BuyPlanItemState = 'draft' | 'quoted' | 'approved' | 'booking' | 'confirmed' | 'pending' | 'needs_input' | 'failed' | 'skipped' | 'cancelled';
8
+ export interface BuyPlanItem {
9
+ /** The item's key in its plan: `flight`, `hotel`, `car`. */
10
+ key: string;
11
+ kind: string;
12
+ state: BuyPlanItemState;
13
+ name: string;
14
+ /** What the shopper pays for it: the confirmed price once booked, the quote before. */
15
+ price_cents: number | null;
16
+ /** A changed price waiting for the shopper's answer, or null. */
17
+ asked_cents: number | null;
18
+ /** The supplier's confirmation number once booked. */
19
+ confirmation: string | null;
20
+ /** The keys of the items it books after. */
21
+ depends_on: string[];
22
+ /** Why it waits or did not book, such as `price_changed` or `approval_required`, or null. */
23
+ reason: string | null;
24
+ }
25
+ export interface BuyPlan {
26
+ id: string;
27
+ status: BuyPlanStatus;
28
+ /** Moves on every change: keep the highest one seen. */
29
+ version: number;
30
+ currency: string;
31
+ total_cents: number;
32
+ charged_cents: number;
33
+ /** When the items waiting on a paused one are let go, or null. */
34
+ hold_until: string | null;
35
+ items: BuyPlanItem[];
36
+ }
37
+ /**
38
+ * The plan an answer carries, or null when it carries none or one this version cannot read: a status or an item state this
39
+ * version does not know, a missing total, or an item it cannot read. A tracker never shows a guessed state or a zero total.
40
+ */
41
+ export declare function planOf(value: unknown): BuyPlan | null;
package/dist/plan.js ADDED
@@ -0,0 +1,34 @@
1
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
3
+ const text = (v) => (typeof v === 'string' ? v : null);
4
+ const STATUSES = new Set(['preparing', 'running', 'done', 'partly_done', 'stopped']);
5
+ const STATES = new Set(['draft', 'quoted', 'approved', 'booking', 'confirmed', 'pending', 'needs_input', 'failed', 'skipped', 'cancelled']);
6
+ function itemOf(i) {
7
+ if (!isRecord(i) || !text(i.key) || !text(i.state) || !STATES.has(i.state))
8
+ return null;
9
+ return {
10
+ key: i.key, kind: text(i.kind) ?? '', state: i.state, name: text(i.name) ?? '',
11
+ price_cents: num(i.price_cents), asked_cents: num(i.asked_cents), confirmation: text(i.confirmation),
12
+ depends_on: Array.isArray(i.depends_on) ? i.depends_on.filter((d) => typeof d === 'string') : [], reason: text(i.reason),
13
+ };
14
+ }
15
+ /**
16
+ * The plan an answer carries, or null when it carries none or one this version cannot read: a status or an item state this
17
+ * version does not know, a missing total, or an item it cannot read. A tracker never shows a guessed state or a zero total.
18
+ */
19
+ export function planOf(value) {
20
+ if (!isRecord(value) || !text(value.id) || !Array.isArray(value.items) || !STATUSES.has(text(value.status) ?? ''))
21
+ return null;
22
+ const total = num(value.total_cents);
23
+ const charged = num(value.charged_cents);
24
+ const version = num(value.version);
25
+ if (total === null || charged === null || version === null)
26
+ return null;
27
+ const items = value.items.map(itemOf);
28
+ if (items.some((i) => i === null))
29
+ return null;
30
+ return {
31
+ id: value.id, status: value.status, version, currency: text(value.currency) ?? 'USD',
32
+ total_cents: total, charged_cents: charged, hold_until: text(value.hold_until), items: items,
33
+ };
34
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Where each store a turn searches stands: the `merchant` events of a streamed turn, one each time a store's status changes, and
3
+ * the `search` block of the answer, every store at once.
4
+ */
5
+ /**
6
+ * Where one store stands in a turn's search.
7
+ *
8
+ * - `searching`: Agentcard is still searching the store.
9
+ * - `confirmed`: the store's own site answered with products, their price and stock read live in this search.
10
+ * - `recently_confirmed`: products Agentcard already knew, their price and stock read live at the store in the last hour.
11
+ * - `confirmation_unavailable`: products found for the store, none of them read live there in this search.
12
+ * - `no_results`: the store answered, with nothing for the ask.
13
+ * - `timed_out`: the search answered without the store, which was still running.
14
+ * - `failed`: the store's search failed.
15
+ */
16
+ export type BuyMerchantSearchStatus = 'searching' | 'confirmed' | 'recently_confirmed' | 'no_results' | 'timed_out' | 'confirmation_unavailable' | 'failed';
17
+ /** One store in the answer's `search` block, as Agentcard sends it. */
18
+ export interface BuyMerchantStatus {
19
+ /** The store's id: a store Agentcard knows by name, such as `walmart`, or the store's domain, such as `livlyclothing.com`. */
20
+ merchant: string;
21
+ /** The store's name, to show the shopper: `Walmart`. */
22
+ merchant_name: string;
23
+ status: BuyMerchantSearchStatus;
24
+ /** How many products the search found at the store. */
25
+ results: number;
26
+ /** How many of them had their price and stock read live (`confirmed`) or in the last hour (`recently_confirmed`). */
27
+ confirmed: number;
28
+ /** Milliseconds from the start of the store's search to its first product; null when it found none. */
29
+ first_result_ms: number | null;
30
+ /** Milliseconds the store's search took, or has taken so far while it reads `searching`. */
31
+ ms: number;
32
+ }
33
+ /** Every store the turn searched, as the answer carries them. */
34
+ export interface BuySearchStatus {
35
+ /** The id of this turn's search. A later turn that searches again has a new one. */
36
+ search_id: string;
37
+ /** True once no store is still being searched. False when the answer left while one still ran. */
38
+ final: boolean;
39
+ /** Milliseconds from the first store's search to the last store's answer. */
40
+ elapsed_ms: number;
41
+ /** Each store, in the order its search started. */
42
+ merchants: BuyMerchantStatus[];
43
+ /** Agentcard's own product index, when the turn read it: how many products it answered and in how many milliseconds. */
44
+ index: {
45
+ results: number;
46
+ ms: number;
47
+ } | null;
48
+ }
49
+ /** One store's status the moment it changes, while the turn runs. */
50
+ export interface BuyMerchantStatusUpdate {
51
+ conversationId: string;
52
+ searchId: string;
53
+ merchant: string;
54
+ merchantName: string;
55
+ status: BuyMerchantSearchStatus;
56
+ results: number;
57
+ confirmed: number;
58
+ firstResultMs: number | null;
59
+ ms: number;
60
+ }
61
+ /** The answer's `search` block, or null when the answer carries none. */
62
+ export declare function searchOf(v: unknown): BuySearchStatus | null;
63
+ /** A `merchant` event's update, or null for one with no conversation, no search or a store this version cannot read. */
64
+ export declare function merchantStatusOf(data: Record<string, unknown>): BuyMerchantStatusUpdate | null;
65
+ /**
66
+ * The search block with only the store you name, matched by its id, its domain or its name, so a caller that searched one store
67
+ * reads that store's status: `searchAt(turn.search, 'walmart.com')`. Null when the block is null; an empty `merchants` when the
68
+ * turn did not search that store.
69
+ */
70
+ export declare function searchAt(search: BuySearchStatus | null, store: string): BuySearchStatus | null;
71
+ /** Whether a `merchant` event is about the store you name, as `searchAt` matches it. */
72
+ export declare function isMerchantStatusAt(update: BuyMerchantStatusUpdate, store: string): boolean;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Where each store a turn searches stands: the `merchant` events of a streamed turn, one each time a store's status changes, and
3
+ * the `search` block of the answer, every store at once.
4
+ */
5
+ const MERCHANT_STATUSES = new Set(['searching', 'confirmed', 'recently_confirmed', 'no_results', 'timed_out', 'confirmation_unavailable', 'failed']);
6
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
7
+ const text = (v) => (typeof v === 'string' && v !== '' ? v : null);
8
+ const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v) : 0);
9
+ const msOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v) : null);
10
+ /** One store as Agentcard sent it, or null for a line with no store or a status this version does not know. */
11
+ function merchantOf(v) {
12
+ if (!isRecord(v))
13
+ return null;
14
+ const merchant = text(v.merchant);
15
+ if (!merchant || typeof v.status !== 'string' || !MERCHANT_STATUSES.has(v.status))
16
+ return null;
17
+ return {
18
+ merchant,
19
+ merchant_name: text(v.merchant_name) ?? merchant,
20
+ status: v.status,
21
+ results: count(v.results),
22
+ confirmed: count(v.confirmed),
23
+ first_result_ms: msOrNull(v.first_result_ms),
24
+ ms: count(v.ms),
25
+ };
26
+ }
27
+ /** The answer's `search` block, or null when the answer carries none. */
28
+ export function searchOf(v) {
29
+ if (!isRecord(v) || !text(v.search_id) || !Array.isArray(v.merchants))
30
+ return null;
31
+ const index = isRecord(v.index) ? { results: count(v.index.results), ms: count(v.index.ms) } : null;
32
+ return {
33
+ search_id: v.search_id,
34
+ final: v.final !== false,
35
+ elapsed_ms: count(v.elapsed_ms),
36
+ merchants: v.merchants.flatMap((m) => merchantOf(m) ?? []),
37
+ index,
38
+ };
39
+ }
40
+ /** A `merchant` event's update, or null for one with no conversation, no search or a store this version cannot read. */
41
+ export function merchantStatusOf(data) {
42
+ const conversationId = text(data.conversation_id);
43
+ const searchId = text(data.search_id);
44
+ const line = merchantOf(data);
45
+ if (!conversationId || !searchId || !line)
46
+ return null;
47
+ return {
48
+ conversationId, searchId, merchant: line.merchant, merchantName: line.merchant_name, status: line.status,
49
+ results: line.results, confirmed: line.confirmed, firstResultMs: line.first_result_ms, ms: line.ms,
50
+ };
51
+ }
52
+ /** The stores whose domain is not their Agentcard id with `.com` after it, as Agentcard maps them (merchant-status.ts). */
53
+ const KEY_OF_DOMAIN = { 'mcmaster.com': 'mcmastercarr', 'shop.tiktok.com': 'tiktok', 'tiktok.com': 'tiktok' };
54
+ /** A store as you may name it, and as Agentcard names it, folded to one form: `Walmart`, `walmart.com` and `walmart` are one store. */
55
+ function storeKey(store) {
56
+ const bare = store.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '');
57
+ if (KEY_OF_DOMAIN[bare])
58
+ return KEY_OF_DOMAIN[bare];
59
+ // An accent and an ampersand are spelled out as Agentcard's ids spell them: `Pokémon Center`, `Barnes & Noble`.
60
+ const word = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/&/g, 'and').replace(/[^a-z0-9]/g, '');
61
+ return bare.endsWith('.com') ? word(bare.slice(0, -4)) : bare.includes('.') ? bare : word(bare.replace(/^the\s+/, ''));
62
+ }
63
+ /**
64
+ * The search block with only the store you name, matched by its id, its domain or its name, so a caller that searched one store
65
+ * reads that store's status: `searchAt(turn.search, 'walmart.com')`. Null when the block is null; an empty `merchants` when the
66
+ * turn did not search that store.
67
+ */
68
+ export function searchAt(search, store) {
69
+ if (!search)
70
+ return null;
71
+ const want = storeKey(store);
72
+ return { ...search, merchants: search.merchants.filter((m) => storeKey(m.merchant) === want || storeKey(m.merchant_name) === want) };
73
+ }
74
+ /** Whether a `merchant` event is about the store you name, as `searchAt` matches it. */
75
+ export function isMerchantStatusAt(update, store) {
76
+ const want = storeKey(store);
77
+ return storeKey(update.merchant) === want || storeKey(update.merchantName) === want;
78
+ }
@@ -0,0 +1,27 @@
1
+ import type { BuyToolProduct } from './buy-tool.js';
2
+ /**
3
+ * What happens to a store that answers after the reply: `stream` sends its cards after the reply, in the same turn, as
4
+ * the card events every streamed turn carries; `hold` keeps them out of the turn, so the reply is the turn's last word.
5
+ */
6
+ export type BuyLateResults = 'stream' | 'hold';
7
+ /** The store wait of a tool or a turn. */
8
+ export interface BuyStoreWait {
9
+ /** How long, in milliseconds, the reply waits for store searches before Agentcard writes it. */
10
+ storeWaitMs?: number;
11
+ /** What happens to a store that answers after the reply. */
12
+ lateResults?: BuyLateResults;
13
+ }
14
+ /** The request fields a store wait sends, none when it names nothing. */
15
+ export interface BuyStoreWaitFields {
16
+ store_wait_ms?: number;
17
+ late_results?: BuyLateResults;
18
+ }
19
+ /** Checks a store wait passed to `buyTool` (`where` is empty) or to a turn (`where` reads ' to the turn'), and throws a TypeError for one that cannot work. */
20
+ export declare function checkStoreWait(wait: BuyStoreWait, where?: string): void;
21
+ /** The fields a request carries for a turn's store wait: the turn's own, else the tool's. */
22
+ export declare function storeWaitFields(turn: BuyStoreWait, tool: BuyStoreWait): BuyStoreWaitFields;
23
+ /**
24
+ * The answer's products with the cards of a store that answered after the reply added at the end, each once: the answer
25
+ * can list them already, when the turn's last word came after them, and a late card it lists is left as the answer has it.
26
+ */
27
+ export declare function withLateCards(products: readonly BuyToolProduct[], late: readonly BuyToolProduct[]): BuyToolProduct[];
@@ -0,0 +1,41 @@
1
+ /**
2
+ * How long a reply waits for store searches, and what happens to a store that answers after it: the `storeWaitMs` and
3
+ * `lateResults` options of `buyTool` and `buy.turn`, sent on every request as `store_wait_ms` and `late_results`. Left
4
+ * out, nothing is sent and Agentcard keeps its own wait.
5
+ */
6
+ import { pickId, productCardKey } from './model-view.js';
7
+ /** Checks a store wait passed to `buyTool` (`where` is empty) or to a turn (`where` reads ' to the turn'), and throws a TypeError for one that cannot work. */
8
+ export function checkStoreWait(wait, where = '') {
9
+ const ms = wait.storeWaitMs;
10
+ if (ms !== undefined && (typeof ms !== 'number' || !Number.isInteger(ms) || ms < 0)) {
11
+ throw new TypeError(`Pass storeWaitMs${where} as a whole number of milliseconds, 0 or more, or leave it out.`);
12
+ }
13
+ const late = wait.lateResults;
14
+ if (late !== undefined && late !== 'stream' && late !== 'hold') {
15
+ throw new TypeError(`Pass lateResults${where} as 'stream' or 'hold', or leave it out.`);
16
+ }
17
+ }
18
+ /** The fields a request carries for a turn's store wait: the turn's own, else the tool's. */
19
+ export function storeWaitFields(turn, tool) {
20
+ const ms = turn.storeWaitMs ?? tool.storeWaitMs;
21
+ const late = turn.lateResults ?? tool.lateResults;
22
+ return { ...(ms !== undefined ? { store_wait_ms: ms } : {}), ...(late !== undefined ? { late_results: late } : {}) };
23
+ }
24
+ /**
25
+ * The answer's products with the cards of a store that answered after the reply added at the end, each once: the answer
26
+ * can list them already, when the turn's last word came after them, and a late card it lists is left as the answer has it.
27
+ */
28
+ export function withLateCards(products, late) {
29
+ // A product with no link-free id is told apart by its page, so two stores' offers of one title stay two.
30
+ const keyOf = (product) => pickId(product) ?? productCardKey(product) ?? `name:${product.name}`;
31
+ const held = new Set(products.map(keyOf));
32
+ const added = [];
33
+ for (const product of late) {
34
+ const key = keyOf(product);
35
+ if (held.has(key))
36
+ continue;
37
+ held.add(key);
38
+ added.push(product);
39
+ }
40
+ return [...products, ...added];
41
+ }
@@ -13,7 +13,7 @@
13
13
  * an ask goes straight only with a shopping verb (`book a flight to Loreto`), as before.
14
14
  *
15
15
  * `inCapabilities` on: the tool's description says it plans and books trips, flights and hotels, tells the model to call it
16
- * for a trip ask with no dates or airport, and names flights and hotels when the shopper asks what it can do. Off: the
16
+ * for a trip ask with no dates or airport, and leads with a whole trip when the shopper asks what it can do for travel. Off: the
17
17
  * description is the text it was before, word for word.
18
18
  */
19
19
  export declare const TRAVEL: {
package/dist/trip-ask.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * an ask goes straight only with a shopping verb (`book a flight to Loreto`), as before.
14
14
  *
15
15
  * `inCapabilities` on: the tool's description says it plans and books trips, flights and hotels, tells the model to call it
16
- * for a trip ask with no dates or airport, and names flights and hotels when the shopper asks what it can do. Off: the
16
+ * for a trip ask with no dates or airport, and leads with a whole trip when the shopper asks what it can do for travel. Off: the
17
17
  * description is the text it was before, word for word.
18
18
  */
19
19
  export const TRAVEL = { goStraight: true, inCapabilities: true };
@@ -52,9 +52,9 @@ export function withoutTripVerb(text) {
52
52
  return TRAVEL.goStraight && isTripAsk(text) ? text.replace(TRIP_VERB, '') : text;
53
53
  }
54
54
  const OPENING_LINE = 'Buy what this shopper asks for at real stores.';
55
- const TRAVEL_LINES = 'You also plan and book trips: flights and hotels. When the shopper asks to plan a trip, or for flights or a hotel, such as "help me plan a trip to Loreto" or "flights and hotels in loreto", call this tool with their words, even when they name no dates and no place they leave from: Agentcard asks them for what it needs.';
56
- const CAPABILITY_END = 'so name those, and no other store unless a result showed it.';
57
- const CAPABILITY_TRAVEL = 'Name flights and hotels for a trip among the kinds of things you can do.';
55
+ const TRAVEL_LINES = 'You also plan and book trips: flights, hotels and rental cars. When the shopper asks to plan a trip, or for flights or a hotel, such as "help me plan a trip to Loreto" or "flights and hotels in loreto", call this tool with their words, even when they name no dates and no place they leave from: Agentcard asks them for what it needs.';
56
+ const CAPABILITY_END = 'Name no other store unless a result showed it.';
57
+ const CAPABILITY_TRAVEL = 'Name whole trips among the kinds of things you can do. When the shopper asks what you can do for travel, lead with planning a whole trip, the flights, a hotel and a rental car together, then name the parts they can book on their own: flights, hotels and rental cars.';
58
58
  /** The tool's description with travel named, when `inCapabilities` is on (or `on` says so); the description as given when off. */
59
59
  export function withTravel(description, on = TRAVEL.inCapabilities) {
60
60
  if (!on)
@@ -6,7 +6,7 @@
6
6
  // Sandbox credentials place no real order. SHOPPER_ID is your own id for the shopper you play.
7
7
  import readline from 'node:readline';
8
8
  import Anthropic from '@anthropic-ai/sdk';
9
- import { AgentcardClient, buyTool } from '@agent-cards/commerce-sdk';
9
+ import { AgentcardClient, appInstructions, buyTool } from '@agent-cards/commerce-sdk';
10
10
 
11
11
  const agentcard = new AgentcardClient({
12
12
  clientId: process.env.AGENTCARD_CLIENT_ID ?? '',
@@ -33,7 +33,8 @@ for await (const line of terminal) {
33
33
  const runner = anthropic.beta.messages.toolRunner({
34
34
  model: 'claude-opus-5-5',
35
35
  max_tokens: 16000,
36
- system: 'You are a shopping assistant. Buy what the shopper asks for with the buy tool.',
36
+ // Agentcard's recommended instructions: what it can do and how to use the buy tool. A terminal shows plain text.
37
+ system: appInstructions({ markdown: false }),
37
38
  tools: [buy],
38
39
  messages: [...messages, { role: 'user', content: said }],
39
40
  // When a safety classifier declines a request, Anthropic answers it with a fallback model instead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
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
  "homepage": "https://opencommerce.sh",
6
6
  "type": "module",
@@ -27,11 +27,12 @@
27
27
  },
28
28
  "scripts": {
29
29
  "_comment_build": "Zero runtime dependencies: node:crypto is the one import, and the buy tool calls the global fetch. TypeScript is pinned, not installed, as in @agent-cards/sdk. types/node.d.ts declares the two node:crypto functions the source calls, so the build needs no @types package and the published declarations name no Node type; FetchLike names AbortSignal, which every runtime with fetch declares.",
30
- "build": "npx -y -p typescript@5.9.3 tsc",
30
+ "build": "node scripts/app-instructions.mjs && npx -y -p typescript@5.9.3 tsc",
31
31
  "prepublishOnly": "pnpm build",
32
32
  "test": "pnpm build && node --test test/*.test.mjs",
33
33
  "verify:pack": "node scripts/verify-pack.mjs",
34
- "_comment_pack": "verify:pack lists what npm would publish (npm pack --dry-run) and fails on a missing manifest target, README or example, or on a test, source, shim or build-cache file; then it loads the built root and checks the names the README imports. The release workflow runs it after the build."
34
+ "_comment_pack": "verify:pack lists what npm would publish (npm pack --dry-run) and fails on a missing manifest target, README or example, or on a test, source, shim or build-cache file; then it loads the built root and checks the names the README imports. The release workflow runs it after the build.",
35
+ "_comment_instructions": "The build first copies the instructions for an app's own model (appInstructions) from apps/agent-voice/kb/app.md into src/app-instructions.generated.ts, which git ignores, so the published text always matches the voice package at the commit it was built from."
35
36
  },
36
37
  "keywords": [
37
38
  "agentcard",