@agent-cards/commerce-sdk 0.11.0 → 0.14.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.
@@ -11,12 +11,22 @@ import type { BuyNextAction } from './client.js';
11
11
  import type { BuyToolProduct, BuyToolResult, BuyToolStatus, BuyToolStore } from './buy-tool.js';
12
12
  export interface BuyToolModelCart {
13
13
  store: string;
14
+ /**
15
+ * `price` is one unit's; `line_total` is what the line costs with its add-ons and quantity, and the
16
+ * lines' totals add up before `fees`. `options` are the choices made on the line and `variant` the
17
+ * variant it is. `product_id` is the id the line's product carries in `products`, or null.
18
+ */
14
19
  items: Array<{
20
+ product_id: string | null;
15
21
  name: string;
16
22
  qty: number;
17
23
  price: string | null;
24
+ line_total: string | null;
25
+ options: string[];
26
+ variant: string | null;
18
27
  }>;
19
28
  fees: string | null;
29
+ fees_status?: 'known' | 'pending' | 'unknown';
20
30
  /** The store's own fee and tax lines inside `fees`, by the store's labels. Empty when it itemized none. */
21
31
  fee_lines: Array<{
22
32
  label: string;
@@ -26,6 +36,8 @@ export interface BuyToolModelCart {
26
36
  total: string;
27
37
  /** True when the store has not priced its fees and tax yet: `total` is an estimate, never the final amount. */
28
38
  total_is_estimate: boolean;
39
+ /** What an estimated `total` leaves out, in words the model can repeat; null when the store priced the cart. */
40
+ total_leaves_out: string | null;
29
41
  /** 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
42
  max_charge: string | null;
31
43
  }
@@ -53,6 +65,13 @@ export interface BuyToolModelView {
53
65
  status: BuyToolStatus;
54
66
  /** The one thing to ask the shopper for in this message, or null when nothing waits on them. Never ask for a later step. */
55
67
  next_action: BuyNextAction | null;
68
+ /**
69
+ * The store whose checkout is still building the cart this answer named, absent when none is: its cart and total reach the
70
+ * shopper in a message from Agentcard, and only the shopper's yes to that total confirms it.
71
+ */
72
+ cart_building?: {
73
+ store: string;
74
+ };
56
75
  /** Agentcard's answer for the shopper, with every link taken out. */
57
76
  reply: string;
58
77
  /** The newest cart on the table. */
@@ -61,9 +80,18 @@ export interface BuyToolModelView {
61
80
  carts: BuyToolModelCart[];
62
81
  products: BuyToolModelProduct[];
63
82
  approval_expires_at: string | null;
83
+ /**
84
+ * How long the newest link this answer sent (Vault, approval or store login) still works, in words the
85
+ * shopper reads, such as `about 24 hours` or `about 15 minutes`; `no longer` once it ran out; null when
86
+ * Agentcard named no expiry. Read when the answer came back, so the model never reckons it from a timestamp.
87
+ */
88
+ link_expires_in: string | null;
89
+ /** True when the shopper need not reply to this answer, as when Agentcard places the order on its own. False beside a step `next_action` names, whose ask is one short line. */
64
90
  shopper_need_not_reply: boolean;
65
91
  /** True when the shopper already got a message this turn saying their request is being worked on, so the reply opens with no acknowledgement of its own. */
66
92
  shopper_already_told_looking: boolean;
93
+ /** True when the shopper already read `reply`, every message of it, as Agentcard wrote it, so the answer adds only what it leaves out. */
94
+ shopper_already_read_reply: boolean;
67
95
  /** True when the shopper already has the link this answer needed. */
68
96
  link_sent: boolean;
69
97
  /**
@@ -99,6 +127,15 @@ export declare function stripLinks(text: string): string;
99
127
  * decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
100
128
  * Agentcard did not name shows with no symbol, because a dollar sign on it could be wrong.
101
129
  */
130
+ /**
131
+ * A product's price as the shopper reads it: `$12.99`, or `from $10.69` for a product priced by a
132
+ * required choice (a size, a patty count), whose base price is often $0. Null when Agentcard named none.
133
+ */
134
+ export declare function productPrice(product: {
135
+ priceCents: number | null;
136
+ currency: string | null;
137
+ priceFrom?: true;
138
+ }): string | null;
102
139
  export declare function money(minor: number | null | undefined, currency: string | null | undefined): string | null;
103
140
  /**
104
141
  * The products the model reads: the first twenty that keep a name and a store
@@ -129,6 +166,8 @@ export declare function cardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'ur
129
166
  * taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
130
167
  */
131
168
  export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight'>): string | null;
169
+ /** How long until `iso`, from `now`, as the shopper reads it: `about 24 hours`, `about 15 minutes`, `no longer`, or null. */
170
+ export declare function expiresIn(iso: string | null | undefined, now?: number): string | null;
132
171
  /**
133
172
  * The model's view of one answer: the status, Agentcard's reply, the newest
134
173
  * cart and every cart on the table, the products by id, name and price, and
@@ -136,6 +175,33 @@ export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref
136
175
  * reads as `needs_input`.
137
176
  */
138
177
  export declare function forModel(result: BuyToolResult): BuyToolModelView;
178
+ /**
179
+ * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
180
+ * `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
181
+ * total and the ask for their yes. The reply comes with every link taken out, as the model reads it.
182
+ * Null for any other answer, which your model puts in its own words, and for a line the tool wrote
183
+ * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
184
+ * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
185
+ * Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
186
+ *
187
+ * ```ts
188
+ * replyAsWritten(result); // 'found two at Amazon. want the first one?'
189
+ * ```
190
+ */
191
+ export declare function replyAsWritten(result: BuyToolResult): string | null;
192
+ /**
193
+ * Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
194
+ * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
195
+ * points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
196
+ * words, each further sentence that names another card. What is left is the reply around the list, such as `found
197
+ * these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
198
+ *
199
+ * ```ts
200
+ * withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
201
+ * // 'Which one?'
202
+ * ```
203
+ */
204
+ export declare function withoutCardList(reply: string, cardNames: readonly string[]): string;
139
205
  /**
140
206
  * A thrown error as the model may read it: the same message with every link
141
207
  * taken out, and the original error on `cause` for your logs. A TypeError
@@ -1,5 +1,7 @@
1
+ /** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
2
+ const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
1
3
  const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
2
- /** At most twenty products reach the model. Agentcard's /buy answer lists three, so this cap holds only for an older answer. */
4
+ /** At most twenty products reach the model. Agentcard's /buy answer lists up to eight, so this cap holds only for an older answer. */
3
5
  const MAX_PRODUCTS = 20;
4
6
  const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
5
7
  const SCHEME_URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`)\]]*/gi;
@@ -82,6 +84,14 @@ function currencyFormat(currency) {
82
84
  * decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
83
85
  * Agentcard did not name shows with no symbol, because a dollar sign on it could be wrong.
84
86
  */
87
+ /**
88
+ * A product's price as the shopper reads it: `$12.99`, or `from $10.69` for a product priced by a
89
+ * required choice (a size, a patty count), whose base price is often $0. Null when Agentcard named none.
90
+ */
91
+ export function productPrice(product) {
92
+ const price = money(product.priceCents, product.currency);
93
+ return price !== null && product.priceFrom ? `from ${price}` : price;
94
+ }
85
95
  export function money(minor, currency) {
86
96
  if (typeof minor !== 'number' || !Number.isFinite(minor))
87
97
  return null;
@@ -136,16 +146,62 @@ export function productCardKey(product) {
136
146
  function cartView(cart) {
137
147
  return {
138
148
  store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
139
- items: cart.items.map((line) => ({ name: stripLinks(line.name), qty: line.qty, price: money(line.priceCents, cart.currency) })),
140
- fees: cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
149
+ items: cart.items.map((line) => ({
150
+ product_id: pickId({ id: line.product_id ?? '', ref: line.ref ?? null }),
151
+ name: stripLinks(line.name),
152
+ qty: line.qty,
153
+ // A $0 listed price on a line that costs something is the base of an item priced by its
154
+ // required choice, such as a burger's patty count: one unit at its configuration then reads
155
+ // the line's own cost over its quantity, never $0 (2026-10-20: "1× Cheeseburger $0.00" under
156
+ // a $10.69 total). Any other line keeps its listed price beside line_total.
157
+ price: money(line.priceCents === 0 && typeof line.lineTotalCents === 'number' && line.lineTotalCents > 0 && line.qty > 0 ? Math.round(line.lineTotalCents / line.qty) : line.priceCents, cart.currency),
158
+ line_total: money(line.lineTotalCents ?? (typeof line.priceCents === 'number' ? line.priceCents * line.qty : null), cart.currency),
159
+ options: (line.options ?? []).map(stripLinks).filter((o) => o !== ''),
160
+ variant: line.variant ? stripLinks(line.variant) || null : null,
161
+ })),
162
+ fees: cart.serviceFeesCents !== null && cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
163
+ fees_status: cart.serviceFeesStatus,
141
164
  fee_lines: (cart.merchantFeeLines ?? []).map((line) => ({ label: stripLinks(line.label), amount: money(line.amountCents, cart.currency) })),
142
165
  tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
143
166
  total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
144
167
  total_is_estimate: cart.totalIsEstimate === true,
168
+ // 2026-10-21, swarm-bigbox-a10: an estimate read "about $18.41 total with tax and shipping" to the shopper. The words
169
+ // ride beside the flag, so the model never has to guess which side of the total tax and shipping sit on.
170
+ total_leaves_out: cart.totalIsEstimate === true ? leftOut(cart.merchantFeeLines ?? []) : null,
145
171
  // Agentcard names a ceiling equal to the total only for a hold of exactly the total, as a flight's.
146
172
  max_charge: typeof cart.approvedCeilingCents === 'number' && cart.approvedCeilingCents > 0 && cart.approvedCeilingCents >= cart.totalCents ? money(cart.approvedCeilingCents, cart.currency) : null,
147
173
  };
148
174
  }
175
+ /** What an estimated total leaves out: tax, and shipping or delivery, less what the store's own fee lines already carry
176
+ * (a 1-800-Flowers estimate holds its Florist Delivery and Sales Tax). Null when the lines carry both. */
177
+ function leftOut(lines) {
178
+ const parts = [];
179
+ if (!lines.some((l) => /\btax\b/i.test(l.label)))
180
+ parts.push('tax');
181
+ if (!lines.some((l) => /\b(?:deliver(?:y|ies)?|shipping)\b/i.test(l.label)))
182
+ parts.push('shipping or delivery');
183
+ return parts.length ? `${parts.join(' and ')}, which the store adds at checkout` : null;
184
+ }
185
+ /** How long until `iso`, from `now`, as the shopper reads it: `about 24 hours`, `about 15 minutes`, `no longer`, or null. */
186
+ export function expiresIn(iso, now = Date.now()) {
187
+ const at = typeof iso === 'string' ? Date.parse(iso) : NaN;
188
+ if (!Number.isFinite(at))
189
+ return null;
190
+ if (at <= now)
191
+ return 'no longer';
192
+ const minutes = Math.max(1, Math.round((at - now) / 60_000));
193
+ if (minutes < 90)
194
+ return `about ${minutes} minute${minutes === 1 ? '' : 's'}`;
195
+ const hours = Math.round(minutes / 60);
196
+ if (hours < 48)
197
+ return `about ${hours} hours`;
198
+ return `about ${Math.round(hours / 24)} days`;
199
+ }
200
+ /** The expiry of the newest Vault, approval or store login link an answer carries, else its approval's. */
201
+ function linkExpiry(result) {
202
+ const timed = (result.links ?? []).filter((link) => (link.kind === 'vault' || link.kind === 'approval' || link.kind === 'store_login') && typeof link.expiresAt === 'string');
203
+ return timed.length > 0 ? timed[timed.length - 1].expiresAt : (result.approval_expires_at ?? null);
204
+ }
149
205
  /**
150
206
  * The model's view of one answer: the status, Agentcard's reply, the newest
151
207
  * cart and every cart on the table, the products by id, name and price, and
@@ -161,13 +217,17 @@ export function forModel(result) {
161
217
  return {
162
218
  status,
163
219
  next_action: result.next_action ?? null,
220
+ ...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
164
221
  reply: stripLinks(result.reply),
165
222
  cart: result.cart ? cartView(result.cart) : null,
166
223
  carts: (result.carts ?? []).map(cartView),
167
- products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: money(product.priceCents, product.currency), link_sent: held(product) })),
224
+ products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
168
225
  approval_expires_at: typeof result.approval_expires_at === 'string' ? stripLinks(result.approval_expires_at) || null : null,
169
- shopper_need_not_reply: result.shopper_need_not_reply === true,
226
+ link_expires_in: expiresIn(linkExpiry(result)),
227
+ // A step's ask is one short line: an approval Agentcard places on its own needs no "no need to reply" beside it.
228
+ shopper_need_not_reply: result.shopper_need_not_reply === true && !STEP_ACTIONS.has(result.next_action ?? ''),
170
229
  shopper_already_told_looking: result.shopper_already_told_looking === true,
230
+ shopper_already_read_reply: result.shopper_already_read_reply === true,
171
231
  link_sent: result.link_sent === true,
172
232
  links_sent: result.link_sent === true
173
233
  ? [...new Set((result.links ?? []).map((link) => link.kind).filter((kind) => kind === 'vault' || kind === 'approval' || kind === 'store_login'))]
@@ -184,6 +244,148 @@ export function forModel(result) {
184
244
  : null,
185
245
  };
186
246
  }
247
+ /**
248
+ * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
249
+ * `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
250
+ * total and the ask for their yes. The reply comes with every link taken out, as the model reads it.
251
+ * Null for any other answer, which your model puts in its own words, and for a line the tool wrote
252
+ * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
253
+ * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
254
+ * Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
255
+ *
256
+ * ```ts
257
+ * replyAsWritten(result); // 'found two at Amazon. want the first one?'
258
+ * ```
259
+ */
260
+ export function replyAsWritten(result) {
261
+ // The shopper already read every message of it through onText: handing it out again would say it twice.
262
+ if (result.shopper_already_read_reply === true)
263
+ return null;
264
+ // The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
265
+ const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
266
+ const view = forModel(result);
267
+ const reply = view.reply.trim();
268
+ if (!agentcards || view.status !== 'needs_input' || reply === '')
269
+ return null;
270
+ const cards = [...view.products.filter((product) => product.link_sent).map((product) => product.name), ...view.stores.filter((store) => store.link_sent).map((store) => store.name)];
271
+ // A reply with nothing left once the cards' list comes out is your model's to word.
272
+ return withoutCardList(reply, cards) || null;
273
+ }
274
+ /** A line that opens a list: `- `, `* `, `• `, `1. ` or `1) `. */
275
+ const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
276
+ /** A price, in any of the currencies a store writes one in. */
277
+ const PRICE = /[$£€]\s?\d/;
278
+ /** The place a sentence points at in a numbered list, such as `#8` or `number 3`, where `a #2 Phillips head` names a kind of thing. */
279
+ const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
280
+ /** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
281
+ const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|mb|w|mah|v|cm|mm|m|pack|pk|ct|count|pcs|pieces)\b/gi;
282
+ /**
283
+ * A size one text spells one way and a card's name another, so `24 pack` reads as the card's `24 Count`. Inches lose
284
+ * their unit, so `41 in black` reads as the card's `41` and `10 in` as its `10in`: `in` is a word as often as a unit.
285
+ */
286
+ const SAME_UNIT = new Map([['feet', 'ft'], ['foot', 'ft'], ['in', ''], ['inch', ''], ['inches', ''], ['pk', 'pack'], ['ct', 'pack'], ['count', 'pack'], ['pcs', 'pack'], ['pieces', 'pack'], ['lbs', 'lb']]);
287
+ /** A size or a count written as one word, such as `24pack` or `10ft`. */
288
+ const SIZE = /^(\d+)(\p{L}+)$/u;
289
+ /** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
290
+ function wordsOf(text) {
291
+ return text
292
+ .toLowerCase()
293
+ .replace(UNIT, '$1$2')
294
+ .split(/[^\p{L}\p{N}]+/u)
295
+ .filter((word) => word !== '')
296
+ .map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
297
+ }
298
+ /**
299
+ * The card a text names, by the first six words of the card's name: at least two of them, and most of them, in the
300
+ * text. Null when it names none. A name of one word never counts in prose, where a generic word such as `soap` would.
301
+ * A size or a count among those words has to be in the text too, so the 3ft cable's line never reads as the 10ft card.
302
+ */
303
+ function cardNamed(text, cards, inList) {
304
+ const said = new Set(wordsOf(text));
305
+ for (const [index, name] of cards.entries()) {
306
+ const head = name.slice(0, 6);
307
+ if (!head.every((word) => !/\d/.test(word) || said.has(word)))
308
+ continue;
309
+ const hits = head.filter((word) => said.has(word)).length;
310
+ if (head.length === 1 ? inList && hits === 1 : hits >= Math.max(2, Math.ceil(head.length * 0.6)))
311
+ return index;
312
+ }
313
+ return null;
314
+ }
315
+ /**
316
+ * Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
317
+ * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
318
+ * points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
319
+ * words, each further sentence that names another card. What is left is the reply around the list, such as `found
320
+ * these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
321
+ *
322
+ * ```ts
323
+ * withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
324
+ * // 'Which one?'
325
+ * ```
326
+ */
327
+ export function withoutCardList(reply, cardNames) {
328
+ const cards = cardNames.map(wordsOf).filter((name) => name.length > 0);
329
+ if (cards.length === 0)
330
+ return reply.trim();
331
+ // A text that opens with a card's name and gives its price is a list line, as a reply writes one without a bullet.
332
+ const opensCard = (text) => {
333
+ const words = wordsOf(text);
334
+ return PRICE.test(text) && cards.some((name) => name.length >= 2 && name.slice(0, 3).every((word, i) => words[i] === word));
335
+ };
336
+ const lines = reply.split('\n');
337
+ const kind = lines.map((line) => {
338
+ if (LIST_ITEM.test(line))
339
+ return cardNamed(line.replace(LIST_ITEM, ''), cards, true) !== null ? 'item' : 'kept';
340
+ return opensCard(line) ? 'named' : 'kept';
341
+ });
342
+ const dropped = kind.map((of) => of !== 'kept');
343
+ // The numbers the dropped list gave, so a number of a product's own, `a #2 Phillips head`, keeps its sentence.
344
+ const numbers = new Set(lines.flatMap((line, i) => (dropped[i] ? [Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1])] : [])).filter((number) => Number.isInteger(number)));
345
+ // The list a line leads into: the lines from the next one with words on it to the first that is not a list line, so a
346
+ // question on the line after the last item, `Which one?`, is no part of the list.
347
+ const listAfter = (line) => {
348
+ const start = lines.findIndex((after, j) => j > line && after.trim() !== '');
349
+ if (start === -1)
350
+ return [];
351
+ let end = start;
352
+ while (end < lines.length && (LIST_ITEM.test(lines[end]) || kind[end] === 'named'))
353
+ end += 1;
354
+ return dropped.slice(start, end);
355
+ };
356
+ const named = new Set();
357
+ const kept = [];
358
+ lines.forEach((line, i) => {
359
+ if (kind[i] === 'item')
360
+ return;
361
+ let text = line;
362
+ // The line that leads into a list whose every line went loses the sentence that leads in: `Two I'd suggest:` or
363
+ // `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
364
+ const list = listAfter(i);
365
+ if (text.trim().endsWith(':') && list.length > 0 && list.every(Boolean))
366
+ text = text.trim().replace(/(?:^|(?<=[.!?])\s+)[^.!?]*:$/, '');
367
+ const sentences = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
368
+ const place = LIST_NUMBER.exec(sentence);
369
+ if (place && numbers.has(Number(place[1] ?? place[2])))
370
+ return false;
371
+ const card = cardNamed(sentence, cards, false);
372
+ // A list line written without a bullet keeps the sentences its card does not say, such as the question after it.
373
+ if (kind[i] === 'named')
374
+ return card === null && !opensCard(sentence);
375
+ if (card === null || named.has(card))
376
+ return true;
377
+ // A reply recommends one or two products in words; a sentence that names a third is the list again.
378
+ if (named.size >= 2)
379
+ return false;
380
+ named.add(card);
381
+ return true;
382
+ });
383
+ if (kind[i] === 'named' && sentences.length === 0)
384
+ return;
385
+ kept.push(sentences.join(' ').trimEnd());
386
+ });
387
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
388
+ }
187
389
  /**
188
390
  * A thrown error as the model may read it: the same message with every link
189
391
  * taken out, and the original error on `cause` for your logs. A TypeError
@@ -9,9 +9,18 @@ import { type PurchaseFacts, type PurchaseMessageKind } from './message.js';
9
9
  * The event types that carry a message for a shopper. Subscribe your endpoint to each by name:
10
10
  * a catch-all `*` alone does not turn `purchase.message` on.
11
11
  */
