@agent-cards/commerce-sdk 0.11.0 → 0.13.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.
@@ -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,61 @@ 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) })),
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
+ })),
140
162
  fees: cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
141
163
  fee_lines: (cart.merchantFeeLines ?? []).map((line) => ({ label: stripLinks(line.label), amount: money(line.amountCents, cart.currency) })),
142
164
  tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
143
165
  total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
144
166
  total_is_estimate: cart.totalIsEstimate === true,
167
+ // 2026-10-21, swarm-bigbox-a10: an estimate read "about $18.41 total with tax and shipping" to the shopper. The words
168
+ // ride beside the flag, so the model never has to guess which side of the total tax and shipping sit on.
169
+ total_leaves_out: cart.totalIsEstimate === true ? leftOut(cart.merchantFeeLines ?? []) : null,
145
170
  // Agentcard names a ceiling equal to the total only for a hold of exactly the total, as a flight's.
146
171
  max_charge: typeof cart.approvedCeilingCents === 'number' && cart.approvedCeilingCents > 0 && cart.approvedCeilingCents >= cart.totalCents ? money(cart.approvedCeilingCents, cart.currency) : null,
147
172
  };
148
173
  }
174
+ /** What an estimated total leaves out: tax, and shipping or delivery, less what the store's own fee lines already carry
175
+ * (a 1-800-Flowers estimate holds its Florist Delivery and Sales Tax). Null when the lines carry both. */
176
+ function leftOut(lines) {
177
+ const parts = [];
178
+ if (!lines.some((l) => /\btax\b/i.test(l.label)))
179
+ parts.push('tax');
180
+ if (!lines.some((l) => /\b(?:deliver(?:y|ies)?|shipping)\b/i.test(l.label)))
181
+ parts.push('shipping or delivery');
182
+ return parts.length ? `${parts.join(' and ')}, which the store adds at checkout` : null;
183
+ }
184
+ /** How long until `iso`, from `now`, as the shopper reads it: `about 24 hours`, `about 15 minutes`, `no longer`, or null. */
185
+ export function expiresIn(iso, now = Date.now()) {
186
+ const at = typeof iso === 'string' ? Date.parse(iso) : NaN;
187
+ if (!Number.isFinite(at))
188
+ return null;
189
+ if (at <= now)
190
+ return 'no longer';
191
+ const minutes = Math.max(1, Math.round((at - now) / 60_000));
192
+ if (minutes < 90)
193
+ return `about ${minutes} minute${minutes === 1 ? '' : 's'}`;
194
+ const hours = Math.round(minutes / 60);
195
+ if (hours < 48)
196
+ return `about ${hours} hours`;
197
+ return `about ${Math.round(hours / 24)} days`;
198
+ }
199
+ /** The expiry of the newest Vault, approval or store login link an answer carries, else its approval's. */
200
+ function linkExpiry(result) {
201
+ const timed = (result.links ?? []).filter((link) => (link.kind === 'vault' || link.kind === 'approval' || link.kind === 'store_login') && typeof link.expiresAt === 'string');
202
+ return timed.length > 0 ? timed[timed.length - 1].expiresAt : (result.approval_expires_at ?? null);
203
+ }
149
204
  /**
150
205
  * The model's view of one answer: the status, Agentcard's reply, the newest
151
206
  * cart and every cart on the table, the products by id, name and price, and
@@ -161,12 +216,15 @@ export function forModel(result) {
161
216
  return {
162
217
  status,
163
218
  next_action: result.next_action ?? null,
219
+ ...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
164
220
  reply: stripLinks(result.reply),
165
221
  cart: result.cart ? cartView(result.cart) : null,
166
222
  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) })),
223
+ products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
168
224
  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,
225
+ link_expires_in: expiresIn(linkExpiry(result)),
226
+ // A step's ask is one short line: an approval Agentcard places on its own needs no "no need to reply" beside it.
227
+ shopper_need_not_reply: result.shopper_need_not_reply === true && !STEP_ACTIONS.has(result.next_action ?? ''),
170
228
  shopper_already_told_looking: result.shopper_already_told_looking === true,
171
229
  link_sent: result.link_sent === true,
172
230
  links_sent: result.link_sent === true
@@ -184,6 +242,25 @@ export function forModel(result) {
184
242
  : null,
185
243
  };
186
244
  }
245
+ /**
246
+ * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
247
+ * `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
248
+ * total and the ask for their yes. The reply comes with every link taken out, as the model reads it.
249
+ * Null for any other answer, which your model puts in its own words, and for a line the tool wrote
250
+ * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
251
+ * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
252
+ *
253
+ * ```ts
254
+ * replyAsWritten(result); // 'found two at Amazon. want the first one?'
255
+ * ```
256
+ */
257
+ export function replyAsWritten(result) {
258
+ // The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
259
+ const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
260
+ const view = forModel(result);
261
+ const reply = view.reply.trim();
262
+ return agentcards && view.status === 'needs_input' && reply !== '' ? reply : null;
263
+ }
187
264
  /**
188
265
  * A thrown error as the model may read it: the same message with every link
189
266
  * taken out, and the original error on `cause` for your logs. A TypeError
@@ -12,6 +12,15 @@ import { type PurchaseFacts, type PurchaseMessageKind } from './message.js';
12
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.13.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": [