@agent-cards/commerce-sdk 0.11.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +39,7 @@ agentcard.listen({
39
39
  });
40
40
  ```
41
41
 
42
- Once `onMessage` returns, the client keeps the message for that shopper's next `buy.turn`, and `turn.messages(history)` hands it to your model before the shopper's words, so the model knows the shopper saved a card or got an order. "Tell the model what Agentcard told the shopper" shows it.
42
+ Once `onMessage` returns, the client keeps the message for that shopper's next `buy.turn`, and `turn.messages(history)` hands it to your model before the shopper's words, so the model knows the shopper saved a card or got an order. "Tell the model what Agentcard told the shopper" shows it. A message whose `advances` is true ends a step the shopper took outside the chat, such as saving a card: "Take the next step once the shopper finishes one outside the chat" shows how your agent moves on without waiting for their "ok".
43
43
 
44
44
  `listen` returns `{ ready, close }`: `ready` resolves once the listener reaches Agentcard, and `close()` stops it after the callback that is running returns. Run one listener per company and mode. Two listeners each hear every event, so with two, skip a `message.eventId` you already stored. Sandbox credentials hear sandbox events, live credentials live ones, and a shopper message is always live.
45
45
 
@@ -76,7 +76,7 @@ const answer = await new Anthropic().beta.messages.toolRunner({
76
76
 
77
77
  Build the client once and keep it. Every shopper's tool shares the client's access token, and the client renews the token a minute before it expires. Keep each shopper's tool as well. Agentcard keeps each purchase between calls, so a tool built for every message buys the same way, but it sends the same product links again each time Agentcard shows the same products.
78
78
 
79
- The model sends `ask`, `confirm`, `new_order`, `product_id` and `store_id`, and nothing else. Pass `deliveryAddress` when you already hold the shopper's address: the order goes there, and Agentcard never asks the shopper for it. The model can change neither the address nor the shopper.
79
+ The model sends `ask`, `confirm`, `new_order`, `product_id` and `store_id`, and nothing else. A product link the shopper sends, such as a `buy.trychannel3.com` link, goes in `ask` exactly as sent, and Agentcard buys that exact product from it. Pass `deliveryAddress` when you already hold the shopper's address: the order goes there, and Agentcard never asks the shopper for it. The model can change neither the address nor the shopper.
80
80
 
81
81
  ## Use the tool in any framework
82
82
 
@@ -136,12 +136,48 @@ console.log(await result.getText());
136
136
 
137
137
  A purchase needs a link at three moments: the shopper approves the purchase on an approval page, a new shopper adds a card on a Vault page, and a shopper signs in to a store's own account on its login page before Agentcard can buy there. Each link's `kind` says which: `approval`, `vault` or `store_login`, and a `store_login` link names the store in `store`. The tool hands each link to `sendLink` and waits for it, so the shopper holds the link before the model answers. The model reads `link_sent: true` and never the link itself, because a tool runner puts each answer straight into the model's context, and a model that holds a link writes it into the chat.
138
138
 
139
- The tool also hands `sendLink` a `product` link for each product a search finds, up to three per answer. The link carries the product's page at its store in `url`, the product's name in `title`, its price in `price`, such as `$12.99`, Agentcard's id for it in `productId`, and its picture in `imageUrl`, or null when the store sent none. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. Each product gets its own link, so the three dishes a restaurant's reply offers are three cards, though they share the store's one page. A flight has no page at a store, so each flight a search finds is a `product` link whose `url` is null, with the airline, the route and its times in `title`: show it as a card with no link to open, and hand its `productId` back as the shopper's pick. The model reads `product_links_sent` and never a URL. The tool's description tells it that the shopper already sees those products as cards, so its reply never lists them again by name or price: it writes one short line, such as which one they want, and a shopper who asks for links hears that the links are there. When a later answer in the conversation only stands beside the same products, as a cart answer does, the tool sends none of their links again. When a later turn's reply presents them again, as the answer to a repeated ask does, the tool sends their links again, so every product the reply names arrives with its link. A product link your `sendLink` throws on counts as not sent, and the call goes on.
139
+ The tool also hands `sendLink` a `product` link for each product a search finds, up to eight per answer. The link carries the product's page at its store in `url`, the product's name in `title`, its price in `price`, such as `$12.99`, or `from $10.69` for a product priced by a size or another choice the shopper makes, such as a burger whose patty count sets its price, Agentcard's id for it in `productId`, what taking it does in `action` (`reserve` for a restaurant table, `book` for a flight, room, class or ticket, `buy` for anything else, so a button can say Reserve instead of Buy this), its picture in `imageUrl`, or null when the store sent none, and every photo of the product in `images`, `imageUrl` first and at most eight, so a card can show the others as thumbnails that swap the main picture. `images` holds one entry for a product with one photo and none for a product with none. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. Each product gets its own link, so the three dishes a restaurant's reply offers are three cards, though they share the store's one page. A flight has no page at a store, so each flight a search finds is a `product` link whose `url` is null, with the airline, the route and its times in `title`: show it as a card with no link to open, and hand its `productId` back as the shopper's pick. The model reads `product_links_sent` and never a URL. The tool's description tells it that the shopper already sees those products as cards, so its reply never lists them again by name or price: it writes one short line, such as which one they want, and a shopper who asks for links hears that the links are there. When a later answer in the conversation only stands beside the same products, as a cart answer does, the tool sends none of their links again. When a later turn's reply presents them again, as the answer to a repeated ask does, the tool sends their links again, so every product the reply names arrives with its link. A product link your `sendLink` throws on counts as not sent, and the call goes on.
140
140
 
141
141
  `productId` holds no link: it is the same `product_id` the model reads for that product, and the page is `url`. An answer that only adds a product the shopper picked lists that product alone, so it sends no card for a product nobody offered on that turn, and the pages the search already sent still count as sent on the answers after it.
142
142
 
143
143
  Without `sendLink`, `result.links` still carries each Vault, approval and store login link for your code, each product in `result.products` carries its `url`, and the model still never reads one.
144
144
 
145
+ ## Open each step in a pop-up, and close it when it is done
146
+
147
+ A Vault, approval or store login link is a step the shopper takes outside the chat. Each one carries a stable `id`, such as `vault_1x9k2qd`: the same every time the same link is handed over, on your server and in the browser, with no part of the URL in it, so it is safe as a DOM key or in a log. `actionLinkId(link)` reads it from a link that has none.
148
+
149
+ Each step ends with a shopper message: `actionLinkOutcome(link, message)` says how a message ended the step, or null when it left it open. `ACTION_LINK_ENDINGS` is the whole table:
150
+
151
+ | Link | `completed` | `declined` | `failed` | `expired` |
152
+ | --- | --- | --- | --- | --- |
153
+ | `vault` | `card_added` | | | `card_link_expired` |
154
+ | `store_login` | `store_connected` | | `store_connect_failed` | `store_connect_expired` |
155
+ | `approval` | `order_placed`, `order_not_placed_yet`, `order_unconfirmed`, `order_confirmed`, `order_failed`, `payment_declined` | `approval_declined` | | `approval_expired` |
156
+
157
+ A reminder, such as `card_link_unfinished` or `approval_expiring`, leaves the step open. An approval is `completed` once the shopper approved, even when the order then failed or the payment was refused: the approval page has nothing more to do, and the message tells the shopper what happened. A message whose facts name another link of the same kind (`facts.approvalUrl`, `facts.cardLinkUrl`) leaves this one open, and so does one about another store: a store login link names its store in `store`, and so does an approval link when the answer names the cart's store, so DoorDash's `store_connected` (`facts.merchantName: 'DoorDash'`) never ends a Locale sign-in the shopper has open beside it.
158
+
159
+ In the browser, `actionWindows` from `@agent-cards/commerce-sdk/browser` opens each step in a pop-up window centered over the chat, so the shopper never leaves it, and closes the pop-up the moment the message that ends the step arrives. The browser entry imports no Node module, so any bundler takes it:
160
+
161
+ ```ts
162
+ import { actionWindows } from '@agent-cards/commerce-sdk/browser';
163
+
164
+ const windows = actionWindows({
165
+ onEnded: (step, outcome) => markStepDone(step.id, outcome),
166
+ onClosed: (step) => keepOfferingStep(step.id),
167
+ });
168
+
169
+ // The link's own click handler, so the browser counts the tap as the reason for the pop-up. When the
170
+ // browser blocks it, `open` returns null and the link opens the way it would have, as a new tab.
171
+ stepLink.addEventListener('click', (event) => {
172
+ if (windows.open(link)) event.preventDefault();
173
+ });
174
+
175
+ // Each shopper message your server forwards from `listen` or the webhook, with its `kind` and `facts`.
176
+ onShopperMessage((message) => windows.settle(message));
177
+ ```
178
+
179
+ `markStepDone`, `keepOfferingStep` and `onShopperMessage` stand for your own code. `settle` closes each open pop-up whose step the message ends and returns `[{ step, outcome }]`. A pop-up the shopper closes before the step ends reaches `onClosed`, checked every 500 milliseconds (`pollMs`): the step is still open, so keep its link on screen, and a tap opens it again. Tapping a step whose pop-up is open brings that window to the front instead of opening a second one. A phone's browser opens the pop-up as a tab, and `settle` closes that tab the same way, since the chat opened it. The pop-up is 480 by 760 pixels by default (`width`, `height`), kept inside the screen. It keeps its opener on purpose: a window opened with `noopener` hands back no handle, so the chat could neither close it nor tell when the shopper closed it. `dispose()` stops watching and leaves every pop-up open, so a chat that remounts never shuts the page a shopper is typing a card into.
180
+
145
181
  ## Answer each shopper message in its own turn
146
182
 
147
183
  A shopper can send a second message before the first is answered. The `sendLink`, `onWorking` and `onResult` you pass to `buyTool` hear every call, whichever message the call answers, so a callback that hands each link to "the message running now" gives the first message's cards to the second. Open a turn for each message instead, and pass the turn to your model in place of the tool:
@@ -175,6 +211,53 @@ A callback you leave out of `buy.turn` falls back to the one the tool was built
175
211
 
176
212
  A call made on `buy` itself still works as before: it belongs to the turn the tool reads from its timing, and its links go to the tool's `sendLink`.
177
213
 
214
+ ### Show Agentcard's reply as written
215
+
216
+ Most answers only ask the shopper something: which product, which size, which store, or a yes to a cart's total. Agentcard's reply already says that, so a second model call that rewords it costs the shopper one more model round trip and changes nothing. Pass `replyAsWritten: true`, to `buyTool` for every turn or to `buy.turn` for one, and `turn.replyAsWritten` holds Agentcard's reply, with every link taken out, whenever the turn's last answer only asks. Show it and end the turn without calling your model again:
217
+
218
+ ```ts
219
+ const turn = buy.turn(message, { replyAsWritten: true, onMessage: (text) => sendText(shopper.id, text) });
220
+ const runner = new Anthropic().beta.messages.toolRunner({ model: 'claude-sonnet-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(history), stream: true });
221
+ let written: string | null = null;
222
+ for await (const stream of runner) {
223
+ written = turn.replyAsWritten;
224
+ if (written !== null) {
225
+ stream.abort(); // the reply says it already: no second model call
226
+ break;
227
+ }
228
+ const paragraphs = turn.messageStream();
229
+ stream.on('text', (delta) => paragraphs.push(delta));
230
+ await stream.done();
231
+ await paragraphs.end();
232
+ }
233
+ if (written !== null) await turn.say(written);
234
+ history = written !== null ? [...runner.params.messages, { role: 'assistant', content: written }] : runner.params.messages;
235
+ ```
236
+
237
+ Any other answer, such as an approval sent, an order placed or a refusal, leaves `turn.replyAsWritten` null, and your model words it. So does a line the tool writes itself for your model, such as the note that the cart changed or a `buy.nextStep` turn's ask for the shopper's yes: only a reply Agentcard sent goes out as written. `replyAsWritten(result)` applies the same rule to one answer, for a loop that reads `turn.results` itself. Leave the option off to have your model write every answer.
238
+
239
+ ### Send a clear shopping message straight to Agentcard
240
+
241
+ Your model's first call on most messages only decides to call the buy tool. Pass `direct: true` to `buyTool`, and each turn reads its message by rule, with no model, and sets `turn.direct` when the message goes straight to Agentcard: a request to buy or book something (`find me usb-c cables under $20`, `aa batteries on amazon`), a pick of what Agentcard just showed (a card's tap, `the first one`, `1 and 3`), or the answer to the question Agentcard just asked (the address, a code, `medium`, or a plain yes to the total it asked the shopper to confirm). Send the call yourself, and with `replyAsWritten` the turn makes no model call at all:
242
+
243
+ ```ts
244
+ const turn = buy.turn(message, { onMessage: (text) => sendText(shopper.id, text) });
245
+ let messages = turn.messages(history);
246
+ if (turn.direct) {
247
+ const id = `toolu_direct_${crypto.randomUUID().replace(/-/g, '')}`;
248
+ const content = await turn.run(turn.direct.input);
249
+ messages = [...messages, { role: 'assistant', content: [{ type: 'tool_use', id, name: turn.name, input: turn.direct.input }] }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content }] }];
250
+ if (turn.replyAsWritten !== null) {
251
+ await turn.say(turn.replyAsWritten);
252
+ history = [...messages, { role: 'assistant', content: turn.replyAsWritten }];
253
+ return;
254
+ }
255
+ }
256
+ // Otherwise run your model on `messages` as before: it words the answer to the call already made, or decides the call itself.
257
+ ```
258
+
259
+ Everything else leaves `turn.direct` null for your model: a question, small talk, a change of mind such as `no` or `wait`, a command to the cart, and anything about the account or an order. A pick or an answer goes straight only when the shopper's last turn ended on Agentcard's answer, so a reply to something your model said reaches your model. `directCall(text, context)` applies the same rule to one message, for an app that keeps its own state. The option is off by default, because it moves the decision of when to call Agentcard from your model into the tool.
260
+
178
261
  ### Hand the app the turn in order
179
262
 
180
263
  A turn reaches the shopper as messages and links that interleave: the instant reply, the cards a search found, then your model's words about them. Pass `onItem` to `buy.turn`, and the turn hands your app all of them as one sequence, one item at a time, in the order they happened. Show each item below the one before it:
@@ -193,10 +276,12 @@ for await (const stream of runner) {
193
276
  }
194
277
  ```