12
- export declare const SHOPPER_EVENT_TYPES: readonly ["purchase.message", "order.confirmed", "order.updated"];
12
+ export declare const SHOPPER_EVENT_TYPES: readonly ['purchase.message', 'order.confirmed', 'order.updated'];
13
13
  /** One of the event types that carry a message for a shopper. */
14
14
  export type ShopperEventType = (typeof SHOPPER_EVENT_TYPES)[number];
15
+ /**
16
+ * The kinds after which your agent takes the next step on its own, as a turn of its own, instead of
17
+ * posting `text` and waiting for the shopper's "ok": the shopper saved a card (`card_added`), signed in
18
+ * to the store (`store_connected`), or declined the approval (`approval_declined`), outside the chat.
19
+ * `buy.nextStep(message)` opens that turn. Every other kind's `text` is the whole message: post it as is.
20
+ */
21
+ export declare const NEXT_STEP_KINDS: readonly ['card_added', 'store_connected', 'approval_declined'];
22
+ /** A kind after which the agent takes the next step on its own. */
23
+ export type NextStepKind = (typeof NEXT_STEP_KINDS)[number];
15
24
  /**
16
25
  * The delivery stage of an order, as `order.updated` names it. A stage Agentcard adds later reads
17
26
  * as its own name.
@@ -65,6 +74,12 @@ export interface ShopperMessage {
65
74
  /** For your agent: the facts of what happened, marked as written by the system and not typed by the shopper. */
