@agent-cards/commerce-sdk 0.18.10 → 0.18.13

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.
@@ -34,6 +34,7 @@
34
34
  import type { BuyCardSize } from './card-size.js';
35
35
  import type { BuyPlan } from './plan.js';
36
36
  import type { BuyMerchantStatusUpdate, BuySearchStatus } from './search-status.js';
37
+ import { type BuyGroupsUpdate } from './search-groups.js';
37
38
  import type { BuyFreshness, BuyRecheck, BuyStoreCheckUpdate } from './client.js';
38
39
  import type { BuyFulfilledBy } from './fulfilled-by.js';
39
40
  import { AgentcardClient, type BuyCart, type BuyCartBuilding, type BuyChoice, type BuyCodeRequest, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type BuyTurnStatus, type BuyAvailability, type BuyStorePick, type BuyTurn, type FetchLike } from './client.js';
@@ -421,6 +422,11 @@ export interface BuyToolOptions extends BuyStoreWait {
421
422
  * search starts, then what it found. One that throws costs nothing but the call to it.
422
423
  */
423
424
  onMerchantStatus?: (update: BuyMerchantStatusUpdate) => void | Promise<void>;
425
+ /**
426
+ * Hears every group of a call's products each time one changes, and once more from the answer (`BuyGroupsUpdate`), each
427
+ * group's `product_ids` naming the cards' `productId`s. One that throws costs nothing but the call to it.
428
+ */
429
+ onGroups?: (update: BuyGroupsUpdate) => void | Promise<void>;
424
430
  /**
425
431
  * Hears each store behind a card while Agentcard checks its price and stock, then the store's answer
426
432
  * (`BuyStoreCheckUpdate`): `cardId` is the card's `productId`. One that throws costs nothing but the call to it.
@@ -662,6 +668,8 @@ export interface BuyToolTurnOptions extends BuyStoreWait {
662
668
  onAvailability?: BuyToolOptions['onAvailability'];
663
669
  /** Hears each store this turn's searches ask, as the tool's `onMerchantStatus` does. */
664
670
  onMerchantStatus?: BuyToolOptions['onMerchantStatus'];
671
+ /** Hears the groups of this turn's products, as the tool's `onGroups` does. */
672
+ onGroups?: BuyToolOptions['onGroups'];
665
673
  /** Hears each store behind this turn's cards as it is checked, as the tool's `onStoreCheck` does. */
666
674
  onStoreCheck?: BuyToolOptions['onStoreCheck'];
667
675
  /** Hears this turn's re-check of the shopper's quotes, as the tool's `onRecheck` does. */
package/dist/buy-tool.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { groupsOf } from './search-groups.js';
1
2
  import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
2
3
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
3
4
  import { directCall, isProductAsk } from './direct.js';
4
5
  import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
5
6
  import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
7
+ import { onlyListMarks } from './list-marks.js';
6
8
  import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
7
9
  import { isStale, messageInChat } from './shopper-message.js';
8
10
  import { actionLinkId, isActionLink } from './action-links.js';
@@ -777,7 +779,7 @@ export function buyTool(options) {
777
779
  if (o.stream !== undefined && o.stream !== false)
778
780
  throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
779
781
  const onProgress = o.onProgress;
780
- for (const key of ['onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onStoreCheck', 'onRecheck', 'onFreshness']) {
782
+ for (const key of ['onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onGroups', 'onStoreCheck', 'onRecheck', 'onFreshness']) {
781
783
  if (o[key] !== undefined && typeof o[key] !== 'function')
782
784
  throw new TypeError(`Pass ${key} as a function, or leave it out.`);
783
785
  }
@@ -859,6 +861,7 @@ export function buyTool(options) {
859
861
  onFound: o.onFound,
860
862
  onAvailability: o.onAvailability,
861
863
  onMerchantStatus: o.onMerchantStatus,
864
+ onGroups: o.onGroups,
862
865
  onStoreCheck: o.onStoreCheck,
863
866
  onRecheck: o.onRecheck,
864
867
  onFreshness: o.onFreshness,
@@ -1783,7 +1786,9 @@ export function buyTool(options) {
1783
1786
  let heldTexts = [];
1784
1787
  /** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
1785
1788
  const showMessage = async (message, texts, conversationId) => {
1786
- await speak(messageForShopper(message, leadIn, t.texting), { conversationId });
1789
+ // A message of list marks or punctuation alone, such as `1.; 2.`, says nothing, and the shopper never reads it (list-marks.ts).
1790
+ if (!onlyListMarks(message))
1791
+ await speak(messageForShopper(message, leadIn, t.texting), { conversationId });
1787
1792
  leadIn = leadsIntoList(message);
1788
1793
  told.push(...texts);
1789
1794
  };
@@ -1995,6 +2000,21 @@ export function buyTool(options) {
1995
2000
  },
1996
2001
  }
1997
2002
  : {}),
2003
+ ...(t.onGroups
2004
+ ? {
2005
+ onGroups: async (update) => {
2006
+ // The answer's groups go out once the call has its answer, below, whether it streamed or not.
2007
+ if (update.final)
2008
+ return;
2009
+ try {
2010
+ await t.onGroups({ ...update, groups: update.groups.map((g) => ({ ...g, product_ids: g.product_ids.map((id) => cardOfId.get(id) ?? id) })) });
2011
+ }
2012
+ catch {
2013
+ // The app's display failing never fails the turn.
2014
+ }
2015
+ },
2016
+ }
2017
+ : {}),
1998
2018
  ...(t.onStoreCheck
1999
2019
  ? {
2000
2020
  onStoreCheck: async (update) => {
@@ -2138,6 +2158,17 @@ export function buyTool(options) {
2138
2158
  }
2139
2159
  }
2140
2160
  ack?.cancel();
2161
+ // The answer's groups, each product named by the card it goes out as, as `pickId` names a card.
2162
+ if (t.onGroups && turn?.catalog) {
2163
+ const cardOf = new Map(turn.catalog.items.map((item) => [item.id, pickId({ id: item.id, ref: item.ref }) ?? item.id]));
2164
+ const groups = groupsOf(turn).map(({ products: _, ...g }) => ({ ...g, product_ids: g.product_ids.map((id) => cardOf.get(id) ?? id) }));
2165
+ try {
2166
+ await t.onGroups({ conversationId: turn.conversation_id || null, groups, final: true });
2167
+ }
2168
+ catch {
2169
+ // The app's display failing never fails the call.
2170
+ }
2171
+ }
2141
2172
  const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
2142
2173
  await t.ackOut;
2143
2174
  // A newer message from the shopper stopped this turn, and its own turn answers: this one ends here and says nothing
@@ -2393,7 +2424,7 @@ export function buyTool(options) {
2393
2424
  const own = options ?? {};
2394
2425
  if (typeof own !== 'object')
2395
2426
  throw new TypeError('Pass the turn options as an object, or leave them out.');
2396
- for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onStoreCheck', 'onRecheck', 'onFreshness', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
2427
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onGroups', 'onStoreCheck', 'onRecheck', 'onFreshness', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
2397
2428
  if (own[key] !== undefined && typeof own[key] !== 'function')
2398
2429
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
2399
2430
  }
@@ -2478,6 +2509,7 @@ export function buyTool(options) {
2478
2509
  onFound: own.onFound ?? o.onFound,
2479
2510
  onAvailability: own.onAvailability ?? o.onAvailability,
2480
2511
  onMerchantStatus: own.onMerchantStatus ?? o.onMerchantStatus,
2512
+ onGroups: own.onGroups ?? o.onGroups,
2481
2513
  onStoreCheck: own.onStoreCheck ?? o.onStoreCheck,
2482
2514
  onRecheck: own.onRecheck ?? o.onRecheck,
2483
2515
  onFreshness: own.onFreshness ?? o.onFreshness,
package/dist/client.d.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { type BuyTrip } from './trip.js';
12
12
  import { type BuyPlan } from './plan.js';
13
13
  import { type BuyMerchantStatusUpdate, type BuySearchStatus } from './search-status.js';
14
+ import { type BuyGroup, type BuyGroupsUpdate } from './search-groups.js';
14
15
  import { type BuyFulfilledBy } from './fulfilled-by.js';
15
16
  import { type BuyCardSize } from './card-size.js';
16
17
  import { type AgentcardListener, type ListenOptions } from './listen.js';
@@ -196,6 +197,11 @@ export interface BuyStreamHandlers {
196
197
  * follow one store, keep the updates `isMerchantStatusAt(update, 'walmart.com')` accepts. The answer's `search` holds every store.
197
198
  */
198
199
  onMerchantStatus?: (update: BuyMerchantStatusUpdate) => void | Promise<void>;
200
+ /**
201
+ * Every group of the turn's products (BuyGroup), each time one changes, and once more from the answer. An answer with no
202
+ * groups comes as one group holding every product, as `groupsOf` reads it. Draw one grid per group, in the order given.
203
+ */
204
+ onGroups?: (update: BuyGroupsUpdate) => void | Promise<void>;
199
205
  /**
200
206
  * Agentcard reads the shopper's quotes again: on their return after a gap, or before a yes to a price older than its
201
207
  * window. Comes with `phase: 'started'`, the line to show at once and every item `checking`, then `phase: 'finished'` with
@@ -507,6 +513,8 @@ export interface BuyFlightItinerary {
507
513
  export interface BuyFlightParty {
508
514
  adults: number;
509
515
  children: number;
516
+ /** How many of `children` fly on an adult's lap, under 2. Absent when none, and from an older API. */
517
+ infants?: number;
510
518
  totalCents: number;
511
519
  perPassengerCents?: number[];
512
520
  }
@@ -555,6 +563,8 @@ export interface BuyCatalog {
555
563
  merchant_name: string;
556
564
  items: BuyCatalogItem[];
557
565
  presented: boolean;
566
+ /** The groups `items` come in, one per store or item asked for (BuyGroup); absent from an older API. `groupsOf` reads them. */
567
+ groups?: BuyGroup[];
558
568
  }
559
569
  /** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
560
570
  export interface BuyPick {
package/dist/client.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { tripOf } from './trip.js';
12
12
  import { planOf } from './plan.js';
13
13
  import { merchantStatusOf, searchOf } from './search-status.js';
14
+ import { groupListOf, groupOf, groupsOf } from './search-groups.js';
14
15
  import { isRecord } from './verify.js';
15
16
  import { failedPartyOf, fulfilledByField } from './fulfilled-by.js';
16
17
  import { cardSizeField } from './card-size.js';
@@ -25,7 +26,7 @@ export const STATUS_WORDING = Object.freeze({
25
26
  searching_wider: 'Checking more stores for this…',
26
27
  });
27
28
  /** The handlers that ask Agentcard to stream a turn. */
28
- const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability', 'onReplyWritten', 'onMerchantStatus', 'onRecheck', 'onFreshness', 'onStoreCheck'];
29
+ const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability', 'onReplyWritten', 'onMerchantStatus', 'onGroups', 'onRecheck', 'onFreshness', 'onStoreCheck'];
29
30
  const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
30
31
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
31
32
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -292,13 +293,15 @@ function partyOf(value) {
292
293
  if (adults === null || children === null || totalCents === null || adults + children < 2)
293
294
  return {};
294
295
  const each = Array.isArray(value.perPassengerCents) ? value.perPassengerCents.map(num) : null;
295
- return { party: { adults, children, totalCents, ...(each && each.length === adults + children && each.every((c) => c !== null) ? { perPassengerCents: each } : {}) } };
296
+ const infants = num(value.infants);
297
+ return { party: { adults, children, ...(infants !== null && infants > 0 && infants <= children ? { infants } : {}), totalCents, ...(each && each.length === adults + children && each.every((c) => c !== null) ? { perPassengerCents: each } : {}) } };
296
298
  }
297
299
  function catalogOf(v) {
298
300
  if (!isRecord(v))
299
301
  return null;
300
302
  const items = catalogItemsOf(v.items);
301
- return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
303
+ const groups = groupListOf(v.groups);
304
+ return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true, ...(groups ? { groups } : {}) };
302
305
  }
303
306
  /** A catalog block's products, as `items` or `more` lists them. */
304
307
  function catalogItemsOf(v) {
@@ -595,6 +598,11 @@ async function readStreamedTurn(res, stream, timeoutMs) {
595
598
  delivering = delivering.then(() => hand()).catch(() => { });
596
599
  };
597
600
  let failure = null;
601
+ // The turn's groups so far, in the order they first came; a group sent again takes its own place.
602
+ let groups = [];
603
+ // Whether a `group` event already carried the turn's final groups, which an answer with no catalog (a search that found
604
+ // nothing, a failed turn) can only say that way; the envelope's groups are the same, so they are not handed on twice.
605
+ let groupsFinal = false;
598
606
  try {
599
607
  await eachEvent(res, (event, text) => {
600
608
  const data = parseJson(text);
@@ -605,6 +613,12 @@ async function readStreamedTurn(res, stream, timeoutMs) {
605
613
  const named = conversationId;
606
614
  if (event === 'envelope') {
607
615
  envelope = data;
616
+ const catalog = catalogOf(data.catalog);
617
+ if (stream.onGroups && catalog && !groupsFinal) {
618
+ // An answer whose search found nothing still comes, as one `empty` group, so the app hears the search ended.
619
+ const final = groupsOf({ catalog, search: searchOf(data.search) }).map(({ products: _, ...g }) => g);
620
+ deliver(() => stream.onGroups({ conversationId: named, groups: final, final: true }));
621
+ }
608
622
  return true;
609
623
  }
610
624
  if (event === 'progress' && typeof data.stage === 'string' && PROGRESS_STAGES.has(data.stage) && stream.onProgress) {
@@ -693,6 +707,25 @@ async function readStreamedTurn(res, stream, timeoutMs) {
693
707
  deliver(() => stream.onStoreCheck(update));
694
708
  }
695
709
  }
710
+ else if (event === 'group' && stream.onGroups) {
711
+ // One group as it changes, or the whole list; an event with neither is read past.
712
+ const changed = Array.isArray(data.groups) ? groupListOf(data.groups) ?? [] : [groupOf(data.group)].flatMap((g) => g ?? []);
713
+ if (!changed.length)
714
+ return;
715
+ const final = data.final === true;
716
+ // A final list is the turn's whole answer: it replaces the groups held, whose ids it may have split into one per ask.
717
+ if (final && Array.isArray(data.groups))
718
+ groups = changed;
719
+ else {
720
+ for (const g of changed) {
721
+ const at = groups.findIndex((held) => held.id === g.id);
722
+ groups = at < 0 ? [...groups, g] : groups.map((held, i) => (i === at ? g : held));
723
+ }
724
+ }
725
+ const now = groups;
726
+ groupsFinal ||= final;
727
+ deliver(() => stream.onGroups({ conversationId: named, groups: now, final }));
728
+ }
696
729
  else if (event === 'merchant' && stream.onMerchantStatus) {
697
730
  const update = merchantStatusOf(data);
698
731
  if (update)
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, type
4
4
  export { ORDER_STEPS, updateOrder, arrivalText, type ShopperOrder, type OrderStep, type OrderPathStep, type OrderStepState, type ArrivalTextOptions, } from './order.js';
5
5
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
6
6
  export { searchAt, isMerchantStatusAt, merchantStatusLine, searchStatusLines, type BuySearchStatus, type BuyMerchantStatus, type BuyMerchantSearchStatus, type BuyMerchantStatusUpdate, } from './search-status.js';
7
+ export { groupsOf, type BuyGroup, type BuyGroupKind, type BuyGroupStatus, type BuyGroupWithProducts, type BuyGroupsSource, type BuyGroupsUpdate, } from './search-groups.js';
7
8
  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 BuyStoreCheckUpdate, type BuyFoundUpdate, type BuyTurnStatus, STATUS_WORDING, type BuySegmentUpdate, type BuyRecheck, type BuyFreshness, type BuyFreshnessState, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyLastAnswer, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyCartDiscount, type BuyCartHold, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyFlightParty, type BuyPaymentSource, type BuyConversation, type BuyLatestReply, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyCodeRequest, type BuyChoiceGroup, type BuyChoiceDelivery, type BuyChoiceOption, } from './client.js';
8
9
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
9
10
  export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, } fr
4
4
  export { ORDER_STEPS, updateOrder, arrivalText, } from './order.js';
5
5
  export { agentcardWebhook, purchaseAgentWebhook, } from './handler.js';
6
6
  export { searchAt, isMerchantStatusAt, merchantStatusLine, searchStatusLines, } from './search-status.js';
7
+ export { groupsOf, } from './search-groups.js';
7
8
  export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, STATUS_WORDING, } from './client.js';
8
9
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
9
10
  export { toMessages, messageStream, typingDelay, } from './messages.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A list mark with nothing after it is no message. When a reply's list items were only links or photos, such as
3
+ * `1. ![Bentgo Classic Lunch Box](...); 2. ![Twfbrm Bento Boxes](...)`, taking the links out (stripLinks) left the marks
4
+ * alone, and on 2026-11-04 a shopper on chat.opencommerce.sh read a message that said only `1.; 2.`. These read a text for
5
+ * marks a removed item emptied, so an item that keeps its words keeps its number, and a mark with none goes.
6
+ */
7
+ /** True when a message holds list marks or punctuation and nothing else, so it says nothing to the shopper. */
8
+ export declare function onlyListMarks(text: string): boolean;
9
+ /**
10
+ * The text without the list marks an item no longer follows, and without a line left with marks and punctuation alone.
11
+ * A line that was blank stays blank, and every other line stays as written.
12
+ */
13
+ export declare function withoutEmptyMarks(text: string): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A list mark with nothing after it is no message. When a reply's list items were only links or photos, such as
3
+ * `1. ![Bentgo Classic Lunch Box](...); 2. ![Twfbrm Bento Boxes](...)`, taking the links out (stripLinks) left the marks
4
+ * alone, and on 2026-11-04 a shopper on chat.opencommerce.sh read a message that said only `1.; 2.`. These read a text for
5
+ * marks a removed item emptied, so an item that keeps its words keeps its number, and a mark with none goes.
6
+ */
7
+ /** A list mark as a reply writes one: `1.`, `2)`, or a bullet. */
8
+ const MARK = String.raw `(?:\d{1,2}[.)]|[•*+-])`;
9
+ /** A line, or a whole message, of list marks and punctuation alone, with no word or number of an item. */
10
+ const ONLY_MARKS = new RegExp(String.raw `^(?:\s|[.,;:!?…·]|${MARK})*$`, 'u');
11
+ /** True when a message holds list marks or punctuation and nothing else, so it says nothing to the shopper. */
12
+ export function onlyListMarks(text) {
13
+ return /\S/.test(text) && ONLY_MARKS.test(text);
14
+ }
15
+ // An emptied mark that opens a list, at the line's start or after the colon that leads into it, before another mark
16
+ // (`1.; 2. Twfbrm`, `Options: 1.; 2. Travel mug`); one between two semicolons or at the line's end after one (`Bentgo; 2.;
17
+ // 3. Twfbrm`, `Bentgo; 2.`). A number after a colon with nothing after it stays, since `Size: 9.` gives a value, and so does a
18
+ // number inside a sentence, `size 9, 10.`.
19
+ const LEADING_EMPTY = new RegExp(String.raw `(^[ \t]*|:[ \t]*)(?:${MARK}[ \t;,]*)+(?=\d{1,2}[.)][ \t]|•[ \t])`, 'gm');
20
+ const BETWEEN_EMPTY = new RegExp(String.raw `[ \t]*;[ \t]*${MARK}[ \t]*(?=;|$)`, 'gm');
21
+ // A list a colon leads into whose every item went, `Options: 1.; 2.`: its numbers go and the lead-in keeps its colon. A
22
+ // single number after a colon, `Size: 9.`, has no `;` to mark it as a list, and stays.
23
+ const EMPTY_AFTER_COLON = new RegExp(String.raw `:[ \t]*${MARK}(?:[ \t]*;[ \t]*${MARK})+[ \t;]*$`, 'gm');
24
+ /**
25
+ * The text without the list marks an item no longer follows, and without a line left with marks and punctuation alone.
26
+ * A line that was blank stays blank, and every other line stays as written.
27
+ */
28
+ export function withoutEmptyMarks(text) {
29
+ return text
30
+ .split('\n')
31
+ .flatMap((line) => {
32
+ if (!/\S/.test(line))
33
+ return [line];
34
+ const kept = line.replace(LEADING_EMPTY, '$1').replace(EMPTY_AFTER_COLON, ':').replace(BETWEEN_EMPTY, '');
35
+ return ONLY_MARKS.test(kept) ? [] : [kept];
36
+ })
37
+ .join('\n')
38
+ .replace(/\n{3,}/g, '\n\n')
39
+ .trim();
40
+ }
@@ -184,7 +184,8 @@ export interface BuyToolModelView {
184
184
  };
185
185
  }
186
186
  /**
187
- * The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
187
+ * The text with every link taken out: a Markdown link keeps its label unless the label only names the link
188
+ * (store-pages.ts LINK_WORD_LINKS), a Markdown image goes whole, an address with a path or a scheme goes
188
189
  * entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
189
190
  */
190
191
  export declare function stripLinks(text: string): string;
@@ -192,9 +193,10 @@ export declare function stripLinks(text: string): string;
192
193
  * Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
193
194
  * 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
194
195
  * 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:
195
- * "here's the stool on Wayfair:" reached the shopper with nothing after it.
196
+ * "here's the stool on Wayfair:" reached the shopper with nothing after it. The page of a card the answer sent, `pages`,
197
+ * stays as written.
196
198
  */
197
- export declare function replyWithoutLinks(reply: string, sent: boolean): string;
199
+ export declare function replyWithoutLinks(reply: string, sent: boolean, pages?: readonly (string | null | undefined)[]): string;
198
200
  /**
199
201
  * An amount in its currency's minor unit, as the model reads it: the currency's own minor units place the
200
202
  * decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
@@ -213,8 +215,9 @@ export declare function productPrice(product: {
213
215
  };
214
216
  }): string | null;
215
217
  /**
216
- * Who a party's flight price covers, as every app shows it after the price: `for 2`, or `for 2 adults and 1 child` when
217
- * children fly. `$1,610 for 2` is the price of both seats together.
218
+ * Who a party's flight price covers, as every app shows it after the price: `for 2`, `for 2 adults and 1 child` when
219
+ * children fly, and `for 2 adults, 1 child, 1 infant` when an infant flies on a lap. `$1,610 for 2` is the price of both
220
+ * seats together. Every traveller is named, so a card never reads as a price for fewer people than fly.
218
221
  */
219
222
  export declare function flightPartyWords(party: BuyFlightParty): string;
220
223
  export declare function money(minor: number | null | undefined, currency: string | null | undefined): string | null;
@@ -1,5 +1,7 @@
1
1
  import { SENTENCE_END, lastSentenceStart, leadsIn, withoutLeadIn } from './sentences.js';
2
2
  import { toMessages } from './messages.js';
3
+ import { GONE, LINK_WORD_LINKS, MARKDOWN_IMAGE, holdStorePages, namesOnlyALink, withoutGoneLines } from './store-pages.js';
4
+ import { withoutEmptyMarks } from './list-marks.js';
3
5
  /** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
4
6
  const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
5
7
  const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
@@ -44,11 +46,15 @@ function wwwName(address) {
44
46
  return host && !/[/?#]/.test(host) ? siteName(host) + (closing ?? '') : '';
45
47
  }
46
48
  /**
47
- * The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
49
+ * The text with every link taken out: a Markdown link keeps its label unless the label only names the link
50
+ * (store-pages.ts LINK_WORD_LINKS), a Markdown image goes whole, an address with a path or a scheme goes
48
51
  * entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
49
52
  */
50
53
  export function stripLinks(text) {
51
- return String(text ?? '')
54
+ // A list item that was only a link leaves its mark behind, and the mark goes with it (list-marks.ts).
55
+ return withoutEmptyMarks(withoutGoneLines(String(text ?? '')
56
+ .replace(MARKDOWN_IMAGE, GONE)
57
+ .replace(LINK_WORD_LINKS, GONE))
52
58
  .replace(MARKDOWN_LINK, '$1')
53
59
  .replace(SCHEME_URL, '')
54
60
  .replace(WWW_URL, wwwName)
@@ -59,7 +65,7 @@ export function stripLinks(text) {
59
65
  .replace(/[ \t]{2,}/g, ' ')
60
66
  .replace(/[ \t]+$/gm, '')
61
67
  .replace(/\n{3,}/g, '\n\n')
62
- .trim();
68
+ .trim());
63
69
  }
64
70
  /**
65
71
  * 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
@@ -74,10 +80,14 @@ const POINTS_AT_LINK = /^([^\n]*?\S)[ \t]*:[ \t]*(?:\n[ \t]*)?<?(?:https?:\/\/|(
74
80
  * Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
75
81
  * 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
76
82
  * 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:
77
- * "here's the stool on Wayfair:" reached the shopper with nothing after it.
83
+ * "here's the stool on Wayfair:" reached the shopper with nothing after it. The page of a card the answer sent, `pages`,
84
+ * stays as written.
78
85
  */
79
- export function replyWithoutLinks(reply, sent) {
80
- return stripLinks(String(reply ?? '').replace(POINTS_AT_LINK, (_line, lead) => (sent ? `${lead}.` : '')));
86
+ export function replyWithoutLinks(reply, sent, pages = []) {
87
+ // A card's own store page stays where the reply wrote it (store-pages.ts), and a line whose words only named a link goes with it.
88
+ // A bold label keeps its colon outside the bold (`**Photo:** <address>` reads `**Photo**: <address>`), so it reads as a label.
89
+ const { text, restore } = holdStorePages(String(reply ?? '').replace(/(\*\*|__)([^*_\n]+?):\1/g, '$1$2$1:'), pages);
90
+ return restore(stripLinks(withoutGoneLines(text.replace(POINTS_AT_LINK, (_line, lead) => (sent && !namesOnlyALink(lead) ? `${lead}.` : GONE)))));
81
91
  }
82
92
  /** A currency's minor units as Intl knows them: 2 for USD and CAD, 0 for JPY. */
83
93
  function minorUnits(format) {
@@ -115,13 +125,21 @@ export function productPrice(product) {
115
125
  return price !== null && product.priceFrom ? `from ${price}` : price;
116
126
  }
117
127
  /**
118
- * Who a party's flight price covers, as every app shows it after the price: `for 2`, or `for 2 adults and 1 child` when
119
- * children fly. `$1,610 for 2` is the price of both seats together.
128
+ * Who a party's flight price covers, as every app shows it after the price: `for 2`, `for 2 adults and 1 child` when
129
+ * children fly, and `for 2 adults, 1 child, 1 infant` when an infant flies on a lap. `$1,610 for 2` is the price of both
130
+ * seats together. Every traveller is named, so a card never reads as a price for fewer people than fly.
120
131
  */
121
132
  export function flightPartyWords(party) {
122
133
  if (party.children === 0)
123
134
  return `for ${party.adults}`;
124
- return `for ${party.adults} adult${party.adults === 1 ? '' : 's'} and ${party.children} ${party.children === 1 ? 'child' : 'children'}`;
135
+ const lap = Math.min(Math.max(0, party.infants ?? 0), party.children);
136
+ const kids = party.children - lap;
137
+ const groups = [
138
+ `${party.adults} adult${party.adults === 1 ? '' : 's'}`,
139
+ ...(kids ? [`${kids} ${kids === 1 ? 'child' : 'children'}`] : []),
140
+ ...(lap ? [`${lap} infant${lap === 1 ? '' : 's'}`] : []),
141
+ ];
142
+ return `for ${groups.length === 2 ? groups.join(' and ') : groups.join(', ')}`;
125
143
  }
126
144
  export function money(minor, currency) {
127
145
  if (typeof minor !== 'number' || !Number.isFinite(minor))
@@ -293,7 +311,7 @@ export function forModel(result) {
293
311
  question: result.choice.question === null ? null : stripLinks(result.choice.question),
294
312
  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)})` : ''}`) })),
295
313
  } } : {}),
