@agent-cards/commerce-sdk 0.18.0 → 0.18.3

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
@@ -136,7 +136,7 @@ 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 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.
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. The link names the store that sells the product in `store`, such as `Walmart`, and the store's own website in `storeDomain`, such as `walmart.com` or `jadeyoga.com`, or null when Agentcard knows none, such as a restaurant's: show `storeDomain` on the card as where the product comes from, never the host of `url`, which can be a tracking link. 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
 
@@ -178,6 +178,40 @@ onShopperMessage((message) => windows.settle(message));
178
178
 
179
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
180
 
181
+ ## Keep the answer when the shopper's connection drops
182
+
183
+ A phone that loses its connection while a reply streams back would show the shopper an error, though the turn finished on your server. Serve the turn with `turnStream` and read it in the browser with `streamTurn`, and the shopper gets the answer anyway. On your server:
184
+
185
+ ```ts
186
+ import { readTurn, turnStream, isTurnId } from '@agent-cards/commerce-sdk';
187
+
188
+ // POST /chat: the browser names the turn in `x-turn-id`.
189
+ const turnId = request.headers.get('x-turn-id');
190
+ if (!isTurnId(turnId)) return Response.json({ error: 'Name the turn.' }, { status: 400 });
191
+ const { stream, finished } = turnStream({ store, key: `${shopperId}:${turnId}`, run: (send) => runTurn(send) });
192
+ waitUntil(finished);
193
+ return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
194
+
195
+ // GET /chat/turn?id=...: the turn's record, for the browser to pick up where its stream stopped.
196
+ return Response.json(await readTurn(store, `${shopperId}:${id}`));
197
+ ```
198
+
199
+ `store` is yours, such as Redis: `load(key)` and `save(key, record, ttlSeconds)`. `runTurn` stands for your agent's turn, and `send(event, data)` takes each event the chat shows, the last one included, such as `done`. The turn runs to its end after the browser leaves, and `finished` resolves once its record is saved: hand it to your host's `waitUntil`. The record is kept for ten minutes (`ttlSeconds`). Put your shopper's id in the key, so no shopper reads another's turn. When one turn at a time runs per shopper, answer a message sent during a turn with a 409 whose body names that turn, `{ "error": "...", "turn_id": "..." }`.
200
+
201
+ In the browser:
202
+
203
+ ```ts
204
+ import { streamTurn, readTurnResponse } from '@agent-cards/commerce-sdk/browser';
205
+
206
+ const { refused } = await streamTurn({
207
+ start: (turnId) => fetch('/chat', { method: 'POST', headers: { 'x-turn-id': turnId }, body }),
208
+ read: (turnId) => fetch(`/chat/turn?id=${turnId}`).then(readTurnResponse),
209
+ onEvent: (event, data) => show(event, data),
210
+ });
211
+ ```
212
+
213
+ `onEvent` hears each event once, in order. When the stream drops, before its first event or after it, `streamTurn` reads the turn's record every 1.5 seconds (`pollMs`) and hands over the events the stream did not carry, until the turn's last one. A 409 that names a running turn waits for that turn's answer the same way. Any other refusal comes back in `refused`, the response, for you to show its error. A record that never appears within 15 seconds (`startWaitMs`) means the message never reached your server, and `streamTurn` throws the connection's error.
214
+
181
215
  ## Answer each shopper message in its own turn
182
216
 
183
217
  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:
@@ -251,6 +285,8 @@ const turn = buy.turn(message, {
251
285
  onItem: (item) => (item.kind === 'text' ? sendText(shopper.id, item.text) : sendCard(shopper.id, item.link)), // your code
252
286
  });
