@agent-cards/commerce-sdk 0.9.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/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type AgentcardListener, type ListenOptions } from './listen.js';
2
+ import type { ShopperMessage } from './shopper-message.js';
2
3
  /** The fetch the client calls: Node 18 and later have one built in. */
3
4
  export type FetchLike = (url: string, init: {
4
5
  method: string;
@@ -125,14 +126,28 @@ export interface BuyCartLine {
125
126
  * A cart as Agentcard answers it. `totalCents` is the all-in figure the shopper sees; `hash` is what a confirm names.
126
127
  * Every amount is in the minor unit of `currency`, the store's ISO 4217 code.
127
128
  */
129
+ /** One of the store's own fee or tax lines, as the store labels it: `Delivery Fee`, `Service Fee`, `Estimated Tax`. */
130
+ export interface BuyCartFeeLine {
131
+ label: string;
132
+ amountCents: number;
133
+ }
128
134
  export interface BuyCart {
129
135
  merchant: string;
130
136
  merchant_name: string;
131
137
  store: BuyStore | null;
132
138
  items: BuyCartLine[];
139
+ /** The items alone, or null from an older API. */
140
+ subtotalCents: number | null;
133
141
  serviceFeesCents: number;
142
+ /** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
143
+ merchantFeeLines: BuyCartFeeLine[] | null;
134
144
  tipCents: number;
135
145
  totalCents: number;
146
+ /**
147
+ * True when the store has not priced the cart's fees and tax yet, so `totalCents` covers the items and
148
+ * known fees only: show it as an estimate. DoorDash prices a cart once the shopper's own account holds it.
149
+ */
150
+ totalIsEstimate: boolean;
136
151
  /** The ISO 4217 code the store prices the cart in, or null when Agentcard sent none. */
137
152
  currency: string | null;
138
153
  approvedCeilingCents: number | null;
@@ -184,13 +199,15 @@ export interface BuyPick {
184
199
  /**
185
200
  * One store a delivery store search found that the answer's reply names. `id` holds no link and is what
186
201
  * `pick.store_id` takes; `store_page_url` is the store's page at the merchant. `eta_minutes` is the
187
- * merchant's delivery estimate, or null when it showed none.
202
+ * merchant's delivery estimate, or null when it showed none. `image_url` is the picture the store's
203
+ * card shows at the merchant, or null when it shows none.
188
204
  */
189
205
  export interface BuyStoreChoice {
190
206
  id: string;
191
207
  name: string;
192
208
  store_page_url: string | null;
193
209
  eta_minutes: number | null;
210
+ image_url: string | null;
194
211
  }
195
212
  /** The stores an answer's reply presents to choose from, at most three, in the order the reply names them. */
196
213
  export interface BuyStores {
@@ -213,11 +230,12 @@ export interface BuyPaymentSource {
213
230
  /**
214
231
  * The one thing the shopper does next, as Agentcard names it, in the order a purchase asks for them:
215
232
  * `shipping_details` (a shipping address and phone), `guest_details` (a name, email and phone for a
216
- * booking), `add_card` (a card in their Vault), `confirm` (their yes to the cart), `verification_code`
233
+ * booking), `store_login` (a login to their own account at a store that checks out only on it, with
234
+ * the `store_login` link), `add_card` (a card in their Vault), `confirm` (their yes to the cart), `verification_code`
217
235
  * (the code a booking texted them), then `approve` (the approval link). A value this version does
218
236
  * not know reads as null.
219
237
  */
220
- export type BuyNextAction = 'shipping_details' | 'guest_details' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
238
+ export type BuyNextAction = 'shipping_details' | 'guest_details' | 'store_login' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
221
239
  /** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
222
240
  export interface BuyTurn {
223
241
  conversation_id: string;
@@ -333,6 +351,9 @@ export declare class AgentcardClient {
333
351
  private token;
334
352
  private inflight;
335
353
  private learnedSandbox;
354
+ /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
355
+ private readonly inbox;
356
+ private readonly deliveredIds;
336
357
  constructor(options: AgentcardClientOptions);
337
358
  /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
338
359
  get company(): boolean;
@@ -365,6 +386,21 @@ export declare class AgentcardClient {
365
386
  * webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
366
387
  */
367
388
  listen(opts: ListenOptions): AgentcardListener;
389
+ /**
390
+ * Notes that `message` reached the chat of the shopper you call `externalUserId`, so that shopper's
391
+ * next `buy.turn` hands it to your model (`turn.messages`), until a history the model ran with holds it. `listen` and `agentcardWebhook({ agentcard })`
392
+ * call it once `onMessage` returns, so call it yourself only for a message you delivered another way.
393
+ * A message is kept once per event id. The messages live in this process's memory, at most 20 per
394
+ * shopper: the listener and the buy tool share one client in one process.
395
+ */
396
+ delivered(externalUserId: string, message: ShopperMessage): void;
397
+ /**
398
+ * The messages kept for this shopper, oldest first: each one that reached their chat and that no
399
+ * history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
400
+ */
401
+ keptMessages(externalUserId: string): ShopperMessage[];
402
+ /** Forgets these messages for this shopper: `turn.messages` calls it once the history it reads holds them. */
403
+ forgetMessages(externalUserId: string, messages: readonly ShopperMessage[]): void;
368
404
  /** A company names the shopper exactly once; a shopper's own token already names them. */
369
405
  private checkShopper;
370
406
  private call;
package/dist/client.js CHANGED
@@ -18,7 +18,13 @@ export const BUY_TURN_TIMEOUT_MS = 130_000;
18
18
  const CALL_TIMEOUT_MS = 30_000;
19
19
  /** A token is renewed this long before it expires, so it never expires mid-call. */
20
20
  const RENEW_EARLY_MS = 60_000;
21
- const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'add_card', 'confirm', 'verification_code', 'approve']);
21
+ /** The messages a shopper's chat got that the client keeps for that shopper's next turn, the oldest dropped first. */
22
+ const DELIVERED_PER_SHOPPER = 20;
23
+ /** The shoppers the client keeps such messages for, the one written to longest ago dropped first. */
24
+ const DELIVERED_SHOPPERS = 10_000;
25
+ /** The event ids the client remembers taking in, so a message delivered twice reaches the model once. */
26
+ const DELIVERED_IDS_KEPT = 10_000;
27
+ const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'store_login', 'add_card', 'confirm', 'verification_code', 'approve']);
22
28
  /**
23
29
  * A refusal, or a failure to reach Agentcard. `code` is the API's own
24
30
  * (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
@@ -75,6 +81,12 @@ function storeOf(v) {
75
81
  return null;
76
82
  return { id: v.id, ...(str(v.name) ? { name: v.name } : {}) };
77
83
  }
84
+ function feeLinesOf(v) {
85
+ if (!Array.isArray(v))
86
+ return null;
87
+ const lines = v.filter(isRecord).flatMap((f) => (str(f.label) && num(f.amountCents) !== null ? [{ label: f.label, amountCents: f.amountCents }] : []));
88
+ return lines.length ? lines : null;
89
+ }
78
90
  function cartOf(v) {
79
91
  if (!isRecord(v) || !str(v.hash) || !str(v.merchant))
80
92
  return null;
@@ -89,9 +101,12 @@ function cartOf(v) {
89
101
  product_id: str(i.product_id),
90
102
  url: pageUrl(i.url),
91
103
  })) : [],
104
+ subtotalCents: num(v.subtotalCents),
92
105
  serviceFeesCents: num(v.serviceFeesCents) ?? 0,
106
+ merchantFeeLines: feeLinesOf(v.merchantFeeLines),
93
107
  tipCents: num(v.tipCents) ?? 0,
94
108
  totalCents: num(v.totalCents) ?? 0,
109
+ totalIsEstimate: v.totalIsEstimate === true,
95
110
  currency: str(v.currency),
96
111
  approvedCeilingCents: num(v.approvedCeilingCents),
97
112
  hash: v.hash,
@@ -140,7 +155,7 @@ function storesOf(v) {
140
155
  const id = str(i.id);
141
156
  const name = str(i.name);
142
157
  const eta = num(i.eta_minutes);
143
- return id && name ? [{ id, name, store_page_url: pageUrl(i.store_page_url), eta_minutes: eta }] : [];
158
+ return id && name ? [{ id, name, store_page_url: pageUrl(i.store_page_url), eta_minutes: eta, image_url: str(i.image_url) }] : [];
144
159
  });
145
160
  return items.length ? { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, as_of: str(v.as_of) } : null;
146
161
  }
@@ -380,6 +395,9 @@ export class AgentcardClient {
380
395
  token = null;
381
396
  inflight = null;
382
397
  learnedSandbox = null;
398
+ /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
399
+ inbox = new Map();
400
+ deliveredIds = new Set();
383
401
  constructor(options) {
384
402
  const o = (options ?? {});
385
403
  const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
@@ -470,6 +488,52 @@ export class AgentcardClient {
470
488
  listen(opts) {
471
489
  return listenToAgentcard(this, opts);
472
490
  }
491
+ /**
492
+ * Notes that `message` reached the chat of the shopper you call `externalUserId`, so that shopper's
493
+ * next `buy.turn` hands it to your model (`turn.messages`), until a history the model ran with holds it. `listen` and `agentcardWebhook({ agentcard })`
494
+ * call it once `onMessage` returns, so call it yourself only for a message you delivered another way.
495
+ * A message is kept once per event id. The messages live in this process's memory, at most 20 per
496
+ * shopper: the listener and the buy tool share one client in one process.
497
+ */
498
+ delivered(externalUserId, message) {
499
+ if (!str(externalUserId))
500
+ throw new TypeError('Pass externalUserId: your own id for the shopper whose chat got the message.');
501
+ if (!isRecord(message) || typeof message.text !== 'string')
502
+ throw new TypeError('Pass the message onMessage got, with its text.');
503
+ if (message.text.trim() === '')
504
+ return;
505
+ if (str(message.eventId)) {
506
+ if (this.deliveredIds.has(message.eventId))
507
+ return;
508
+ this.deliveredIds.add(message.eventId);
509
+ if (this.deliveredIds.size > DELIVERED_IDS_KEPT)
510
+ this.deliveredIds.delete(this.deliveredIds.values().next().value);
511
+ }
512
+ const kept = this.inbox.get(externalUserId) ?? [];
513
+ // The shopper written to last moves to the end, so the one dropped first is the one written to longest ago.
514
+ this.inbox.delete(externalUserId);
515
+ this.inbox.set(externalUserId, [...kept, message].slice(-DELIVERED_PER_SHOPPER));
516
+ if (this.inbox.size > DELIVERED_SHOPPERS)
517
+ this.inbox.delete(this.inbox.keys().next().value);
518
+ }
519
+ /**
520
+ * The messages kept for this shopper, oldest first: each one that reached their chat and that no
521
+ * history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
522
+ */
523
+ keptMessages(externalUserId) {
524
+ return [...(this.inbox.get(externalUserId) ?? [])];
525
+ }
526
+ /** Forgets these messages for this shopper: `turn.messages` calls it once the history it reads holds them. */
527
+ forgetMessages(externalUserId, messages) {
528
+ const kept = this.inbox.get(externalUserId);
529
+ if (!kept)
530
+ return;
531
+ const left = kept.filter((m) => !messages.includes(m));
532
+ if (left.length > 0)
533
+ this.inbox.set(externalUserId, left);
534
+ else
535
+ this.inbox.delete(externalUserId);
536
+ }
473
537
  /** A company names the shopper exactly once; a shopper's own token already names them. */
474
538
  checkShopper(who) {
475
539
  const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
@@ -10,23 +10,27 @@ export const BUY_TOOL_NAME = 'buy';
10
10
  export const BUY_TOOL_DESCRIPTION = [
11
11
  'Buy what this shopper asks for at real stores.',
12
12
  "Put the shopper's own words in `ask`.",
13
- 'Tell the shopper what `reply` says, in your own voice.',
13
+ 'Tell the shopper what `reply` says, in your own voice, except the products and stores it lists that reach the shopper as cards.',
14
14
  "When `shopper_already_told_looking` is true, the shopper was already told you are working on their request, so never acknowledge it again: open with the answer or the question.",
15
15
  'Show the shopper the cart before you confirm it.',
16
16
  'Ask the shopper for exactly one thing per message: the step `next_action` names, even when `reply` lists more. Never mention a later step, and never ask for the next one until the shopper has done this one.',
17
- 'Set `confirm` to true only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again.',
17
+ 'When `next_action` is null and `reply` is about something other than a cart, ask for no checkout step: a cart from an earlier request gets one short line at most, after the answer to what the shopper just asked, and never a question about confirming or placing it.',
18
+ 'Set `confirm` to true only when the shopper just said yes to the cart in your last result, asks to check out or pay for a cart that waits, says they added the card for a cart that waits, says they approved, or asks for the approval link again.',
19
+ 'When the shopper asks to check out, pay or place the order, call this tool: Agentcard knows whether their card is saved, so never ask the shopper whether they added it.',
18
20
  'Never write a link.',
19
21
  'Each link reaches the shopper on its own.',
20
22
  'When `link_sent` is true, tell the shopper a link is waiting for them.',
21
- 'Product links reach the shopper on their own, beside your reply: when `product_links_sent` is above zero and the shopper asks for links, tell them the links are there; never write or invent a URL.',
23
+ 'Product links reach the shopper on their own, as cards that show each product\'s name, price and picture. When `product_links_sent` is above zero, the shopper already sees those products, so never list them again, by name, price or number, even when `reply` lists them: write one short line, such as which one they want, plus only what the cards do not show, such as why one is the pick. When the shopper asks for links, tell them the links are there; never write or invent a URL.',
22
24
  'A `placing` status means the order is going through, so never ask for another approval.',
23
25
  'Tell the shopper they need not reply only when `shopper_need_not_reply` is true.',
24
26
  'After `needs_card` with a cart, Agentcard keeps the cart, so set `confirm` to true once the shopper says they added the card.',
25
27
  "After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
26
28
  'Set `new_order` to true only when the shopper drops the cart.',
29
+ "Never tell the shopper that you or Agentcard can't see their name, phone number or address: Agentcard may use the ones saved on their Agentcard account for an order, and `reply` says when it does.",
27
30
  "When the shopper picks one of the `products` in your last result, by its name, its place in the list or a tap that names it, set `product_id` to that product's `product_id`, so Agentcard adds exactly that product; otherwise set it to null.",
28
31
  "When the shopper picks one of the `stores` in your last result, by its name, its place in the list or a tap that names it, set `store_id` to that store's `store_id`, so Agentcard orders from exactly that store; otherwise set it to null. Never set both `product_id` and `store_id`.",
29
- 'Store links reach the shopper on their own, beside your reply, as product links do: `store_links_sent` says how many.',
32
+ 'Store links reach the shopper on their own, as cards that show each store\'s name and delivery time. When `store_links_sent` is above zero, never list those stores again: ask which one in one short line.',
33
+ 'When `store_links_sent` is 0 and you offer the `stores`, name each one by its `name`, never only "their links are there".',
30
34
  'When `pick.in_cart` is false, the product the shopper picked is not in the cart: tell the shopper what `reply` says, and never ask them to confirm the cart.',
31
35
  ].join(' ');
32
36
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
@@ -40,7 +44,7 @@ export function buyToolDefinition() {
40
44
  ask: { type: 'string', description: 'What the shopper said, in their own words.' },
41
45
  confirm: {
42
46
  type: 'boolean',
43
- description: 'True only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again. False otherwise.',
47
+ description: 'True only when the shopper just said yes to the cart in your last result, asks to check out or pay for a cart that waits, says they added the card for a cart that waits, says they approved, or asks for the approval link again. False otherwise.',
44
48
  },
45
49
  new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
46
50
  product_id: {
package/dist/handler.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { type AgentcardEvent, type VerifyOptions } from './verify.js';
9
9
  import { type PurchaseMessage } from './message.js';
10
+ import type { AgentcardClient } from './client.js';
10
11
  import { type ShopperMessage } from './shopper-message.js';
11
12
  /** The parts of a Node or Express request the handler reads. */
12
13
  export interface WebhookRequest {
@@ -80,6 +81,11 @@ export interface AgentcardWebhookOptions extends PurchaseAgentWebhookOptions {
80
81
  * such as `vault.card_stored`, and a shopper message for a shopper you gave Agentcard no id for.
81
82
  */
82
83
  onEvent?: (event: AgentcardEvent) => void | Promise<void>;
84
+ /**
85
+ * The client your buy tools share. Once `onMessage` returns, the client keeps the message for that
86
+ * shopper's next `buy.turn`, which hands it to your model, as `agentcard.listen` does.
87
+ */
88
+ agentcard?: AgentcardClient;
83
89
  }
84
90
  /** The handler `agentcardWebhook` returns: a Node or Express `(req, res)` handler, with `fetch` for a web `Request`. */
85
91
  export interface AgentcardWebhookHandler {
package/dist/handler.js CHANGED
@@ -284,6 +284,9 @@ export function agentcardWebhook(opts) {
284
284
  if (typeof opts.onMessage !== 'function') {
285
285
  throw new TypeError('Pass onMessage, the function that receives each message for a shopper.');
286
286
  }
287
+ if (opts.agentcard !== undefined && typeof opts.agentcard?.delivered !== 'function') {
288
+ throw new TypeError('Pass agentcard as the AgentcardClient your buy tools share, or leave it out.');
289
+ }
287
290
  const secret = opts.secret;
288
291
  const verifyOpts = { toleranceSeconds: toleranceOf(opts) };
289
292
  const report = reporter(opts.onError);
@@ -316,6 +319,7 @@ export function agentcardWebhook(opts) {
316
319
  try {
317
320
  if (message?.externalUserId) {
318
321
  await opts.onMessage(message.externalUserId, message, event);
322
+ opts.agentcard?.delivered(message.externalUserId, message);
319
323
  }
320
324
  else if (opts.onEvent) {
321
325
  await opts.onEvent(event);
package/dist/index.d.ts CHANGED
@@ -2,10 +2,10 @@ export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatur
2
2
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
3
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
4
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
5
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, } from './client.js';
5
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, } from './client.js';
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
7
- export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
8
- export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, type BuyToolStore, } from './buy-tool.js';
7
+ export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
8
+ export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, } from './buy-tool.js';
9
9
  export { instantAck, ackFor, type AckForOptions } from './ack.js';
10
10
  export { forModel, forModelError, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, type BuyToolModelStore, } from './model-view.js';
11
11
  export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, } from './shopper-mes
4
4
  export { agentcardWebhook, purchaseAgentWebhook, } from './handler.js';
5
5
  export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, } from './client.js';
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
7
- export { toMessages, messageStream, } from './messages.js';
7
+ export { toMessages, messageStream, typingDelay, } from './messages.js';
8
8
  export { buyTool, } from './buy-tool.js';
9
9
  export { instantAck, ackFor } from './ack.js';
10
10
  export { forModel, forModelError, stripLinks, } from './model-view.js';
package/dist/listen.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface ListenOptions {
47
47
  * Receives each event for one of your shoppers, under your own id for them, with the message
48
48
  * ready to post into that shopper's chat: the same as `agentcardWebhook`'s `onMessage`. A message
49
49
  * whose link or window closed before it arrived reaches no shopper: its event goes to `onEvent`.
50
+ * Once it returns, the client keeps the message for that shopper's next `buy.turn`, which hands it
51
+ * to your model, so the model knows what the shopper read between turns.
50
52
  */
51
53
  onMessage?: (externalUserId: string, message: ShopperMessage, event: AgentcardEvent<AgentcardEventType>) => void | Promise<void>;
52
54
  /** Receives every event `onMessage` does not take: every event, when you pass no `onMessage`. */
package/dist/listen.js CHANGED
@@ -198,8 +198,11 @@ export function listenToAgentcard(agentcard, opts) {
198
198
  const forShopper = message && message.externalUserId !== null && !isStale(message) ? message : null;
199
199
  for (let attempt = 1;; attempt++) {
200
200
  try {
201
- if (opts.onMessage && forShopper?.externalUserId)
201
+ if (opts.onMessage && forShopper?.externalUserId) {
202
202
  await opts.onMessage(forShopper.externalUserId, forShopper, event);
203
+ // The shopper's chat holds the message now, so their next `buy.turn` tells the model.
204
+ agentcard.delivered(forShopper.externalUserId, forShopper);
205
+ }
203
206
  else if (opts.onEvent)
204
207
  await opts.onEvent(event);
205
208
  return finish(event.id);
@@ -14,6 +14,23 @@
14
14
  * ```
15
15
  */
16
16
  export declare function toMessages(text: string): string[];
17
+ /**
18
+ * How long someone texting takes to type `message`, in milliseconds: 300, plus 12 a character, at most
19
+ * 1,500. `messageStream` and `buy.turn` wait this long between two messages by default, so a model that
20
+ * writes three paragraphs in 100 ms still delivers them one at a time.
21
+ *
22
+ * ```ts
23
+ * typingDelay('which one do you want?'); // 564
24
+ * ```
25
+ */
26
+ export declare function typingDelay(message: string): number;
27
+ /** How messages are spaced: `true` by `typingDelay`, `false` not at all, or a function that returns the pause for a message. */
28
+ export type MessagePace = boolean | ((message: string) => number);
29
+ /** The pause for each message `pace` asks for, or null for none. Throws a TypeError for a pace that cannot work. */
30
+ export declare function paceOf(pace: unknown): ((message: string) => number) | null;
31
+ /** A typing hook that never throws: one that fails costs nothing but the indicator. */
32
+ export declare function typingHook(onTyping: unknown): (typing: boolean) => void;
33
+ export declare const sleep: (ms: number) => Promise<void>;
17
34
  /** What `messageStream` hands back: push each piece of text as it arrives, then end once it stops. */
18
35
  export interface MessageStream {
19
36
  /** Adds the next piece of text. Each message the piece completes goes to `onMessage` in order, after the one before it settled. */
@@ -31,10 +48,24 @@ export interface MessageStreamOptions {
31
48
  * After a send fails, no later message is sent, and `end` rejects with the failure.
32
49
  */
33
50
  onMessage: (message: string) => void | Promise<void>;
51
+ /**
52
+ * Spaces the messages like someone texting. Each message after the first waits until `typingDelay`
53
+ * of it has passed since the one before it went out, so a model that writes three paragraphs in
54
+ * 100 ms still delivers them one at a time. Time the model spent writing counts toward the pause, so
55
+ * a slow model gets no added wait. Default `true`. `false` hands each message over the moment it is
56
+ * written, and a function returns the pause in milliseconds for a message.
57
+ */
58
+ pace?: MessagePace;
59
+ /**
60
+ * Hears `true` when the pause before a message starts and `false` the moment that message goes out,
61
+ * so your app can show a typing indicator between messages. One that throws costs nothing.
62
+ */
63
+ onTyping?: (typing: boolean) => void;
34
64
  }
35
65
  /**
36
- * Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
37
- * `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
66
+ * Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph is ready the
67
+ * moment the blank line that ends it arrives, whatever the pieces split it into, and goes to
68
+ * `onMessage` then, or, after the first, once its typing pause since the last one passed (`pace`). A
38
69
  * paragraph inside a list waits for the first characters of the line after the blank, which say
39
70
  * whether the list goes on. `end` hands over the last message and waits for every send.
40
71
  *
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
  };
@@ -17,8 +17,17 @@ export interface BuyToolModelCart {
17
17
  price: string | null;
18
18
  }>;
19
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
+ }>;
20
25
  tip: string | null;
21
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;
22
31
  }
23
32
  export interface BuyToolModelProduct {
24
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. */
@@ -57,7 +66,7 @@ export interface BuyToolModelView {
57
66
  shopper_already_told_looking: boolean;
58
67
  /** True when the shopper already has the link this answer needed. */
59
68
  link_sent: boolean;
60
- /** 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. */
61
70
  product_links_sent: number;
62
71
  /** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
63
72
  pick: {
@@ -102,6 +111,18 @@ export declare function shownStores(stores: readonly BuyToolStore[] | null | und
102
111
  * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
103
112
  */
104
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;
105
126
  /**
106
127
  * The model's view of one answer: the status, Agentcard's reply, the newest
107
128
  * cart and every cart on the table, the products by id, name and price, and
@@ -109,13 +109,35 @@ export function pickId(product) {
109
109
  return product.ref;
110
110
  return typeof product.id === 'string' && product.id !== '' && stripLinks(product.id) === product.id ? product.id : null;
111
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
+ }
112
130
  function cartView(cart) {
113
131
  return {
114
132
  store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
115
133
  items: cart.items.map((line) => ({ name: stripLinks(line.name), qty: line.qty, price: money(line.priceCents, cart.currency) })),
116
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) })),
117
136
  tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
118
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,
119
141
  };
120
142
  }
121
143
  /**
@@ -127,7 +149,7 @@ function cartView(cart) {
127
149
  export function forModel(result) {
128
150
  const status = typeof result.status === 'string' && MODEL_STATUSES.has(result.status) ? result.status : 'needs_input';
129
151
  const products = shownProducts(result.products);
130
- const held = (product) => product.linkSent === true && typeof product.url === 'string' && product.url !== '';
152
+ const held = (product) => product.linkSent === true && productCardKey(product) !== null;
131
153
  const stores = shownStores(result.stores);
132
154
  const heldStore = (store) => store.linkSent === true && typeof store.url === 'string' && store.url !== '';
133
155
  return {
@@ -141,8 +163,8 @@ export function forModel(result) {
141
163
  shopper_need_not_reply: result.shopper_need_not_reply === true,
142
164
  shopper_already_told_looking: result.shopper_already_told_looking === true,
143
165
  link_sent: result.link_sent === true,
144
- // Two products on one page share one link, so the count is of pages.
145
- 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,
146
168
  // The id the model sent, never the catalog id, which is a store page for a retail product.
147
169
  pick: result.pick ? { product_id: pickId({ id: result.pick.product_id, ref: result.pick.ref }), in_cart: result.pick.in_cart === true } : null,
148
170
  stores: stores.map((store) => ({ store_id: store.id, name: stripLinks(store.name), eta_minutes: store.etaMinutes, link_sent: heldStore(store) })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.9.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",