195
278
 
196
- - A `text` item is the instant reply, or a message your model wrote through `turn.messageStream()` or `turn.say(text)`. A `link` item is each link the tool hands over, with the fields `sendLink` would get.
197
- - Messages arrive one at a time, like someone texting. Each one waits until `typingDelay` of it has passed since the item before it went out: 300 ms, plus 12 ms a character, at most 1.5 seconds. Time your model spent writing counts toward the pause, so a slow model gets no added wait, and links never wait. `onTyping` hears `true` when a pause starts and `false` when its message goes out. Pass `pace: false` to send each message the moment it is ready, or a function that returns the pause in milliseconds.
279
+ - A `text` item is the instant reply, or a message your model wrote through `turn.messageStream()` or `turn.say(text)`. A `link` item is each link the tool hands over, with the fields `sendLink` would get. A message of Agentcard's that `onText` takes holds its place in `turn.items` as a `text` item too, and reaches `onText` alone.
280
+ - Each message goes out the moment it is ready. Pass `pace: true` to space them like someone texting: each one then waits until `typingDelay` of it has passed since the item before it went out, 300 ms plus 12 ms a character, at most 1.5 seconds. Time your model spent writing counts toward the pause, so a slow model gets no added wait, and links never wait. `onTyping` hears `true` when a pause starts and `false` when its message goes out. A function returns your own pause in milliseconds.
198
281
  - With `onItem`, the turn sends no link to a `sendLink` and no instant reply to an `onWorking`, so a tool built with those for other turns sends nothing twice. Passing `onItem` beside `sendLink` or `onWorking` to the same turn throws.
