@agent-cards/commerce-sdk 0.8.0 → 0.10.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/dist/messages.js CHANGED
@@ -116,9 +116,60 @@ export function toMessages(text) {
116
116
  scanner.end();
117
117
  return messages;
118
118
  }
119
+ /** The pause before a message, as someone texting takes it: 300 ms, plus 12 ms a character, at most 1.5 seconds. */
120
+ const TYPING_BASE_MS = 300;
121
+ const TYPING_MS_PER_CHARACTER = 12;
122
+ const TYPING_MAX_MS = 1_500;
119
123
  /**
120
- * Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
121
- * `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
124
+ * How long someone texting takes to type `message`, in milliseconds: 300, plus 12 a character, at most
125
+ * 1,500. `messageStream` and `buy.turn` wait this long between two messages by default, so a model that
126
+ * writes three paragraphs in 100 ms still delivers them one at a time.
127
+ *
128
+ * ```ts
129
+ * typingDelay('which one do you want?'); // 564
130
+ * ```
131
+ */
132
+ export function typingDelay(message) {
133
+ return Math.min(TYPING_MAX_MS, TYPING_BASE_MS + TYPING_MS_PER_CHARACTER * [...String(message ?? '')].length);
134
+ }
135
+ /** The pause for each message `pace` asks for, or null for none. Throws a TypeError for a pace that cannot work. */
136
+ export function paceOf(pace) {
137
+ if (pace === undefined || pace === true)
138
+ return typingDelay;
139
+ if (pace === false)
140
+ return null;
141
+ if (typeof pace !== 'function')
142
+ throw new TypeError('Pass pace as true, false, or a function that returns the pause before a message in milliseconds.');
143
+ return (message) => {
144
+ let ms;
145
+ try {
146
+ ms = Number(pace(message));
147
+ }
148
+ catch {
149
+ // A pace function that fails costs only the pause.
150
+ return 0;
151
+ }
152
+ return Number.isFinite(ms) && ms > 0 ? ms : 0;
153
+ };
154
+ }
155
+ /** A typing hook that never throws: one that fails costs nothing but the indicator. */
156
+ export function typingHook(onTyping) {
157
+ if (onTyping !== undefined && typeof onTyping !== 'function')
158
+ throw new TypeError('Pass onTyping as a function that takes true when a pause starts and false when its message goes out.');
159
+ return (typing) => {
160
+ try {
161
+ onTyping?.(typing);
162
+ }
163
+ catch {
164
+ // The app's indicator failing never stops a message.
165
+ }
166
+ };
167
+ }
168
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
169
+ /**
170
+ * Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph is ready the
171
+ * moment the blank line that ends it arrives, whatever the pieces split it into, and goes to
172
+ * `onMessage` then, or, after the first, once its typing pause since the last one passed (`pace`). A
122
173
  * paragraph inside a list waits for the first characters of the line after the blank, which say
123
174
  * whether the list goes on. `end` hands over the last message and waits for every send.
124
175
  *
@@ -133,16 +184,38 @@ export function messageStream(options) {
133
184
  const onMessage = options?.onMessage;
134
185
  if (typeof onMessage !== 'function')
135
186
  throw new TypeError('Pass onMessage as a function that takes each message.');
187
+ const pauseOf = paceOf(options.pace);
188
+ const typing = typingHook(options.onTyping);
136
189
  // The line still being written. A trailing \r waits here too, in case its \n is in the next piece.
137
190
  let open = '';
138
191
  let ended = false;
139
192
  let failure = null;
140
- // Messages waiting for the send before them. A send that returns no promise goes out at once.
193
+ // Messages waiting for the send before them, or for their pause. A send that returns no promise goes out at once.
141
194
  const queue = [];
142
195
  let running = null;
196
+ // When the last message went out, and whether the app was told a pause started.
197
+ let lastAt = null;
198
+ let paused = false;
199
+ // The pause chosen for the message at the head of the queue: chosen once, so a pace that varies never makes it wait twice.
200
+ let headPause = null;
143
201
  const drain = () => {
144
202
  while (queue.length > 0 && !failure) {
145
- const message = queue.shift();
203
+ const message = queue[0];
204
+ if (pauseOf && lastAt !== null) {
205
+ headPause ??= pauseOf(message);
206
+ const wait = lastAt + headPause - Date.now();
207
+ if (wait > 0) {
208
+ if (!paused)
209
+ typing(true);
210
+ paused = true;
211
+ return sleep(wait).then(() => drain() ?? undefined);
212
+ }
213
+ }
214
+ queue.shift();
215
+ headPause = null;
216
+ if (paused)
217
+ typing(false);
218
+ paused = false;
146
219
  let sent;
147
220
  try {
148
221
  sent = onMessage(message);
@@ -152,10 +225,14 @@ export function messageStream(options) {
152
225
  break;
153
226
  }
154
227
  if (sent && typeof sent.then === 'function') {
155
- return Promise.resolve(sent).then(() => drain() ?? undefined, (error) => {
228
+ return Promise.resolve(sent).then(() => {
229
+ lastAt = Date.now();
230
+ return drain() ?? undefined;
231
+ }, (error) => {
156
232
  failure = { error };
157
233
  });
158
234
  }
235
+ lastAt = Date.now();
159
236
  }
160
237
  return null;
161
238
  };
@@ -4,10 +4,11 @@
4
4
  * model that holds a link writes it into the chat. So a link never enters
5
5
  * this view: `sendLink` delivers it, and the model reads `link_sent` for the
6
6
  * Vault and approval links, and `link_sent` on each product and
7
- * `product_links_sent` for the products' store pages.
7
+ * `product_links_sent` for the products' store pages, and `link_sent` on each
8
+ * store and `store_links_sent` for the pages of the stores to pick from.
8
9
  */
