@agent-cards/commerce-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -127,6 +127,12 @@ export interface BuyStreamHandlers {
127
127
  onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
128
128
  /** Each message Agentcard writes, the moment it is written. */
129
129
  onSegment?: (update: BuySegmentUpdate) => void | Promise<void>;
130
+ /**
131
+ * The line that says what Agentcard is looking for, such as `one sec, checking target for la croix`, written while the
132
+ * search runs on an ask for something new. It comes before the turn's cards and messages, or not at all, and the
133
+ * answer's `messages` never hold it.
134
+ */
135
+ onAck?: (update: BuySegmentUpdate) => void | Promise<void>;
130
136
  /** The carts, each time the turn builds or changes one. */
131
137
  onCart?: (update: BuyCartUpdate) => void | Promise<void>;
132
138
  /** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
@@ -234,6 +240,31 @@ export interface BuyRequest {
234
240
  delivery_address?: BuyDeliveryAddress;
235
241
  external_user_id?: string;
236
242
  user_id?: string;
243
+ /** `false` asks for no line that says what Agentcard is looking for (`onAck`): the shopper already read one. */
244
+ ack?: false;
245
+ /**
246
+ * `true` asks Agentcard to answer at once, with `handed_back` and no turn run, when the ask is not shopping: a request to
247
+ * buy, a pick of what it showed, an answer to its question or a yes to a total runs as any ask, and every other message
248
+ * is your model's. `buy.turn`'s `handled()` sends it. An older API ignores it and runs the ask.
249
+ */
250
+ hand_back?: true;
251
+ /** With `hand_back`: what Agentcard's last answer showed and asked, which decides whether a pick or an answer is shopping. */
252
+ last_answer?: BuyLastAnswer;
253
+ }
254
+ /** What the shopper's last answer from Agentcard showed and asked, sent with `hand_back`. */
255
+ export interface BuyLastAnswer {
256
+ /** True when the shopper's last turn ended on Agentcard's answer, with no turn your model answered alone since. */
257
+ answered: boolean;
258
+ next_action: string | null;
259
+ asked: string | null;
260
+ products: Array<{
261
+ product_id: string | null;
262
+ name: string;
263
+ }>;
264
+ stores: Array<{
265
+ store_id: string;
266
+ name: string;
267
+ }>;
237
268
  }
238
269
  export type BuyStatus = 'needs_input' | 'needs_approval' | 'order_placed' | 'partially_placed' | 'declined';
