@agent-cards/commerce-sdk 0.9.0 → 0.11.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,24 +10,34 @@ 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.',
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.",
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
+ "When `shopper_already_told_looking` is true, the shopper already knows you are working on their request, from a reply or a 👀 on their message, 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.',
35
+ "Talk to the shopper only about their order, in their terms: what is in the cart, what was found and what they can do next. Leave out how it was done, even when `reply` says it: never mention this tool, `reply`, a field, a system, \"my end\" or what you can or can't see, the searches run, the stores checked or not, a timeout, account setup, or a link that did not go out. When parts of the result disagree, say what you are sure of and ask, never which part said what.",
36
+ 'Say as fact only what the result shows: never promise what a store will do later, such as confirm a size when the order goes through.',
37
+ 'Never tell the shopper to remove, delete or replace a card or to change a setting, even when `reply` says to.',
38
+ 'Name each link by what it is for, from `next_action`: the link to add a card, to sign in to the store, or to approve the payment.',
39
+ 'Within a step, ask for one detail per message, such as the delivery address before the name for the label.',
40
+ 'Write every sentence in your own style, casing included, even where `reply` is written another way.',
31
41
  ].join(' ');
32
42
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
33
43
  export function buyToolDefinition() {
@@ -40,7 +50,7 @@ export function buyToolDefinition() {
40
50
  ask: { type: 'string', description: 'What the shopper said, in their own words.' },
41
51
  confirm: {
42
52
  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.',
53
+ 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
54
  },
45
55
  new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
46
56
  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,11 +2,11 @@ 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';
9
- export { instantAck, ackFor, type AckForOptions } from './ack.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, type BuyAckMode, } from './buy-tool.js';
9
+ export { instantAck, instantReaction, ackFor, ACK_REACTION, 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';
12
12
  export { listenToAgentcard, type AgentcardListener, type ListenOptions, type ListenCursorStore, type StreamFetch, } from './listen.js';
package/dist/index.js CHANGED
@@ -4,9 +4,9 @@ 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
- export { instantAck, ackFor } from './ack.js';
9
+ export { instantAck, instantReaction, ackFor, ACK_REACTION } from './ack.js';
10
10
  export { forModel, forModelError, stripLinks, } from './model-view.js';
11
11
  export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
12
12
  export { listenToAgentcard, } from './listen.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);
package/dist/message.d.ts CHANGED
@@ -12,12 +12,17 @@ import { type AgentcardEvent } from './verify.js';
12
12
  * - `order_unconfirmed`: the store's reply to the order was lost, so the order may exist; Agentcard is confirming it.
13
13
  * - `approval_expiring`: the approval link expires soon; `text` carries the link.
14
14
  * - `approval_expired`: the approval expired before the shopper answered, and nothing was charged.
15
+ * - `approval_declined`: the shopper declined the approval, and nothing was charged.
16
+ * - `payment_declined`: the shopper approved, and the payment was refused before the order went in; `facts.reason` says why. Nothing was charged.
15
17
  * - `card_link_unfinished`: the shopper has not added a card from their card link; `text` carries the link again.
16
18
  * - `card_link_expired`: the card link expired before the shopper added a card.
17
19
  * - `cart_idle`: a cart sat without being sent for approval; `facts.nudgeNumber` counts the nudges.
18
20
  * - `card_added`: the shopper saved a card from the card link your app sent, and can check out with it.
21
+ * - `store_connected`: the shopper signed in to their own account at the store from the sign-in link your app sent, so the order can go through on it.
22
+ * - `store_connect_failed`: the shopper's sign-in to the store did not go through, and the link no longer works.
23
+ * - `store_connect_expired`: the sign-in link expired before the shopper finished signing in.
19
24
  */
20
- export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'order_unconfirmed' | 'approval_expiring' | 'approval_expired' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle' | 'card_added';
25
+ export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'order_unconfirmed' | 'approval_expiring' | 'approval_expired' | 'approval_declined' | 'payment_declined' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle' | 'card_added' | 'store_connected' | 'store_connect_failed' | 'store_connect_expired';
21
26
  /** The message's values one by one, for a company that writes its own copy. A fact that does not apply is absent. */
22
27
  export interface PurchaseFacts {
23
28
  merchantName?: string;
@@ -28,7 +33,7 @@ export interface PurchaseFacts {
28
33
  cardBrand?: string;
29
34
  cardLast4?: string;
30
35
  orderId?: string;
31
- /** Why an order failed or was not placed, in words. */
36
+ /** Why an order failed or was not placed, or a payment was declined, in words. */
32
37
  reason?: string;
33
38
  approvalUrl?: string;
34
39
  /** When the approval link stops working, as an ISO 8601 timestamp. */
@@ -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,13 @@ 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
+ /**
70
+ * Which links went out with this answer: `store_login` is the store's own login page (the shopper
71
+ * logs in to their account there), `vault` is the page where they add a card, `approval` the page
72
+ * where they approve the payment. Name the link by what it is: a store login is never a card link.
73
+ */
74
+ links_sent: Array<'vault' | 'approval' | 'store_login'>;
75
+ /** How many of the products above the shopper holds as cards, each with its name, price and picture; 0 when none went out. */
61
76
  product_links_sent: number;
62
77
  /** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
63
78
  pick: {
@@ -102,6 +117,18 @@ export declare function shownStores(stores: readonly BuyToolStore[] | null | und
102
117
  * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
103
118
  */
104
119
  export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): string | null;
120
+ /**
121
+ * What tells one product card from another: the product's link-free id, else its page. A restaurant gives
122
+ * every menu item the store's one page, so a card per page sent one card for a reply that offered three
123
+ * dishes (2026-10-20, Suavecito Birria & Tacos on DoorDash). Each product the model reads gets its own card.
124
+ */
125
+ export declare function cardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url'>): string | null;
126
+ /**
127
+ * The key of the card a product gets, or null when it gets none: a product with a page at its store,
128
+ * or a flight option, which has no page, with an id a pick takes. A flight's card is how the shopper
129
+ * taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
130
+ */
131
+ export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight'>): string | null;
105
132
  /**
106
133
  * The model's view of one answer: the status, Agentcard's reply, the newest
107
134
  * cart and every cart on the table, the products by id, name and price, and