@agent-cards/commerce-sdk 0.14.0 → 0.16.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.
@@ -0,0 +1,3 @@
1
+ export declare const SENTENCE_END: RegExp;
2
+ /** Where the last sentence of `text` begins: 0 when the text is one sentence. */
3
+ export declare function lastSentenceStart(text: string): number;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where one sentence of Agentcard's words ends and the next begins: the space after a full stop, a question mark or an
3
+ * exclamation mark. A stop that ends a short form or an initial ends no sentence, so `Mr. Coffee`, `Dr. Pepper`,
4
+ * `St. Louis`, `J. Crew`, `U.S. Polo`, `e.g. a mug`, `Acme Inc. mugs` and `No. 7` each stay in one sentence. A price
5
+ * (`$24.99`), a size (`8.5`) and a domain (`target.com`) carry no space after their stop, so they end none either.
6
+ *
7
+ * On 2026-10-27 a reply that led into its list with `target has two colors of the Mr. Coffee Retro Brew French Press:`
8
+ * reached the shopper as `target has two colors of the Mr.`: the stop after `Mr` was read as a sentence's end.
9
+ */
10
+ const SHORT_FORMS = ['mr', 'mrs', 'ms', 'dr', 'st', 'jr', 'sr', 'mt', 'ft', 'vs', 'inc', 'co', 'ltd', 'e.g', 'i.e'];
11
+ const anyCase = (word) => word.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`).replace(/\./g, '\\.');
12
+ /** What comes before a letter that is a size, not an initial: `size `, `sizes `, `in `, `and `, `or `, or another size and a comma. */
13
+ const NOT_A_SIZE = '(?<!(?:\\b(?:[Ss]izes?|in|and|or)|\\b[A-Z]{1,3},)\\s)';
14
+ export const SENTENCE_END = new RegExp([
15
+ '(?<=[.!?])',
16
+ `(?<!(?:^|[^\\p{L}.])(?:${SHORT_FORMS.map(anyCase).join('|')})\\.)`,
17
+ // An initial, or initials such as `U.S.`, written as capitals. A size, `size S.` or `in S, M and L.`, is no initial.
18
+ `(?<!(?:^|[^\\p{L}])${NOT_A_SIZE}(?:\\p{L}\\.)*\\p{Lu}\\.)`,
19
+ // `No.` stands for a number before a digit; anywhere else it is the answer "no".
20
+ '(?!(?<=(?:^|[^\\p{L}])[Nn][Oo]\\.)\\s+\\d)',
21
+ '\\s+',
22
+ ].join(''), 'u');
23
+ /** Where the last sentence of `text` begins: 0 when the text is one sentence. */
24
+ export function lastSentenceStart(text) {
25
+ let start = 0;
26
+ for (const end of text.matchAll(new RegExp(SENTENCE_END.source, 'gu')))
27
+ start = end.index + end[0].length;
28
+ return start;
29
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { type AgentcardEvent } from './verify.js';
7
7
  import { type PurchaseFacts, type PurchaseMessageKind } from './message.js';
8
+ import { type ShopperOrder } from './order.js';
8
9
  /**
9
10
  * The event types that carry a message for a shopper. Subscribe your endpoint to each by name:
10
11
  * a catch-all `*` alone does not turn `purchase.message` on.
@@ -36,6 +37,8 @@ export type ShopperMessageKind = PurchaseMessageKind | 'order_confirmed' | 'orde
36
37
  export interface ShopperFacts extends PurchaseFacts {
37
38
  /** The retailer's own order number, as on its confirmation and account page. */
38
39
  retailerOrderId?: string;
40
+ /** The place the order comes from, such as the restaurant on DoorDash, when Agentcard names it. */
41
+ storeName?: string;
39
42
  /** The retailer's quoted delivery window, in its own words. */
40
43
  deliveryWindow?: string;
41
44
  /** The order's delivery stage. */
@@ -74,6 +77,13 @@ export interface ShopperMessage {
74
77
  /** For your agent: the facts of what happened, marked as written by the system and not typed by the shopper. */
75
78
  note: string;
76
79
  facts: ShopperFacts;
80
+ /**
81
+ * Where the order is, for a message about an order a tracker can show: `order_placed`,
82
+ * `order_failed`, `order_confirmed`, and `order_updated` at a stage the package knows. Null for
83
+ * every other message. Keep one tracker per `order.orderId` and fold each new one in with
84
+ * `updateOrder`, so successive messages move one tracker instead of adding lines.
85
+ */
86
+ order: ShopperOrder | null;
77
87
  /**
78
88
  * True when the message ends a step the shopper took outside the chat, one of `NEXT_STEP_KINDS`:
79
89
  * open `buy.nextStep(message)` and let your model take the next step, such as the card ask or the
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { isRecord } from './verify.js';
7
7
  import { readPurchaseMessage } from './message.js';
8
+ import { orderFrom, orderStepOf } from './order.js';
8
9
  /**
9
10
  * The event types that carry a message for a shopper. Subscribe your endpoint to each by name:
10
11
  * a catch-all `*` alone does not turn `purchase.message` on.
@@ -30,6 +31,8 @@ function text(value, max = 200) {
30
31
  function id(value) {
31
32
  return typeof value === 'string' && value !== '' ? value : undefined;
32
33
  }
34
+ /** Unix seconds as an ISO 8601 timestamp. */
35
+ const isoOf = (created) => new Date(created * 1000).toISOString();
33
36
  function count(value) {
34
37
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
35
38
  }
@@ -101,12 +104,18 @@ function readOrderConfirmed(event) {
101
104
  const deliveryWindow = text(data.delivery_window, 80);
102
105
  const facts = present({
103
106
  merchantName: store ?? undefined,
107
+ storeName: text(data.store_name, 64),
104
108
  totalCents: count(data.final_total_cents) ?? count(data.charged_cents),
105
109
  currency: text(data.currency, 8),
106
110
  orderId: id(data.order_id),
107
111
  retailerOrderId,
108
112
  deliveryWindow,
109
113
  });
114
+ const order = orderFrom({
115
+ ...facts,
116
+ step: 'confirmed',
117
+ updatedAt: text(data.confirmed_at, 40) ?? isoOf(event.created),
118
+ });
110
119
  const who = store ? `${store} confirmed your order` : 'The store confirmed your order';
111
120
  return {
112
121
  ...base,
@@ -117,6 +126,7 @@ function readOrderConfirmed(event) {
117
126
  `${retailerOrderId ? ` The store's order number is ${retailerOrderId}.` : ''}` +
118
127
  `${deliveryWindow ? ` The store quotes delivery ${deliveryWindow}.` : ''} ${SYSTEM_LINE}]`,
119
128
  facts,
129
+ order,
120
130
  };
121
131
  }
122
132
  function readOrderUpdated(event) {
@@ -134,6 +144,30 @@ function readOrderUpdated(event) {
134
144
  return null;
135
145
  const arrival = isRecord(data.estimated_arrival) ? data.estimated_arrival : {};
136
146
  const previous = text(data.previous_status, 40);
147
+ const facts = present({
148
+ merchantName: store ?? undefined,
149
+ storeName: text(data.store_name, 64),
150
+ orderId: id(data.order_id),
151
+ orderStatus: status,
152
+ previousOrderStatus: previous,
153
+ statusTitle,
154
+ arrivalEarliest: text(arrival.earliest, 40),
155
+ arrivalLatest: text(arrival.latest, 40),
156
+ deliveredAt: text(data.delivered_at, 40),
157
+ canceledReason,
158
+ });
159
+ const step = orderStepOf(status);
160
+ const order = step
161
+ ? orderFrom({
162
+ ...facts,
163
+ step,
164
+ reached: orderStepOf(previous),
165
+ title: statusTitle,
166
+ detail: text(data.status_detail, 200),
167
+ reason: canceledReason,
168
+ updatedAt: text(data.updated_at, 40) ?? isoOf(event.created),
169
+ })
170
+ : null;
137
171
  return {
138
172
  ...base,
139
173
  kind: 'order_updated',
@@ -142,17 +176,8 @@ function readOrderUpdated(event) {
142
176
  note: `[event: this user's order${store ? ` at ${store}` : ''} moved to the stage ${status}` +
143
177
  `${previous ? ` from ${previous}` : ''}, outside this conversation.` +
144
178
  `${statusTitle ? ` The store says: ${statusTitle}.` : ''}${canceledReason ? ` The store's reason: ${canceledReason}.` : ''} ${SYSTEM_LINE}]`,
145
- facts: present({
146
- merchantName: store ?? undefined,
147
- orderId: id(data.order_id),
148
- orderStatus: status,
149
- previousOrderStatus: previous,
150
- statusTitle,
151
- arrivalEarliest: text(arrival.earliest, 40),
152
- arrivalLatest: text(arrival.latest, 40),
153
- deliveredAt: text(data.delivered_at, 40),
154
- canceledReason,
155
- }),
179
+ facts,
180
+ order,
156
181
  };