239
270
  export interface BuyStore {
@@ -427,6 +458,8 @@ export interface BuyTurn {
427
458
  conversation_id: string;
428
459
  /** A status Agentcard adds later reads as `needs_input`. */
429
460
  status: BuyStatus;
461
+ /** True when Agentcard answered a `hand_back` ask as your model's to answer: no turn ran, and the other fields are empty. */
462
+ handed_back?: true;
430
463
  reply: string;
431
464
  messages: string[];
432
465
  cart: BuyCart | null;
package/dist/client.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { isRecord } from './verify.js';
12
12
  import { listenToAgentcard } from './listen.js';
13
13
  /** The handlers that ask Agentcard to stream a turn. */
14
- const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onCart', 'onDelta', 'onAvailability'];
14
+ const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability'];
15
15
  const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
16
16
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
17
17
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -272,6 +272,7 @@ export function turnOf(body) {
272
272
  return {
273
273
  conversation_id: str(body.conversation_id) ?? '',
274
274
  status: typeof body.status === 'string' && STATUSES.has(body.status) ? body.status : 'needs_input',
275
+ ...(body.handed_back === true ? { handed_back: true } : {}),
275
276
  reply: typeof body.reply === 'string' ? body.reply : '',
276
277
  messages: Array.isArray(body.messages) ? body.messages.filter((m) => typeof m === 'string') : [],
277
278
  cart: cartOf(body.cart),
@@ -535,6 +536,9 @@ async function readStreamedTurn(res, stream, timeoutMs) {
535
536
  else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
536
537
  deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
537
538
  }
539
+ else if (event === 'ack' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onAck) {
540
+ deliver(() => stream.onAck({ conversationId: named, text: data.text }));
541
+ }
538
542
  else if (event === 'venue_availability' && stream.onAvailability && str(data.conversation_id) && Array.isArray(data.items)) {
539
543
  const items = data.items.filter(isRecord).flatMap((i) => {
540
544
  const id = str(i.id);
@@ -13,6 +13,7 @@ export const BUY_TOOL_DESCRIPTION = [
13
13
  '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.',
14
14
  'Tell the shopper what `reply` says, in your own voice, except the products and stores it lists that reach the shopper as cards.',
15
15
  "When `shopper_already_told_looking` is true, the shopper already knows you are working on their request, from a reply or a šŸ‘€ on their message, so never acknowledge it again: open with the answer or the question.",
16
+ 'When the shopper asks you to find, buy, book or order something new, such as a product, a gift idea, a meal, a table, a flight or a store page they pasted a link to, first write one short line that says what you are about to look for, then call this tool in the same reply: only what they asked for, and the store or place they named, in their own words, such as "on it, looking for la croix at target", "one sec, checking nopa for a table for 2 at 7pm" or, for a pasted link, "one sec, checking that link", never a price, a total, stock, a result, a question or that anything was found, added or ordered. A store named by its site, such as "the polishing cloth from myrqvist.com", is a store, not a link. For a yes, a pick, a size, an address, a code, a change to a cart or a question about an order, write no line first, and after this tool answers, never say again that you are looking.',
16
17
  'When `shopper_already_read_reply` is true, the shopper already read `reply` as Agentcard wrote it, so never say it again in any words: answer with only what `reply` leaves out, in one short line, or with nothing.',
17
18
  'Show the shopper the cart before you confirm it.',
18
19
  'When `cart_building` is set, the store is still building that cart: tell the shopper in one short line that its total is on its way, never ask them to confirm it, and confirm it only after they say yes to the total in the message Agentcard sends next.',
package/dist/direct.d.ts CHANGED
@@ -21,11 +21,21 @@ export interface DirectContext {
21
21
  nextAction?: BuyNextAction | null;
22
22
  /** Agentcard's last reply, the question a short answer answers. */
23
23
  asked?: string | null;
24
- /** The products Agentcard's last answer showed, in order, with the `product_id` a pick sends. */
24
+ /** The products Agentcard's last answer showed, in order, with the `product_id` a pick sends. A place (`the second one`) reads these. */
25
25
  products?: ReadonlyArray<{
26
26
  productId: string | null;
27
27
  name: string;
28
28
  }>;
29
+ /**
30
+ * Every product card the shopper can tap by its name, each once: the answer's and the ones its searches sent as they answered,
31
+ * which an answer about one item can leave out (a menu turn that answered with the Burrito beside eight menu cards). A pick by
32
+ * name (a card's tap, a product's whole name, a word only one name holds) reads these; a place never does, so `the last one` is
33
+ * the last of `products`. Left out, `products` stands for both.
34
+ */
35
+ tappable?: ReadonlyArray<{
36
+ productId: string | null;
37
+ name: string;
38
+ }>;
29
39
  /** The stores Agentcard's last answer showed, with the `store_id` a pick sends. */
30
40
  stores?: ReadonlyArray<{
31
41
  storeId: string;
package/dist/direct.js CHANGED
@@ -9,7 +9,10 @@
9
9
  * - a pick of what Agentcard just showed, by a card's tap (`I'll take the KODAK AA (24 Pack) ($13.49)`,
10
10
  * `Order from Shake Shack`), its place (`the first one`, `#2`, `1 and 3`) or a reference (`that one`);
11
11
  * - the answer to the question Agentcard just asked: the address, name or code it asked for, a short
12
- * answer such as `medium` or `size 9`, or a plain yes to the total it asked the shopper to confirm.
12
+ * answer such as `medium` or `size 9`, or a plain yes to the total it asked the shopper to confirm;
13
+ * - a price limit for the products Agentcard just showed: `only options under $20`, `under 20`, `cheaper ones`.
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).
13
16
  *
14
17
  * Everything else goes to the model: a question, small talk, a change of mind (`no`, `wait`, `cancel`),
15
18
  * a command to the cart, and anything about the account, an order or how the chat works. A pick or an
@@ -21,6 +24,11 @@
21
24
  * the 188 labelled messages in `test/fixtures/direct-messages.json`, it sends none it should not.
22
25
  */
23
26
  import { ABOUT, ACKNOWLEDGED, CAPABILITY, CART_COMMAND, CHECKOUT, CHITCHAT, FILLER, asksForDetails, isNewRequest, isPickText, namesStore, plain } from './ack.js';
27
+ /**
28
+ * A price limit put on what was just shown: `under $20`, `under 20`, `less than 15 bucks`, `between 10 and 20`, `$20 or
29
+ * less`, `over $50`, `cheaper ones`, `menos de 20`. Only whether the message states one: Agentcard reads the limit itself.
30
+ */
31
+ const PRICE_REFINE = /(?:\b(?:under|below|less than|cheaper than|up to|at most|no more than|max(?:imum)?|between|over|above|more than|at least|menos de|hasta|entre)\s+\$?\s?\d)|\$\s?\d+(?:\.\d{1,2})?\s*(?:or (?:less|under|below|more)|and (?:under|below|up)|max|tops|\+)|\b\d+\s*(?:dollars|bucks)\b|\b(?:cheaper|less expensive|more affordable|lower[- ]priced)\b(?!\s+than\b)|\bm[aĆ”]s barat/;
24
32
  /** The longest message that goes straight: a long one is read by the model. */
25
33
  const MAX_CHARS = 500;
26
34
  /** The steps a shopper answers with their own details or a code. */
@@ -143,9 +151,17 @@ function placesOf(bare, count) {
143
151
  function nameWords(text) {
144
152
  return String(text ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
145
153
  }
154
+ /** A card's tap as an app writes it, `I'll take the <name> (<price>)`: the name in it, or null for any other message. */
155
+ const TAP = /^(?:i'?ll (?:take|have|get)|i want|add)\s+(?:the\s+)?(.+?)\s*\(\s*(?:[$€£]\s?\d[\d,]*(?:\.\d{1,2})?|from [$€£]\s?\d[\d,]*(?:\.\d{1,2})?)\s*\)\s*[.!]?$/i;
156
+ function tappedName(raw) {
157
+ const name = TAP.exec(raw.trim())?.[1]?.trim();
158
+ return name ? name : null;
159
+ }
146
160
  /** The pick a message makes of what Agentcard just showed, null when it picks nothing, or false when it counts past the list. */
147
161
  function pickOf(raw, bare, context) {
148
162
  const products = context.products ?? [];
163
+ // A pick by name reads every card the shopper can tap; a place reads `products`, in the order the answer showed them.
164
+ const byName = context.tappable?.length ? context.tappable : products;
149
165
  const stores = context.stores ?? [];
150
166
  const call = (fields) => ({ input: { ask: raw, confirm: false, new_order: false, product_id: null, store_id: null, ...fields }, reason: 'pick' });
151
167
  if (stores.length > 0) {
@@ -159,18 +175,26 @@ function pickOf(raw, bare, context) {
159
175
  if (named.length > 1)
160
176
  return call({});
161
177
  }
162
- if (products.length > 0) {
163
- // A card's tap names the product whole: `I'll take the KODAK AA Alkaline Batteries (24 Pack) ($13.49)`.
178
+ if (byName.length > 0) {
179
+ // A card's tap names the product whole, `I'll take the Burrito Bowl ($15.15)`: the product of exactly that name is the pick,
180
+ // never the Burrito whose one word it shares, and a tap that names no product here (a card this list does not hold), or two
181
+ // alike, sends none, so Agentcard reads the words against every card it showed.
182
+ const tap = tappedName(raw);
183
+ if (tap !== null) {
184
+ const exact = byName.filter((product) => [nameWords(tap), nameWords(`the ${tap}`)].includes(nameWords(product.name)));
185
+ return call(exact.length === 1 ? { product_id: exact[0].productId } : {});
186
+ }
187
+ // A product's whole name inside the message: `I want the KODAK AA Alkaline Batteries (24 Pack)`.
164
188
  const said = nameWords(raw);
165
- const named = products.filter((product) => nameWords(product.name).length >= 12 && said.includes(nameWords(product.name)));
189
+ const named = byName.filter((product) => nameWords(product.name).length >= 12 && said.includes(nameWords(product.name)));
166
190
  if (named.length === 1)
167
191
  return call({ product_id: named[0].productId });
168
192
  if (named.length > 1)
169
193
  return call({});
170
194
  // A word only one product's name holds, such as its brand: `lets do the coppertop ones`, `duracell 24 count is fine`.
171
195
  const words = new Set(said.split(' '));
172
- const owners = (word) => products.filter((product) => nameWords(product.name).split(' ').includes(word));
173
- const marked = products.filter((product) => nameWords(product.name).split(' ').some((word) => word.length >= 4 && !/^\d/.test(word) && words.has(word) && owners(word).length === 1));
196
+ const owners = (word) => byName.filter((product) => nameWords(product.name).split(' ').includes(word));
197
+ const marked = byName.filter((product) => nameWords(product.name).split(' ').some((word) => word.length >= 4 && !/^\d/.test(word) && words.has(word) && owners(word).length === 1));
174
198
  if (marked.length === 1 && said.split(' ').length <= 10 && !QUESTION_START.test(bare) && !DOUBT.test(said))
175
199
  return call({ product_id: marked[0].productId });
176
200
  const places = placesOf(bare, products.length);
@@ -182,7 +206,7 @@ function pickOf(raw, bare, context) {
182
206
  if (/^(?:both|all|all of them|both of them|all three|all 3)$/.test(bare))
183
207
  return call({});
184
208
  }
185
- if ((products.length > 0 || stores.length > 0) && isPickText(raw))
209
+ if ((byName.length > 0 || stores.length > 0) && isPickText(raw))
186
210
  return call({});
187
211
  return null;
188
212
  }
@@ -220,6 +244,9 @@ export function directCall(shopperText, context = {}) {
220
244
  return null;
221
245
  if (pick)
222
246
  return pick;
247
+ // A price limit on the products just shown: Agentcard shows the ones inside it again, as cards.
248
+ if ((ctx.products ?? []).length > 0 && PRICE_REFINE.test(words) && words.split(' ').length <= 15)
249
+ return plainCall('product_ask');
223
250
  // The address, name or code Agentcard asked for: what the shopper sends is the answer, unless it objects.
224
251
  const details = (typeof ctx.nextAction === 'string' && DETAIL_STEPS.has(ctx.nextAction)) || asksForDetails(ctx.asked);
225
252
  if (details && !OBJECTION.test(words) && !REQUEST_START.test(bare) && !QUESTION_START.test(bare) && !PAST.test(words) && !/\b(?:same as|on file|you have|the usual|ur|your)\b/.test(words) && !/^(?:resend|send|again|still)\b/.test(bare) && !NOT_SHOPPING.test(words) && words.split(' ').length <= 40)
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, ty
3
3
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, type NextStepKind, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
4
  export { ORDER_STEPS, updateOrder, arrivalText, type ShopperOrder, type OrderStep, type OrderPathStep, type OrderStepState, type ArrivalTextOptions, } from './order.js';
5
5
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
6
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyChoiceGroup, type BuyChoiceOption, } from './client.js';
6
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyLastAnswer, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyChoiceGroup, type BuyChoiceOption, } from './client.js';
7
7
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
8
8
  export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
9
9
  export { buyTool, nextStepPrompt, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolFound, type BuyToolAvailability, type BuyToolProduct, type BuyToolProductAction, productAction, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, type BuyAckMode, } from './buy-tool.js';
@@ -1,19 +1,39 @@
1
1
  /**
2
- * Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
3
- * own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
4
- * paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
5
- * items a blank line separates.
2
+ * Joins each message that leads in with a colon (`two options came in under $20:`) to the message after it, so a shopper
3
+ * never reads a bubble that ends on a colon: the lead-in and its first item or list arrive as one message, a line apart.
4
+ * `add` returns the message to send, or null while it holds a lead-in for the next one. `end` returns what a held lead-in
5
+ * leaves once nothing followed it, such as when every item it led into went because the cards show them: the text less
6
+ * its colon sentence (withoutLeadIn), or null when nothing is left. On 2026-10-27 a chat showed "two options came in
7
+ * under $20:" as a bubble of its own, above the item it led into.
6
8
  */
9
+ export declare function leadInJoiner(): {
10
+ add(message: string): string | null;
11
+ end(): string | null;
12
+ };
7
13
  /**
8
14
  * The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
9
15
  * out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
10
- * lines it holds, and so does a list whose items blank lines separate.
16
+ * lines it holds, and so does a list whose items blank lines separate. A paragraph that ends with a
17
+ * colon goes with the paragraph after it, one line apart, and loses its colon sentence when nothing
18
+ * follows it, so no message ends on a colon.
11
19
  *
12
20
  * ```ts
13
21
  * toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
22
+ * toMessages('two came in under $20:\n\n• Everydaze, $8.99'); // ['two came in under $20:\n• Everydaze, $8.99']
14
23
  * ```
15
24
  */
16
25
  export declare function toMessages(text: string): string[];
26
+ /**
27
+ * The text's paragraphs as `toMessages` cuts them, before any lead-in joins the paragraph after it: the units Agentcard
28
+ * and a model write, and what a count of the messages a shopper read is checked against, paragraph for paragraph.
29
+ */
30
+ export declare function paragraphsOf(text: string): string[];
31
+ /**
32
+ * Messages in order, each one that leads in with a colon joined to the ones after it by one joiner across them all
33
+ * (leadInJoiner), so a lead-in that is a whole message of its own, as one of Agentcard's `messages` can be, still goes
34
+ * with the message after it, and a lead-in nothing follows at the very end loses its colon sentences.
35
+ */
36
+ export declare function joinLeadIns(messages: readonly string[]): string[];
17
37
  /**
18
38
  * How long someone texting takes to type `message`, in milliseconds: 300, plus 12 a character, at most
19
39
  * 1,500. With `pace: true`, `messageStream` and `buy.turn` wait this long between two messages, so a
@@ -67,7 +87,8 @@ export interface MessageStreamOptions {
67
87
  * moment the blank line that ends it arrives, whatever the pieces split it into, and goes to
68
88
  * `onMessage` then, or, after the first, once its typing pause since the last one passed (`pace`). A
69
89
  * paragraph inside a list waits for the first characters of the line after the blank, which say
70
- * whether the list goes on. `end` hands over the last message and waits for every send.
90
+ * whether the list goes on, and a paragraph that ends with a colon waits for the paragraph after it,
91
+ * so the two go out as one message. `end` hands over the last message and waits for every send.
71
92
  *
72
93
  * ```ts
73
94
  * const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
package/dist/messages.js CHANGED
@@ -2,8 +2,10 @@
2
2
  * Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
3
3
  * own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
4
4
  * paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
5
- * items a blank line separates.
5
+ * items a blank line separates. A paragraph that leads in with a colon never stands alone: it goes out
6
+ * with the one after it (leadInJoiner).
6
7
  */
8
+ import { leadsIn, withoutLeadIn } from './sentences.js';
7
9
  /** A line holding nothing but spaces or tabs. */
8
10
  const BLANK = /^[ \t]*$/;
9
11
  /** A list item: `-`, `*`, `+` or the bullet `•`, or a number followed by `.` or `)`, then a space or the line's end. */
@@ -30,6 +32,29 @@ function closesFence(line, open) {
30
32
  const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
31
33
  return !!match && match[1][0] === open[0] && match[1].length >= open.length;
32
34
  }
35
+ /**
36
+ * Joins each message that leads in with a colon (`two options came in under $20:`) to the message after it, so a shopper
37
+ * never reads a bubble that ends on a colon: the lead-in and its first item or list arrive as one message, a line apart.
38
+ * `add` returns the message to send, or null while it holds a lead-in for the next one. `end` returns what a held lead-in
39
+ * leaves once nothing followed it, such as when every item it led into went because the cards show them: the text less
40
+ * its colon sentence (withoutLeadIn), or null when nothing is left. On 2026-10-27 a chat showed "two options came in
41
+ * under $20:" as a bubble of its own, above the item it led into.
42
+ */
43
+ export function leadInJoiner() {
44
+ let held = null;
45
+ return {
46
+ add(message) {
47
+ const whole = held === null ? message : `${held}\n${message}`;
48
+ held = leadsIn(whole) ? whole : null;
49
+ return held === null ? whole : null;
50
+ },
51
+ end() {
52
+ const rest = held === null ? '' : withoutLeadIn(held);
53
+ held = null;
54
+ return rest || null;
55
+ },
56
+ };
57
+ }
33
58
  /**
34
59
  * Reads text line by line and closes each message once nothing later can change it. It keeps only
35
60
  * the message being built, so text fed in pieces costs what the text costs once.
@@ -102,19 +127,47 @@ class Scanner {
102
127
  /**
103
128
  * The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
104
129
  * out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
105
- * lines it holds, and so does a list whose items blank lines separate.
130
+ * lines it holds, and so does a list whose items blank lines separate. A paragraph that ends with a
131
+ * colon goes with the paragraph after it, one line apart, and loses its colon sentence when nothing
132
+ * follows it, so no message ends on a colon.
106
133
  *
107
134
  * ```ts
108
135
  * toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
136
+ * toMessages('two came in under $20:\n\n• Everydaze, $8.99'); // ['two came in under $20:\n• Everydaze, $8.99']
109
137
  * ```
110
138
  */
111
139
  export function toMessages(text) {
112
- const messages = [];
113
- const scanner = new Scanner((message) => messages.push(message));
140
+ return joinLeadIns(paragraphsOf(text));
141
+ }
142
+ /**
143
+ * The text's paragraphs as `toMessages` cuts them, before any lead-in joins the paragraph after it: the units Agentcard
144
+ * and a model write, and what a count of the messages a shopper read is checked against, paragraph for paragraph.
145
+ */
146
+ export function paragraphsOf(text) {
147
+ const paragraphs = [];
148
+ const scanner = new Scanner((paragraph) => paragraphs.push(paragraph));
114
149
  for (const line of String(text ?? '').replace(/\r\n?/g, '\n').split('\n'))
115
150
  scanner.line(line);
116
151
  scanner.end();
117
- return messages;
152
+ return paragraphs;
153
+ }
154
+ /**
155
+ * Messages in order, each one that leads in with a colon joined to the ones after it by one joiner across them all
156
+ * (leadInJoiner), so a lead-in that is a whole message of its own, as one of Agentcard's `messages` can be, still goes
157
+ * with the message after it, and a lead-in nothing follows at the very end loses its colon sentences.
158
+ */
159
+ export function joinLeadIns(messages) {
160
+ const leads = leadInJoiner();
161
+ const out = [];
162
+ for (const message of messages) {
163
+ const ready = leads.add(message);
164
+ if (ready)
165
+ out.push(ready);
166
+ }
167
+ const rest = leads.end();
168
+ if (rest)
169
+ out.push(rest);
170
+ return out;
118
171
  }
119
172
  /** The pause before a message, as someone texting takes it: 300 ms, plus 12 ms a character, at most 1.5 seconds. */
120
173
  const TYPING_BASE_MS = 300;
@@ -171,7 +224,8 @@ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
171
224
  * moment the blank line that ends it arrives, whatever the pieces split it into, and goes to
172
225
  * `onMessage` then, or, after the first, once its typing pause since the last one passed (`pace`). A
173
226
  * paragraph inside a list waits for the first characters of the line after the blank, which say
174
- * whether the list goes on. `end` hands over the last message and waits for every send.
227
+ * whether the list goes on, and a paragraph that ends with a colon waits for the paragraph after it,
228
+ * so the two go out as one message. `end` hands over the last message and waits for every send.
175
229
  *
176
230
  * ```ts
177
231
  * const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
@@ -247,10 +301,15 @@ export function messageStream(options) {
247
301
  });
248
302
  }
249
303
  };
250
- const scanner = new Scanner((message) => {
304
+ // A paragraph that leads in with a colon waits for the one after it, so the two go out as one message.
305
+ const leads = leadInJoiner();
306
+ const send = (message) => {
307
+ if (!message)
308
+ return;
251
309
  queue.push(message);
252
310
  kick();
253
- });
311
+ };
312
+ const scanner = new Scanner((paragraph) => send(leads.add(paragraph)));
254
313
  return {
255
314
  push(delta) {
256
315
  if (ended)
@@ -273,6 +332,7 @@ export function messageStream(options) {
273
332
  scanner.line(line);
274
333
  open = '';
275
334
  scanner.end();
335
+ send(leads.end());
276
336
  }
277
337
  while (running)
278
338
  await running;
@@ -242,8 +242,11 @@ export interface ShownCard {
242
242
  * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
243
243
  * points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
244
244
  * are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
245
- * further sentence that names another card. What is left is the reply around the list, such as `found these at
246
- * amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
245
+ * further sentence that names another card. A list written on one line goes the same way, item by item (inlineList),
246
+ * and so does a list line written without a bullet, whole, up to the question on it. What is left is the reply around
247
+ * the list, such as `found these at amazon.` and `which one do you want?`. A reply whose cards went out names none of
248
+ * them twice over. A list that keeps an item for a product no card shows keeps its lead-in, and the two read as one
249
+ * message (toMessages), so the shopper never reads `two options came in under $20:` on its own.
247
250
  *
248
251
  * Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
249
252
  * a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).