@agent-cards/commerce-sdk 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -458,6 +458,7 @@ const buy = buyTool({
458
458
  - `onStatus` hears each step the moment Agentcard starts it, as a line to show in place of loading dots: `searching Target and Walmart`, `adding it to your cart`, `working through the store's checkout`. `step` names the step, such as `searching` or `adding_to_cart`, for an app that writes its own words. A call that only writes its reply sends none, so keep your dots until the first line comes.
459
459
  - `onFound` hears how many products the searches found, after the cards it counts: `shown` cards of `found` products, such as "8 of 23 shown", each time the count grows. `more` holds the products past the cards as product links, in the order they were found, for a "show more" that runs no new search. The model never reads them.
460
460
  - `onProgress` still hears the stage, `searching`, `building_cart`, `checking_out` or `working`.
461
+ - `onAvailability` hears whether the places a booking's cards show have room at the time the shopper asked for. A place's card can go out before Agentcard's live search knows: its `availability` reads `checking`, so show it without its choose button. The update names each card by its `productId` with `available` (its open times in `slots`, empty when none were named), `unavailable` (full then) or `unknown` (the search could not say). Change the card in place, and offer it as a choice only when it reads `available`. A call that ends with a card still `checking` sends `unknown` for it. Every other card, a retail product's included, carries no `availability`.
461
462
  - `onProducts` hears the products a search found the moment it answers, before Agentcard writes its reply, with the conversation, when the search ran (`asOf`) and the store's name. With `sendLink`, each product's card goes out then too, a few seconds before the answer, and the answer sends only the cards it adds. Pass `earlyCards: false` to send only the cards the answer names, with the answer: the reply often names only some of a search's products.
462
463
  - `onText` hears each message Agentcard writes, links included, the moment it is written, and after the cards of the search it is about.
463
464
  - `onCart` hears the cart a confirm would place, or null, and every cart on the table, each time a call builds or changes one, before the message that announces it.
@@ -31,7 +31,7 @@
31
31
  * messages and links as one sequence, in the order they happened, and spaces
32
32
  * the messages like someone texting.
33
33
  */
34
- import { AgentcardClient, type BuyCart, type BuyCartBuilding, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type BuyTurnStatus, type BuyStorePick, type BuyTurn, type FetchLike } from './client.js';
34
+ import { AgentcardClient, type BuyCart, type BuyCartBuilding, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type BuyTurnStatus, type BuyAvailability, type BuyStorePick, type BuyTurn, type FetchLike } from './client.js';
35
35
  import { type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolStandardSchema, type OpenAIBuyTool } from './definition.js';
36
36
  import { type DirectCall } from './direct.js';
37
37
  import { type BuyToolModelView } from './model-view.js';
@@ -112,6 +112,12 @@ export interface BuyToolLink {
112
112
  storeId?: string;
113
113
  /** On a store link: the store's delivery estimate in minutes, or null when the store showed none. */
114
114
  etaMinutes?: number | null;
115
+ /**
116
+ * On a product link for a place to book: whether it has room at the time the shopper asked for. Offer the card as a
117
+ * choice only when it reads `available`; `checking` changes in place once `onAvailability` hears the live answer.
118
+ * Absent on every other card, such as a retail product's.
119
+ */
120
+ availability?: BuyAvailability;
115
121
  }
116
122
  /** A store the answer's reply presents to choose from. */
117
123
  export interface BuyToolStore {
@@ -158,6 +164,8 @@ export interface BuyToolProduct {
158
164
  * conversation.
159
165
  */
160
166
  linkSent: boolean;
167
+ /** For a place to book: whether it has room at the asked time. Absent on a product with no such question. */
168
+ availability?: BuyAvailability;
161
169
  }
162
170
  /** One answer of the buy tool, whole, for your code. Hand it to `forModel` before a model reads it. */
163
171
  export interface BuyToolResult {
@@ -230,6 +238,18 @@ export type BuyAckMode = 'reaction' | 'text' | 'none';
230
238
  * product links the app reveals on "show more" with no new search; the model never reads them. Find mode sends a count
231
239
  * with no `more`.
232
240
  */
241
+ /**
242
+ * What Agentcard's live search said of places whose cards went out as `checking`, each matched to its card by the
243
+ * card's `productId`. A call that ends without saying sends `unknown` for each card it left `checking`, so no card
244
+ * waits for an answer that is not coming.
245
+ */
246
+ export interface BuyToolAvailability {
247
+ conversationId: string;
248
+ items: Array<{
249
+ productId: string;
250
+ availability: BuyAvailability;
251
+ }>;
252
+ }
233
253
  export interface BuyToolFound {
234
254
  conversationId: string;
235
255
  shown: number;
@@ -317,6 +337,12 @@ export interface BuyToolOptions {
317
337
  * comes. One that throws costs nothing but the call to it.
318
338
  */
319
339
  onFound?: (found: BuyToolFound) => void | Promise<void>;
340
+ /**
341
+ * Hears whether the places a call's cards show have room (`BuyToolAvailability`), right after the cards it changes went
342
+ * out through `sendLink`: change each card in place, by its `productId`. It needs `earlyCards`, the default. One that
343
+ * throws costs nothing but the call to it.
344
+ */
345
+ onAvailability?: (update: BuyToolAvailability) => void | Promise<void>;
320
346
  /**
321
347
  * Hears the products a call's search found, the moment the search answers and before Agentcard writes its reply,
322
348
  * with the conversation, when the search ran (`asOf`, as the answer's `catalog.as_of` names it) and the store's name.
@@ -532,6 +558,8 @@ export interface BuyToolTurnOptions {
532
558
  onStatus?: BuyToolOptions['onStatus'];
533
559
  /** Hears how many products this turn's calls found, as the tool's `onFound` does. */
534
560
  onFound?: BuyToolOptions['onFound'];
561
+ /** Hears whether the places this turn's cards show have room, as the tool's `onAvailability` does. */
562
+ onAvailability?: BuyToolOptions['onAvailability'];
535
563
  /** Hears the products each search of this turn's calls found, the moment it answers, as the tool's `onProducts` does. */
536
564
  onProducts?: BuyToolOptions['onProducts'];
537
565
  /**
package/dist/buy-tool.js CHANGED
@@ -201,7 +201,7 @@ export function productsOf(turn) {
201
201
  /** A product's card as `sendLink` hears it: the id the model reads and a pick takes, never the store page, which is `url`. A flight option has no page at a store: its card carries no url, and its productId picks it. */
202
202
  function productLink(product) {
203
203
  const productId = pickId(product);
204
- return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null };
204
+ return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null, ...(product.availability ? { availability: product.availability } : {}) };
205
205
  }
206
206
  function productsOfCatalog(catalog) {
207
207
  return (catalog.items ?? [])
@@ -220,6 +220,7 @@ function productsOfCatalog(catalog) {
220
220
  url: item.url ?? null,
221
221
  action: productAction(catalog.merchant),
222
222
  linkSent: false,
223
+ ...(item.availability ? { availability: item.availability } : {}),
223
224
  }));
224
225
  }
225
226
  /** The stores an answer presents, as the tool reads them. */
@@ -673,7 +674,7 @@ export function buyTool(options) {
673
674
  if (o.stream !== undefined && o.stream !== false)
674
675
  throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
675
676
  const onProgress = o.onProgress;
676
- for (const key of ['onStatus', 'onFound']) {
677
+ for (const key of ['onStatus', 'onFound', 'onAvailability']) {
677
678
  if (o[key] !== undefined && typeof o[key] !== 'function')
678
679
  throw new TypeError(`Pass ${key} as a function, or leave it out.`);
679
680
  }
@@ -745,6 +746,7 @@ export function buyTool(options) {
745
746
  onProgress,
746
747
  onStatus: o.onStatus,
747
748
  onFound: o.onFound,
749
+ onAvailability: o.onAvailability,
748
750
  onProducts: o.onProducts,
749
751
  onText: o.onText,
750
752
  onCart: o.onCart,
@@ -1476,6 +1478,8 @@ export function buyTool(options) {
1476
1478
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1477
1479
  // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1478
1480
  const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1481
+ /** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
1482
+ let settleLeft = null;
1479
1483
  try {
1480
1484
  let turn = null;
1481
1485
  let refusal;
@@ -1491,6 +1495,55 @@ export function buyTool(options) {
1491
1495
  /** Whether this call's cards went out, and a count that came before them. */
1492
1496
  let cardsOut = false;
1493
1497
  let heldCount = null;
1498
+ /** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
1499
+ const cardOfId = new Map();
1500
+ const checking = new Map();
1501
+ /**
1502
+ * What the live search answered for each card, so a later copy of the same place, such as a card whose first send
1503
+ * failed and goes out again with the answer, carries that answer and never goes back to `checking`.
1504
+ */
1505
+ const answered = new Map();
1506
+ const withAnswers = (products) => answered.size === 0 ? products : products.map((product) => {
1507
+ const known = product.availability ? answered.get(pickId(product) ?? '') : undefined;
1508
+ return known ? { ...product, availability: known } : product;
1509
+ });
1510
+ /**
1511
+ * Notes the card each product goes out as, and each place still `checking`, whichever way the app got it: a card
1512
+ * through `sendLink` now or on a later try, or a product past the cards that `onFound` hands over for "show more".
1513
+ */
1514
+ const noteCards = (products, conversationId) => {
1515
+ for (const product of products) {
1516
+ const productId = pickId(product);
1517
+ if (!productId)
1518
+ continue;
1519
+ cardOfId.set(product.id, productId);
1520
+ if (product.availability?.status === 'checking' && !answered.has(productId))
1521
+ checking.set(productId, conversationId);
1522
+ }
1523
+ };
1524
+ /** Hands the app what the live search said of the places, each by its card, and settles those cards. */
1525
+ settleLeft = async () => {
1526
+ if (!t.onAvailability || !early)
1527
+ return;
1528
+ const left = [...checking];
1529
+ for (const conversationId of new Set(left.map(([, c]) => c))) {
1530
+ await tellAvailability(conversationId, left.filter(([, c]) => c === conversationId).map(([productId]) => ({ productId, availability: { status: 'unknown' } })));
1531
+ }
1532
+ };
1533
+ const tellAvailability = async (conversationId, items) => {
1534
+ for (const item of items) {
1535
+ checking.delete(item.productId);
1536
+ answered.set(item.productId, item.availability);
1537
+ }
1538
+ if (!t.onAvailability || items.length === 0)
1539
+ return;
1540
+ try {
1541
+ await t.onAvailability({ conversationId, items });
1542
+ }
1543
+ catch {
1544
+ // The app's display failing never fails the call.
1545
+ }
1546
+ };
1494
1547
  /** Hands the app one count with the products past the cards of the catalog it counts. */
1495
1548
  const tellCount = async ({ conversationId, shown, found }) => {
1496
1549
  const more = moreFound?.conversationId === conversationId ? moreFound.links : [];
@@ -1546,7 +1599,9 @@ export function buyTool(options) {
1546
1599
  ack?.cancel();
1547
1600
  await t.ackOut;
1548
1601
  // The turn's own search presents its products, as a reply that lists them does.
1549
- await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
1602
+ await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1603
+ // A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
1604
+ noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
1550
1605
  keepMore();
1551
1606
  cardsOut = true;
1552
1607
  // A count held for these cards goes out now, unless the catalog carries its own, which follows it.
@@ -1557,6 +1612,13 @@ export function buyTool(options) {
1557
1612
  },
1558
1613
  }
1559
1614
  : {}),
1615
+ ...(t.onAvailability && early
1616
+ ? {
1617
+ onAvailability: async ({ conversationId, items }) => {
1618
+ await tellAvailability(conversationId, items.map(({ id, availability }) => ({ productId: cardOfId.get(id) ?? id, availability })));
1619
+ },
1620
+ }
1621
+ : {}),
1560
1622
  ...(t.onText
1561
1623
  ? {
1562
1624
  onSegment: async ({ text, conversationId }) => {
@@ -1654,13 +1716,19 @@ export function buyTool(options) {
1654
1716
  // Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
1655
1717
  // whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
1656
1718
  const read = !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
1657
- const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1719
+ const answer = withMessages(settled, turn);
1720
+ const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1658
1721
  // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
1659
1722
  if (heldCount && (delivered.products?.length ?? 0) > 0) {
1660
1723
  const held = heldCount;
1661
1724
  heldCount = null;
1662
1725
  await tellCount(held);
1663
1726
  }
1727
+ // A call that ends with cards still `checking`, the answer's own cards included, says `unknown` of each, so none waits
1728
+ // for an answer that is not coming.
1729
+ if (delivered.conversation_id)
1730
+ noteCards(delivered.products ?? [], delivered.conversation_id);
1731
+ await settleLeft();
1664
1732
  // The message that asks for a step link already went out, so the link follows it now instead of waiting for the model's.
1665
1733
  // The call waits for it, and one that fails to send fails the call, as a step link the turn does not hold does.
1666
1734
  if (read)
@@ -1709,6 +1777,8 @@ export function buyTool(options) {
1709
1777
  return result;
1710
1778
  }
1711
1779
  finally {
1780
+ // A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
1781
+ await settleLeft?.().catch(() => { });
1712
1782
  ack?.cancel();
1713
1783
  running -= 1;
1714
1784
  lastEndedAt = Date.now();
@@ -1782,7 +1852,7 @@ export function buyTool(options) {
1782
1852
  const own = options ?? {};
1783
1853
  if (typeof own !== 'object')
1784
1854
  throw new TypeError('Pass the turn options as an object, or leave them out.');
1785
- for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
1855
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
1786
1856
  if (own[key] !== undefined && typeof own[key] !== 'function')
1787
1857
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1788
1858
  }
@@ -1861,6 +1931,7 @@ export function buyTool(options) {
1861
1931
  onProgress: own.onProgress ?? onProgress,
1862
1932
  onStatus: own.onStatus ?? o.onStatus,
1863
1933
  onFound: own.onFound ?? o.onFound,
1934
+ onAvailability: own.onAvailability ?? o.onAvailability,
1864
1935
  onProducts: own.onProducts ?? o.onProducts,
1865
1936
  // Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
1866
1937
  // an app that passes onItem too hears it once.
package/dist/client.d.ts CHANGED
@@ -51,6 +51,30 @@ export interface BuyCatalogUpdate {
51
51
  /** The products found past the cards, in the order they were found, to show on "show more" with no new search. */
52
52
  more: BuyCatalog['items'];
53
53
  }
54
+ /**
55
+ * Whether a place a card shows has room at the time the shopper asked for: `checking` while Agentcard's live search runs,
56
+ * `available` once it found the place open, with the open times it named in `slots` (empty when it named none),
57
+ * `unavailable` when it found the place full then, and `unknown` when the search ended without saying either way. Only
58
+ * `available` is a place to offer as a choice. A product with no such question, such as a retail product, carries none.
59
+ */
60
+ export type BuyAvailability = {
61
+ status: 'checking';
62
+ } | {
63
+ status: 'available';
64
+ slots: string[];
65
+ } | {
66
+ status: 'unavailable';
67
+ } | {
68
+ status: 'unknown';
69
+ };
70
+ /** What Agentcard's live search said of the places a running turn's cards show, matched to each card by its catalog `id`. */
71
+ export interface BuyAvailabilityUpdate {
72
+ conversationId: string;
73
+ items: Array<{
74
+ id: string;
75
+ availability: BuyAvailability;
76
+ }>;
77
+ }
54
78
  /** How many products a running turn found so far, and how many of them it shows: "8 of 23 shown". */
55
79
  export interface BuyFoundUpdate {
56
80
  conversationId: string;
@@ -107,6 +131,8 @@ export interface BuyStreamHandlers {
107
131
  onCart?: (update: BuyCartUpdate) => void | Promise<void>;
108
132
  /** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
109
133
  onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
134
+ /** Whether the places the turn's cards show have room, once Agentcard's live search says, after the cards it changes. */
135
+ onAvailability?: (update: BuyAvailabilityUpdate) => void | Promise<void>;
110
136
  }
111
137
  export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
112
138
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -301,6 +327,8 @@ export interface BuyCatalogItem {
301
327
  store: BuyStore | null;
302
328
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
303
329
  url: string | null;
330
+ /** Whether a place has room at the asked time (BuyAvailability). Absent on a product with no such question. */
331
+ availability?: BuyAvailability;
304
332
  }
305
333
  /**
306
334
  * The products the last search found. `presented` is true when this answer's reply presents them: the
package/dist/client.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { isRecord } from './verify.js';
12
12
  import { listenToAgentcard } from './listen.js';
13
13
  /** The handlers that ask Agentcard to stream a turn. */
14
- const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onCart', 'onDelta'];
14
+ const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onCart', 'onDelta', 'onAvailability'];
15
15
  const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
16
16
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
17
17
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -223,9 +223,30 @@ function catalogItemsOf(v) {
223
223
  ...(flight ? { flight } : {}),
224
224
  store: storeOf(i.store),
225
225
  url: pageUrl(i.url),
226
+ ...availabilityField(i),
226
227
  };
227
228
  }) : [];
228
229
  }
230
+ /**
231
+ * A place's availability as Agentcard marks it: `checking`, `open` or `available` with the times in `slots`, `none` or
232
+ * `unavailable`, and `unknown`. A mark this version does not know reads as `unknown`, so no place is offered on a guess.
233
+ */
234
+ function availabilityOf(v) {
235
+ const mark = v.availability;
236
+ if (mark === 'checking')
237
+ return { status: 'checking' };
238
+ if (mark === 'open' || mark === 'available') {
239
+ const slots = Array.isArray(v.slots) ? v.slots.filter((s) => typeof s === 'string' && s.trim() !== '') : [];
240
+ return { status: 'available', slots };
241
+ }
242
+ if (mark === 'none' || mark === 'unavailable')
243
+ return { status: 'unavailable' };
244
+ return typeof mark === 'string' ? { status: 'unknown' } : null;
245
+ }
246
+ function availabilityField(v) {
247
+ const availability = availabilityOf(v);
248
+ return availability ? { availability } : {};
249
+ }
229
250
  /** A count Agentcard sent: a whole number, never negative. Null otherwise. */
230
251
  function countOf(v) {
231
252
  return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null;
@@ -424,7 +445,7 @@ async function eachEvent(res, onEvent) {
424
445
  }
425
446
  /**
426
447
  * A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
427
- * status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment` and `cart` goes to its handler
448
+ * status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment`, `cart` and `venue_availability` goes to its handler
428
449
  * on the way, and any other event, such as a `ping` or a `tool`, is read past. A stream that breaks or
429
450
  * ends before its envelope, once it named the conversation, reads as a turn past its budget
430
451
  * (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
@@ -503,6 +524,15 @@ async function readStreamedTurn(res, stream, timeoutMs) {
503
524
  else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
504
525
  deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
505
526
  }
527
+ else if (event === 'venue_availability' && stream.onAvailability && str(data.conversation_id) && Array.isArray(data.items)) {
528
+ const items = data.items.filter(isRecord).flatMap((i) => {
529
+ const id = str(i.id);
530
+ const availability = availabilityOf(i);
531
+ return id && availability ? [{ id, availability }] : [];
532
+ });
533
+ if (items.length)
534
+ deliver(() => stream.onAvailability({ conversationId: data.conversation_id, items }));
535
+ }
506
536
  else if (event === 'cart' && stream.onCart && str(data.conversation_id)) {
507
537
  const update = { conversationId: data.conversation_id, cart: cartOf(data.cart), carts: cartsOf(data.carts) };
508
538
  deliver(() => stream.onCart(update));
package/dist/index.d.ts CHANGED
@@ -2,10 +2,10 @@ export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatur
2
2
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
3
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, type NextStepKind, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
4
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
5
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, } from './client.js';
5
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, } from './client.js';
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
7
7
  export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
8
- export { buyTool, nextStepPrompt, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolFound, type BuyToolProduct, type BuyToolProductAction, productAction, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, type BuyAckMode, } from './buy-tool.js';
8
+ export { buyTool, nextStepPrompt, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolFound, type BuyToolAvailability, type BuyToolProduct, type BuyToolProductAction, productAction, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, type BuyAckMode, } from './buy-tool.js';
9
9
  export { instantAck, instantReaction, ackFor, keepsYes, ACK_REACTION, type AckForOptions } from './ack.js';
10
10
  export { directCall, isProductAsk, type DirectCall, type DirectContext, type DirectReason } from './direct.js';
11
11
  export { forModel, forModelError, replyAsWritten, withoutCardList, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, type BuyToolModelStore, } from './model-view.js';
@@ -122,6 +122,13 @@ export interface BuyToolModelView {
122
122
  * entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
123
123
  */
124
124
  export declare function stripLinks(text: string): string;
125
+ /**
126
+ * Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
127
+ * 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
128
+ * 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:
129
+ * "here's the stool on Wayfair:" reached the shopper with nothing after it.
130
+ */
131
+ export declare function replyWithoutLinks(reply: string, sent: boolean): string;
125
132
  /**
126
133
  * An amount in its currency's minor unit, as the model reads it: the currency's own minor units place the
127
134
  * decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
@@ -162,10 +169,11 @@ export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): str
162
169
  export declare function cardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url'>): string | null;
163
170
  /**
164
171
  * The key of the card a product gets, or null when it gets none: a product with a page at its store,
165
- * or a flight option, which has no page, with an id a pick takes. A flight's card is how the shopper
166
- * taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
172
+ * or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
173
+ * is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
174
+ * and a place's card is where the shopper reads whether it has room.
167
175
  */
168
- export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight'>): string | null;
176
+ export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight' | 'availability'>): string | null;
169
177
  /** How long until `iso`, from `now`, as the shopper reads it: `about 24 hours`, `about 15 minutes`, `no longer`, or null. */
170
178
  export declare function expiresIn(iso: string | null | undefined, now?: number): string | null;
171
179
  /**
@@ -59,6 +59,24 @@ export function stripLinks(text) {
59
59
  .replace(/\n{3,}/g, '\n\n')
60
60
  .trim();
61
61
  }
62
+ /**
63
+ * 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
64
+ * Wayfair: https://www.wayfair.com/...`, one in angle brackets, or a bare address with a path or query (`vault.agentcard.sh?token=...`,
65
+ * never a bare `www.walmart.com`, which stripLinks keeps as the store's name). Every such
66
+ * link comes out of what the shopper reads (stripLinks), and the colon would then point at nothing. A Markdown link is not one:
67
+ * stripLinks keeps its label, which the shopper reads after the colon. A question mark that ends the sentence is not a query,
68
+ * as HOST_PATH reads it (`Buy from: glossier.com?`), and a page whose path ends in a bracket keeps it (`stool_(oak)`).
69
+ */
70
+ const POINTS_AT_LINK = /^([^\n]*?\S)[ \t]*:[ \t]*(?:\n[ \t]*)?<?(?:https?:\/\/|(?:www\.)?(?:[\p{L}\p{N}-]+\.)+\p{L}{2,}(?:[/#]|\?(?![.,;:!?]*(?:[\s<>"'`)\]]|$))))[^\s<>"'`\]]*>?[ \t]*[.!]?[ \t]*$/gimu;
71
+ /**
72
+ * Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
73
+ * 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
74
+ * 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:
75
+ * "here's the stool on Wayfair:" reached the shopper with nothing after it.
76
+ */
77
+ export function replyWithoutLinks(reply, sent) {
78
+ return stripLinks(String(reply ?? '').replace(POINTS_AT_LINK, (_line, lead) => (sent ? `${lead}.` : '')));
79
+ }
62
80
  /** A currency's minor units as Intl knows them: 2 for USD and CAD, 0 for JPY. */
63
81
  function minorUnits(format) {
64
82
  return format.resolvedOptions().maximumFractionDigits ?? 0;
@@ -135,13 +153,14 @@ export function cardKey(product) {
135
153
  }
136
154
  /**
137
155
  * 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).
156
+ * or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
157
+ * is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
158
+ * and a place's card is where the shopper reads whether it has room.
140
159
  */
141
160
  export function productCardKey(product) {
142
161
  if (typeof product.url === 'string' && product.url !== '')
143
162
  return cardKey(product);
144
- return product.flight ? pickId(product) : null;
163
+ return product.flight || product.availability ? pickId(product) : null;
145
164
  }
146
165
  function cartView(cart) {
147
166
  return {
@@ -218,7 +237,7 @@ export function forModel(result) {
218
237
  status,
219
238
  next_action: result.next_action ?? null,
220
239
  ...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
221
- reply: stripLinks(result.reply),
240
+ reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
222
241
  cart: result.cart ? cartView(result.cart) : null,
223
242
  carts: (result.carts ?? []).map(cartView),
224
243
  products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",