199
282
  - `turn.items` holds every item that went out, in order, so an app that answers the whole turn at once returns it as is. A turn without `onItem` keeps `turn.items` as well, unpaced.
283
+ - A step link, the `vault`, `approval` or `store_login` page the shopper opens to go on, comes after the message that asks for it: the turn holds it until your model's next message stream ends (or `turn.say` is called), then sends it right after that message, so the shopper reads "add your card to place the order" and then taps. Product and store cards go out the moment the tool hands them over. A held link also goes out when the turn's next call starts, or after 20 seconds, so a model that writes nothing still sends it. `turn.say` and the stream's `end` resolve once the held links went out too, and reject when one failed to send, so your app never answers without a link the shopper needs; a link that failed to go out at the next call or the 20 seconds rejects the next reply that ends.
284
+ - Without `onItem`, pass `onMessage` beside `sendLink`, and write the model's text through `turn.messageStream()`: `onMessage` hears each message, paced, and `sendLink` hears each step link after the message that asks for it. An app that streams the model through the package's own `messageStream` gets each link the moment the tool hands it over, before the words about it.
200
285
 
201
286
  ## Tell the model what Agentcard told the shopper
202
287
 
@@ -227,6 +312,57 @@ With a webhook, pass the client to the handler: `agentcardWebhook({ secret, agen
227
312
 
228
313
  The messages live in the client's memory, at most 20 per shopper. So the listener, or the webhook, and the turn have to run in one process on one client. When your listener runs in one process and your model in another, such as a serverless chat route, keep each message where your chat history lives as `onMessage` delivers it, as an assistant message, and the model reads it from the history.
229
314
 
315
+ ## Take the next step once the shopper finishes one outside the chat
316
+
317
+ Some steps happen outside the chat: the shopper saves a card from the Vault link, signs in to DoorDash from the sign-in link, or declines the approval. Agentcard tells your server the moment each one ends, and its message reads like `Your Visa ending 4242 is saved, so you're ready to check out.`. Posted as is, it leaves the shopper to say "ok" or "done" before anything moves. Take the next step for them instead: when `message.advances` is true, open `buy.nextStep(message)` and run your model on it, and post the model's answer in place of `message.text`:
318
+
319
+ ```ts
320
+ /** Each shopper's model runs, in order: the chat's own turns should wait on it too. */
321
+ const runs = new Map<string, Promise<void>>();
322
+
323
+ agentcard.listen({
324
+ onMessage: (shopperId, message) => {
325
+ const step = buyFor(shopperId).nextStep(message);
326
+ if (!step) return postToChat(shopperId, message.text);
327
+ // Run it without awaiting, so the listener moves on to the next event. One shopper's runs go in order, after
328
+ // their last one, so an older run never writes its history over a newer one.
329
+ // A turn that fails is released, so its text reaches later turns and a redelivery can take the step again.
330
+ const run = (runs.get(shopperId) ?? Promise.resolve()).then(() => runModel(shopperId, step)).catch(() => {
331
+ step.release();
332
+ agentcard.delivered(shopperId, message);
333
+ postToChat(shopperId, message.text);
334
+ });
335
+ runs.set(shopperId, run);
336
+ },
337
+ });
338
+
339
+ async function runModel(shopperId: string, turn: BuyToolTurn) {
340
+ const runner = anthropic.beta.messages.toolRunner({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(histories.get(shopperId)) });
341
+ const answer = await runner;
342
+ histories.set(shopperId, runner.params.messages);
343
+ postToChat(shopperId, textOf(answer), turn.links);
344
+ }
345
+ ```
346
+
347
+ `buyFor` stands for the buy tool you keep for each shopper, and `textOf` for the text of the model's answer. The turn is a `buy.turn` in every way but one: no shopper wrote anything, so `turn.messages(history)` ends with the event, marked as written by the system, and the instruction to take the next step now in one short message, never "you're all set, what's next?". The shopper reads one message that does the next thing:
348
+
349
+ | The shopper finished | The next message |
350
+ | --- | --- |
351
+ | Saving a card, after their yes stopped for it | The approval link: `i sent you a link to approve the payment for your chicken salad. it works for about 15 minutes, and the most you'll be charged is $19.02.` |
352
+ | Saving a card, with no yes yet | The total and the ask for the yes: `your Souvla chicken salad comes to $17.43 with fees. should i go ahead and place the order?` |
353
+ | Signing in to the store, with a card still to add | The add-card link: `next, add a card with the link i sent. it works for about 24 hours.` |
354
+ | Signing in to the store, with a card on file | The total and the ask for the yes, or the approval link after a yes that stopped for the sign-in |
355
+ | Declining the approval | `you declined the payment, so you haven't been charged. your chicken salad is still in the cart, so just say yes if you want a new approval link, or tell me what to change` |
356
+
357
+ The turn moves the purchase only up to the shopper's own decision, and the tool holds it there whatever the model calls:
358
+
359
+ - No call of the turn sends an ask to Agentcard, which could read the model's words as the shopper's go-ahead. Each call reads where the purchase stands and answers with the next step: the card link the sign-in held back, or the cart and the ask for the shopper's yes.
360
+ - A confirm goes in only for the yes the shopper already gave in the chat, such as "lets place it", when that confirm stopped for the card or the sign-in they just finished. It goes in once, for that cart's hash, so Agentcard refuses it if the cart changed. A shopper message after the yes that is not a plain go-ahead, such as "wait, make it two", takes it back. Any other confirm the model sends is refused by the tool, and nothing reaches Agentcard.
361
+ - The approval in the Vault is still the shopper's: the confirm sends the approval link, and the order goes in only once they approve it.
362
+ - A shopper who was not connected yet when they asked gets their own first ask run, in their words, once their card is saved.
363
+
364
+ `buy.nextStep` returns null when there is no step to take: a message whose `advances` is false, such as `order_placed`, whose `text` is already the whole message; a message for another shopper, or one whose link closed; a message it already took a turn for; and one that arrives while a call for the shopper's own message is still running, since that call's answer tells the model where the purchase stands. Post `message.text` then. `NEXT_STEP_KINDS` lists the kinds that advance: `card_added`, `store_connected` and `approval_declined`. Once `buy.nextStep` opens a turn, the client never hands the message's text to a later turn as words the shopper read, since they never read it. When your agent cannot finish the turn, call `step.release()` before you post `message.text`: the text then reaches later turns once `agentcard.delivered` records it, and a redelivery of the event opens a turn again.
365
+
230
366
  ## Add the exact product the shopper picks