9
10
  import type { BuyNextAction } from './client.js';
10
- import type { BuyToolProduct, BuyToolResult, BuyToolStatus } from './buy-tool.js';
11
+ import type { BuyToolProduct, BuyToolResult, BuyToolStatus, BuyToolStore } from './buy-tool.js';
11
12
  export interface BuyToolModelCart {
12
13
  store: string;
13
14
  items: Array<{
@@ -16,8 +17,17 @@ export interface BuyToolModelCart {
16
17
  price: string | null;
17
18
  }>;
18
19
  fees: string | null;
20
+ /** The store's own fee and tax lines inside `fees`, by the store's labels. Empty when it itemized none. */
21
+ fee_lines: Array<{
22
+ label: string;
23
+ amount: string;
24
+ }>;
19
25
  tip: string | null;
20
26
  total: string;
27
+ /** True when the store has not priced its fees and tax yet: `total` is an estimate, never the final amount. */
28
+ total_is_estimate: boolean;
29
+ /** The most the shopper's card can be held for this cart: above `total` at a store that prices its final at checkout, `total` itself for a flight, which holds exactly that; else null. */
30
+ max_charge: string | null;
21
31
  }
22
32
  export interface BuyToolModelProduct {
23
33
  /** What the model passes as `product_id` when the shopper picks this product: Agentcard's ref, never a link. Null when there is none. */
@@ -29,6 +39,15 @@ export interface BuyToolModelProduct {
29
39
  /** True when the shopper holds this product's store page, which `sendLink` delivered beside the reply. */
30
40
  link_sent: boolean;
31
41
  }
42
+ export interface BuyToolModelStore {
43
+ /** What the model passes as `store_id` when the shopper picks this store: Agentcard's id, never a link. */
44
+ store_id: string;
45
+ name: string;
46
+ /** The delivery estimate in minutes, or null when the store showed none. */
47
+ eta_minutes: number | null;
48
+ /** True when the shopper holds this store's page, which `sendLink` delivered beside the reply. */
49
+ link_sent: boolean;
50
+ }
32
51
  /** The buy tool's answer as the model reads it. */
33
52
  export interface BuyToolModelView {
34
53
  status: BuyToolStatus;
@@ -47,13 +66,22 @@ export interface BuyToolModelView {
47
66
  shopper_already_told_looking: boolean;
48
67
  /** True when the shopper already has the link this answer needed. */
49
68
  link_sent: boolean;
50
- /** How many store pages of the products above the shopper holds, 0 when none went out. */
69
+ /** How many of the products above the shopper holds as cards, each with its name, price and picture; 0 when none went out. */
51
70
  product_links_sent: number;
52
71
  /** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
53
72
  pick: {
54
73
  product_id: string | null;
55
74
  in_cart: boolean;
56
75
  } | null;
76
+ /** The stores the reply presents to choose from, in the order it names them. */
77
+ stores: BuyToolModelStore[];
78
+ /** How many store pages of the stores above the shopper holds, 0 when none went out. */
79
+ store_links_sent: number;
80
+ /** The store this call's `store_id` (or an answer such as `the second one`) picked, and whether Agentcard selected it; null when none was picked. */
81
+ store_pick: {
82
+ store_id: string;
83
+ selected: boolean;
84
+ } | null;
57
85
  }
58
86
  /**
59
87
  * The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
@@ -73,11 +101,28 @@ export declare function money(minor: number | null | undefined, currency: string
73
101
  * shopper never holds a product link the model cannot name.
74
102
  */
75
103
  export declare function shownProducts(products: readonly BuyToolProduct[] | null | undefined): BuyToolProduct[];
104
+ /**
105
+ * The stores the model reads: each one whose id and name hold no link, so the model never reads a
106
+ * link and never offers a store without its name. The tool sends pages for these same stores.
107
+ */
108
+ export declare function shownStores(stores: readonly BuyToolStore[] | null | undefined): BuyToolStore[];
76
109
  /**
77
110
  * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
78
111
  * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
79
112
  */
80
113
  export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): string | null;
114
+ /**
115
+ * What tells one product card from another: the product's link-free id, else its page. A restaurant gives
116
+ * every menu item the store's one page, so a card per page sent one card for a reply that offered three
117
+ * dishes (2026-10-20, Suavecito Birria & Tacos on DoorDash). Each product the model reads gets its own card.
118
+ */
119
+ export declare function cardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url'>): string | null;
120
+ /**
121
+ * The key of the card a product gets, or null when it gets none: a product with a page at its store,
122
+ * or a flight option, which has no page, with an id a pick takes. A flight's card is how the shopper
123
+ * taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
124
+ */
125
+ export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight'>): string | null;
81
126
  /**
82
127
  * The model's view of one answer: the status, Agentcard's reply, the newest
83
128
  * cart and every cart on the table, the products by id, name and price, and
@@ -93,6 +93,13 @@ export function money(minor, currency) {
93
93
  export function shownProducts(products) {
94
94
  return (products ?? []).filter((product) => stripLinks(product.name) !== '' && stripLinks(product.store ?? '') !== '').slice(0, MAX_PRODUCTS);
95
95
  }
96
+ /**
97
+ * The stores the model reads: each one whose id and name hold no link, so the model never reads a
98
+ * link and never offers a store without its name. The tool sends pages for these same stores.
99
+ */
100
+ export function shownStores(stores) {
101
+ return (stores ?? []).filter((store) => store.id !== '' && stripLinks(store.id) === store.id && stripLinks(store.name) !== '');
102
+ }
96
103
  /**
97
104
  * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
98
105
  * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
@@ -102,13 +109,35 @@ export function pickId(product) {
102
109
  return product.ref;
103
110
  return typeof product.id === 'string' && product.id !== '' && stripLinks(product.id) === product.id ? product.id : null;
104
111
  }
112
+ /**
113
+ * What tells one product card from another: the product's link-free id, else its page. A restaurant gives
114
+ * every menu item the store's one page, so a card per page sent one card for a reply that offered three
115
+ * dishes (2026-10-20, Suavecito Birria & Tacos on DoorDash). Each product the model reads gets its own card.
116
+ */
117
+ export function cardKey(product) {
118
+ return pickId(product) ?? (typeof product.url === 'string' && product.url !== '' ? product.url : null);
119
+ }
120
+ /**
121
+ * The key of the card a product gets, or null when it gets none: a product with a page at its store,
122
+ * or a flight option, which has no page, with an id a pick takes. A flight's card is how the shopper
123
+ * taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
124
+ */
125
+ export function productCardKey(product) {
126
+ if (typeof product.url === 'string' && product.url !== '')
127
+ return cardKey(product);
128
+ return product.flight ? pickId(product) : null;
129
+ }
105
130
  function cartView(cart) {
106
131
  return {
107
132
  store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
108
133
  items: cart.items.map((line) => ({ name: stripLinks(line.name), qty: line.qty, price: money(line.priceCents, cart.currency) })),
109
134
  fees: cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
135
+ fee_lines: (cart.merchantFeeLines ?? []).map((line) => ({ label: stripLinks(line.label), amount: money(line.amountCents, cart.currency) })),
110
136
  tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
111
137
  total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
138
+ total_is_estimate: cart.totalIsEstimate === true,
139
+ // Agentcard names a ceiling equal to the total only for a hold of exactly the total, as a flight's.
140
+ max_charge: typeof cart.approvedCeilingCents === 'number' && cart.approvedCeilingCents > 0 && cart.approvedCeilingCents >= cart.totalCents ? money(cart.approvedCeilingCents, cart.currency) : null,
112
141
  };
113
142
  }
114
143
  /**
@@ -120,7 +149,9 @@ function cartView(cart) {
120
149
  export function forModel(result) {
121
150
  const status = typeof result.status === 'string' && MODEL_STATUSES.has(result.status) ? result.status : 'needs_input';
122
151
  const products = shownProducts(result.products);
123
- const held = (product) => product.linkSent === true && typeof product.url === 'string' && product.url !== '';
152
+ const held = (product) => product.linkSent === true && productCardKey(product) !== null;
153
+ const stores = shownStores(result.stores);
154
+ const heldStore = (store) => store.linkSent === true && typeof store.url === 'string' && store.url !== '';
124
155
  return {
125
156
  status,
126
157
  next_action: result.next_action ?? null,
@@ -132,10 +163,16 @@ export function forModel(result) {
132
163
  shopper_need_not_reply: result.shopper_need_not_reply === true,
133
164
  shopper_already_told_looking: result.shopper_already_told_looking === true,
134
165
  link_sent: result.link_sent === true,
135
- // Two products on one page share one link, so the count is of pages.
136
- product_links_sent: new Set(products.filter(held).map((product) => product.url)).size,
166
+ // A card per product, so two dishes on a restaurant's one page are two cards.
167
+ product_links_sent: new Set(products.filter(held).map(productCardKey)).size,
137
168
  // The id the model sent, never the catalog id, which is a store page for a retail product.
138
169
  pick: result.pick ? { product_id: pickId({ id: result.pick.product_id, ref: result.pick.ref }), in_cart: result.pick.in_cart === true } : null,
170
+ stores: stores.map((store) => ({ store_id: store.id, name: stripLinks(store.name), eta_minutes: store.etaMinutes, link_sent: heldStore(store) })),
171
+ store_links_sent: new Set(stores.filter(heldStore).map((store) => store.url)).size,
172
+ // A store id with a link in it is never read back to the model.
173
+ store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
174
+ ? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
175
+ : null,
139
176
  };
140
177
  }
141
178
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",