296
- reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
314
+ reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true, [...products.filter(held).map((product) => product.url), ...stores.filter(heldStore).map((store) => store.url)]),
297
315
  cart: result.cart ? cartView(result.cart, result.cart_building?.store) : null,
298
316
  carts: (result.carts ?? []).map((cart) => cartView(cart, result.cart_building?.store)),
299
317
  products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The groups an answer's products come in: one per store or item the shopper asked for, such as a vacuum at Amazon and a
3
+ * laundry hamper at Target, each with its own stores, status and products. The answer's `catalog.groups` carries them beside
4
+ * the flat `catalog.items`, which every group's `product_ids` point into; a streamed turn's `group` events carry each group
5
+ * as it changes. `groupsOf` reads them, and reads an answer with no groups as one group holding every product, so an app
6
+ * draws a grid per group whatever the answer carries.
7
+ */
8
+ import { type BuyMerchantStatus } from './search-status.js';
9
+ import type { BuyCatalog, BuyCatalogItem } from './client.js';
10
+ /** What a group holds: products, or one part of a trip. */
11
+ export type BuyGroupKind = 'product' | 'flight' | 'stay' | 'car' | 'event' | 'table';
12
+ /**
13
+ * Where a group stands. `loading`: its searches still run. `ready`: it has products to show. `empty`: its searches
14
+ * answered with none. `failed`: its searches failed. `needs`: it waits for details the shopper has not given, in `missing`.
15
+ */
16
+ export type BuyGroupStatus = 'loading' | 'ready' | 'empty' | 'failed' | 'needs';
17
+ /** One group of an answer's products, as Agentcard sends it. */
18
+ export interface BuyGroup {
19
+ /** The group's id, the same on every update of the group within a turn. */
20
+ id: string;
21
+ kind: BuyGroupKind;
22
+ /** The group's heading, to show the shopper: `Vacuums at Amazon`. */
23
+ label: string;
24
+ /** The part of the shopper's message this group answers: `a vacuum on Amazon`. */
25
+ ask: string;
26
+ /** Each store the group searched, in the order its search started. */
27
+ stores: BuyMerchantStatus[];
28
+ status: BuyGroupStatus;
29
+ /** `full`: draw the group's products as a grid. `summary`: draw only its `summary` line, which the shopper taps to see them. */
30
+ display: 'full' | 'summary';
31
+ /** The ids of the group's products in `catalog.items`, in the order to show them. */
32
+ product_ids: string[];
33
+ /** One line for the group, such as `Also at Home Depot from $89`; absent when Agentcard wrote none. */
34
+ summary?: string;
35
+ /** What the group assumed that the shopper did not say, such as `arrival Dec 24`; absent when it assumed nothing. */
36
+ assumes?: string;
37
+ /** The details a `needs` group waits for, such as `pickup time`; absent otherwise. */
38
+ missing?: string[];
39
+ }
40
+ /** A group with its products read from the answer's catalog, in the group's order. */
41
+ export interface BuyGroupWithProducts extends BuyGroup {
42
+ products: BuyCatalogItem[];
43
+ }
44
+ /** The groups of a running turn, every time one of them changes. */
45
+ export interface BuyGroupsUpdate {
46
+ conversationId: string | null;
47
+ /** Every group the turn has so far, in the shopper's order. */
48
+ groups: BuyGroup[];
49
+ /** True for the groups the finished answer carries, the last update of the turn; false while its searches run. */
50
+ final: boolean;
51
+ }
52
+ /** One group as Agentcard sent it, or null for one with no id or a kind or status this version does not know. */
53
+ export declare function groupOf(v: unknown): BuyGroup | null;
54
+ /** A list of groups as Agentcard sent it, or undefined when it sent none. */
55
+ export declare function groupListOf(v: unknown): BuyGroup[] | undefined;
56
+ /** The parts of an answer `groupsOf` reads: a `BuyTurn`, or any answer that carries its catalog and search. */
57
+ export interface BuyGroupsSource {
58
+ catalog: BuyCatalog | null;
59
+ search?: {
60
+ final: boolean;
61
+ merchants: BuyMerchantStatus[];
62
+ } | null;
63
+ }
64
+ /**
65
+ * The answer's groups, each with its products read from the catalog in the group's order; an id the catalog does not hold
66
+ * is left out. An answer with no groups reads as one `product` group holding every product, `full`, with the answer's
67
+ * stores: `loading` while a store's search still runs, `ready` with products, `empty` without. An answer with no catalog
68
+ * reads as no groups. It reads the answer alone, so a stored answer read back later gives the groups the live turn gave.
69
+ */
70
+ export declare function groupsOf(answer: BuyGroupsSource): BuyGroupWithProducts[];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The groups an answer's products come in: one per store or item the shopper asked for, such as a vacuum at Amazon and a
3
+ * laundry hamper at Target, each with its own stores, status and products. The answer's `catalog.groups` carries them beside
4
+ * the flat `catalog.items`, which every group's `product_ids` point into; a streamed turn's `group` events carry each group
5
+ * as it changes. `groupsOf` reads them, and reads an answer with no groups as one group holding every product, so an app
6
+ * draws a grid per group whatever the answer carries.
7
+ */
8
+ import { searchOf } from './search-status.js';
9
+ const KINDS = new Set(['product', 'flight', 'stay', 'car', 'event', 'table']);
10
+ const STATUSES = new Set(['loading', 'ready', 'empty', 'failed', 'needs']);
11
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
12
+ const text = (v) => (typeof v === 'string' && v.trim() !== '' ? v : null);
13
+ const texts = (v) => (Array.isArray(v) ? v.filter((s) => text(s) !== null) : []);
14
+ /** One group as Agentcard sent it, or null for one with no id or a kind or status this version does not know. */
15
+ export function groupOf(v) {
16
+ if (!isRecord(v))
17
+ return null;
18
+ const id = text(v.id);
19
+ if (!id || typeof v.kind !== 'string' || !KINDS.has(v.kind) || typeof v.status !== 'string' || !STATUSES.has(v.status))
20
+ return null;
21
+ const stores = searchOf({ search_id: id, merchants: Array.isArray(v.stores) ? v.stores : [] })?.merchants ?? [];
22
+ const summary = text(v.summary);
23
+ const assumes = text(v.assumes);
24
+ const missing = texts(v.missing);
25
+ return {
26
+ id, kind: v.kind, label: text(v.label) ?? '', ask: text(v.ask) ?? '', stores, status: v.status,
27
+ display: v.display === 'summary' ? 'summary' : 'full', product_ids: texts(v.product_ids),
28
+ ...(summary ? { summary } : {}), ...(assumes ? { assumes } : {}), ...(missing.length ? { missing } : {}),
29
+ };
30
+ }
31
+ /** A list of groups as Agentcard sent it, or undefined when it sent none. */
32
+ export function groupListOf(v) {
33
+ if (!Array.isArray(v))
34
+ return undefined;
35
+ const groups = v.flatMap((g) => groupOf(g) ?? []);
36
+ return groups.length ? groups : undefined;
37
+ }
38
+ /**
39
+ * The answer's groups, each with its products read from the catalog in the group's order; an id the catalog does not hold
40
+ * is left out. An answer with no groups reads as one `product` group holding every product, `full`, with the answer's
41
+ * stores: `loading` while a store's search still runs, `ready` with products, `empty` without. An answer with no catalog
42
+ * reads as no groups. It reads the answer alone, so a stored answer read back later gives the groups the live turn gave.
43
+ */
44
+ export function groupsOf(answer) {
45
+ const catalog = answer.catalog;
46
+ if (!catalog)
47
+ return [];
48
+ const byId = new Map(catalog.items.map((item) => [item.id, item]));
49
+ // Read again here, so an answer kept as JSON and read back later, or a raw body, gives the same groups as the live turn.
50
+ const groups = groupListOf(catalog.groups);
51
+ if (groups) {
52
+ return groups.map((g) => ({ ...g, products: g.product_ids.flatMap((id) => byId.get(id) ?? []) }));
53
+ }
54
+ const search = answer.search ?? null;
55
+ const status = search && !search.final ? 'loading' : catalog.items.length ? 'ready' : 'empty';
56
+ return [{
57
+ id: 'all', kind: 'product', label: catalog.merchant_name, ask: '', stores: search?.merchants ?? [], status, display: 'full',
58
+ product_ids: catalog.items.map((item) => item.id), products: catalog.items,
59
+ }];
60
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The store pages a reply may keep, and the words that only name a link.
3
+ *
4
+ * Every link comes out of what the shopper reads (model-view.ts stripLinks), with one exception: the page at the store of a
5
+ * product or store card this answer sent, written as the card carries it. The shopper asked for it, and it is the real page
6
+ * at the store. A referral or tracking address (buy.trychannel3.com) is never one, and neither is a photo's address.
7
+ *
8
+ * 2026-11-02, chat.opencommerce.sh (conv_b1eb3d1508196604653217c0): a reply about a rack at Amazon ended
9
+ * "Link: https://www.amazon.com/dp/B0BS765YQL" and "Photo: https://m.media-amazon.com/...jpg", and the shopper read "link."
10
+ * and "photo." as a message of its own. The card's page now stays, and a line that named only a removed link goes.
11
+ */
12
+ /** True when `text` holds nothing but words that name a link, so it goes with the link. */
13
+ export declare function namesOnlyALink(text: string): boolean;
14
+ /** A Markdown image. A chat shows its alt text, never the picture. */
15
+ export declare const MARKDOWN_IMAGE: RegExp;
16
+ /** Markdown links whose labels only name the link, at the end of a line or making up all of it, with the marks between them. A sentence's own closing mark before them stays. */
17
+ export declare const LINK_WORD_LINKS: RegExp;
18
+ /**
19
+ * The reply with each address of a card's store page held aside, and the function that puts them back once every other link
20
+ * is gone. A Markdown link to such a page reads as its label then the address, or the address alone when its label only named
21
+ * it. An address that only shares a page's host is not that page and comes out as before.
22
+ */
23
+ export declare function holdStorePages(reply: string, pages: readonly (string | null | undefined)[]): {
24
+ text: string;
25
+ restore: (text: string) => string;
26
+ };
27
+ /** What a removed line leaves in its place until its newline goes with it (withoutGoneLines). */
28
+ export declare const GONE = "\u2064";
29
+ /** The text with every line that held only a removed link taken out with its newline, so no paragraph break is left where it was. */
30
+ export declare function withoutGoneLines(text: string): string;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The store pages a reply may keep, and the words that only name a link.
3
+ *
4
+ * Every link comes out of what the shopper reads (model-view.ts stripLinks), with one exception: the page at the store of a
5
+ * product or store card this answer sent, written as the card carries it. The shopper asked for it, and it is the real page
6
+ * at the store. A referral or tracking address (buy.trychannel3.com) is never one, and neither is a photo's address.
7
+ *
8
+ * 2026-11-02, chat.opencommerce.sh (conv_b1eb3d1508196604653217c0): a reply about a rack at Amazon ended
9
+ * "Link: https://www.amazon.com/dp/B0BS765YQL" and "Photo: https://m.media-amazon.com/...jpg", and the shopper read "link."
10
+ * and "photo." as a message of its own. The card's page now stays, and a line that named only a removed link goes.
11
+ */
12
+ /** A label that only names the link it stood for: `link`, `photo`, `here's the link`. Once the link is gone it says nothing. */
13
+ const LINK_WORDS = String.raw `(?:here(?:'s|\s+is)?\s+)?(?:(?:the|a|an|this|its|product|store)\s+)*(?:links?|photos?|pics?|pictures?|images?|urls?|pages?|listings?|here)`;
14
+ const ONLY_LINK_WORDS = new RegExp(String.raw `^\s*[-*•]?\s*${LINK_WORDS}\s*[.:!]?\s*$`, 'i');
15
+ /** True when `text` holds nothing but words that name a link, so it goes with the link. */
16
+ export function namesOnlyALink(text) {
17
+ return ONLY_LINK_WORDS.test(String(text ?? '').replace(/\*\*|__/g, ''));
18
+ }
19
+ /** A Markdown image. A chat shows its alt text, never the picture. */
20
+ export const MARKDOWN_IMAGE = /!\[[^\]\n]*\]\([^)\n]*\)/g;
21
+ /** Markdown links whose labels only name the link, at the end of a line or making up all of it, with the marks between them. A sentence's own closing mark before them stays. */
22
+ export const LINK_WORD_LINKS = new RegExp(String.raw `(?:[ \t]*(?:[,;|&·•-]|\band\b|\bor\b)?[ \t]*\[[ \t]*(?:\*\*|__)?${LINK_WORDS}(?:\*\*|__)?[ \t]*\]\([^)\n]*\)[.,;:!]?)+[ \t]*$`, 'gim');
23
+ /** Hosts that only count a click and send the shopper on: never the store's own page. */
24
+ const TRACKING_HOSTS = ['trychannel3.com'];
25
+ /** A page as compared here: host without `www.`, path without a trailing slash, and its query. Null for an address that is no web page. */
26
+ function pageKey(address) {
27
+ try {
28
+ const url = new URL(address);
29
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
30
+ return null;
31
+ const host = url.hostname.toLowerCase().replace(/^www\./, '');
32
+ if (TRACKING_HOSTS.some((tracking) => host === tracking || host.endsWith(`.${tracking}`)))
33
+ return null;
34
+ return `${host}${url.pathname.replace(/\/+$/, '')}${url.search}`;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** A full address in a reply, and the closing marks of its sentence after it. */
41
+ const ADDRESS = /https?:\/\/[^\s<>"'`)\]]+/gi;
42
+ /** A Markdown link with a full address. */
43
+ const MARKDOWN_ADDRESS = /\[([^\]\n]*)\]\((https?:\/\/[^)\s]+)\)/gi;
44
+ /** The mark that holds a kept page's place while every other link comes out: invisible, and no link pattern reads it. */
45
+ const HOLD = (i) => `⁣${i}⁣`;
46
+ const HELD = /⁣(\d+)⁣/g;
47
+ /**
48
+ * The reply with each address of a card's store page held aside, and the function that puts them back once every other link
49
+ * is gone. A Markdown link to such a page reads as its label then the address, or the address alone when its label only named
50
+ * it. An address that only shares a page's host is not that page and comes out as before.
51
+ */
52
+ export function holdStorePages(reply, pages) {
53
+ const keys = new Set(pages.map((page) => (typeof page === 'string' ? pageKey(page) : null)).filter((key) => key !== null));
54
+ const held = [];
55
+ const hold = (address) => {
56
+ const key = pageKey(address);
57
+ if (key === null || !keys.has(key))
58
+ return null;
59
+ held.push(address);
60
+ return HOLD(held.length - 1);
61
+ };
62
+ if (keys.size === 0)
63
+ return { text: reply, restore: (text) => text };
64
+ /** A Markdown link to a held page reads as its label then the page, or the page alone when its label only named it. */
65
+ const labelled = (label, mark) => (namesOnlyALink(label) || label.trim() === '' ? mark : `${label.trim()}: ${mark}`);
66
+ // Each card's page exactly as the card carries it goes first, in a Markdown link or on its own, so a page whose address
67
+ // holds a bracket, such as `stool_(oak)`, is held whole before MARKDOWN_ADDRESS and ADDRESS, which stop at a bracket.
68
+ const exact = [...new Set(pages.filter((page) => typeof page === 'string' && pageKey(page) !== null))].sort((a, b) => b.length - a.length);
69
+ const text = exact
70
+ .reduce((out, page) => {
71
+ const quoted = page.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ const inLink = out.replace(new RegExp(String.raw `\[([^\]\n]*)\]\(${quoted}\)`, 'g'), (_whole, label) => labelled(label, HOLD(held.push(page) - 1)));
73
+ // The page alone, never the start of a longer address: what follows it ends the address or the sentence.
74
+ return inLink.replace(new RegExp(String.raw `${quoted}(?=$|[\s<>"'\x60\]]|[.,;:!?)]+(?:\s|$))`, 'g'), () => HOLD(held.push(page) - 1));
75
+ }, reply)
76
+ .replace(MARKDOWN_ADDRESS, (whole, label, address) => {
77
+ const mark = hold(address);
78
+ return mark === null ? whole : labelled(label, mark);
79
+ })
80
+ .replace(ADDRESS, (whole) => {
81
+ const [, address, closing] = /^(.*?)([.,;:!?]*)$/.exec(whole) ?? [];
82
+ const mark = address ? hold(address) : null;
83
+ return mark === null ? whole : `${mark}${closing ?? ''}`;
84
+ });
85
+ return { text, restore: (out) => out.replace(HELD, (_mark, i) => held[Number(i)] ?? '') };
86
+ }
87
+ /** What a removed line leaves in its place until its newline goes with it (withoutGoneLines). */
88
+ export const GONE = '\u2064';
89
+ /** The text with every line that held only a removed link taken out with its newline, so no paragraph break is left where it was. */
90
+ export function withoutGoneLines(text) {
91
+ return text.replace(/^[ \t]*\u2064[ \t]*(?:\n|$)/gm, '').replace(/[ \t]*\u2064/g, '');
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.18.10",
3
+ "version": "0.18.13",
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",