231
367
 
232
368
  Each product in the model's view carries a `product_id`, Agentcard's id for it with no link in it. When the shopper picks one of those products, by its name, its place in the list or a tap on its card that sends its title, the model passes that `product_id`, and Agentcard adds exactly that product from the products it showed, with no new search. Your app sends no id of its own: a "Buy this" button that sends the shopper's words, such as `I'll take the KODAK AA Alkaline Batteries (24 Pack) ($13.49)`, is enough, because the model reads the `product_id` beside that name in its last result. Keep each tool result in the model's history, as a tool runner does, so the model still holds those ids on the shopper's next message. Your own code can pick too: a card's button can call `buy.answer` with the link's `productId` as `product_id`, and Agentcard adds that product.
@@ -235,7 +371,7 @@ Each product in the model's view carries a `product_id`, Agentcard's id for it w
235
371
 
236
372
  ## Show the stores the shopper picks from
237
373
 
238
- A delivery order often starts with a choice of store: the shopper asks for a taco, and Agentcard answers with the taquerias near them that deliver. Each store the reply names reaches `sendLink` as a `store` link, up to three per answer, in the order the reply names them. The link carries the store's page at the merchant in `url`, its name in `title`, Agentcard's id for it in `storeId`, the delivery estimate in minutes in `etaMinutes`, or null when the store showed none, and the picture the store's card shows at the merchant in `imageUrl`, or null when it shows none. Show it as a card with the picture and an "Order here" button. `result.stores` carries the same stores for your code, each with its `url` and `linkSent`.
374
+ A delivery order often starts with a choice of store: the shopper asks for a taco, and Agentcard answers with the taquerias near them that deliver. Each store the reply names reaches `sendLink` as a `store` link, up to eight per answer, in the order the reply names them. The link carries the store's page at the merchant in `url`, its name in `title`, Agentcard's id for it in `storeId`, the delivery estimate in minutes in `etaMinutes`, or null when the store showed none, and the picture the store's card shows at the merchant in `imageUrl`, or null when it shows none. Show it as a card with the picture and an "Order here" button. `result.stores` carries the same stores for your code, each with its `url` and `linkSent`.
239
375
 
240
376
  The model reads each store in `stores` by its `store_id`, name and `eta_minutes`, never its page, and `store_links_sent` says how many store pages the shopper holds. When the shopper picks a store, by its name, its place in the list or a tap on its card, the model passes that `store_id`, and Agentcard selects exactly that store before its agent answers, so the products it finds next are from that store. A card's button can call `buy.answer` with the link's `storeId` as `store_id`. A shopper who answers only with a place in the list, such as `the second one` or `2`, picks the store at that place in the last answer's list, whether or not the model passed the id. A store pick starts no search, so it gets no instant reply: a card tap that sends `Order from Tacos El Grullense`, the store's name alone or with `please`, and `order here` or `this place` all read as a pick of a store the last answer listed. A dish beside the store's name, as in `order tacos from el farolito`, is a search.
241
377
 
@@ -304,18 +440,35 @@ Without a buy tool, `instantReaction(message, { nextAction, asked, stores })` an
304
440
 
305
441
  ## Show what Agentcard is doing
306
442
 
307
- Pass `onProgress`, and the tool asks Agentcard to stream each call. Each stage the call reaches goes to `onProgress` as it happens, `searching`, `building_cart`, `checking_out` or `working`, for a status line such as "searching amazon":
443
+ The tool streams every call, so each part of an answer reaches your app the moment Agentcard produces it: the stage the call reached, the products a search found, each message Agentcard writes and the cart it builds. Pass a callback for each part you show:
308
444
 
309
445
  ```ts
310
446
  const buy = buyTool({
311
447
  agentcard,
312
448
  externalUserId: shopper.id,
313
449
  sendLink: (link) => textShopper(shopper.id, link.url),
314
- onProgress: ({ stage }) => showStatus(shopper.id, stage),
450
+ onStatus: ({ text }) => showStatus(shopper.id, text),
451
+ onFound: ({ shown, found, more }) => showCount(shopper.id, `${shown} of ${found} shown`, more),
452
+ onProducts: (products, { merchantName }) => showProducts(shopper.id, merchantName, products),
453
+ onText: (text) => textShopper(shopper.id, text),
454
+ onCart: (cart, { carts }) => showCart(shopper.id, cart, carts),
315
455
  });
316
456
  ```
317
457
 
318
- The product cards still go out with the answer, so each card is a product the reply names. `earlyCards: true` sends the search's first three cards the moment the search answers instead, a few seconds sooner, and the answer then sends only the cards it adds. The reply often recommends other products than the search's first three, so with `earlyCards` a shopper can hold cards the reply never names. A stream that breaks after the call started reads the conversation for what the turn left, as a turn past Agentcard's budget does. Pass `stream: false` to wait for each whole answer.
458
+ - `onStatus` hears each step the moment Agentcard starts it, as a line to show in place of loading dots: `searching Target and Walmart`, `adding it to your cart`, `working through the store's checkout`. `step` names the step, such as `searching` or `adding_to_cart`, for an app that writes its own words. A call that only writes its reply sends none, so keep your dots until the first line comes.
459
+ - `onFound` hears how many products the searches found, after the cards it counts: `shown` cards of `found` products, such as "8 of 23 shown", each time the count grows. `more` holds the products past the cards as product links, in the order they were found, for a "show more" that runs no new search. The model never reads them.
460
+ - `onProgress` still hears the stage, `searching`, `building_cart`, `checking_out` or `working`.
461
+ - `onProducts` hears the products a search found the moment it answers, before Agentcard writes its reply, with the conversation, when the search ran (`asOf`) and the store's name. With `sendLink`, each product's card goes out then too, a few seconds before the answer, and the answer sends only the cards it adds. Pass `earlyCards: false` to send only the cards the answer names, with the answer: the reply often names only some of a search's products.
462
+ - `onText` hears each message Agentcard writes, links included, the moment it is written, and after the cards of the search it is about.
463
+ - `onCart` hears the cart a confirm would place, or null, and every cart on the table, each time a call builds or changes one, before the message that announces it.
464
+
465
+ `buy.turn` takes the same callbacks, and each one left out falls back to the tool's. A turn's `onText` hears each message in its place in the turn, after the instant reply and the cards, paced by `pace`, and `turn.items` lists it; `onItem` does not hear it, so an app that passes both hears each message once.
466
+
467
+ When your app shows Agentcard's messages through `onText`, the model reads `shopper_already_read_reply: true` in that call's answer and adds nothing to them, as it reads `shopper_already_told_looking` after an instant reply: end the turn without a second model call, as with `replyAsWritten`, or let the model add one short line for what the reply left out. `turn.replyAsWritten` stays null for that answer, so the reply never reaches the shopper twice, and a step link the turn holds for your model's next message goes out right after Agentcard's ask instead.
468
+
469
+ A stream that breaks after the call started reads the conversation for what the turn left, as a turn past Agentcard's budget does. Pass `stream: false` to wait for each whole answer, with its cards sent beside it.
470
+
471
+ Code that calls the client itself streams with `agentcard.buy(body, handlers)`: `onCatalog`, `onSegment`, `onCart` and `onDelta` hear their events in the order Agentcard sent them, each once the one before it settled, and the answer waits for the last of them. `onDelta` asks Agentcard for the pieces of each message as it writes them, a draft that each `onSegment` message replaces.
319
472
 
