@agent-cards/commerce-sdk 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInp
7
7
  export { toMessages, messageStream, typingDelay, } from './messages.js';
8
8
  export { buyTool, nextStepPrompt, productAction, } from './buy-tool.js';
9
9
  export { instantAck, instantReaction, ackFor, keepsYes, ACK_REACTION } from './ack.js';
10
- export { forModel, forModelError, replyAsWritten, stripLinks, } from './model-view.js';
10
+ export { directCall, isProductAsk } from './direct.js';
11
+ export { forModel, forModelError, replyAsWritten, withoutCardList, stripLinks, } from './model-view.js';
11
12
  export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
12
13
  export { listenToAgentcard, } from './listen.js';
13
14
  export { ACTION_LINK_ENDINGS, ACTION_LINK_KINDS, actionLinkId, actionLinkOutcome, isActionLink, } from './action-links.js';
@@ -26,6 +26,7 @@ export interface BuyToolModelCart {
26
26
  variant: string | null;
27
27
  }>;
28
28
  fees: string | null;
29
+ fees_status?: 'known' | 'pending' | 'unknown';
29
30
  /** The store's own fee and tax lines inside `fees`, by the store's labels. Empty when it itemized none. */
30
31
  fee_lines: Array<{
31
32
  label: string;
@@ -89,6 +90,8 @@ export interface BuyToolModelView {
89
90
  shopper_need_not_reply: boolean;
90
91
  /** True when the shopper already got a message this turn saying their request is being worked on, so the reply opens with no acknowledgement of its own. */
91
92
  shopper_already_told_looking: boolean;
93
+ /** True when the shopper already read `reply`, every message of it, as Agentcard wrote it, so the answer adds only what it leaves out. */
94
+ shopper_already_read_reply: boolean;
92
95
  /** True when the shopper already has the link this answer needed. */
93
96
  link_sent: boolean;
94
97
  /**
@@ -179,12 +182,26 @@ export declare function forModel(result: BuyToolResult): BuyToolModelView;
179
182
  * Null for any other answer, which your model puts in its own words, and for a line the tool wrote
180
183
  * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
181
184
  * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
185
+ * Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
182
186
  *
183
187
  * ```ts
184
188
  * replyAsWritten(result); // 'found two at Amazon. want the first one?'
185
189
  * ```
186
190
  */
187
191
  export declare function replyAsWritten(result: BuyToolResult): string | null;
192
+ /**
193
+ * Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
194
+ * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
195
+ * points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
196
+ * words, each further sentence that names another card. What is left is the reply around the list, such as `found
197
+ * these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
198
+ *
199
+ * ```ts
200
+ * withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
201
+ * // 'Which one?'
202
+ * ```
203
+ */
204
+ export declare function withoutCardList(reply: string, cardNames: readonly string[]): string;
188
205
  /**
189
206
  * A thrown error as the model may read it: the same message with every link
190
207
  * taken out, and the original error on `cause` for your logs. A TypeError
@@ -159,7 +159,8 @@ function cartView(cart) {
159
159
  options: (line.options ?? []).map(stripLinks).filter((o) => o !== ''),
160
160
  variant: line.variant ? stripLinks(line.variant) || null : null,
161
161
  })),
162
- fees: cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
162
+ fees: cart.serviceFeesCents !== null && cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
163
+ fees_status: cart.serviceFeesStatus,
163
164
  fee_lines: (cart.merchantFeeLines ?? []).map((line) => ({ label: stripLinks(line.label), amount: money(line.amountCents, cart.currency) })),
164
165
  tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
165
166
  total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
@@ -226,6 +227,7 @@ export function forModel(result) {
226
227
  // A step's ask is one short line: an approval Agentcard places on its own needs no "no need to reply" beside it.
227
228
  shopper_need_not_reply: result.shopper_need_not_reply === true && !STEP_ACTIONS.has(result.next_action ?? ''),
228
229
  shopper_already_told_looking: result.shopper_already_told_looking === true,
230
+ shopper_already_read_reply: result.shopper_already_read_reply === true,
229
231
  link_sent: result.link_sent === true,
230
232
  links_sent: result.link_sent === true
231
233
  ? [...new Set((result.links ?? []).map((link) => link.kind).filter((kind) => kind === 'vault' || kind === 'approval' || kind === 'store_login'))]
@@ -249,17 +251,140 @@ export function forModel(result) {
249
251
  * Null for any other answer, which your model puts in its own words, and for a line the tool wrote
250
252
  * itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
251
253
  * shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
254
+ * Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
252
255
  *
253
256
  * ```ts
254
257
  * replyAsWritten(result); // 'found two at Amazon. want the first one?'
255
258
  * ```
256
259
  */
257
260
  export function replyAsWritten(result) {
261
+ // The shopper already read every message of it through onText: handing it out again would say it twice.
262
+ if (result.shopper_already_read_reply === true)
263
+ return null;
258
264
  // The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
259
265
  const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
260
266
  const view = forModel(result);
261
267
  const reply = view.reply.trim();
262
- return agentcards && view.status === 'needs_input' && reply !== '' ? reply : null;
268
+ if (!agentcards || view.status !== 'needs_input' || reply === '')
269
+ return null;
270
+ const cards = [...view.products.filter((product) => product.link_sent).map((product) => product.name), ...view.stores.filter((store) => store.link_sent).map((store) => store.name)];
271
+ // A reply with nothing left once the cards' list comes out is your model's to word.
272
+ return withoutCardList(reply, cards) || null;
273
+ }
274
+ /** A line that opens a list: `- `, `* `, `• `, `1. ` or `1) `. */
275
+ const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
276
+ /** A price, in any of the currencies a store writes one in. */
277
+ const PRICE = /[$£€]\s?\d/;
278
+ /** The place a sentence points at in a numbered list, such as `#8` or `number 3`, where `a #2 Phillips head` names a kind of thing. */
279
+ const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
280
+ /** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
281
+ const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|mb|w|mah|v|cm|mm|m|pack|pk|ct|count|pcs|pieces)\b/gi;
282
+ /**
283
+ * A size one text spells one way and a card's name another, so `24 pack` reads as the card's `24 Count`. Inches lose
284
+ * their unit, so `41 in black` reads as the card's `41` and `10 in` as its `10in`: `in` is a word as often as a unit.
285
+ */
286
+ const SAME_UNIT = new Map([['feet', 'ft'], ['foot', 'ft'], ['in', ''], ['inch', ''], ['inches', ''], ['pk', 'pack'], ['ct', 'pack'], ['count', 'pack'], ['pcs', 'pack'], ['pieces', 'pack'], ['lbs', 'lb']]);
287
+ /** A size or a count written as one word, such as `24pack` or `10ft`. */
288
+ const SIZE = /^(\d+)(\p{L}+)$/u;
289
+ /** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
290
+ function wordsOf(text) {
291
+ return text
292
+ .toLowerCase()
293
+ .replace(UNIT, '$1$2')
294
+ .split(/[^\p{L}\p{N}]+/u)
295
+ .filter((word) => word !== '')
296
+ .map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
297
+ }
298
+ /**
299
+ * The card a text names, by the first six words of the card's name: at least two of them, and most of them, in the
300
+ * text. Null when it names none. A name of one word never counts in prose, where a generic word such as `soap` would.
301
+ * A size or a count among those words has to be in the text too, so the 3ft cable's line never reads as the 10ft card.
302
+ */
303
+ function cardNamed(text, cards, inList) {
304
+ const said = new Set(wordsOf(text));
305
+ for (const [index, name] of cards.entries()) {
306
+ const head = name.slice(0, 6);
307
+ if (!head.every((word) => !/\d/.test(word) || said.has(word)))
308
+ continue;
309
+ const hits = head.filter((word) => said.has(word)).length;
310
+ if (head.length === 1 ? inList && hits === 1 : hits >= Math.max(2, Math.ceil(head.length * 0.6)))
311
+ return index;
312
+ }
313
+ return null;
314
+ }
315
+ /**
316
+ * Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
317
+ * store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
318
+ * points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
319
+ * words, each further sentence that names another card. What is left is the reply around the list, such as `found
320
+ * these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
321
+ *
322
+ * ```ts
323
+ * withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
324
+ * // 'Which one?'
325
+ * ```
326
+ */
327
+ export function withoutCardList(reply, cardNames) {
328
+ const cards = cardNames.map(wordsOf).filter((name) => name.length > 0);
329
+ if (cards.length === 0)
330
+ return reply.trim();
331
+ // A text that opens with a card's name and gives its price is a list line, as a reply writes one without a bullet.
332
+ const opensCard = (text) => {
333
+ const words = wordsOf(text);
334
+ return PRICE.test(text) && cards.some((name) => name.length >= 2 && name.slice(0, 3).every((word, i) => words[i] === word));
335
+ };
336
+ const lines = reply.split('\n');
337
+ const kind = lines.map((line) => {
338
+ if (LIST_ITEM.test(line))
339
+ return cardNamed(line.replace(LIST_ITEM, ''), cards, true) !== null ? 'item' : 'kept';
340
+ return opensCard(line) ? 'named' : 'kept';
341
+ });
342
+ const dropped = kind.map((of) => of !== 'kept');
343
+ // The numbers the dropped list gave, so a number of a product's own, `a #2 Phillips head`, keeps its sentence.
344
+ const numbers = new Set(lines.flatMap((line, i) => (dropped[i] ? [Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1])] : [])).filter((number) => Number.isInteger(number)));
345
+ // The list a line leads into: the lines from the next one with words on it to the first that is not a list line, so a
346
+ // question on the line after the last item, `Which one?`, is no part of the list.
347
+ const listAfter = (line) => {
348
+ const start = lines.findIndex((after, j) => j > line && after.trim() !== '');
349
+ if (start === -1)
350
+ return [];
351
+ let end = start;
352
+ while (end < lines.length && (LIST_ITEM.test(lines[end]) || kind[end] === 'named'))
353
+ end += 1;
354
+ return dropped.slice(start, end);
355
+ };
356
+ const named = new Set();
357
+ const kept = [];
358
+ lines.forEach((line, i) => {
359
+ if (kind[i] === 'item')
360
+ return;
361
+ let text = line;
362
+ // The line that leads into a list whose every line went loses the sentence that leads in: `Two I'd suggest:` or
363
+ // `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
364
+ const list = listAfter(i);
365
+ if (text.trim().endsWith(':') && list.length > 0 && list.every(Boolean))
366
+ text = text.trim().replace(/(?:^|(?<=[.!?])\s+)[^.!?]*:$/, '');
367
+ const sentences = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
368
+ const place = LIST_NUMBER.exec(sentence);
369
+ if (place && numbers.has(Number(place[1] ?? place[2])))
370
+ return false;
371
+ const card = cardNamed(sentence, cards, false);
372
+ // A list line written without a bullet keeps the sentences its card does not say, such as the question after it.
373
+ if (kind[i] === 'named')
374
+ return card === null && !opensCard(sentence);
375
+ if (card === null || named.has(card))
376
+ return true;
377
+ // A reply recommends one or two products in words; a sentence that names a third is the list again.
378
+ if (named.size >= 2)
379
+ return false;
380
+ named.add(card);
381
+ return true;
382
+ });
383
+ if (kind[i] === 'named' && sentences.length === 0)
384
+ return;
385
+ kept.push(sentences.join(' ').trimEnd());
386
+ });
387
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
263
388
  }