157
182
  }
158
183
  /**
@@ -174,8 +199,23 @@ export function readShopperMessage(event) {
174
199
  type: 'purchase.message',
175
200
  conversationId: id(event.data.conversation_id) ?? null,
176
201
  advances: NEXT_STEP.has(message.kind),
202
+ order: purchaseOrder(message.kind, message.facts, event.created),
177
203
  };
178
204
  }
205
+ /** The order an `order_placed` or `order_failed` message describes; null for every other kind. */
206
+ function purchaseOrder(kind, facts, created) {
207
+ if (kind !== 'order_placed' && kind !== 'order_failed')
208
+ return null;
209
+ return orderFrom({
210
+ orderId: facts.orderId,
211
+ merchantName: facts.merchantName,
212
+ totalCents: facts.totalCents,
213
+ currency: facts.currency,
214
+ step: kind === 'order_placed' ? 'placed' : 'failed',
215
+ reason: kind === 'order_failed' ? facts.reason : undefined,
216
+ updatedAt: isoOf(created),
217
+ });
218
+ }
179
219
  /**
180
220
  * Whether the message stopped holding before `now` (unix milliseconds, default the system clock):
181
221
  * the link or the window its text names has closed. Drop a stale message instead of sending it.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Agentcard's words as a chat shows them: every list as bullet lines that start with `• `, and, for an app that asks,
3
+ * the texting style a chat writes in. The reply a turn hands out as written (`replyAsWritten`) and each message
4
+ * `onText` hears come through here, so an app shows them with no formatting of its own.
5
+ */
6
+ import type { BuyToolResult } from './buy-tool.js';
7
+ import type { BuyChoice } from './client.js';
8
+ /**
9
+ * The text with every list written as bullet lines that start with `• `: lines marked `- `, `* `, `• `, `1. ` or
10
+ * `1) `, and two or more short lines in a row that follow a line ending in `:` or `?`, such as the choices of bread
11
+ * after `which bread do you want?`. A list's lead-in line stays, and so does prose. One line on its own is no list.
12
+ *
13
+ * ```ts
14
+ * asBullets('which bread do you want?\n\nSourdough\nWhole wheat'); // 'which bread do you want?\n\n• Sourdough\n• Whole wheat'
15
+ * ```
16
+ */
17
+ export declare function asBullets(text: string): string;
18
+ /**
19
+ * The text in a chat's texting style: every list as `• ` bullet lines (`asBullets`), no other markdown (headings, bold,
20
+ * a lone `- ` or `* ` marker), no spaced dashes, which become commas, and lowercase at the start of each sentence and
21
+ * for "I". A capital inside a sentence is a name and stays, and so does an all-caps word such as "USB". A list line
22
+ * keeps its first word as written, since it is usually a product's or a store's name.
23
+ *
24
+ * ```ts
25
+ * textingStyle('Here are two — both ship today. Which one?\n- Anker cable\n- Onn cable');
26
+ * // 'here are two, both ship today. which one?\n• Anker cable\n• Onn cable'
27
+ * ```
28
+ */
29
+ export declare function textingStyle(text: string): string;
30
+ /** Options for `replyAsWritten`. */
31
+ export interface ReplyAsWrittenOptions {
32
+ /** `true` writes the reply in the texting style (`textingStyle`): lowercase sentence starts, no markdown, no spaced dashes, `• ` bullets kept. */
33
+ textingStyle?: boolean;
34
+ }
35
+ /**
36
+ * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a `needs_input`
37
+ * answer with a reply, such as a size, a bread or a store to pick, or a cart's total and the ask for their yes. The
38
+ * reply comes with every link taken out, as the model reads it, without the list of products or stores whose cards
39
+ * went out (`withoutCardList`), and with every list left in it as `• ` bullet lines (`asBullets`). A numbered list
40
+ * that names the answer's products or stores keeps its numbers, because the shopper picks from it by number ("number
41
+ * 6"), and Agentcard and `directCall` read that number against the list. Null for any other answer, which your model
42
+ * puts in its own words, for a line the tool wrote itself for your model, and once the shopper read the reply through
43
+ * `onText` (`shopper_already_read_reply`). `{ textingStyle: true }` hands it over in the texting style (`textingStyle`).
44
+ *
45
+ * ```ts
46
+ * replyAsWritten(result); // 'which bread do you want?\n\n• Sourdough\n• Whole wheat'
47
+ * ```
48
+ */
49
+ export declare function replyAsWritten(result: BuyToolResult, options?: ReplyAsWrittenOptions): string | null;
50
+ /**
51
+ * One message of Agentcard's, as `onText` hands it to the app: its lists as `• ` bullet lines and, with `texting`, in
52
+ * the texting style. A numbered list keeps its numbers, since a streamed message still lists the products its cards
53
+ * show and the shopper may pick one by its number. `leadIn` says the message before it ended in a colon or a question
54
+ * mark, so the choices that open this one are that question's list.
55
+ */
56
+ export declare function messageForShopper(text: string, leadIn: boolean, texting: boolean): string;
57
+ /** True when a message ends in a colon or a question mark, so a list that opens the next one belongs to it. */
58
+ export declare function leadsIntoList(text: string): boolean;
59
+ /**
60
+ * A choice's values as the shopper reads them, one `• ` line each with its extra price after it: `• Gluten-free bread
61
+ * (+$2.46)`. A choice of more than one group leads each group's lines with its name, as `Size:`.
62
+ *
63
+ * ```ts
64
+ * choiceLines(result.choice!); // '• Ciabatta\n• Sourdough\n• Gluten-free bread (+$2.46)'
65
+ * ```
66
+ */
67
+ export declare function choiceLines(choice: BuyChoice): string;
68
+ /**
69
+ * The text with the choice's values listed one per line (choiceLines) under the line that asks the question, when the text
70
+ * does not list them already: Agentcard's reply may name them inside a sentence, as "i can also do sourdough, whole wheat,
71
+ * or gluten-free bread for $2.46 more" did. A bracket that holds only the values goes, since the list replaces it. A text
72
+ * that asks nothing gets the list at its end. When every value already opens a line, a priced one that leaves its price out
73
+ * gets it at the end of its line, and the text is otherwise as it is, as it is with no choice.
74
+ *
75
+ * ```ts
76
+ * withChoice('which bread do you want?', result.choice); // 'which bread do you want?\n• Ciabatta\n• Sourdough'
77
+ * ```
78
+ */
79
+ export declare function withChoice(text: string, choice: BuyChoice | null): string;
80
+ /** True when the shopper's messages already list every value of the choice one per line, each priced one with its extra price. */
81
+ export declare function messagesListChoice(messages: readonly string[], choice: BuyChoice): boolean;
@@ -0,0 +1,347 @@
1
+ import { forModel, replyAsWritten as replyWithoutCardList } from './model-view.js';
2
+ /** The mark every list line starts with. */
3
+ const BULLET = '• ';
4
+ /** A line that is a list item: `- `, `* `, `• `, `1. ` or `1) `, then text. The number, when it has one, is group 2. */
5
+ const MARKED = /^(\s*)(?:[-*•]|(\d{1,2})[.)])\s+(?=\S)/;
6
+ /** A line that leads into a list: it ends with a colon or a question mark, as `which bread do you want?` does. */
7
+ const LEAD = /[:?]["')\]]*$/;
8
+ /** A line indented under a list item, such as a description under its name: two spaces or a tab, then text. */
9
+ const CONTINUATION = /^(?: {2,}|\t)\S/;
10
+ /**
11
+ * A word whose period ends no sentence: a title or a short form such as `St.`, `Dr.`, `vs.` or `e.g.`, or initials
12
+ * such as `J.` or `U.S.`, so the word after it keeps its capital ("St. Louis", "Dr. Pepper").
13
+ */
14
+ const ABBREVIATIONS = new Set(['st', 'dr', 'mr', 'mrs', 'ms', 'jr', 'sr', 'mt', 'ft', 'vs', 'e.g', 'i.e', 'etc']);
15
+ const INITIALS = /^(?:[A-Za-z]\.)*[A-Z]$/;
16
+ /** `No.` stands for a number before a digit, as in `No. 7`; anywhere else it is the answer "no" and ends its sentence. */
17
+ const NUMBER_SIGN = /^no$/i;
18
+ const DIGIT_NEXT = /^\s*\d/;
19
+ /**
20
+ * True when `word`, read up to the period after it and the text `after` that period, is an abbreviation or initials
21
+ * rather than a sentence's last word.
22
+ */
23
+ function isAbbreviation(word, after) {
24
+ const bare = word.replace(/^[^\p{L}]+/u, '');
25
+ if (NUMBER_SIGN.test(bare))
26
+ return DIGIT_NEXT.test(after);
27
+ return ABBREVIATIONS.has(bare.toLowerCase()) || INITIALS.test(bare);
28
+ }
29
+ /** At most this many characters and words make a line short enough to be a choice written on its own, with no marker. */
30
+ const OPTION_CHARACTERS = 60;
31
+ const OPTION_WORDS = 8;
32
+ /**
33
+ * A line that is one choice written on its own, as a list of breads or times is written with no marker: short, and not
34
+ * the end of a sentence (`.`, `!`, `?`, `:` or `;`), so a line of prose never reads as one.
35
+ */
36
+ function isOption(line) {
37
+ const text = line.trim();
38
+ return text !== '' && text.length <= OPTION_CHARACTERS && text.split(/\s+/).length <= OPTION_WORDS && !/[.!?:;]$/.test(text) && !MARKED.test(line);
39
+ }
40
+ /** The text with every list written as bullet lines, read by `options`. */
41
+ function bulletLists(text, options = {}) {
42
+ const lines = text.split('\n');
43
+ // A marked list: two or more item lines, with only blank lines and lines indented under an item between them. An
44
+ // indented line, such as ` the chef's pick` under `- Sourdough`, stays as written under its item's bullet.
45
+ for (let start = 0; start < lines.length; start += 1) {
46
+ if (!MARKED.test(lines[start]))
47
+ continue;
48
+ const items = [start];
49
+ let end = start + 1;
50
+ while (end < lines.length) {
51
+ if (MARKED.test(lines[end]))
52
+ items.push(end);
53
+ else if (lines[end].trim() !== '' && !CONTINUATION.test(lines[end]))
54
+ break;
55
+ end += 1;
56
+ }
57
+ start = items[items.length - 1];
58
+ // One marked line is not a list, so it stays as written.
59
+ if (items.length < 2)
60
+ continue;
61
+ const numbered = items.some((i) => MARKED.exec(lines[i])[2] !== undefined);
62
+ if (numbered && options.keepNumbers?.(items.map((i) => lines[i].replace(MARKED, ''))))
63
+ continue;
64
+ for (const i of items)
65
+ lines[i] = lines[i].replace(MARKED, (_, indent) => `${indent}${BULLET}`);
66
+ }
67
+ // Choices with no marker: two or more short lines in a row right after a line that leads into them, such as the
68
+ // breads after `which bread do you want?`. Blank lines may sit between the lead and the first choice.
69
+ const runFrom = (first) => {
70
+ let end = first;
71
+ while (end < lines.length && isOption(lines[end]))
72
+ end += 1;
73
+ if (end - first < 2)
74
+ return;
75
+ for (let i = first; i < end; i += 1)
76
+ lines[i] = BULLET + lines[i].trim();
77
+ };
78
+ const nextWithWords = (from) => {
79
+ let i = from;
80
+ while (i < lines.length && lines[i].trim() === '')
81
+ i += 1;
82
+ return i;
83
+ };
84
+ if (options.leadIn)
85
+ runFrom(nextWithWords(0));
86
+ for (let i = 0; i < lines.length; i += 1) {
87
+ if (LEAD.test(lines[i].trim()))
88
+ runFrom(nextWithWords(i + 1));
89
+ }
90
+ return lines.join('\n');
91
+ }
92
+ /**
93
+ * The text with every list written as bullet lines that start with `• `: lines marked `- `, `* `, `• `, `1. ` or
94
+ * `1) `, and two or more short lines in a row that follow a line ending in `:` or `?`, such as the choices of bread
95
+ * after `which bread do you want?`. A list's lead-in line stays, and so does prose. One line on its own is no list.
96
+ *
97
+ * ```ts
98
+ * asBullets('which bread do you want?\n\nSourdough\nWhole wheat'); // 'which bread do you want?\n\n• Sourdough\n• Whole wheat'
99
+ * ```
100
+ */
101
+ export function asBullets(text) {
102
+ return bulletLists(text);
103
+ }
104
+ /**
105
+ * The text with the capital that opens each sentence after its first lowercased. A capital after an abbreviation or
106
+ * initials, such as `St. Louis` or `J. Crew`, is a name and stays.
107
+ */
108
+ function sentenceStarts(text) {
109
+ let out = '';
110
+ let from = 0;
111
+ // The capital is looked ahead, never consumed, so `vs. Mt. Bachelor` reads `Mt.` as a word of its own.
112
+ for (const match of text.matchAll(/(\S*)([.!?])\s+(?=[A-Z][a-z])/g)) {
113
+ const at = match.index + match[0].length;
114
+ if (match[2] === '.' && isAbbreviation(match[1], text.slice(match.index + match[1].length + 1)))
115
+ continue;
116
+ out += text.slice(from, at) + text[at].toLowerCase();
117
+ from = at + 1;
118
+ }
119
+ return out + text.slice(from);
120
+ }
121
+ /** Each line in the texting style: the work of `textingStyle` on text whose lists are bullets already. */
122
+ function styleLines(text) {
123
+ return text
124
+ .split('\n')
125
+ .map((raw) => {
126
+ const item = MARKED.test(raw);
127
+ const line = raw
128
+ .replace(/^\s*#{1,6}\s+/, '')
129
+ // A markdown marker on a line of its own goes; a bullet stays.
130
+ .replace(/^(\s*)[-*]\s+/, '$1')
131
+ .replace(/\*\*(.+?)\*\*|__(.+?)__/g, (_, a, b) => a ?? b ?? '')
132
+ .replace(/\s+[—–-]\s+/g, ', ')
133
+ .replace(/\s*—\s*/g, ', ')
134
+ .replace(/\bI(?=('(m|ll|ve|d))?\b)/g, 'i');
135
+ const marker = /^\s*(?:\d+[.)]\s+|•\s+)?/.exec(line)?.[0] ?? '';
136
+ const rest = sentenceStarts(line.slice(marker.length));
137
+ // A list line keeps its first word as written: it is usually a name, such as a product's or a store's. So does a
138
+ // line that opens with an abbreviation, such as `Mt. Hood`.
139
+ const opening = /^(\S*)\./.exec(rest);
140
+ return marker + (item || (opening !== null && isAbbreviation(opening[1], rest.slice(opening[0].length))) ? rest : rest.replace(/^([A-Z])(?=[a-z])/, (letter) => letter.toLowerCase()));
141
+ })
142
+ .join('\n')
143
+ .trim();
144
+ }
145
+ /**
146
+ * The text in a chat's texting style: every list as `• ` bullet lines (`asBullets`), no other markdown (headings, bold,
147
+ * a lone `- ` or `* ` marker), no spaced dashes, which become commas, and lowercase at the start of each sentence and
148
+ * for "I". A capital inside a sentence is a name and stays, and so does an all-caps word such as "USB". A list line
149
+ * keeps its first word as written, since it is usually a product's or a store's name.
150
+ *
151
+ * ```ts
152
+ * textingStyle('Here are two — both ship today. Which one?\n- Anker cable\n- Onn cable');
153
+ * // 'here are two, both ship today. which one?\n• Anker cable\n• Onn cable'
154
+ * ```
155
+ */
156
+ export function textingStyle(text) {
157
+ return styleLines(bulletLists(text));
158
+ }
159
+ /** A text's words, lowercased, letters and digits only. */
160
+ function wordsOf(text) {
161
+ return text.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== '');
162
+ }
163
+ /**
164
+ * True when a list item names one of `names`, by the first four words of the name: two of them, and most of them, in
165
+ * the item, or the name's one word as the item's first.
166
+ */
167
+ function namesOne(item, names) {
168
+ const said = wordsOf(item);
169
+ const set = new Set(said);
170
+ return names.some((name) => {
171
+ const head = name.slice(0, 4);
172
+ if (head.length === 1)
173
+ return said[0] === head[0];
174
+ const hits = head.filter((word) => set.has(word)).length;
175
+ return hits >= Math.max(2, Math.ceil(head.length * 0.6));
176
+ });
177
+ }
178
+ /**
179
+ * Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a `needs_input`
180
+ * answer with a reply, such as a size, a bread or a store to pick, or a cart's total and the ask for their yes. The
181
+ * reply comes with every link taken out, as the model reads it, without the list of products or stores whose cards
182
+ * went out (`withoutCardList`), and with every list left in it as `• ` bullet lines (`asBullets`). A numbered list
183
+ * that names the answer's products or stores keeps its numbers, because the shopper picks from it by number ("number
184
+ * 6"), and Agentcard and `directCall` read that number against the list. Null for any other answer, which your model
185
+ * puts in its own words, for a line the tool wrote itself for your model, and once the shopper read the reply through
186
+ * `onText` (`shopper_already_read_reply`). `{ textingStyle: true }` hands it over in the texting style (`textingStyle`).
187
+ *
188
+ * ```ts
189
+ * replyAsWritten(result); // 'which bread do you want?\n\n• Sourdough\n• Whole wheat'
190
+ * ```
191
+ */
192
+ export function replyAsWritten(result, options = {}) {
193
+ const reply = replyWithoutCardList(result);
194
+ if (reply === null)
195
+ return null;
196
+ const view = forModel(result);
197
+ const names = [...view.products.map((product) => product.name), ...view.stores.map((store) => store.name)].map(wordsOf).filter((name) => name.length > 0);
198
+ const bulleted = withChoice(bulletLists(reply, { keepNumbers: (items) => items.some((item) => namesOne(item, names)) }), result.choice ?? null);
199
+ return (options.textingStyle ? styleLines(bulleted) : bulleted) || null;
200
+ }
201
+ /**
202
+ * One message of Agentcard's, as `onText` hands it to the app: its lists as `• ` bullet lines and, with `texting`, in
203
+ * the texting style. A numbered list keeps its numbers, since a streamed message still lists the products its cards
204
+ * show and the shopper may pick one by its number. `leadIn` says the message before it ended in a colon or a question
205
+ * mark, so the choices that open this one are that question's list.
206
+ */
207
+ export function messageForShopper(text, leadIn, texting) {
208
+ const bulleted = bulletLists(text, { leadIn, keepNumbers: () => true });
209
+ return texting ? styleLines(bulleted) : bulleted;
210
+ }
211
+ /** True when a message ends in a colon or a question mark, so a list that opens the next one belongs to it. */
212
+ export function leadsIntoList(text) {
213
+ return LEAD.test(text.trim());
214
+ }
215
+ /** A label as plain words: lowercase letters and digits, one space between words. */
216
+ const plainLabel = (text) => text.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
217
+ /** A value's extra price as a shopper reads it after the value: ` (+$2.46)`, nothing when it is free or not known. */
218
+ const extraPrice = (cents) => (cents && cents > 0 ? ` (+$${(cents / 100).toFixed(2)})` : '');
219
+ /**
220
+ * A choice's values as the shopper reads them, one `• ` line each with its extra price after it: `• Gluten-free bread
221
+ * (+$2.46)`. A choice of more than one group leads each group's lines with its name, as `Size:`.
222
+ *
223
+ * ```ts
224
+ * choiceLines(result.choice!); // '• Ciabatta\n• Sourdough\n• Gluten-free bread (+$2.46)'
225
+ * ```
226
+ */
227
+ export function choiceLines(choice) {
228
+ const groups = choice.groups.filter((group) => group.options.length > 0);
229
+ return groups
230
+ .flatMap((group) => [
231
+ ...(groups.length > 1 && group.name ? [`${group.name}:`] : []),
232
+ ...group.options.map((option) => `${BULLET}${option.label}${extraPrice(option.price_delta_cents)}`),
233
+ ])
234
+ .join('\n');
235
+ }
236
+ /** A value's extra price as its line names it: "2.46" for 246 cents; null when it is free or not known. */
237
+ const priceWords = (cents) => (cents && cents > 0 ? (cents / 100).toFixed(2) : null);
238
+ /**
239
+ * How `text` lists the choice's values: each value's own line (a list marker before it allowed), the values no line holds, and
240
+ * the lines of priced values that leave their extra price out. A line belongs to the one value whose label it opens with, the
241
+ * longest such label, so `• 7.5` is the line of 7.5 and never of 7.
242
+ */
243
+ function choiceListing(text, choice) {
244
+ const raw = text.split('\n');
245
+ // Short lines in a row after a question are a list too (bulletLists), whether or not they carry a marker.
246
+ const read = bulletLists(text, { keepNumbers: () => true }).split('\n');
247
+ const lines = raw.map((line) => plainLabel(line.replace(MARKED, '')));
248
+ const options = choice.groups.flatMap((group, g) => group.options.map((option) => ({ group: g, option, label: plainLabel(option.label), price: priceWords(option.price_delta_cents) })));
249
+ const owner = lines.map((line, n) => {
250
+ const marked = MARKED.test(read[n] ?? raw[n]);
251
+ let best = -1;
252
+ options.forEach((option, i) => {
253
+ // A list line may say more after its value (a price, a note); a line of prose that only opens with a value is no list
254
+ // line. An unmarked line that is the value alone, or the value and its price, is one.
255
+ const listLine = marked || line === option.label || (!!option.price && line === `${option.label} ${plainLabel(option.price)}`);
256
+ if (option.label && listLine && (line === option.label || line.startsWith(`${option.label} `)) && (best < 0 || option.label.length > options[best].label.length))
257
+ best = i;
258
+ });
259
+ return best;
260
+ });
261
+ const unpriced = [];
262
+ options.forEach((option, i) => {
263
+ const line = owner.indexOf(i);
264
+ if (line >= 0 && option.price && !raw[line].includes(option.price))
265
+ unpriced.push({ line, price: option.price });
266
+ });
267
+ const lastLine = Math.max(-1, ...owner.map((o, line) => (o >= 0 ? line : -1)));
268
+ // Each value no line holds goes after the last listed value of its own group; a group none of whose values is listed follows
269
+ // the whole list, led by its name when the choice has more than one group.
270
+ const insertions = [];
271
+ let missing = 0;
272
+ choice.groups.forEach((group, g) => {
273
+ const absent = options.filter((option, i) => option.group === g && !owner.includes(i));
274
+ if (!absent.length)
275
+ return;
276
+ missing += absent.length;
277
+ const bullets = absent.map(({ option }) => `${BULLET}${option.label}${extraPrice(option.price_delta_cents)}`);
278
+ const own = Math.max(-1, ...owner.map((o, line) => (o >= 0 && options[o].group === g ? line : -1)));
279
+ insertions.push(own >= 0 ? { after: own, lines: bullets } : { after: lastLine, lines: [...(choice.groups.length > 1 && group.name ? [`${group.name}:`] : []), ...bullets] });
280
+ });
281
+ return { missing, lastLine, insertions, unpriced };
282
+ }
283
+ /** True when every value of the choice has a line of its own in `text`, each priced one naming its extra price. */
284
+ function listsChoiceWithPrices(text, choice) {
285
+ const listing = choiceListing(text, choice);
286
+ return listing.missing === 0 && listing.unpriced.length === 0;
287
+ }
288
+ /**
289
+ * The text less a bracket that holds nothing but the choice's values, such as a store's "(Size: 3 oz, 4.375 oz, 8.375 oz)":
290
+ * the values go under the question instead, so the shopper never reads them twice. A bracket whose values follow anything but a
291
+ * group's name, such as "(Choose up to 2: Garlic, Herb)", keeps what it says about the choice and stays.
292
+ */
293
+ function withoutInlineValues(text, choice) {
294
+ const labels = new Set(choice.groups.flatMap((group) => group.options.map((option) => plainLabel(option.label))));
295
+ const groupNames = new Set(choice.groups.map((group) => plainLabel(group.name)));
296
+ return text.replace(/\s*\(([^()\n]*)\)/g, (whole, inside) => {
297
+ const parts = inside.split(/;\s*/).map((part) => /^([^:,]{1,40}):\s*(.*)$/.exec(part) ?? [part, null, part]);
298
+ if (parts.some(([, lead]) => lead != null && !groupNames.has(plainLabel(lead))))
299
+ return whole;
300
+ const values = parts.flatMap(([, , rest]) => (rest ?? '').split(/,\s*(?:or\s+|and\s+)?|\s+(?:or|and)\s+/))
301
+ .map((value) => value.trim()).filter((value) => value !== '' && value !== '…' && value !== '...');
302
+ return values.length >= 2 && values.every((value) => labels.has(plainLabel(value))) ? '' : whole;
303
+ });
304
+ }
305
+ /**
306
+ * The text with the choice's values listed one per line (choiceLines) under the line that asks the question, when the text
307
+ * does not list them already: Agentcard's reply may name them inside a sentence, as "i can also do sourdough, whole wheat,
308
+ * or gluten-free bread for $2.46 more" did. A bracket that holds only the values goes, since the list replaces it. A text
309
+ * that asks nothing gets the list at its end. When every value already opens a line, a priced one that leaves its price out
310
+ * gets it at the end of its line, and the text is otherwise as it is, as it is with no choice.
311
+ *
312
+ * ```ts
313
+ * withChoice('which bread do you want?', result.choice); // 'which bread do you want?\n• Ciabatta\n• Sourdough'
314
+ * ```
315
+ */
316
+ export function withChoice(text, choice) {
317
+ if (!choice || !choice.groups.some((group) => group.options.length > 0))
318
+ return text;
319
+ const listing = choiceListing(text, choice);
320
+ if (listing.lastLine >= 0) {
321
+ // Some values have their line already: a priced one that leaves its price out gets it at the line's end, and the values
322
+ // no line holds follow the last listed value of their own group, so the list is whole and none is read twice.
323
+ if (!listing.missing && !listing.unpriced.length)
324
+ return text;
325
+ const listed = text.split('\n');
326
+ for (const { line, price } of listing.unpriced)
327
+ listed[line] = `${listed[line].trimEnd()} (+$${price})`;
328
+ // From the bottom up, so each insertion leaves the lines above it where they were; at one place, in the groups' order.
329
+ for (const { after, lines } of [...listing.insertions].reverse().sort((x, y) => y.after - x.after))
330
+ listed.splice(after + 1, 0, ...lines);
331
+ return listed.join('\n');
332
+ }
333
+ const lines = withoutInlineValues(text, choice).split('\n');
334
+ const question = choice.question?.trim();
335
+ let at = question ? lines.findIndex((line) => line.includes(question)) : -1;
336
+ for (let i = lines.length - 1; at < 0 && i >= 0; i -= 1)
337
+ if (lines[i].includes('?'))
338
+ at = i;
339
+ if (at < 0)
340
+ at = lines.length - 1;
341
+ lines.splice(at + 1, 0, choiceLines(choice));
342
+ return lines.join('\n');
343
+ }
344
+ /** True when the shopper's messages already list every value of the choice one per line, each priced one with its extra price. */
345
+ export function messagesListChoice(messages, choice) {
346
+ return listsChoiceWithPrices(messages.map((message) => bulletLists(message, { leadIn: true, keepNumbers: () => true })).join('\n'), choice);
347
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
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",