320
473
  ## Send each paragraph as its own message
321
474
 
@@ -342,7 +495,7 @@ await paragraphs.end();
342
495
 
343
496
  `stream` stands for your model's streamed answer. `toMessages` and `messageStream` keep nothing between two texts, so a shopper's overlapping messages never mix in them: the paragraphs of one model message go to the `onMessage` of its own stream, and the links of that turn's tool calls are in its `turn.links`. When `onMessage` returns a promise, the stream waits for it before it sends the next paragraph, so the shopper reads them in order, and `end` resolves once every paragraph went out. After a send fails, no later paragraph goes out, and `end` rejects with that error. A paragraph inside a list waits for the first characters of the next line, which say whether the list goes on. Start a new `messageStream` for each model message, so one message never runs into the next.
344
497
 
345
- A model often writes three paragraphs within 200 ms, and a shopper who gets them at once reads a wall, not a conversation. So `messageStream` spaces them like someone texting: the first goes out at once, and each one after it waits until `typingDelay` of it has passed since the one before, at most 1.5 seconds. Time the model spent writing counts toward the pause, so a slow model gets no added wait. Pass `onTyping` to show a typing indicator during each pause, `pace: false` to hand each paragraph over the moment it is cut, or `pace: (text) => ms` for your own pause. With `buy.turn`'s `onItem`, use `turn.messageStream()` instead, which spaces the model's messages after the cards and the instant reply too.
498
+ Each paragraph goes out the moment it is cut. To space them like someone texting, pass `pace: true`: the first goes out at once, and each one after it waits until `typingDelay` of it has passed since the one before, at most 1.5 seconds. Time the model spent writing counts toward the pause, so a slow model gets no added wait. Pass `onTyping` to show a typing indicator during each pause, or `pace: (text) => ms` for your own pause. With `buy.turn`'s `onItem`, use `turn.messageStream()` instead, which keeps the model's messages after the cards and the instant reply too.
346
499
 
347
500
  ## Sign up a new shopper
348
501
 
@@ -378,6 +531,7 @@ The model reads one object per call:
378
531
  "approval_expires_at": null,
379
532
  "shopper_need_not_reply": false,
380
533
  "shopper_already_told_looking": false,
534
+ "shopper_already_read_reply": false,
381
535
  "link_sent": false,
382
536
  "product_links_sent": 0,
383
537
  "pick": null,
@@ -601,6 +755,9 @@ The facts are `merchantName`, `totalCents`, `currency`, `cardBrand`, `cardLast4`
601
755
  | `store_connected` | The shopper signed in to their own account at the store from the sign-in link your app sent, such as DoorDash, so the order can go through on it. |
602
756
  | `store_connect_failed` | The shopper's sign-in to the store did not go through, and the link no longer works. `text` offers a new one. |
603
757
  | `store_connect_expired` | The sign-in link expired before the shopper finished signing in. `text` offers a new one. |
758
+ | `cart_ready` | The store's checkout built the cart an answer named as still being built (`cart_building`). `text` gives the store's own total, and nothing is paid until the shopper says yes to it. |
759
+ | `cart_needs_input` | That checkout asks the shopper something first, such as a size, a delivery address, a sign-in to the store or an approval of the most the order can cost. Their reply continues the cart. |
760
+ | `cart_failed` | That checkout built no cart, and nothing was ordered or charged. |
604
761
 
605
762
  Agentcard can add a kind later. A kind your code does not know still carries `text`, ready to send as is.
606
763
 
package/dist/ack.d.ts CHANGED
@@ -14,6 +14,28 @@
14
14
  * instant reply at all, because it starts no search and Agentcard answers it fast. So does a pick of a
15
15
  * store the shopper saw, such as `order here`, or `Order from Tacos El Grullense` as a store card sends it.
16
16
  */
17
+ /** Words a reply opens with before it says what it means, such as `ok` in `ok lets checkout` or `yes,` in `yes, sounds good`. */
18
+ export declare const FILLER: RegExp;
19
+ /** A bare acknowledgement, such as `its ok`, `thats fine`, `sounds good` or `works for me`, after the filler comes off. */
20
+ export declare const ACKNOWLEDGED: RegExp;
21
+ /** A step toward paying for the cart already on the table, such as `lets checkout`, `place the order` or `pay`, after the filler comes off. */
22
+ export declare const CHECKOUT: RegExp;
23
+ /**
24
+ * A question about the conversation, an order or how things work, such as `how did it know to msg that
25
+ * number?`, `is it booked?` or `where is my order`: the model answers it straight away, with no search.
26
+ */
27
+ export declare const ABOUT: RegExp;
28
+ /** A command to the cart or the order, such as `lets clear the cart`, `remove the taco` or `change the quantity to 2`. */
29
+ export declare const CART_COMMAND: RegExp;
30
+ /** Small talk that asks for nothing to be bought, after `please` and `thanks` come off. */
31
+ export declare const CHITCHAT: RegExp;
32
+ /**
33
+ * A question about what the agent can buy or where it shops, such as `what stores can you buy from`,
34
+ * `where can you shop` or `which stores do you support`: the model answers it in a line or two, with no
35
+ * search, so no instant reply says it is looking. A question that names a product, such as `which stores
36
+ * can you buy aa batteries from`, is a search and keeps its reply.
37
+ */
38
+ export declare const CAPABILITY: RegExp;
17
39
  /**
18
40
  * True when the message names one of these stores and nothing else to buy: the store's whole name, or
19
41
  * two words of it in a row, such as `el grullense` for `Tacos El Grullense`, alone or after a frame such
@@ -23,6 +45,8 @@
23
45
  export declare function namesStore(text: string, storeNames: readonly string[] | null | undefined): boolean;
24
46
  /** True when the shopper's words pick a product they already saw, such as `the first one`, `2` or a card's title with its price. */
25
47
  export declare function isPickText(text: string): boolean;
48
+ /** The text as one plain line: lowercase, no quotes, and no dash between words. */
49
+ export declare function plain(text: string): string;
26
50
  /**
27
51
  * True when the shopper's words start a request, and false for an answer to a question, a pick, a
28
52
  * code, small talk, or a message that carries the shopper's own details, such as a phone number or a
@@ -106,5 +130,17 @@ export declare const ACK_REACTION = "\uD83D\uDC40";
106
130
  * ```
107
131
  */
108
132
  export declare function instantReaction(shopperText: string, options?: AckForOptions): string | null;
133
+ /**
134
+ * True when the shopper's words leave a yes they already gave standing: an acknowledgement such as
135
+ * `ok` or `sounds good`, a go-ahead such as `lets place it`, or a report that they took the step they
136
+ * were sent, such as `done` or `signed in`. False for anything else, `wait`, `no`, `cancel`, a
137
+ * question or a new request among them, which a yes given before it does not cover.
138
+ *
139
+ * ```ts
140
+ * keepsYes('ok done'); // true
141
+ * keepsYes('wait, make it two'); // false
142
+ * ```
143
+ */
144
+ export declare function keepsYes(shopperText: string): boolean;
109
145
  /** The earlier name of `instantAck`, kept for apps that call it. */