264
389
  /**
265
390
  * A thrown error as the model may read it: the same message with every link
@@ -9,7 +9,7 @@ import { type PurchaseFacts, type PurchaseMessageKind } from './message.js';
9
9
  * The event types that carry a message for a shopper. Subscribe your endpoint to each by name:
10
10
  * a catch-all `*` alone does not turn `purchase.message` on.
11
11
  */
12
- export declare const SHOPPER_EVENT_TYPES: readonly ["purchase.message", "order.confirmed", "order.updated"];
12
+ export declare const SHOPPER_EVENT_TYPES: readonly ['purchase.message', 'order.confirmed', 'order.updated'];
13
13
  /** One of the event types that carry a message for a shopper. */
14
14
  export type ShopperEventType = (typeof SHOPPER_EVENT_TYPES)[number];
15
15
  /**
@@ -18,7 +18,7 @@ export type ShopperEventType = (typeof SHOPPER_EVENT_TYPES)[number];
18
18
  * to the store (`store_connected`), or declined the approval (`approval_declined`), outside the chat.
19
19
  * `buy.nextStep(message)` opens that turn. Every other kind's `text` is the whole message: post it as is.
20
20
  */
21
- export declare const NEXT_STEP_KINDS: readonly ["card_added", "store_connected", "approval_declined"];
21
+ export declare const NEXT_STEP_KINDS: readonly ['card_added', 'store_connected', 'approval_declined'];
22
22
  /** A kind after which the agent takes the next step on its own. */
23
23
  export type NextStepKind = (typeof NEXT_STEP_KINDS)[number];
24
24
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -40,6 +40,14 @@
40
40
  "webhooks",
41
41
  "purchases"
42
42
  ],
43
+ "peerDependencies": {
44
+ "@opentelemetry/api": "^1.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@opentelemetry/api": {
48
+ "optional": true
49
+ }
50
+ },
43
51
  "engines": {
44
52
  "node": ">=18"
45
53
  },