253
287
  if (await turn.handled()) {
288
+ // A newer message from the shopper stopped this turn: that message's turn answers and keeps the exchange.
289
+ if (turn.stopped) return;
254
290
  // Agentcard answered the shopper: keep the exchange, so your model reads it on the next message.
255
291
  const said = turn.items.flatMap((item) => (item.kind === 'text' ? [item.text] : [])).join('\n\n');
256
292
  history = [...turn.messages(history), { role: 'assistant', content: said }];
@@ -362,7 +398,7 @@ async function runModel(shopperId: string, turn: BuyToolTurn) {
362
398
  | Saving a card, with no yes and no pick standing | 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?` |
363
399
  | 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.` |
364
400
  | 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 |
365
- | Signing in or saving a card, after a yes, when the store priced its fees and tax in since | The new total, then the next link: `it's $17.89 with fees and tax now. approve the payment to place your order (up to $17.89)` |
401
+ | Signing in or saving a card, after a yes, when the store priced its fees and tax in since | The new total and the ask for the yes, with no link: `The cart changed after your yes: it comes to $17.89 with tax and shipping now. Nothing was paid. Say yes to pay $17.89.` |
366
402
  | 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` |
367
403
 
368
404
  With `direct` and `replyAsWritten` on the tool, a step needs no model when its answer is a link: send `step.run(step.direct.input)` first, and when `step.replyAsWritten` is set, post it, with `step.links`, in place of the model's answer. When it is null, as for the ask for a yes, run your model as above.
@@ -370,8 +406,8 @@ With `direct` and `replyAsWritten` on the tool, a step needs no model when its a
370
406
  The turn moves the purchase only up to the shopper's own decision, and the tool holds it there whatever the model calls:
371
407
 
372
408
  - 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.
373
- - 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, and the shopper is never asked for that yes again. It goes on past each step, the sign-in and then the card, as long as the cart holds the same items, in the same quantities and at the same prices, at the same store: a total that moved only because the store priced its fees, tax or delivery in keeps the yes, and the next ask says the new total (`yes_carried` on the answer). Each confirm names the cart's hash as it stands, so Agentcard refuses it if the cart changed. A cart whose items, quantities or store changed, a shopper message after the yes that is not a plain go-ahead, such as "wait, make it two", and a declined approval each take the yes back, and the shopper is asked again. Any other confirm the model sends is refused by the tool, and nothing reaches Agentcard.
374
- - A shopper who picked what is in the cart, by tapping a product's card or asking to buy it, and since answered only the checkout's questions (an address, a phone number, a name, an email, a yes to an address read back to them) or said a plain go-ahead, gave the same go-ahead: saving the card or signing in to the store confirms that cart by its hash as it stands, with no ask for a yes (`yes_carried.picked`). A cart whose items, quantities, item prices or store changed since the pick, any other message, such as "actually wait" or a question, and a declined approval take it back.
409
+ - 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, and the shopper is never asked for that yes again. It goes on past each step, the sign-in and then the card, as long as the cart holds the same items, in the same quantities and at the same prices, at the same store. Each confirm names the cart's hash as it stands, and in `carried` the hash of the cart the shopper said yes to: Agentcard pays only that cart, so a total that moved because the store priced its fees, tax or delivery in comes back with the new total and the ask for the shopper's yes, and nothing is paid. A cart whose items, quantities or store changed, a shopper message after the yes that is not a plain go-ahead, such as "wait, make it two", and a declined approval each take the yes back, and the shopper is asked again. Any other confirm the model sends is refused by the tool, and nothing reaches Agentcard.
410
+ - A shopper who picked what is in the cart, by tapping a product's card or asking to buy it, and since answered only the checkout's questions (an address, a phone number, a name, an email, a yes to an address read back to them) or said a plain go-ahead, gave the same go-ahead: saving the card or signing in to the store confirms that cart by its hash as it stands, with no ask for a yes (`yes_carried.picked`). The confirm names in `carried` the cart the chat showed, and a cart the store priced again since comes back with its total and the ask for a yes. A cart whose items, quantities, item prices or store changed since the pick, any other message, such as "actually wait" or a question, and a declined approval take it back.
375
411
  - The approval in the Vault is still the shopper's: the confirm sends the approval link, which names the most they can be charged, and the order goes in only once they approve it.
376
412
  - A shopper who was not connected yet when they asked gets their own first ask run, in their words, once their card is saved.
377
413
 
@@ -432,7 +468,7 @@ const buy = buyTool({
432
468
 
433
469
  `ack` takes `'reaction'`, `'text'` or `'none'`. Left out, the tool reacts when the tool or the turn has an `onReaction`, and sends text through `onWorking` otherwise, so an app built on 0.9 with only `onWorking` keeps its text replies. With both callbacks, the shopper gets the 👀 and never the text too. `'none'`, or `false`, sends nothing.
434
470
 
435
- In text mode, a streamed search's reply is Agentcard's own line, written from the conversation while it searches, such as `one sec, checking target for la croix`, and it reaches `onWorking` before the cards or not at all. A search `buy.turn` sends straight that has shown the shopper nothing by twice `ackDelayMs`, because Agentcard wrote no line or its line was dropped, sends `one moment` once, and a line of Agentcard's that comes after it is dropped. When your model's run fails before it wrote or called anything, call `turn.end()` before you show your error, so no wait line follows it. With `stream: false`, the tool sends its own reply when Agentcard has not answered a new request within `ackDelayMs`, 1 second by default, so a quick answer arrives alone. It sends one for the calls the model makes in one turn, where a call that starts within 15 seconds of the last one ending counts as the same turn. A slow confirm or pick never hears `looking for`: a pick, of a product, a store or a place in a list such as `the first one`, hears `got it, one moment`, a confirm hears `on it, one moment`, and any other answer hears `one moment`. A turn opened with `buy.turn` that has shown the shopper nothing, no line and no card, after the delay sends that line once. None of them names anything the shopper said, and a code hears nothing. The words rotate through six templates, and one tool never sends the same template twice in a row. A short paraphrase of the request goes in when the tool can read one: it stops before any link, email address, phone number, street address or long number, and a request that starts with one gets a reply that names nothing, such as `looking for that now`. The call waits for `onWorking`, so the shopper reads the reply before any link and before the model answers. An `onWorking` that throws costs only the instant reply.
471
+ In text mode, a streamed search's reply is Agentcard's own line, written from the conversation while it searches, such as `one sec, checking target for la croix`, and it reaches `onWorking` before the cards or not at all. A search `buy.turn` sends straight that has shown the shopper nothing by twice `ackDelayMs`, because Agentcard wrote no line or its line was dropped, sends `one moment` once, and a line of Agentcard's that comes after it is dropped. When your model's run fails before it wrote or called anything, call `turn.end()` before you show your error, so no wait line follows it. When `turn.stopped` is true after a call, the shopper sent a new message while this turn ran and Agentcard stopped the turn for it: end the turn there, with no model call, no error and no saved history, because the new message's turn answers. With `stream: false`, the tool sends its own reply when Agentcard has not answered a new request within `ackDelayMs`, 1 second by default, so a quick answer arrives alone. It sends one for the calls the model makes in one turn, where a call that starts within 15 seconds of the last one ending counts as the same turn. A slow confirm or pick never hears `looking for`: a pick, of a product, a store or a place in a list such as `the first one`, hears `got it, one moment`, a confirm hears `on it, one moment`, and any other answer hears `one moment`. A turn opened with `buy.turn` that has shown the shopper nothing, no line and no card, after the delay sends that line once. None of them names anything the shopper said, and a code hears nothing. The words rotate through six templates, and one tool never sends the same template twice in a row. A short paraphrase of the request goes in when the tool can read one: it stops before any link, email address, phone number, street address or long number, and a request that starts with one gets a reply that names nothing, such as `looking for that now`. The call waits for `onWorking`, so the shopper reads the reply before any link and before the model answers. An `onWorking` that throws costs only the instant reply.
436
472
 
437
473
  Pass `ack: (ask) => text` to write your own text from the shopper's words, where an empty answer sends none, and `ackDelayMs` to change the 1 second for either mode.
438
474
 
package/dist/ack.d.ts CHANGED
@@ -20,6 +20,13 @@ export declare const CHITCHAT: RegExp;
20
20
  * can you buy aa batteries from`, is a search and keeps its reply.
21
21
  */
22
22
  export declare const CAPABILITY: RegExp;
23
+ /**
24
+ * True when the shopper's words ask for other products like one they saw, such as `More like the KODAK AA Alkaline Batteries (60
25
+ * Pack)`, `something similar to the first one`, `alternatives to the Duracell` or `cheaper than the KODAK one`: a new search,
26
+ * never a pick of the product it names. `names` are the products the shopper saw: their words are read out first, so a product
27
+ * whose own name says `alternative` or `similar` is still picked by its tap.
28
+ */
29
+ export declare function asksForSimilar(text: string, names?: readonly string[]): boolean;
23
30
  /**
24
31
  * True when the message names one of these stores and nothing else to buy: the store's whole name, or
25
32
  * two words of it in a row, such as `el grullense` for `Tacos El Grullense`, alone or after a frame such
package/dist/ack.js CHANGED
@@ -15,6 +15,7 @@
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
17
  import { SENTENCE_END } from './sentences.js';
18
+ import { withoutTripVerb } from './trip-ask.js';
18
19
  /** Each template twice: with the paraphrase in place of `{x}`, and without one. */
19
20
  const TEMPLATES = [
20
21
  ['on it, looking for {x}', 'on it, looking for that now'],
@@ -140,6 +141,45 @@ const PLACE = '(?:first|second|third|fourth|fifth|sixth|last|other|\\d{1,2}(?:st
140
141
  const PICK_REF = new RegExp(`^(?:(?:the|that|this) (?:[\\w'$.-]+ ){0,3}one\\b|(?:that|this|those|these|both|it)(?: ones?)?$|the ${PLACE}(?: (?:one|option|item|product|choice|result))?$|${PLACE}(?: (?:one|option|item|product|choice|result)\\b|$)|(?:option|number|item|choice|#) ?#?\\d{1,2}\\b|\\d{1,2}$)`);
141
142
  /** A budget word in front of a price, so `under $13.49` stays a search. */
142
143
  const BUDGET = /(?:under|below|than|max|maximum|around|about|to|budget|within|over|above|for|most)\s*$/;
144
+ /** What a request for other products opens with before `more like`: `show me more like the KODAK one`, `any more like it`. */
145
+ const MORE_LIKE_LEAD = "(?:(?:ok|okay|hey|hi|so|um|uh|also|and|cool|great|nice|hmm|actually|yes|yeah|can you|could you|can i|could i|i'?d like to|i want to|please)\\s+)*(?:(?:show|find|get|give|send)(?: me| us)?\\s+|(?:i want|i'?d like|i would like|i need|i'?m looking for|im looking for|looking for|search for|look for|do you have|got|have you got|are there|is there|see)\\s+)?(?:some\\s+|any\\s+)?";
146
+ /**
147
+ * Words that ask for other products like one the shopper saw, never for that product: `more like the KODAK AA (60 Pack)`,
148
+ * `something similar`, `alternatives to the Duracell`, `cheaper than the KODAK one`, `like the first one but in blue`, and in
149
+ * Spanish `algo parecido al KODAK`, `más como este`. A number or a price after the words is a correction (`more like $20`,
150
+ * `something like 2`), and stays out.
151
+ */
152
+ const SIMILAR = [
153
+ new RegExp(`^${MORE_LIKE_LEAD}more (?:like|similar)\\b(?!\\s*(?:\\$|\\d))`),
154
+ /\bmore like (?:the|this|that|these|those|it|them|one|ones)\b/,
155
+ /\b(?:something|anything|stuff|things|ones|others|other ones|other options|options|products|items|other products|other items|more)(?: else)? (?:like|similar to)\b(?!\s*(?:\$|\d))/,
156
+ /\bsimilar (?:to|ones?|items?|products?|options?|stuff|things|styles?|kinds?|types?|brands?|models?)\b|\b(?:something|anything|more|some|any|show me|find|see|got|have|want|need|others?) similar\b|^similar\b/,
157
+ /\b(?:an?|some|any|other|cheaper|good|better|similar) alternatives?\b|\balternatives? (?:to|for|like)\b|^alternatives?\b/,
158
+ /\b(?:cheaper|less expensive|more affordable|lower[- ]priced|lower cost) than\b(?!\s*(?:\$|\d|(?:that|this|my|the) (?:price|budget)))/,
159
+ /\blike (?:the|this|that|these|those|it)\b.*\bbut\b|\bbut (?:cheaper|less expensive|more affordable)\b/,
160
+ /\b(?:anything|something|others?|options?|ones?|products?|items?)(?: else)? (?:other than|besides|apart from)\b/,
161
+ /\bparecid[oa]s?\b|\bsimilar(?:es)? a[ls]?\b|\balgo (?:como|parecido|similar|as[ií])\b(?!\s*(?:\$|\d))|\bm[aá]s (?:como|del estilo)\b|\botr[oa]s? (?:opciones |cosas |productos |marcas )?(?:como|parecid[oa]s?|similares)\b|\balternativas?\b|\bm[aá]s barat[oa]s? que\b|\bcomo (?:el|la|los|las|este|esta|estos|estas|ese|esa)\b.*\bpero\b/,
162
+ ];
163
+ /** Text as lowercase words with single spaces, accents kept, as `asksForSimilar` reads it. */
164
+ function similarWords(text) {
165
+ return plain(String(text ?? '')).replace(/[^\p{L}\p{N}\s$#'-]+/gu, ' ').replace(/\s+/g, ' ').trim();
166
+ }
167
+ /**
168
+ * True when the shopper's words ask for other products like one they saw, such as `More like the KODAK AA Alkaline Batteries (60
169
+ * Pack)`, `something similar to the first one`, `alternatives to the Duracell` or `cheaper than the KODAK one`: a new search,
170
+ * never a pick of the product it names. `names` are the products the shopper saw: their words are read out first, so a product
171
+ * whose own name says `alternative` or `similar` is still picked by its tap.
172
+ */
173
+ export function asksForSimilar(text, names = []) {
174
+ let said = ` ${similarWords(text)} `;
175
+ for (const name of names) {
176
+ const own = similarWords(name);
177
+ if (own.length >= 3)
178
+ said = said.split(` ${own} `).join(' ');
179
+ }
180
+ said = said.replace(/\s+/g, ' ').trim();
181
+ return said !== '' && SIMILAR.some((pattern) => pattern.test(said));
182
+ }
143
183
  /**
144
184
  * True for a pick of a product the shopper already saw: a frame such as `i'll take` or `i want the`, a
145
185
  * reference such as `that one`, `the second` or `#2`, or a product title with its price, as a product
@@ -147,6 +187,9 @@ const BUDGET = /(?:under|below|than|max|maximum|around|about|to|budget|within|ov
147
187
  * gets no instant reply.
148
188
  */
149
189
  function isPick(line, words) {
190
+ // `More like the KODAK AA (60 Pack) ($26.05)` names a card to search for others like it: a new search, with its instant reply.
191
+ if (asksForSimilar(line))
192
+ return false;
150
193
  if (/\(\s*\$\s*\d[\d,]*(?:\.\d{2})?\s*\)/.test(line))
151
194
  return true;
152
195
  const price = line.match(/\$\s*\d[\d,]*\.\d{2}\s*$/);
@@ -259,7 +302,7 @@ export function paraphrase(text) {
259
302
  }
260
303
  for (let before = ''; before !== rest;) {
261
304
  before = rest;
262
- rest = rest.replace(LEAD, '');
305
+ rest = withoutTripVerb(rest.replace(LEAD, ''));
263
306
  }
264
307
  // `order from tacos el grullense` names the store, never `from tacos el grullense`.
265
308
  rest = rest.replace(/^(?:from|at)\s+/, '').replace(/[\s,]+(?:please|pls|thanks|thank you|thx|ty)$/, '').replace(/[\s,.:!?]+$/, '');
package/dist/browser.d.ts CHANGED
@@ -105,3 +105,5 @@ export interface ActionWindows {
105
105
  * so the chat could neither close it nor tell when the shopper closed it.
106
106
  */
107
107
  export declare function actionWindows(options?: ActionWindowsOptions): ActionWindows;
108
+ export { streamTurn, readTurnResponse, type StreamTurnOptions, type StreamTurnResult, type TurnResponseLike } from './turn-reader.js';
109
+ export { TURN_RECOVERY_ON, isTurnId, type TurnEvent, type TurnRecord } from './turn-record.js';
package/dist/browser.js CHANGED
@@ -118,3 +118,6 @@ export function actionWindows(options = {}) {
118
118
  },
119
119
  };
120
120
  }
121
+ // A chat turn read in the browser: a dropped connection reads the turn's answer back by the turn's id.
122
+ export { streamTurn, readTurnResponse } from './turn-reader.js';
123
+ export { TURN_RECOVERY_ON, isTurnId } from './turn-record.js';
@@ -76,9 +76,16 @@ export interface BuyToolLink {
76
76
  expiresAt: string | null;
77
77
  /**
78
78
  * On a store login link: the store's name, or null when Agentcard named none. On an approval link:
79
- * the store whose cart it approves, when the answer names the cart.
79
+ * the store whose cart it approves, when the answer names the cart. On a product link: the name of
80
+ * the store that sells it, such as `Walmart` or `jadeyoga.com`, or null when Agentcard named none.
80
81
  */
81
82
  store?: string | null;
83
+ /**
84
+ * On a product link: the website of the store that sells it, such as `walmart.com` or `jadeyoga.com`, to show on the card
85
+ * as where the product comes from. It is never the host of `url`, which can be a tracking link that names no store. Null
86
+ * when Agentcard knows no website for the store, such as a restaurant, a venue or an airline.
87
+ */
88
+ storeDomain?: string | null;
82
89
  /** On a product link: the product's name. */
83
90
  title?: string;
84
91
  /**
@@ -156,6 +163,8 @@ export interface BuyToolProduct {
156
163
  /** Every photo of the product, `imageUrl` first, at most eight; empty when it has none. */
157
164
  images: string[];
158
165
  store: string | null;
166
+ /** The website of the store that sells it, such as `walmart.com`, or null when Agentcard knows none. */
167
+ storeDomain?: string | null;
159
168
  /** The product's page at its store, or null when Agentcard sent none. */
160
169
  url: string | null;
161
170
  /**
@@ -629,6 +638,14 @@ export interface BuyToolTurn extends Omit<BuyTool, 'instantAck' | 'instantReacti
629
638
  * again does nothing.
630
639
  */
631
640
  end(): void;
641
+ /**
642
+ * True once a call of this turn answered `turn_stopped`: the shopper sent a newer message, and Agentcard stopped this
643
+ * turn for it. The newer message's turn answers the shopper, so this one is over. Do not call your model again, show
644
+ * no error and save no history for it: the cards it already showed stay with the shopper. The call returns to your
645
+ * model as any other, so check `stopped` after each call. The stopped answer goes to no `onResult` and changes nothing
646
+ * a later turn reads, and no wait line or 👀 follows it.
647
+ */
648
+ readonly stopped: boolean;
632
649
  /** The instant reply this turn sent to `onWorking` when it opened, or null when it sent none, as with a reaction. */
633
650
  readonly ack: string | null;
634
651
  /** The reaction this turn put on the shopper's message, `👀`, when it opened or once a call ran slow, or null while it put none or when `onReaction` threw. */
package/dist/buy-tool.js CHANGED
@@ -39,7 +39,7 @@ import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, repl
39
39
  import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
40
40
  import { isStale } from './shopper-message.js';
41
41
  import { actionLinkId, isActionLink } from './action-links.js';
42
- import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
42
+ import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, asksForSimilar, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
43
43
  /** The action a product of `merchant` takes (BuyToolProductAction). */
44
44
  export function productAction(merchant) {
45
45
  if (merchant === 'reservations')
@@ -130,6 +130,8 @@ const APP_TURN_MS = 120_000;
130
130
  const STEP_LINK_KINDS = new Set(['vault', 'approval', 'store_login']);
131
131
  /** The longest a step link waits for its message: an app that never writes one still sends the link. */
132
132
  const STEP_LINK_HOLD_MS = 20_000;
133
+ /** The `error_code` of an answer whose turn a newer message from the same shopper stopped: that message's turn answers instead. */
134
+ const TURN_STOPPED = 'turn_stopped';
133
135
  const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
134
136
  const STILL_BUILDING = "The store is still building the cart for the shopper's last request. Its cart and total reach the shopper in a message from Agentcard: once that message has arrived, call this tool again with the shopper's words.";
135
137
  const CART_NOT_READY = 'The store is still building the cart, so it has no total yet, and nothing was confirmed. Its cart and total reach the shopper in a message from Agentcard: confirm it only after the shopper says yes to that total.';
@@ -210,7 +212,7 @@ function yesAfter(result, yes) {
210
212
  const seen = yes.shown ?? stopped;
211
213
  if (!seen || (yes.shown && stopped && !sameOrder(yes.shown, stopped)))
212
214
  return null;
213
- return { conversationId: result.conversation_id, cart: stopped ?? seen, totalCents: seen.totalCents };
215
+ return { conversationId: result.conversation_id, cart: stopped ?? seen, totalCents: seen.totalCents, consentedHash: seen.hash };
214
216
  }
215
217
  /** The words of the call a `nextStep` turn's `direct` makes. They never reach Agentcard: the turn's calls send no ask. */
216
218
  const NEXT_STEP_ASK = 'take the next step';
@@ -270,7 +272,22 @@ export function productsOf(turn) {
270
272
  /** A product's card as `sendLink` hears it: the id the model reads and a pick takes, never the store page, which is `url`. A flight option has no page at a store: its card carries no url, and its productId picks it. */
271
273
  function productLink(product) {
272
274
  const productId = pickId(product);
273
- return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null, ...(product.availability ? { availability: product.availability } : {}) };
275
+ return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), store: product.store ?? null, storeDomain: product.storeDomain ?? null, expiresAt: null, ...(product.availability ? { availability: product.availability } : {}) };
276
+ }
277
+ /** A host name: dot-separated labels of letters, digits and hyphens, ending in a letters-only top-level label. */
278
+ const STORE_HOST = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
279
+ /**
280
+ * The website of the store a catalog item is at: the `domain` Agentcard sent, else, from an older API that sends none, the
281
+ * store's id when the id is itself a website (`jadeyoga.com`). Never Channel3's tracking host (buy.trychannel3.com). Null
282
+ * when neither names one: a store id such as `walmart` names no website by itself.
283
+ */
284
+ function storeDomainOf(store) {
285
+ for (const raw of [store?.domain, store?.id]) {
286
+ const host = typeof raw === 'string' ? raw.trim().toLowerCase().replace(/^www\./, '').replace(/\.$/, '') : '';
287
+ if (STORE_HOST.test(host) && host !== 'trychannel3.com' && !host.endsWith('.trychannel3.com'))
288
+ return host;
289
+ }
290
+ return null;
274
291
  }
275
292
  function productsOfCatalog(catalog) {
276
293
  return (catalog.items ?? [])
@@ -286,6 +303,7 @@ function productsOfCatalog(catalog) {
286
303
  images: item.image_urls ?? (item.image_url ? [item.image_url] : []),
287
304
  ...(item.flight ? { flight: item.flight } : {}),
288
305
  store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
306
+ storeDomain: storeDomainOf(item.store),
289
307
  url: item.url ?? null,
290
308
  action: productAction(catalog.merchant),
291
309
  linkSent: false,
@@ -719,6 +737,15 @@ function checkoutRefusal(conversation) {
719
737
  return null;
720
738
  return { code, merchant: last.merchant_name, message: last.message };
721
739
  }
740
+ /** Ends a turn (`turn.end()`, or a call it made that a newer message stopped): no wait line and no 👀 go out after it. */
741
+ function endTurn(state) {
742
+ state.ended = true;
743
+ state.dropWaitLine?.();
744
+ // The acknowledgement a call still running holds, a 👀 or a line for a slow pick, confirm or answer, is cancelled too.
745
+ for (const cancel of state.holds ?? [])
746
+ cancel();
747
+ state.holds?.clear();
748
+ }
722
749
  /**
723
750
  * A buy tool for one shopper. With a company client, name the shopper with
724
751
  * `externalUserId`; with a shopper's own `accessToken`, the token names them.
@@ -915,8 +942,10 @@ export function buyTool(options) {
915
942
  let shownStoreNames = [];
916
943
  /** True for a pick of a product or a store the shopper saw, by its name. */
917
944
  const namesShown = (text) => namesShownProduct(text) || namesStore(text, shownStoreNames);
945
+ /** Whether a message asks for other products like one shown (`More like the <card's name>`): a new search, never a pick. */
946
+ const asksLikeShown = (text) => asksForSimilar(text, [...lastTappable, ...lastAnswerProducts].map((product) => product.name));
918
947
  /** What a message picks: a product it names (`product`), a store or a place in a list (`pick`), or nothing (null). */
919
- const pickOf = (text) => namesStore(text, shownStoreNames) ? 'pick' : namesShownProduct(text) ? 'product' : isPickText(text) ? 'pick' : null;
948
+ const pickOf = (text) => asksLikeShown(text) ? null : namesStore(text, shownStoreNames) ? 'pick' : namesShownProduct(text) ? 'product' : isPickText(text) ? 'pick' : null;
920
949
  /** How the shopper's message reads for the instant reply: what it picks, and the reply it gets, or null. */
921
950
  const ackOfMessage = (shopperText) => {
922
951
  const text = String(shopperText ?? '');
@@ -1020,7 +1049,7 @@ export function buyTool(options) {
1020
1049
  };
1021
1050
  }
1022
1051
  // A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
1023
- const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
1052
+ const slow = step ?? t.picked ?? (!t.neutral && !asksLikeShown(ask) && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
1024
1053
  // A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
1025
1054
  // search: like `yes`, a slow call still tells the shopper, in words that look for nothing.
1026
1055
  const neutral = t.neutral || answersQuestion(ask, lastReply);
@@ -1585,7 +1614,7 @@ export function buyTool(options) {
1585
1614
  const inPurchase = (at) => at === null || at === conversationId;
1586
1615
  if (yes && one && conversationId && inPurchase(yes.conversationId) && sameOrder(one, yes.cart)) {
1587
1616
  carriedYes = null;
1588
- return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { ...yes, cart: one, conversationId } };
1617
+ return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', carried: { from: yes.picked ? 'pick' : 'yes', cart_hash: yes.consentedHash }, ...who }, yes: { ...yes, cart: one, conversationId } };
1589
1618
  }
1590
1619
  // The shopper picked what is in the cart and finished the step outside the chat: finishing it is their go-ahead for the
1591
1620
  // same order, confirmed by the cart's hash as it stands now, and the approval link stays their consent to the payment
@@ -1597,7 +1626,7 @@ export function buyTool(options) {
1597
1626
  if (picked?.cart && one && conversationId && inPurchase(picked.conversationId) && (event.kind === 'card_added' || event.kind === 'store_connected')) {
1598
1627
  pickedOrder = null;
1599
1628
  if (sameOrder(one, picked.cart)) {
1600
- return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { conversationId, cart: one, totalCents: picked.cart.totalCents, picked: true } };
1629
+ return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', carried: { from: 'pick', cart_hash: picked.cart.hash }, ...who }, yes: { conversationId, cart: one, totalCents: picked.cart.totalCents, consentedHash: picked.cart.hash, picked: true } };
1601
1630
  }
1602
1631
  }
1603
1632
  // A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
@@ -1640,7 +1669,9 @@ export function buyTool(options) {
1640
1669
  if (!confirm && ask === '')
1641
1670
  throw new TypeError("Put the shopper's words in ask.");
1642
1671
  // A pick chooses from the products or stores this conversation showed: never on a confirm, and never on a new order, which shows none yet.
1643
- const picks = !confirm && !newOrder;
1672
+ // A request for other products like one shown is a new search: a `product_id` or `store_id` the model sent beside it would
1673
+ // put that product in the cart (2026-10-27, "More like <a card's whole title>" on 0.18.0), so none goes.
1674
+ const picks = !confirm && !newOrder && !asksLikeShown(ask);
1644
1675
  const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
1645
1676
  const body = {
1646
1677
  resume: true,
@@ -1985,6 +2016,15 @@ export function buyTool(options) {
1985
2016
  ack?.cancel();
1986
2017
  const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
1987
2018
  await t.ackOut;
2019
+ // A newer message from the shopper stopped this turn, and its own turn answers: this one ends here and says nothing
2020
+ // more. Its empty answer holds no cart and lists no product, so it leaves what the shopper last read, the carts and
2021
+ // the last ask as they were, and the app's onResult never hears it, so no cart panel empties.
2022
+ if (turn?.error_code === TURN_STOPPED) {
2023
+ endTurn(t);
2024
+ t.stopped = true;
2025
+ t.results?.push(settled);
2026
+ return settled;
2027
+ }
1988
2028
  // The streamed messages that waited for the answer go out in order: each the answer kept as written, and a card ask or
1989
2029
  // a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
1990
2030
  // before any link of the answer.
@@ -2545,7 +2585,8 @@ export function buyTool(options) {
2545
2585
  return false;
2546
2586
  throw error;
2547
2587
  }
2548
- if (result.shopper_already_read_reply === true)
2588
+ // A newer message stopped this turn, and that message's turn answers the shopper: nothing is left for the app's model.
2589
+ if (result.shopper_already_read_reply === true || state.stopped)
2549
2590
  return true;
2550
2591
  const reply = replyAsWritten(result, { textingStyle: texting });
2551
2592
  // The reply goes out where the app hears the turn's messages: onItem or onMessage, else onText. With none of them, the
@@ -2577,14 +2618,7 @@ export function buyTool(options) {
2577
2618
  enumerable: false,
2578
2619
  },
2579
2620
  end: {
2580
- value: () => {
2581
- state.ended = true;
2582
- state.dropWaitLine?.();
2583
- // The acknowledgement a call still running holds, a 👀 or a line for a slow pick, confirm or answer, is cancelled too.
2584
- for (const cancel of state.holds ?? [])
2585
- cancel();
2586
- state.holds?.clear();
2587
- },
2621
+ value: () => endTurn(state),
2588
2622
  enumerable: false,
2589
2623
  },
2590
2624
  messages: { value: messagesFor, enumerable: false },
@@ -2593,7 +2627,8 @@ export function buyTool(options) {
2593
2627
  links: { get: () => [...links], enumerable: false },
2594
2628
  results: { get: () => [...results], enumerable: false },
2595
2629
  direct: { value: direct, enumerable: false },
2596
- replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
2630
+ stopped: { get: () => state.stopped === true, enumerable: false },
2631
+ replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
2597
2632
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
2598
2633
  // Each message joins the feed the moment it is cut, so a link the tool hands over during a pause never overtakes text already written.
2599
2634
  // A step link held for this message goes out right after its last paragraph, once the stream ends.
package/dist/client.d.ts CHANGED
@@ -50,6 +50,11 @@ export interface BuyCatalogUpdate {
50
50
  found: number | null;
51
51
  /** The products found past the cards, in the order they were found, to show on "show more" with no new search. */
52
52
  more: BuyCatalog['items'];
53
+ /**
54
+ * False while a search of the turn is still out, such as a second flight source: more cards may join these, and a card
55
+ * already shown may come again with a lower price under the same `id`. True once every source answered.
56
+ */
57
+ final: boolean;
53
58
  }
54
59
  /**
55
60
  * Whether a place a card shows has room at the time the shopper asked for: `checking` while Agentcard's live search runs,
@@ -250,6 +255,15 @@ export interface BuyRequest {
250
255
  hand_back?: true;
251
256
  /** With `hand_back`: what Agentcard's last answer showed and asked, which decides whether a pick or an answer is shopping. */
252
257
  last_answer?: BuyLastAnswer;
258
+ /**
259
+ * On a confirm the app sends on its own after a step outside the chat (a saved card, a store sign-in): the go-ahead it
260
+ * carries, a `yes` to a cart or the `pick` of what is in it, and `cart_hash`, the cart it was given for. Agentcard pays
261
+ * only that cart; a cart that changed since is answered with its total and `next_action: 'confirm'`, and nothing is paid.
262
+ */
263
+ carried?: {
264
+ from: 'yes' | 'pick';
265
+ cart_hash: string;
266
+ };
253
267
  }
254
268
  /** What the shopper's last answer from Agentcard showed and asked, sent with `hand_back`. */
255
269
  export interface BuyLastAnswer {
@@ -267,9 +281,14 @@ export interface BuyLastAnswer {
267
281
  }>;
268
282
  }
269
283
  export type BuyStatus = 'needs_input' | 'needs_approval' | 'order_placed' | 'partially_placed' | 'declined';
284
+ /**
285
+ * The store a product or a cart is at. `domain` is the store's own website, such as `walmart.com` or `jadeyoga.com`: on a
286
+ * catalog item, null when Agentcard knows none (a restaurant, a venue, an airline), and absent from an older API and on a cart.
287
+ */
270
288
  export interface BuyStore {
271
289
  id: string;
272
290
  name?: string;
291
+ domain?: string | null;
273
292
  }
274
293
  export interface BuyCartLine {
275
294
  name: string;
package/dist/client.js CHANGED
@@ -136,7 +136,7 @@ function imagesOf(main, list) {
136
136
  function storeOf(v) {
137
137
  if (!isRecord(v) || !str(v.id))
138
138
  return null;
139
- return { id: v.id, ...(str(v.name) ? { name: v.name } : {}) };
139
+ return { id: v.id, ...(str(v.name) ? { name: v.name } : {}), ...(Object.hasOwn(v, 'domain') ? { domain: str(v.domain) } : {}) };
140
140
  }
141
141
  function feeLinesOf(v) {
142
142
  if (!Array.isArray(v))
@@ -512,7 +512,7 @@ async function readStreamedTurn(res, stream, timeoutMs) {
512
512
  const asOf = str(block.as_of);
513
513
  const shown = countOf(block.shown);
514
514
  const found = countOf(block.found);
515
- const update = { conversationId: data.conversation_id, catalog, asOf, shown, found, more: catalogItemsOf(block.more) };
515
+ const update = { conversationId: data.conversation_id, catalog, asOf, shown, found, more: catalogItemsOf(block.more), final: block.final !== false };
516
516
  // The count follows the cards it counts, so an app never shows "8 of 23" above no cards, and it settles before the
517
517
  // next event, so a later count never lands before an earlier one. A card display that throws never costs the count.
518
518
  deliver(async () => {
@@ -7,6 +7,8 @@
7
7
  */
8
8
  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
+ export declare const BUY_TOOL_RULES: string;
11
+ /** The rules as the model reads them, with travel named when src/trip-ask.ts says so. */
10
12
  export declare const BUY_TOOL_DESCRIPTION: string;
11
13
  /**
12
14
  * What the model sends: `ask`, `confirm` and `new_order` every time, `product_id`, null unless the
@@ -5,9 +5,10 @@
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
  */
8
+ import { withTravel } from './trip-ask.js';
8
9
  export const BUY_TOOL_NAME = 'buy';
9
10
  /** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
10
- export const BUY_TOOL_DESCRIPTION = [
11
+ export const BUY_TOOL_RULES = [
11
12
  'Buy what this shopper asks for at real stores.',
12
13
  "Put the shopper's own words in `ask`.",
13
14
  'When the shopper sends a link to a product, such as a buy.trychannel3.com link, put the whole link in `ask` exactly as they sent it, query string included, with their words beside it: Agentcard buys that exact product from the link, so never describe the link in words instead.',
@@ -65,6 +66,8 @@ export const BUY_TOOL_DESCRIPTION = [
65
66
  'Ask the shopper to "let me know", never "text me", unless your app is a text-message chat.',
66
67
  'Name only the stores and products that reach the shopper as cards in this result, never others in passing, such as "five guys and mcdonald\'s deliver there too", even when `reply` names them: to offer more, ask "want other options?". This is for what you suggest: when you show the cart or ask for a yes to it, name what is in it.',
67
68
  ].join(' ');
69
+ /** The rules as the model reads them, with travel named when src/trip-ask.ts says so. */
70
+ export const BUY_TOOL_DESCRIPTION = withTravel(BUY_TOOL_RULES);
68
71
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
69
72
  export function buyToolDefinition() {
70
73
  return {
package/dist/direct.js CHANGED
@@ -12,7 +12,11 @@
12
12
  * answer such as `medium` or `size 9`, or a plain yes to the total it asked the shopper to confirm;
13
13
  * - a price limit for the products Agentcard just showed: `only options under $20`, `under 20`, `cheaper ones`.
14
14
  * Agentcard holds its cards to the limit and shows the ones inside it again as cards; your model, answering
15
- * from the list it saw, named two products with no card to tap (2026-10-27).
15
+ * from the list it saw, named two products with no card to tap (2026-10-27);
16
+ * - a request for other products like one Agentcard just showed: `More like the KODAK AA Alkaline Batteries (60 Pack)`,
17
+ * `something similar to the first one`, `cheaper than the KODAK one`. It goes as a new search with no `product_id`:
18
+ * on 2026-10-27 (the playground chat on 0.18.0, production /buy) "More like <a card's whole title>" went as a pick of
19
+ * that card, and Agentcard put it in the cart and asked for the shipping address.
16
20
  *
17
21
  * Everything else goes to the model: a question, small talk, a change of mind (`no`, `wait`, `cancel`),
18
22
  * a command to the cart, and anything about the account, an order or how the chat works. A pick or an
@@ -23,7 +27,8 @@
23
27
  * purchase reply to small talk, while one the model reads by mistake costs only the model's call. On
24
28
  * the 188 labelled messages in `test/fixtures/direct-messages.json`, it sends none it should not.
25
29
  */
26
- import { ABOUT, ACKNOWLEDGED, CAPABILITY, CART_COMMAND, CHECKOUT, CHITCHAT, FILLER, asksForDetails, isNewRequest, isPickText, namesStore, plain } from './ack.js';
30
+ import { ABOUT, ACKNOWLEDGED, CAPABILITY, CART_COMMAND, CHECKOUT, CHITCHAT, FILLER, asksForDetails, asksForSimilar, isNewRequest, isPickText, namesStore, plain } from './ack.js';
31
+ import { tripAskGoesStraight } from './trip-ask.js';
27
32
  /**
28
33
  * A price limit put on what was just shown: `under $20`, `under 20`, `less than 15 bucks`, `between 10 and 20`, `$20 or
29
34
  * less`, `over $50`, `cheaper ones`, `menos de 20`. Only whether the message states one: Agentcard reads the limit itself.
@@ -103,6 +108,8 @@ export function isProductAsk(text) {
103
108
  return !QUESTION_START.test(bare) && !NOT_SHOPPING.test(words.replace(/\S*https?:\/\/\S+|\S*www\.\S+/g, ''));
104
109
  if (!isNewRequest(raw) || CAPABILITY.test(words) || NOT_SHOPPING.test(words) || PAST.test(words) || CORRECTION.test(words) || REFERS.test(words) || COMPLAINT.test(words))
105
110
  return false;
111
+ if (tripAskGoesStraight(raw, bare))
112
+ return true;
106
113
  // A question goes to the model, unless it asks Agentcard to get something: `can you find me usb-c cables?`.
107
114
  const framed = SHOP_FRAME.exec(bare);
108
115
  if (/\?/.test(raw) && !(framed && /^(?:can|could|would|will) (?:you|u)\b/.test(bare)))
@@ -164,6 +171,9 @@ function pickOf(raw, bare, context) {
164
171
  const byName = context.tappable?.length ? context.tappable : products;
165
172
  const stores = context.stores ?? [];
166
173
  const call = (fields) => ({ input: { ask: raw, confirm: false, new_order: false, product_id: null, store_id: null, ...fields }, reason: 'pick' });
174
+ // `More like the <card's whole name>` names a card to find others like it: it picks nothing.
175
+ if (asksForSimilar(raw, [...byName, ...products].map((product) => product.name)))
176
+ return null;
167
177
  if (stores.length > 0) {
168
178
  const said = ` ${nameWords(raw)} `;
169
179
  // A store's card names it whole; a store named with the dishes to order from it (`five guys, bacon cheeseburger`) picks it too.
@@ -236,6 +246,12 @@ export function directCall(shopperText, context = {}) {
236
246
  const told = CHITCHAT.test(words) || ABOUT.test(words) || CART_COMMAND.test(words) || CAPABILITY.test(words);
237
247
  if (ctx.nextAction === 'confirm' && isYes(words))
238
248
  return plainCall('confirm', true);
249
+ // A request for other products like one just shown is a new search, never a pick of the one it names: it goes with no
250
+ // `product_id`, and Agentcard searches for products like it and leaves it out.
251
+ const shown = [...(ctx.tappable ?? []), ...(ctx.products ?? [])];
252
+ if (!told && (shown.length > 0 || (ctx.stores ?? []).length > 0) && asksForSimilar(raw, shown.map((product) => product.name))) {
253
+ return NOT_SHOPPING.test(words) || TAKEBACK.test(words) ? null : plainCall('product_ask');
254
+ }
239
255
  // A question, a story of what happened, an objection or a change of mind is the model's to answer, whatever came before.
240
256
  const aside = QUESTION_START.test(bare) || REFERS.test(words) || COMPLAINT.test(words) || PAST.test(words) || NOT_SHOPPING.test(words) || OBJECTION.test(words) || CORRECTION.test(words);
241
257
  if (!told && !aside && !TAKEBACK.test(words)) {
package/dist/index.d.ts CHANGED
@@ -15,3 +15,5 @@ export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRout
15
15
  export { listenToAgentcard, type AgentcardListener, type ListenOptions, type ListenCursorStore, type StreamFetch, } from './listen.js';
16
16
  export { ACTION_LINK_ENDINGS, ACTION_LINK_KINDS, actionLinkId, actionLinkOutcome, isActionLink, type ActionLinkKind, type ActionLinkMessage, type ActionLinkOutcome, } from './action-links.js';
17
17
  export { AGENTCARD_EVENT_TYPES, type AgentcardEventType, } from './events.js';
18
+ export { turnStream, readTurn, TURN_RECORD_TTL_SECONDS, type TurnStream, type TurnStreamOptions } from './turn-stream.js';
19
+ export { TURN_RECOVERY_ON, isTurnId, type TurnEvent, type TurnRecord, type TurnRecordStore } from './turn-record.js';
package/dist/index.js CHANGED
@@ -15,3 +15,5 @@ export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
15
15
  export { listenToAgentcard, } from './listen.js';
16
16
  export { ACTION_LINK_ENDINGS, ACTION_LINK_KINDS, actionLinkId, actionLinkOutcome, isActionLink, } from './action-links.js';
17
17
  export { AGENTCARD_EVENT_TYPES, } from './events.js';
18
+ export { turnStream, readTurn, TURN_RECORD_TTL_SECONDS } from './turn-stream.js';
19
+ export { TURN_RECOVERY_ON, isTurnId } from './turn-record.js';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Trip asks and the tool's travel wording, kept out of direct.ts, ack.ts and definition.ts, which each call in here once.
3
+ *
4
+ * On 2026-10-28 on chat.opencommerce.sh, "help me plan a trip to Loreto" never reached Agentcard: `directCall` read no
5
+ * shopping verb, and the app's model, whose capability rule named stores and restaurants and no travel, answered "i can't
6
+ * plan itineraries" and offered no flight or hotel. Both switches below ship off and change nothing until a later change
7
+ * turns them on; a test turns them on by setting the field.
8
+ */
9
+ /**
10
+ * `goStraight` on: a trip, flights or a hotel with the place they are for, `help me plan a trip to Loreto`, `flights to
11
+ * Loreto`, `flights and hotels in loreto` or `a hotel in Chicago next week`, goes straight to Agentcard, which asks for the
12
+ * airport and the dates it needs, and the instant reply reads the trip back without the verb (`a trip to loreto`). Off: such
13
+ * an ask goes straight only with a shopping verb (`book a flight to Loreto`), as before.
14
+ *
15
+ * `inCapabilities` on: the tool's description says it plans and books trips, flights and hotels, tells the model to call it
16
+ * for a trip ask with no dates or airport, and names flights and hotels when the shopper asks what it can do. Off: the
17
+ * description is the text it was before, word for word.
18
+ */
19
+ export declare const TRAVEL: {
20
+ goStraight: boolean;
21
+ inCapabilities: boolean;
22
+ };
23
+ /** True when the message's words, openings taken off, ask for a trip, flights or a hotel and name the place. */
24
+ export declare function isTripAsk(bare: string): boolean;
25
+ /**
26
+ * Whether a message that passed `isProductAsk`'s checks for small talk, the account and a story of what happened goes straight
27
+ * as a trip ask: a question goes only when it asks Agentcard to plan it (`can you help me plan a trip to italy?`).
28
+ */
29
+ export declare function tripAskGoesStraight(raw: string, bare: string): boolean;
30
+ /** The read-back without the verb that asked for the trip: `plan a trip to loreto` reads back `a trip to loreto`. Any other words, `plan b emergency contraception` too, read back as they are. */
31
+ export declare function withoutTripVerb(text: string): string;
32
+ /** The tool's description with travel named, when `inCapabilities` is on (or `on` says so); the description as given when off. */
33
+ export declare function withTravel(description: string, on?: boolean): string;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Trip asks and the tool's travel wording, kept out of direct.ts, ack.ts and definition.ts, which each call in here once.
3
+ *
4
+ * On 2026-10-28 on chat.opencommerce.sh, "help me plan a trip to Loreto" never reached Agentcard: `directCall` read no
5
+ * shopping verb, and the app's model, whose capability rule named stores and restaurants and no travel, answered "i can't
6
+ * plan itineraries" and offered no flight or hotel. Both switches below ship off and change nothing until a later change
7
+ * turns them on; a test turns them on by setting the field.
8
+ */
9
+ /**
10
+ * `goStraight` on: a trip, flights or a hotel with the place they are for, `help me plan a trip to Loreto`, `flights to
11
+ * Loreto`, `flights and hotels in loreto` or `a hotel in Chicago next week`, goes straight to Agentcard, which asks for the
12
+ * airport and the dates it needs, and the instant reply reads the trip back without the verb (`a trip to loreto`). Off: such
13
+ * an ask goes straight only with a shopping verb (`book a flight to Loreto`), as before.
14
+ *
15
+ * `inCapabilities` on: the tool's description says it plans and books trips, flights and hotels, tells the model to call it
16
+ * for a trip ask with no dates or airport, and names flights and hotels when the shopper asks what it can do. Off: the
17
+ * description is the text it was before, word for word.
18
+ */
19
+ export const TRAVEL = { goStraight: true, inCapabilities: true };
20
+ /** What a trip ask opens with before the trip: `can you help me plan`, `i want to book`, `let's plan`, `plan`. */
21
+ const TRIP_LEAD = "(?:(?:(?:can|could|would|will) (?:you|u) |please |i'?d like (?:you )?to |i want to |i need to |we want to |we need to |let'?s |lets )?(?:help (?:me|us) )?(?:plan|book|organi[sz]e|arrange|find|search for|look for)(?: (?:me|us))? )?";
22
+ /** The trip itself: a trip, a vacation, flights, a hotel, or flights and a hotel together. */
23
+ const TRIP_THING = '(?:(?:a|an|some|the|cheap|cheapest|good) )?(?:(?:(?:weekend|family|beach|ski|honeymoon|birthday|anniversary|business|work|quick|short|long|girls|guys) )?(?:trip|vacation|getaway|holiday)|(?:flights?|hotels?|hotel rooms?|places? to stay)(?: (?:and|&|\\+) (?:a |some )?(?:flights?|hotels?|hotel rooms?|places? to stay))?)';
24
+ /** When the trip is, after its place: `next week`, `this weekend`, `in december`, `on nov 3`, `for 5 days`. */
25
+ const MONTH = '(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)';
26
+ const NUMBER = '(?:a|an|one|two|three|four|five|six|seven|eight|nine|ten|\\d{1,2})';
27
+ const WHEN = `(?: (?:next|this) (?:week|weekend|month|year|summer|winter|spring|fall)| tomorrow| tonight| in ${MONTH}| in ${NUMBER} (?:days?|weeks?|months?)| on ${MONTH} \\d{1,2}(?:st|nd|rd|th)?| ${MONTH} \\d{1,2}(?:st|nd|rd|th)?(?: ?- ?\\d{1,2})?| for ${NUMBER} (?:days?|nights?|weeks?)| over (?:thanksgiving|christmas|new year'?s|the holidays|spring break))*`;
28
+ /** A trip with where it goes: one to three words of place, then only words of when. */
29
+ const TRIP_ASK = new RegExp(`^${TRIP_LEAD}${TRIP_THING} (?:to|in|at|near|for|around) (\\p{L}[\\p{L}'.-]*(?: \\p{L}[\\p{L}'.-]*){0,2}?)${WHEN}$`, 'u');
30
+ /** Words that make the place a sentence about a trip already booked or a price: `my flight to jfk is delayed`, `flights to cancun cost a fortune`. */
31
+ const TRIP_TOLD = /\b(?:is|are|was|were|be|been|got|gets|get|delayed|cancell?ed|booked|landed|left|leaves|leaving|late|cheaper|cheap|expensive|pricey|cost|costs|price|prices|worth|good|bad|ok|okay|fine|over|done|sucks?|suck|lol|sounds?|seems?|looks?)\b/;
32
+ /** Places close to home that a trip word can name and no flight or hotel goes to: `a trip to the store`, `a trip to walmart`, `a trip to target.com`. */
33
+ const NOT_A_TRIP_PLACE = /^(?:the (?:store|shop|mall|grocery store|supermarket|gym|doctor|dentist|bank|vet|post office|office|pharmacy|market)|work|school|bed|me|us|you|my|our|it|this|that|walmart|target|costco|best ?buy|home depot|lowe'?s|trader joe'?s|whole foods|safeway|kroger|cvs|walgreens|ikea|aldi|publix|sam'?s club|amazon)\b|\.(?:com|co|net|org|shop|store|us|uk|ca|io)\b/;
34
+ /** The verb a trip ask opens with, read off before the trip reads back. */
35
+ const TRIP_VERB = /^(?:plan|organi[sz]e|arrange)\b[\s,]*/;
36
+ /** True when the message's words, openings taken off, ask for a trip, flights or a hotel and name the place. */
37
+ export function isTripAsk(bare) {
38
+ const match = TRIP_ASK.exec(bare.replace(/\s*\?+$/, ''));
39
+ return match !== null && !TRIP_TOLD.test(match[1]) && !NOT_A_TRIP_PLACE.test(match[1]);
40
+ }
41
+ /**
42
+ * Whether a message that passed `isProductAsk`'s checks for small talk, the account and a story of what happened goes straight
43
+ * as a trip ask: a question goes only when it asks Agentcard to plan it (`can you help me plan a trip to italy?`).
44
+ */
45
+ export function tripAskGoesStraight(raw, bare) {
46
+ if (!TRAVEL.goStraight || !isTripAsk(bare))
47
+ return false;
48
+ return !/\?/.test(raw) || /^(?:can|could|would|will) (?:you|u)\b/.test(bare);
49
+ }
50
+ /** The read-back without the verb that asked for the trip: `plan a trip to loreto` reads back `a trip to loreto`. Any other words, `plan b emergency contraception` too, read back as they are. */
51
+ export function withoutTripVerb(text) {
52
+ return TRAVEL.goStraight && isTripAsk(text) ? text.replace(TRIP_VERB, '') : text;
53
+ }
54
+ const OPENING_LINE = 'Buy what this shopper asks for at real stores.';
55
+ const TRAVEL_LINES = 'You also plan and book trips: flights and hotels. When the shopper asks to plan a trip, or for flights or a hotel, such as "help me plan a trip to Loreto" or "flights and hotels in loreto", call this tool with their words, even when they name no dates and no place they leave from: Agentcard asks them for what it needs.';
56
+ const CAPABILITY_END = 'so name those, and no other store unless a result showed it.';
57
+ const CAPABILITY_TRAVEL = 'Name flights and hotels for a trip among the kinds of things you can do.';
58
+ /** The tool's description with travel named, when `inCapabilities` is on (or `on` says so); the description as given when off. */
59
+ export function withTravel(description, on = TRAVEL.inCapabilities) {
60
+ if (!on)
61
+ return description;
62
+ return description.replace(OPENING_LINE, `${OPENING_LINE} ${TRAVEL_LINES}`).replace(CAPABILITY_END, `${CAPABILITY_END} ${CAPABILITY_TRAVEL}`);
63
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `streamTurn` sends the shopper's message and reads the turn's server-sent events in the browser. When the connection
3
+ * drops before the turn's last event, or before the first one, it reads the turn's record by its id from your server
4
+ * (`readTurn` behind a route of yours) and hands over each event the stream did not, so the shopper gets the answer.
5
+ * A 409 that names the turn already running for this shopper (`turn_id`) waits for that turn's answer the same way.
6
+ */
7
+ import { type TurnRecord } from './turn-record.js';
8
+ /** The part of a fetch `Response` the reader uses. */
9
+ export interface TurnResponseLike {
10
+ ok: boolean;
11
+ status: number;
12
+ body: ReadableStream<Uint8Array> | null;
13
+ text(): Promise<string>;
14
+ }
15
+ export interface StreamTurnOptions {
16
+ /** Sends the shopper's message with the turn's id, such as in an `x-turn-id` header, and returns the response. */
17
+ start(turnId: string): Promise<TurnResponseLike>;
18
+ /** Reads the turn's record from your server, or null while it has none. `readTurn` answers it there. */
19
+ read(turnId: string): Promise<TurnRecord | null>;
20
+ /** Hears each event once, in the order the turn sent them, whether the stream or the record carried it. */
21
+ onEvent(event: string, data: unknown): void;
22
+ /** The turn's id. Defaults to a new random one. */
23
+ turnId?: string;
24
+ /** How often the record is read after a drop, in milliseconds. Defaults to 1,500. */
25
+ pollMs?: number;
26
+ /** How long a record that never appears is waited for before the drop counts as the error, in milliseconds. Defaults to 15,000. */
27
+ startWaitMs?: number;
28
+ /** How long a turn's record is read before giving up, in milliseconds. Defaults to 300,000. */
29
+ waitMs?: number;
30
+ /** Whether a drop reads the record. Defaults to `TURN_RECOVERY_ON`. */
31
+ recover?: boolean;
32
+ /** Stops reading the record once it aborts, as the abort of your own fetch stops the stream. */
33
+ signal?: AbortSignal;
34
+ }
35
+ export interface StreamTurnResult {
36
+ /** The id of the turn whose events were heard: the running turn's after a 409 that named one. */
37
+ turnId: string;
38
+ /** True when some of the events came from the record, after a drop or a 409. */
39
+ recovered: boolean;
40
+ /** The response, when the server refused the message and no turn's answer was waited for: read its error from it. */
41
+ refused?: TurnResponseLike;
42
+ }
43
+ export declare function streamTurn(options: StreamTurnOptions): Promise<StreamTurnResult>;
44
+ /** The record your server's route answers, read back: a helper for `read`, such as `(id) => fetch(...).then(readTurnResponse)`. */
45
+ export declare function readTurnResponse(response: TurnResponseLike): Promise<TurnRecord | null>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `streamTurn` sends the shopper's message and reads the turn's server-sent events in the browser. When the connection
3
+ * drops before the turn's last event, or before the first one, it reads the turn's record by its id from your server
4
+ * (`readTurn` behind a route of yours) and hands over each event the stream did not, so the shopper gets the answer.
5
+ * A 409 that names the turn already running for this shopper (`turn_id`) waits for that turn's answer the same way.
6
+ */
7
+ import { TURN_RECOVERY_ON, isTurnId, parseTurnRecord } from './turn-record.js';
8
+ const TERMINAL = new Set(['done', 'error']);
9
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+ function newTurnId() {
11
+ const c = globalThis.crypto;
12
+ return c?.randomUUID?.() ?? `turn_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
13
+ }
14
+ export async function streamTurn(options) {
15
+ const recover = options.recover ?? TURN_RECOVERY_ON;
16
+ let turnId = options.turnId ?? newTurnId();
17
+ let heard = 0;
18
+ let ended = false;
19
+ // An error your `onEvent` throws is yours: it ends the read, and the record is not read in its place.
20
+ let appFailed = null;
21
+ const hear = (event, data) => {
22
+ heard++;
23
+ if (TERMINAL.has(event))
24
+ ended = true;
25
+ try {
26
+ options.onEvent(event, data);
27
+ }
28
+ catch (error) {
29
+ appFailed = { error };
30
+ throw error;
31
+ }
32
+ };
33
+ // A drop reads the record unless recovery is off, the turn already ended, the app's own code threw or the app aborted.
34
+ const recovers = (error) => recover && !ended && !appFailed && !options.signal?.aborted && error?.name !== 'AbortError';
35
+ // Reads the record until it holds the turn's last event, handing over what the stream had not.
36
+ const fromRecord = async (cause) => {
37
+ const startWaitMs = options.startWaitMs ?? 15_000;
38
+ const waitMs = options.waitMs ?? 300_000;
39
+ const began = Date.now();
40
+ let seen = false;
41
+ const stopIfAborted = () => {
42
+ if (options.signal?.aborted)
43
+ throw options.signal.reason ?? cause;
44
+ };
45
+ for (;;) {
46
+ stopIfAborted();
47
+ // A read that hangs counts as no record once the limit it waits under passes.
48
+ const left = Math.max(0, (seen ? waitMs : Math.min(startWaitMs, waitMs)) - (Date.now() - began));
49
+ const record = await withDeadline(Promise.resolve().then(() => options.read(turnId)), left, options.signal);
50
+ stopIfAborted();
51
+ if (record) {
52
+ seen = true;
53
+ for (const { event, data } of record.events.slice(heard))
54
+ hear(event, data);
55
+ if (record.done || ended)
56
+ return { turnId, recovered: true };
57
+ }
58
+ const waited = Date.now() - began;
59
+ if ((!seen && waited >= startWaitMs) || waited >= waitMs)
60
+ throw cause;
61
+ await sleep(options.pollMs ?? 1_500);
62
+ }
63
+ };
64
+ let response;
65
+ try {
66
+ response = await options.start(turnId);
67
+ }
68
+ catch (error) {
69
+ if (!recovers(error))
70
+ throw error;
71
+ return fromRecord(error);
72
+ }
73
+ if (!response.ok || !response.body) {
74
+ if (recover && response.status === 409) {
75
+ const text = await response.text().catch(() => '');
76
+ const running = parseJson(text)?.turn_id;
77
+ if (isTurnId(running)) {
78
+ turnId = running;
79
+ return fromRecord(new Error(`The chat answered ${response.status}.`));
80
+ }
81
+ return { turnId, recovered: false, refused: { ok: response.ok, status: response.status, body: null, text: async () => text } };
82
+ }
83
+ return { turnId, recovered: false, refused: response };
84
+ }
85
+ try {
86
+ const reader = response.body.getReader();
87
+ const decoder = new TextDecoder();
88
+ let buffer = '';
89
+ for (;;) {
90
+ const { value, done } = await reader.read();
91
+ if (done)
92
+ break;
93
+ buffer += decoder.decode(value, { stream: true });
94
+ let end;
95
+ while ((end = buffer.indexOf('\n\n')) !== -1) {
96
+ const frame = buffer.slice(0, end);
97
+ buffer = buffer.slice(end + 2);
98
+ const event = /^event: (.*)$/m.exec(frame)?.[1];
99
+ const data = /^data: (.*)$/m.exec(frame)?.[1];
100
+ if (event && data !== undefined)
101
+ hear(event, JSON.parse(data));
102
+ }
103
+ }
104
+ if (ended || !recover)
105
+ return { turnId, recovered: false };
106
+ return fromRecord(new Error('The chat stream ended before the turn did.'));
107
+ }
108
+ catch (error) {
109
+ if (ended && !appFailed)
110
+ return { turnId, recovered: false };
111
+ if (!recovers(error))
112
+ throw error;
113
+ return fromRecord(error);
114
+ }
115
+ }
116
+ /** The read's record, or null when it fails, has not answered within `ms`, or the app aborts first. */
117
+ function withDeadline(read, ms, signal) {
118
+ let timer;
119
+ let stop;
120
+ const late = new Promise((resolve) => {
121
+ timer = setTimeout(() => resolve(null), ms);
122
+ stop = () => resolve(null);
123
+ if (signal?.aborted)
124
+ resolve(null);
125
+ else
126
+ signal?.addEventListener('abort', stop, { once: true });
127
+ });
128
+ return Promise.race([read.catch(() => null), late]).finally(() => {
129
+ clearTimeout(timer);
130
+ if (stop)
131
+ signal?.removeEventListener('abort', stop);
132
+ });
133
+ }
134
+ /** A JSON body read leniently: null when it is not JSON. */
135
+ function parseJson(text) {
136
+ try {
137
+ return JSON.parse(text);
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ /** The record your server's route answers, read back: a helper for `read`, such as `(id) => fetch(...).then(readTurnResponse)`. */
144
+ export async function readTurnResponse(response) {
145
+ if (!response.ok)
146
+ return null;
147
+ return parseTurnRecord(await response.text());
148
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A turn's record: every event a chat turn sent the shopper's browser, in order, kept under the turn's id in a store
3
+ * the app provides, so a browser whose connection dropped reads the turn's answer back instead of losing it.
4
+ * This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
5
+ */
6
+ /**
7
+ * Whether a turn keeps its record and a dropped stream reads it back. Off until the pull request that turns it on:
8
+ * while it is off, `turnStream` streams without saving and `streamTurn` reports a drop as the error it was.
9
+ */
10
+ export declare const TURN_RECOVERY_ON: boolean;
11
+ /** One event of a turn, as its server-sent frame carries it: `event: <event>` and `data: <JSON of data>`. */
12
+ export interface TurnEvent {
13
+ event: string;
14
+ data: unknown;
15
+ }
16
+ /** What a turn sent so far, and whether it ended: `done` is true once the turn's last event is in `events`. */
17
+ export interface TurnRecord {
18
+ events: TurnEvent[];
19
+ done: boolean;
20
+ }
21
+ /** Where a turn's record lives between instances, such as Redis: the same shape as the listener's cursor store. */
22
+ export interface TurnRecordStore {
23
+ /** The record saved last under `key`, or null when none was saved or it expired. */
24
+ load(key: string): Promise<string | null> | string | null;
25
+ /** Saves the record under `key`, to be kept for `ttlSeconds`. */
26
+ save(key: string, record: string, ttlSeconds: number): Promise<void> | void;
27
+ }
28
+ /** A turn id the browser makes up front: 8 to 64 letters, digits, `-` or `_`. Check one before using it in a key. */
29
+ export declare function isTurnId(value: unknown): value is string;
30
+ /** A stored record read back, or null when the text is not one. */
31
+ export declare function parseTurnRecord(text: string | null | undefined): TurnRecord | null;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A turn's record: every event a chat turn sent the shopper's browser, in order, kept under the turn's id in a store
3
+ * the app provides, so a browser whose connection dropped reads the turn's answer back instead of losing it.
4
+ * This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
5
+ */
6
+ /**
7
+ * Whether a turn keeps its record and a dropped stream reads it back. Off until the pull request that turns it on:
8
+ * while it is off, `turnStream` streams without saving and `streamTurn` reports a drop as the error it was.
9
+ */
10
+ export const TURN_RECOVERY_ON = false;
11
+ /** A turn id the browser makes up front: 8 to 64 letters, digits, `-` or `_`. Check one before using it in a key. */
12
+ export function isTurnId(value) {
13
+ return typeof value === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(value);
14
+ }
15
+ /** A stored record read back, or null when the text is not one. */
16
+ export function parseTurnRecord(text) {
17
+ if (typeof text !== 'string')
18
+ return null;
19
+ try {
20
+ const record = JSON.parse(text);
21
+ return Array.isArray(record.events) ? { events: record.events, done: record.done === true } : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `turnStream` serves one chat turn to the shopper's browser as server-sent events and keeps every event it sends in the
3
+ * app's store under the turn's id. The turn runs to its end whether or not the browser is still there: an event the
4
+ * stream can no longer carry still goes in the record, and `finished` resolves once the record holds the turn's last
5
+ * event. Hand `finished` to your host's `waitUntil` so a serverless function is not stopped before then.
6
+ * `readTurn` hands the record back to the browser's reader (`streamTurn` in `@agent-cards/commerce-sdk/browser`).
7
+ */
8
+ import { type TurnRecord, type TurnRecordStore } from './turn-record.js';
9
+ /** How long a record is kept by default: ten minutes, past the longest turn a chat waits on. */
10
+ export declare const TURN_RECORD_TTL_SECONDS = 600;
11
+ export interface TurnStreamOptions {
12
+ /** Where the record lives. */
13
+ store: TurnRecordStore;
14
+ /** The record's key: the turn's id together with your shopper's id, so one shopper never reads another's turn. */
15
+ key: string;
16
+ /**
17
+ * Runs the turn. Call `send` for each event the browser shows, the last one included (such as `done` or `error`).
18
+ * A turn that throws ends with an `error` event carrying the message in `failedText`.
19
+ */
20
+ run(send: (event: string, data: unknown) => void): Promise<void>;
21
+ /** How long the record is kept, in seconds. Defaults to `TURN_RECORD_TTL_SECONDS`. */
22
+ ttlSeconds?: number;
23
+ /** The text of the `error` event a turn that throws ends with. */
24
+ failedText?: string;
25
+ /** Whether to keep the record. Defaults to `TURN_RECOVERY_ON`. */
26
+ recover?: boolean;
27
+ /** Hears each save the store refused: the record then misses events until a later save lands. Log it here. */
28
+ onSaveError?: (error: unknown) => void;
29
+ }
30
+ export interface TurnStream {
31
+ /** The body of the response: `text/event-stream`. */
32
+ stream: ReadableStream<Uint8Array>;
33
+ /** Resolves once the turn ended and its record is saved. It never rejects. */
34
+ finished: Promise<void>;
35
+ }
36
+ export declare function turnStream(options: TurnStreamOptions): TurnStream;
37
+ /** The turn's record under `key`, or null when the store has none, for the browser's reader to pick up where its stream stopped. */
38
+ export declare function readTurn(store: TurnRecordStore, key: string): Promise<TurnRecord | null>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `turnStream` serves one chat turn to the shopper's browser as server-sent events and keeps every event it sends in the
3
+ * app's store under the turn's id. The turn runs to its end whether or not the browser is still there: an event the
4
+ * stream can no longer carry still goes in the record, and `finished` resolves once the record holds the turn's last
5
+ * event. Hand `finished` to your host's `waitUntil` so a serverless function is not stopped before then.
6
+ * `readTurn` hands the record back to the browser's reader (`streamTurn` in `@agent-cards/commerce-sdk/browser`).
7
+ */
8
+ import { TURN_RECOVERY_ON, parseTurnRecord } from './turn-record.js';
9
+ /** How long a record is kept by default: ten minutes, past the longest turn a chat waits on. */
10
+ export const TURN_RECORD_TTL_SECONDS = 600;
11
+ export function turnStream(options) {
12
+ const recover = options.recover ?? TURN_RECOVERY_ON;
13
+ const ttl = options.ttlSeconds ?? TURN_RECORD_TTL_SECONDS;
14
+ const encoder = new TextEncoder();
15
+ const record = { events: [], done: false };
16
+ let controller = null;
17
+ let gone = false;
18
+ // Saves run one after another, so a slow early save never lands over a later one.
19
+ let saving = Promise.resolve();
20
+ const save = () => {
21
+ if (!recover)
22
+ return;
23
+ const text = JSON.stringify(record);
24
+ saving = saving
25
+ .then(() => options.store.save(options.key, text, ttl))
26
+ .catch((error) => {
27
+ try {
28
+ options.onSaveError?.(error);
29
+ }
30
+ catch {
31
+ /* the app's logging failing never stops the turn */
32
+ }
33
+ });
34
+ };
35
+ // Each event is written to JSON once, before it is kept: a value JSON cannot hold throws here, to the turn, and the
36
+ // record keeps a copy, so a later change to the caller's object never rewrites what the shopper was sent.
37
+ const send = (event, data) => {
38
+ if (record.done)
39
+ return;
40
+ const json = JSON.stringify(data) ?? 'null';
41
+ const entry = { event, data: JSON.parse(json) };
42
+ record.events.push(entry);
43
+ save();
44
+ if (gone || !controller)
45
+ return;
46
+ try {
47
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${json}\n\n`));
48
+ }
49
+ catch {
50
+ // The browser left: the record still takes every event.
51
+ gone = true;
52
+ }
53
+ };
54
+ let started;
55
+ const ready = new Promise((resolve) => (started = resolve));
56
+ const stream = new ReadableStream({
57
+ start(c) {
58
+ controller = c;
59
+ started();
60
+ },
61
+ cancel() {
62
+ gone = true;
63
+ },
64
+ });
65
+ const finished = (async () => {
66
+ await ready;
67
+ save();
68
+ try {
69
+ await options.run(send);
70
+ }
71
+ catch {
72
+ send('error', { error: options.failedText ?? 'The turn could not finish.' });
73
+ }
74
+ record.done = true;
75
+ save();
76
+ await saving;
77
+ if (!gone) {
78
+ try {
79
+ controller?.close();
80
+ }
81
+ catch {
82
+ /* already closed */
83
+ }
84
+ }
85
+ })();
86
+ return { stream, finished };
87
+ }
88
+ /** The turn's record under `key`, or null when the store has none, for the browser's reader to pick up where its stream stopped. */
89
+ export async function readTurn(store, key) {
90
+ return parseTurnRecord(await store.load(key));
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.18.0",
3
+ "version": "0.18.3",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",