110
146
  export declare function ackFor(text: string, options?: AckForOptions): string | null;
package/dist/ack.js CHANGED
@@ -31,11 +31,11 @@ const TEMPLATES = [
31
31
  */
32
32
  const WAITS = ['one moment', 'just a sec', 'working on it', 'on it, one sec', 'hang tight', 'one sec, working on it'];
33
33
  /** Words a reply opens with before it says what it means, such as `ok` in `ok lets checkout` or `yes,` in `yes, sounds good`. */
34
- const FILLER = /^(?:(?:ok|okay|k|kk|yes|yeah|yea|yep|yup|ya|sure|alright|all right|cool|great|perfect|nice|so|then|and|um|uh|oh|well|hmm|now)[\s,]+)+/;
34
+ export const FILLER = /^(?:(?:ok|okay|k|kk|yes|yeah|yea|yep|yup|ya|sure|alright|all right|cool|great|perfect|nice|so|then|and|um|uh|oh|well|hmm|now)[\s,]+)+/;
35
35
  /** A bare acknowledgement, such as `its ok`, `thats fine`, `sounds good` or `works for me`, after the filler comes off. */
36
- const ACKNOWLEDGED = /^(?:ok|okay|k|kk|fine|alright|all right|cool|great|perfect|good|nice|sure|yes|yeah|yep|yup|right|correct|exactly|agreed|works|(?:that|this|it) works|(?:it|that|this)(?:'?s| is) (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|all right|all good|it)|its (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|all good)|thats (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|it|all)|sounds (?:good|great|fine|perfect|right|ok|okay)|looks (?:good|great|fine|perfect|right|ok|okay)|all good|all set|no worries|no problem|np|got it|gotcha|understood|makes sense|(?:works|fine|ok|okay|good) (?:for|by|with) me|i'?m good|im good|we'?re good)(?: (?:then|now|with me|for me|to me))?$/;
36
+ export const ACKNOWLEDGED = /^(?:ok|okay|k|kk|fine|alright|all right|cool|great|perfect|good|nice|sure|yes|yeah|yep|yup|right|correct|exactly|agreed|works|(?:that|this|it) works|(?:it|that|this)(?:'?s| is) (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|all right|all good|it)|its (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|all good)|thats (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|it|all)|sounds (?:good|great|fine|perfect|right|ok|okay)|looks (?:good|great|fine|perfect|right|ok|okay)|all good|all set|no worries|no problem|np|got it|gotcha|understood|makes sense|(?:works|fine|ok|okay|good) (?:for|by|with) me|i'?m good|im good|we'?re good)(?: (?:then|now|with me|for me|to me))?$/;
37
37
  /** A step toward paying for the cart already on the table, such as `lets checkout`, `place the order` or `pay`, after the filler comes off. */
38
- const CHECKOUT = /^(?:(?:let'?s|lets|i want to|we want to|i'?d like to|id like to|i'?m ready to|im ready to|ready to|can you|could you|can we|go ahead and|just|please)\s+)*(?:check ?out|pay|pay now|pay for (?:it|this|that|them|the order|my order|everything)|place (?:the |my |this |that |our )?order|place it|confirm(?: (?:the|my) (?:order|purchase|cart)| it)?|complete (?:the |my )?(?:order|purchase|checkout)|finish (?:the |my )?(?:order|purchase|checkout)|proceed(?: to (?:checkout|payment|pay))?|go ahead|go for it|buy (?:it|them|this|that|these|those)(?: now)?|order it|book it|submit (?:the |my )?order|send (?:the |my )?order|do it|go|ready|i'?m ready|im ready)(?: now| then| already)?$/;
38
+ export const CHECKOUT = /^(?:(?:let'?s|lets|i want to|we want to|i'?d like to|id like to|i'?m ready to|im ready to|ready to|can you|could you|can we|go ahead and|just|please)\s+)*(?:check ?out|pay|pay now|pay for (?:it|this|that|them|the order|my order|everything)|place (?:the |my |this |that |our )?order|place it|confirm(?: (?:the|my) (?:order|purchase|cart)| it)?|complete (?:the |my )?(?:order|purchase|checkout)|finish (?:the |my )?(?:order|purchase|checkout)|proceed(?: to (?:checkout|payment|pay))?|go ahead|go for it|buy (?:it|them|this|that|these|those)(?: now)?|order it|book it|submit (?:the |my )?order|send (?:the |my )?order|do it|go|ready|i'?m ready|im ready)(?: now| then| already)?$/;
39
39
  /** Whole replies that answer a question the shopper was asked, after `please` and `thanks` come off. */
40
40
  const ANSWERS = new RegExp('^(?:' +
41
41
  [
@@ -53,9 +53,9 @@ const STATUS = /^(?:(?:ok|okay|yes|yep|yeah|alright|cool|great|so|done|there)[\s
53
53
  * A question about the conversation, an order or how things work, such as `how did it know to msg that
54
54
  * number?`, `is it booked?` or `where is my order`: the model answers it straight away, with no search.
55
55
  */
56
- const ABOUT = /^(?:(?:ok|okay|so|wait|hmm|um|uh|and|but)[\s,]+)*(?:how (?:did|does|do|come|is|was|were|are)|why (?:did|does|do|is|was|are|were|would|didn'?t|isn'?t|won'?t)|what(?:'s| is| was) (?:my|the status|happening|going on|that|this)|what happened|where(?:'s| is| are) (?:my|the order|it)|(?:is|was|has|did|will) (?:it|my|this|that|the order|the booking|the reservation)|did (?:it|that|you) (?:go|work|book|place|send|get|cancel|charge|order|change|add|remove)|will you (?:let|tell|text|notify|email|update|remind|ping|keep (?:me|us) (?:updated|posted|informed|in the loop))|when (?:will|does|do|is|did) (?:it|my|the order|they)|how long (?:will|does|until|till))\b/;
56
+ export const ABOUT = /^(?:(?:ok|okay|so|wait|hmm|um|uh|and|but)[\s,]+)*(?:how (?:did|does|do|come|is|was|were|are)|why (?:did|does|do|is|was|are|were|would|didn'?t|isn'?t|won'?t)|what(?:'s| is| was) (?:my|the status|happening|going on|that|this)|what happened|where(?:'s| is| are) (?:my|the order|it)|(?:is|was|has|did|will) (?:it|my|this|that|the order|the booking|the reservation)|did (?:it|that|you) (?:go|work|book|place|send|get|cancel|charge|order|change|add|remove)|will you (?:let|tell|text|notify|email|update|remind|ping|keep (?:me|us) (?:updated|posted|informed|in the loop))|when (?:will|does|do|is|did) (?:it|my|the order|they)|how long (?:will|does|until|till))\b/;
57
57
  /** A command to the cart or the order, such as `lets clear the cart`, `remove the taco` or `change the quantity to 2`. */
58
- const CART_COMMAND = new RegExp('^(?:(?:ok|okay|so|actually|wait|and|um|uh)[\\s,]+)*(?:let\'?s |lets |can you |could you |pls |i want to |i\'?d like to |id like to |i need to |go ahead and |just )*(?:' +
58
+ export const CART_COMMAND = new RegExp('^(?:(?:ok|okay|so|actually|wait|and|um|uh)[\\s,]+)*(?:let\'?s |lets |can you |could you |pls |i want to |i\'?d like to |id like to |i need to |go ahead and |just )*(?:' +
59
59
  [
60
60
  // Verbs that never open a search: `remove fries`, `cancel order`.
61
61
  'remove|delete|cancel|undo|start over|take off|get rid of',
@@ -66,7 +66,14 @@ const CART_COMMAND = new RegExp('^(?:(?:ok|okay|so|actually|wait|and|um|uh)[\\s,
66
66
  ].join('|') +
67
67
  ')\\b');
68
68
  /** Small talk that asks for nothing to be bought, after `please` and `thanks` come off. */
69
- const CHITCHAT = /^(?:(?:hi|hey|hello|yo|hiya|howdy|sup)(?: there| again)?|good (?:morning|afternoon|evening|night)|g(?:oo)?d ?night|how are (?:you|u)(?: doing)?(?: today)?|how'?s it going|what'?s up|whats up|wassup|who are (?:you|u)|what (?:can|do) (?:you|u) do|are (?:you|u) (?:a )?(?:bot|real|human|there)|you there|test(?:ing)?|bye|see (?:you|ya)|later|love (?:it|you|u)|awesome|amazing|wow|omg|ha(?:ha)+|lmao|:\)|ok cool|cool thanks|so much|very much|a lot)$/;
69
+ export const CHITCHAT = /^(?:(?:hi|hey|hello|yo|hiya|howdy|sup)(?: there| again)?|good (?:morning|afternoon|evening|night)|g(?:oo)?d ?night|how are (?:you|u)(?: doing)?(?: today)?|how'?s it going|what'?s up|whats up|wassup|who are (?:you|u)|what (?:can|do) (?:you|u) do|are (?:you|u) (?:a )?(?:bot|real|human|there)|you there|test(?:ing)?|bye|see (?:you|ya)|later|love (?:it|you|u)|awesome|amazing|wow|omg|ha(?:ha)+|lmao|:\)|ok cool|cool thanks|so much|very much|a lot)$/;
70
+ /**
71
+ * A question about what the agent can buy or where it shops, such as `what stores can you buy from`,
72
+ * `where can you shop` or `which stores do you support`: the model answers it in a line or two, with no
73
+ * search, so no instant reply says it is looking. A question that names a product, such as `which stores
74
+ * can you buy aa batteries from`, is a search and keeps its reply.
75
+ */
76
+ export const CAPABILITY = /^(?:(?:ok|okay|so|and|but|hey|hi|um|uh|also|wait)[\s,]+)*(?:(?:what|which|where)(?: (?:kinds?|types?|sorts?) of)?(?: (?:other|else|all|the))?(?: (?:stores?|shops?|places?|sites?|websites?|merchants?|retailers?|restaurants?|brands?|products?|items?|things?|stuff))?(?: else)? (?:can|do|could|will|are) (?:you|u)(?: (?:able to|even|actually|also))? (?:buy|shop|order|get|purchase|do|use|support|work with|deliver|cover|access|sell|carry|offer|stock)(?: (?:from|at|on|in|with|for me|for us|to))?(?: for me)?|(?:what|which) (?:stores?|shops?|merchants?|retailers?|sites?) (?:are )?(?:supported|available|connected|on here)|(?:what|which) (?:stores?|shops?|merchants?|retailers?) do (?:you|u) (?:have|know|use|support|work with)|what (?:else )?(?:do|can) (?:you|u) (?:offer|sell|carry|get (?:me|us)|buy (?:me|us)|order (?:me|us)|find (?:me|us)))(?: (?:right now|here|today))?$/;
70
77
  /** Unit words a number names a product by, such as `10000 mah`, so it is never read as a zip code. */
71
78
  const UNIT = '(?:packs?|count|ct|pcs?|pieces?|oz|ml|l|g|kg|gb|tb|mb|mah|w|watts?|v|volts?|lumens?|lm|inch(?:es)?|in|ft|feet|mm|cm|lbs?|dollars?|bucks|rpm|dpi|btu|cal|calories|thread)';
72
79
  /**
@@ -219,7 +226,7 @@ const LEADING_PREPOSITION = /^(?:from|at|in|on|to|for|of|with|by|near|into|onto|
219
226
  const MAX_WORDS = 8;
220
227
  const MAX_WORD_LENGTH = 24;
221
228
  /** The text as one plain line: lowercase, no quotes, and no dash between words. */
222
- function plain(text) {
229
+ export function plain(text) {
223
230
  return text.toLowerCase().replace(/[‘’]/g, "'").replace(/["“”`]/g, '').replace(/\s+[-–—]+\s+|[–—]/g, ' ').replace(/\s+/g, ' ').trim();
224
231
  }
225
232
  /**
@@ -237,7 +244,7 @@ export function isNewRequest(text) {
237
244
  const bare = words.replace(FILLER, '');
238
245
  if (bare === '' || ACKNOWLEDGED.test(bare) || CHECKOUT.test(bare))
239
246
  return false;
240
- return !ANSWERS.test(words) && !STATUS.test(words) && !ABOUT.test(words) && !CART_COMMAND.test(words) && !CODE.test(words) && !CHITCHAT.test(words) && !isPick(line, words);
247
+ return !ANSWERS.test(words) && !STATUS.test(words) && !ABOUT.test(words) && !CART_COMMAND.test(words) && !CODE.test(words) && !CHITCHAT.test(words) && !CAPABILITY.test(words) && !isPick(line, words);
241
248
  }
242
249
  /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
243
250
  export function paraphrase(text) {
@@ -388,6 +395,27 @@ export const ACK_REACTION = '👀';
388
395
  export function instantReaction(shopperText, options = {}) {
389
396
  return instantAck(shopperText, options) === null ? null : ACK_REACTION;
390
397
  }
398
+ /**
399
+ * True when the shopper's words leave a yes they already gave standing: an acknowledgement such as
400
+ * `ok` or `sounds good`, a go-ahead such as `lets place it`, or a report that they took the step they
401
+ * were sent, such as `done` or `signed in`. False for anything else, `wait`, `no`, `cancel`, a
402
+ * question or a new request among them, which a yes given before it does not cover.
403
+ *
404
+ * ```ts
405
+ * keepsYes('ok done'); // true
406
+ * keepsYes('wait, make it two'); // false
407
+ * ```
408
+ */
409
+ export function keepsYes(shopperText) {
410
+ const words = plain(String(shopperText ?? '')).replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
411
+ if (words === '')
412
+ return false;
413
+ const bare = words.replace(FILLER, '');
414
+ if (bare === '')
415
+ return true;
416
+ // A report after filler ("well signed in", "k connected") is read on its bare words too (review of #9220).
417
+ return ACKNOWLEDGED.test(bare) || CHECKOUT.test(bare) || STATUS.test(words) || STATUS.test(bare) || /^(?:done|added(?: it)?|i added(?: it| the card| a card)?)$/.test(bare);
418
+ }
391
419
  /** The earlier name of `instantAck`, kept for apps that call it. */
392
420
  export function ackFor(text, options = {}) {
393
421
  return instantAck(text, options);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The steps a shopper takes outside the chat: adding a card (`vault`), signing in to a store
3
+ * (`store_login`) and approving a payment (`approval`). Each one is a link the buy tool hands over,
4
+ * and each one ends with a shopper message Agentcard sends: this file says which message ends which
5
+ * link, and how. It imports nothing, so a browser bundle can use it as well as a server.
6
+ */
7
+ /** A link the shopper opens to do something outside the chat. */
8
+ export type ActionLinkKind = 'vault' | 'store_login' | 'approval';
9
+ /** The kinds of link that are a step outside the chat. */
10
+ export declare const ACTION_LINK_KINDS: readonly ActionLinkKind[];
11
+ /**
12
+ * How a step ended:
13
+ * - `completed`: the shopper finished it: a card saved, a store signed in to, a payment approved.
14
+ * An approval stays `completed` when the order then failed or the payment was refused, since the
15
+ * page has nothing more to do; the message itself tells the shopper what happened next.
16
+ * - `declined`: the shopper declined the payment on the approval page.
17
+ * - `failed`: the step did not go through, and its link no longer works (a store sign-in that failed).
18
+ * - `expired`: the link expired before the shopper finished.
19
+ */
20
+ export type ActionLinkOutcome = 'completed' | 'declined' | 'failed' | 'expired';
21
+ /**
22
+ * For each kind of step, the shopper message kinds that end it, and how. Every other message kind,
23
+ * such as `card_link_unfinished` or `approval_expiring`, leaves the step open.
24
+ */
25
+ export declare const ACTION_LINK_ENDINGS: Readonly<Record<ActionLinkKind, Readonly<Record<string, ActionLinkOutcome>>>>;
26
+ /** Whether a link is a step outside the chat: a `vault`, `store_login` or `approval` link with a page to open. */
27
+ export declare function isActionLink<T extends {
28
+ kind: string;
29
+ url?: string | null;
30
+ }>(link: T): link is T & {
31
+ kind: ActionLinkKind;
32
+ url: string;
33
+ };
34
+ /**
35
+ * A stable id for a step's link, such as `vault_1x9k2qd`: the same for the same link every time it is
36
+ * handed over, on the server and in the browser, and with no part of the URL in it, so it is safe in
37
+ * a log, a DOM key or a window name. The buy tool puts it on each step link as `id`.
38
+ */
39
+ export declare function actionLinkId(link: {
40
+ kind: string;
41
+ url: string;
42
+ }): string;
43
+ /** What `actionLinkOutcome` reads of a shopper message: its kind, and the link URLs and the store its facts name, when they name them. */
44
+ export interface ActionLinkMessage {
45
+ kind: string;
46
+ facts?: {
47
+ approvalUrl?: string;
48
+ cardLinkUrl?: string;
49
+ merchantName?: string;
50
+ } | null;
51
+ }
52
+ /**
53
+ * How `message` ends the step `link` is, or null when it leaves it open: a message about another
54
+ * kind of step, a reminder such as `card_link_unfinished`, a message whose facts name another link of
55
+ * the same kind (`facts.approvalUrl` or `facts.cardLinkUrl`), or one about another store: a store
56
+ * login or an approval link that names its `store` stays open when `facts.merchantName` names a
57
+ * different one, since a shopper can sign in to two stores, or approve two stores' carts, at once.
58
+ * A message whose facts name neither ends the shopper's open step of its kind.
59
+ */
60
+ export declare function actionLinkOutcome(link: {
61
+ kind: string;
62
+ url?: string | null;
63
+ store?: string | null;
64
+ }, message: ActionLinkMessage): ActionLinkOutcome | null;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The steps a shopper takes outside the chat: adding a card (`vault`), signing in to a store
3
+ * (`store_login`) and approving a payment (`approval`). Each one is a link the buy tool hands over,
4
+ * and each one ends with a shopper message Agentcard sends: this file says which message ends which
5
+ * link, and how. It imports nothing, so a browser bundle can use it as well as a server.
6
+ */
7
+ /** The kinds of link that are a step outside the chat. */
8
+ export const ACTION_LINK_KINDS = ['vault', 'store_login', 'approval'];
9
+ /**
10
+ * For each kind of step, the shopper message kinds that end it, and how. Every other message kind,
11
+ * such as `card_link_unfinished` or `approval_expiring`, leaves the step open.
12
+ */
13
+ export const ACTION_LINK_ENDINGS = {
14
+ vault: { card_added: 'completed', card_link_expired: 'expired' },
15
+ store_login: { store_connected: 'completed', store_connect_failed: 'failed', store_connect_expired: 'expired' },
16
+ approval: {
17
+ order_placed: 'completed',
18
+ order_not_placed_yet: 'completed',
19
+ order_unconfirmed: 'completed',
20
+ order_confirmed: 'completed',
21
+ order_failed: 'completed',
22
+ payment_declined: 'completed',
23
+ approval_declined: 'declined',
24
+ approval_expired: 'expired',
25
+ },
26
+ };
27
+ /** Whether a link is a step outside the chat: a `vault`, `store_login` or `approval` link with a page to open. */
28
+ export function isActionLink(link) {
29
+ return ACTION_LINK_KINDS.includes(link.kind) && typeof link.url === 'string' && link.url !== '';
30
+ }
31
+ /** FNV-1a, 32 bits, written out so the id reads the same in every runtime, with no crypto import. */
32
+ function fnv1a(text) {
33
+ let hash = 0x811c9dc5;
34
+ for (let i = 0; i < text.length; i++) {
35
+ hash ^= text.charCodeAt(i);
36
+ hash = Math.imul(hash, 0x01000193) >>> 0;
37
+ }
38
+ return hash;
39
+ }
40
+ /**
41
+ * A stable id for a step's link, such as `vault_1x9k2qd`: the same for the same link every time it is
42
+ * handed over, on the server and in the browser, and with no part of the URL in it, so it is safe in
43
+ * a log, a DOM key or a window name. The buy tool puts it on each step link as `id`.
44
+ */
45
+ export function actionLinkId(link) {
46
+ return `${link.kind}_${fnv1a(`${link.kind} ${link.url}`).toString(36)}`;
47
+ }
48
+ /** A store's name reduced to its letters and digits, so "Walmart" and "walmart.com" read alike. */
49
+ const storeKey = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '');
50
+ /** Whether two names are the same store: one reduced name holds the other. */
51
+ function sameStore(a, b) {
52
+ const x = storeKey(a);
53
+ const y = storeKey(b);
54
+ return x === '' || y === '' || x.includes(y) || y.includes(x);
55
+ }
56
+ /**
57
+ * How `message` ends the step `link` is, or null when it leaves it open: a message about another
58
+ * kind of step, a reminder such as `card_link_unfinished`, a message whose facts name another link of
59
+ * the same kind (`facts.approvalUrl` or `facts.cardLinkUrl`), or one about another store: a store
60
+ * login or an approval link that names its `store` stays open when `facts.merchantName` names a
61
+ * different one, since a shopper can sign in to two stores, or approve two stores' carts, at once.
62
+ * A message whose facts name neither ends the shopper's open step of its kind.
63
+ */
64
+ export function actionLinkOutcome(link, message) {
65
+ if (!isActionLink(link))
66
+ return null;
67
+ const outcome = ACTION_LINK_ENDINGS[link.kind][message.kind];
68
+ if (!outcome)
69
+ return null;
70
+ const named = link.kind === 'approval' ? message.facts?.approvalUrl : link.kind === 'vault' ? message.facts?.cardLinkUrl : undefined;
71
+ if (named && named !== link.url)
72
+ return null;
73
+ const store = message.facts?.merchantName;
74
+ if (link.store && store && !sameStore(link.store, store))
75
+ return null;
76
+ return outcome;
77
+ }