66
75
  note: string;
67
76
  facts: ShopperFacts;
77
+ /**
78
+ * True when the message ends a step the shopper took outside the chat, one of `NEXT_STEP_KINDS`:
79
+ * open `buy.nextStep(message)` and let your model take the next step, such as the card ask or the
80
+ * cart's total and a request for their yes, instead of posting `text`. False for every other message.
81
+ */
82
+ advances: boolean;
68
83
  /** The OAuth client id of the app the shopper connected through, when the event names it. */
69
84
  clientId: string | null;
70
85
  /** False for a sandbox event. */
@@ -10,6 +10,14 @@ import { readPurchaseMessage } from './message.js';
10
10
  * a catch-all `*` alone does not turn `purchase.message` on.
11
11
  */
12
12
  export const SHOPPER_EVENT_TYPES = ['purchase.message', 'order.confirmed', 'order.updated'];
13
+ /**
14
+ * The kinds after which your agent takes the next step on its own, as a turn of its own, instead of
15
+ * posting `text` and waiting for the shopper's "ok": the shopper saved a card (`card_added`), signed in
16
+ * to the store (`store_connected`), or declined the approval (`approval_declined`), outside the chat.
17
+ * `buy.nextStep(message)` opens that turn. Every other kind's `text` is the whole message: post it as is.
18
+ */
19
+ export const NEXT_STEP_KINDS = ['card_added', 'store_connected', 'approval_declined'];
20
+ const NEXT_STEP = new Set(NEXT_STEP_KINDS);
13
21
  const SYSTEM_LINE = 'This note was generated by the system, not typed by the user.';
