@agent-cards/commerce-sdk 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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;
@@ -94,10 +95,14 @@ export interface BuyRequest {
94
95
  new_order?: true;
95
96
  /**
96
97
  * The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
97
- * Agentcard adds exactly that item, with no new search. Goes with an `ask`, never with `confirm` or `new_order`.
98
+ * Agentcard adds exactly that item, with no new search. Or the store the shopper picked from the last
99
+ * answer's `stores.items`, by its `id`: Agentcard selects exactly that store before the turn runs.
100
+ * Goes with an `ask`, never with `confirm` or `new_order`.
98
101
  */
99
102
  pick?: {
100
103
  product_id: string;
104
+ } | {
105
+ store_id: string;
101
106
  };
102
107
  payment_source?: 'vault';
103
108
  delivery_address?: BuyDeliveryAddress;
@@ -121,14 +126,28 @@ export interface BuyCartLine {
121
126
  * A cart as Agentcard answers it. `totalCents` is the all-in figure the shopper sees; `hash` is what a confirm names.
122
127
  * Every amount is in the minor unit of `currency`, the store's ISO 4217 code.
123
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
+ }
124
134
  export interface BuyCart {
125
135
  merchant: string;
126
136
  merchant_name: string;
127
137
  store: BuyStore | null;
128
138
  items: BuyCartLine[];
139
+ /** The items alone, or null from an older API. */
140
+ subtotalCents: number | null;
129
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;
130
144
  tipCents: number;
131
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;
132
151
  /** The ISO 4217 code the store prices the cart in, or null when Agentcard sent none. */
133
152
  currency: string | null;
134
153
  approvedCeilingCents: number | null;
@@ -177,6 +196,32 @@ export interface BuyPick {
177
196
  ref: string | null;
178
197
  in_cart: boolean;
179
198
  }
199
+ /**
200
+ * One store a delivery store search found that the answer's reply names. `id` holds no link and is what
201
+ * `pick.store_id` takes; `store_page_url` is the store's page at the merchant. `eta_minutes` is the
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.
204
+ */
205
+ export interface BuyStoreChoice {
206
+ id: string;
207
+ name: string;
208
+ store_page_url: string | null;
209
+ eta_minutes: number | null;
210
+ image_url: string | null;
211
+ }
212
+ /** The stores an answer's reply presents to choose from, at most three, in the order the reply names them. */
213
+ export interface BuyStores {
214
+ merchant: string;
215
+ merchant_name: string;
216
+ items: BuyStoreChoice[];
217
+ as_of: string | null;
218
+ }
219
+ /** The store a store pick named, and whether Agentcard selected it before the turn ran. */
220
+ export interface BuyStorePick {
221
+ store_id: string;
222
+ name: string | null;
223
+ selected: boolean;
224
+ }
180
225
  export interface BuyPaymentSource {
181
226
  source: string;
182
227
  brand: string | null;
@@ -185,11 +230,12 @@ export interface BuyPaymentSource {
185
230
  /**
186
231
  * The one thing the shopper does next, as Agentcard names it, in the order a purchase asks for them:
187
232
  * `shipping_details` (a shipping address and phone), `guest_details` (a name, email and phone for a
188
- * 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`
189
235
  * (the code a booking texted them), then `approve` (the approval link). A value this version does
190
236
  * not know reads as null.
191
237
  */
192
- 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';
193
239
  /** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
194
240
  export interface BuyTurn {
195
241
  conversation_id: string;
@@ -223,6 +269,10 @@ export interface BuyTurn {
223
269
  store_login_merchant_name: string | null;
224
270
  /** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
225
271
  pick: BuyPick | null;
272
+ /** The stores this answer's reply presents to choose from; null when it presents none, and on an older API. */
273
+ stores: BuyStores | null;
274
+ /** The store this turn's `pick.store_id` named, or an answer such as `the second one` picked; null without one. */
275
+ store_pick: BuyStorePick | null;
226
276
  /** The answer as Agentcard sent it. */
227
277
  raw: Record<string, unknown>;
228
278
  }
@@ -301,6 +351,9 @@ export declare class AgentcardClient {
301
351
  private token;
302
352
  private inflight;
303
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;
304
357
  constructor(options: AgentcardClientOptions);
305
358
  /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
306
359
  get company(): boolean;
@@ -333,6 +386,21 @@ export declare class AgentcardClient {
333
386
  * webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
334
387
  */
335
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;
336
404
  /** A company names the shopper exactly once; a shopper's own token already names them. */
337
405
  private checkShopper;
338
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,
@@ -132,6 +147,18 @@ function catalogOf(v) {
132
147
  }) : [];
133
148
  return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
134
149
  }
150
+ /** The stores an answer presents: each one with an id and a name, and no block when none has both. */
151
+ function storesOf(v) {
152
+ if (!isRecord(v) || !Array.isArray(v.items))
153
+ return null;
154
+ const items = v.items.filter(isRecord).flatMap((i) => {
155
+ const id = str(i.id);
156
+ const name = str(i.name);
157
+ const eta = num(i.eta_minutes);
158
+ return id && name ? [{ id, name, store_page_url: pageUrl(i.store_page_url), eta_minutes: eta, image_url: str(i.image_url) }] : [];
159
+ });
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;
161
+ }
135
162
  function paymentSourceOf(v) {
136
163
  return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
137
164
  }
@@ -165,6 +192,8 @@ export function turnOf(body) {
165
192
  store_login_merchant_name: str(body.store_login_merchant_name),
166
193
  next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
167
194
  pick: isRecord(body.pick) && str(body.pick.product_id) ? { product_id: body.pick.product_id, ref: str(body.pick.ref), in_cart: body.pick.in_cart === true } : null,
195
+ stores: storesOf(body.stores),
196
+ store_pick: isRecord(body.store_pick) && str(body.store_pick.store_id) ? { store_id: body.store_pick.store_id, name: str(body.store_pick.name), selected: body.store_pick.selected === true } : null,
168
197
  raw: body,
169
198
  };
170
199
  }
@@ -366,6 +395,9 @@ export class AgentcardClient {
366
395
  token = null;
367
396
  inflight = null;
368
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();
369
401
  constructor(options) {
370
402
  const o = (options ?? {});
371
403
  const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
@@ -456,6 +488,52 @@ export class AgentcardClient {
456
488
  listen(opts) {
457
489
  return listenToAgentcard(this, opts);
458
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
+ }
459
537
  /** A company names the shopper exactly once; a shopper's own token already names them. */
460
538
  checkShopper(who) {
461
539
  const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with four required fields, `product_id` null unless the shopper
4
- * picks a product they were shown. The delivery address and the
3
+ * JSON Schema with five required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
5
5
  * shopper's id are construction options, never model inputs, so a model can
6
6
  * neither change where an order goes nor buy for someone else.
7
7
  */
@@ -9,8 +9,9 @@ export declare const BUY_TOOL_NAME = "buy";
9
9
  /** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
10
10
  export declare const BUY_TOOL_DESCRIPTION: string;
11
11
  /**
12
- * What the model sends: `ask`, `confirm` and `new_order` every time, and `product_id`, null unless the
13
- * shopper picks a product from the last result. Your own code may leave `product_id` out.
12
+ * What the model sends: `ask`, `confirm` and `new_order` every time, `product_id`, null unless the
13
+ * shopper picks a product from the last result, and `store_id`, null unless they pick a store from it.
14
+ * Your own code may leave `product_id` and `store_id` out.
14
15
  */
15
16
  export interface BuyToolInput {
16
17
  ask: string;
@@ -18,6 +19,8 @@ export interface BuyToolInput {
18
19
  new_order: boolean;
19
20
  /** The `product_id` of the product the shopper picked from the last result's `products`, or null. */
20
21
  product_id?: string | null;
22
+ /** The `store_id` of the store the shopper picked from the last result's `stores`, or null. */
23
+ store_id?: string | null;
21
24
  }
22
25
  /** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
23
26
  export type BuyToolInputSchema = {
@@ -39,6 +42,10 @@ export type BuyToolInputSchema = {
39
42
  type: ['string', 'null'];
40
43
  description: string;
41
44
  };
45
+ store_id: {
46
+ type: ['string', 'null'];
47
+ description: string;
48
+ };
42
49
  };
43
50
  required: Array<keyof BuyToolInput>;
44
51
  additionalProperties: false;
@@ -52,8 +59,9 @@ export interface BuyToolDefinition {
52
59
  export declare function buyToolDefinition(): BuyToolDefinition;
53
60
  /**
54
61
  * The model's input, checked against the schema: an object, or its JSON text,
55
- * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
56
- * string, null or left out, and nothing else. Throws a TypeError whose message
62
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
63
+ * `store_id` each a string, null or left out, at most one of them set, and
64
+ * nothing else. Throws a TypeError whose message
57
65
  * tells the model what to send.
58
66
  */
59
67
  export declare function parseBuyToolInput(value: unknown): BuyToolInput;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with four required fields, `product_id` null unless the shopper
4
- * picks a product they were shown. The delivery address and the
3
+ * JSON Schema with five required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
5
5
  * shopper's id are construction options, never model inputs, so a model can
6
6
  * neither change where an order goes nor buy for someone else.
7
7
  */
@@ -10,21 +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.",
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`.",
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".',
28
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.',
29
35
  ].join(' ');
30
36
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
@@ -38,24 +44,29 @@ export function buyToolDefinition() {
38
44
  ask: { type: 'string', description: 'What the shopper said, in their own words.' },
39
45
  confirm: {
40
46
  type: 'boolean',
41
- 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.',
42
48
  },
43
49
  new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
44
50
  product_id: {
45
51
  type: ['string', 'null'],
46
52
  description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
47
53
  },
54
+ store_id: {
55
+ type: ['string', 'null'],
56
+ description: 'The `store_id` of the store the shopper picked from the `stores` in your last result. Null when they picked none.',
57
+ },
48
58
  },
49
- required: ['ask', 'confirm', 'new_order', 'product_id'],
59
+ required: ['ask', 'confirm', 'new_order', 'product_id', 'store_id'],
50
60
  additionalProperties: false,
51
61
  },
52
62
  };
53
63
  }
54
- const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
64
+ const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id', 'store_id']);
55
65
  /**
56
66
  * The model's input, checked against the schema: an object, or its JSON text,
57
- * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
58
- * string, null or left out, and nothing else. Throws a TypeError whose message
67
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
68
+ * `store_id` each a string, null or left out, at most one of them set, and
69
+ * nothing else. Throws a TypeError whose message
59
70
  * tells the model what to send.
60
71
  */
61
72
  export function parseBuyToolInput(value) {
@@ -74,7 +85,7 @@ export function parseBuyToolInput(value) {
74
85
  const fields = input;
75
86
  const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
76
87
  if (extra !== undefined)
77
- throw new TypeError(`The buy tool takes only ask, confirm, new_order and product_id, and ${extra} is not one of them.`);
88
+ throw new TypeError(`The buy tool takes only ask, confirm, new_order, product_id and store_id, and ${extra} is not one of them.`);
78
89
  if (typeof fields.ask !== 'string')
79
90
  throw new TypeError("Set ask to the shopper's words, as a string.");
80
91
  if (typeof fields.confirm !== 'boolean')
@@ -85,7 +96,15 @@ export function parseBuyToolInput(value) {
85
96
  if (productId !== undefined && productId !== null && typeof productId !== 'string') {
86
97
  throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
87
98
  }
88
- return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null };
99
+ const storeId = fields.store_id;
100
+ if (storeId !== undefined && storeId !== null && typeof storeId !== 'string') {
101
+ throw new TypeError("Set store_id to the picked store's store_id, as a string, or to null.");
102
+ }
103
+ const product = typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null;
104
+ const store = typeof storeId === 'string' && storeId.trim() !== '' ? storeId.trim() : null;
105
+ if (product && store)
106
+ throw new TypeError('Set product_id or store_id for the one thing the shopper picked, and the other to null.');
107
+ return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: product, store_id: store };
89
108
  }
90
109
  /** A fresh copy of the tool in OpenAI's function-calling format: plain JSON, so an SDK that sends it as is sends exactly this. */
91
110
  export function openAIBuyTool() {
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,12 +2,12 @@ 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, } 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, } 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
- export { forModel, forModelError, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
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';
13
13
  export { AGENTCARD_EVENT_TYPES, type AgentcardEventType, } from './events.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
  *