14
22
  function text(value, max = 200) {
15
23
  if (typeof value !== 'string')
@@ -103,6 +111,7 @@ function readOrderConfirmed(event) {
103
111
  return {
104
112
  ...base,
105
113
  kind: 'order_confirmed',
114
+ advances: false,
106
115
  text: `${who}${retailerOrderId ? `, order number ${retailerOrderId}` : ''}.${deliveryWindow ? ` Expected delivery: ${deliveryWindow}.` : ''}`,
107
116
  note: `[event: the store accepted this user's order${store ? ` at ${store}` : ''} on its side, outside this conversation.` +
108
117
  `${retailerOrderId ? ` The store's order number is ${retailerOrderId}.` : ''}` +
@@ -128,6 +137,7 @@ function readOrderUpdated(event) {
128
137
  return {
129
138
  ...base,
130
139
  kind: 'order_updated',
140
+ advances: false,
131
141
  text: message,
132
142
  note: `[event: this user's order${store ? ` at ${store}` : ''} moved to the stage ${status}` +
133
143
  `${previous ? ` from ${previous}` : ''}, outside this conversation.` +
@@ -163,6 +173,7 @@ export function readShopperMessage(event) {
163
173
  ...message,
164
174
  type: 'purchase.message',
165
175
  conversationId: id(event.data.conversation_id) ?? null,
176
+ advances: NEXT_STEP.has(message.kind),
166
177
  };
167
178
  }
168
179
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.14.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",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
+ },
13
+ "./browser": {
14
+ "types": "./dist/browser.d.ts",
15
+ "default": "./dist/browser.js"
12
16
  }
13
17
  },
14
18
  "files": [
@@ -36,6 +40,14 @@
36
40
  "webhooks",
37
41
  "purchases"
38
42
  ],
43
+ "peerDependencies": {
44
+ "@opentelemetry/api": "^1.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@opentelemetry/api": {
48
+ "optional": true
49
+ }
50
+ },
39
51
  "engines": {
40
52
  "node": ">=18"
41
53
  },