@agent-cards/commerce-sdk 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -4
- package/dist/ack.d.ts +24 -0
- package/dist/ack.js +8 -8
- package/dist/buy-tool.d.ts +130 -13
- package/dist/buy-tool.js +347 -17
- package/dist/client.d.ts +132 -3
- package/dist/client.js +176 -17
- package/dist/definition.js +1 -0
- package/dist/direct.d.ts +50 -0
- package/dist/direct.js +237 -0
- package/dist/events.d.ts +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/model-view.d.ts +28 -3
- package/dist/model-view.js +150 -6
- package/dist/shopper-message.d.ts +2 -2
- package/package.json +9 -1
package/dist/direct.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buy call a shopper's message makes with no model deciding it: `directCall` reads the message, and
|
|
3
|
+
* what Agentcard last asked the shopper, and returns the call your app sends straight to Agentcard, or
|
|
4
|
+
* null when your model decides. Only three kinds of message go straight:
|
|
5
|
+
*
|
|
6
|
+
* - a request to buy or book something: a shopping verb with what to get (`find me usb-c cables under
|
|
7
|
+
* $20`, `i need a gift for my dad`, `add a toothbrush from walmart to my cart`), a product link, a
|
|
8
|
+
* product at a named store (`aa batteries on amazon`), or a table (`a table for 2 tonight at 7`);
|
|
9
|
+
* - a pick of what Agentcard just showed, by a card's tap (`I'll take the KODAK AA (24 Pack) ($13.49)`,
|
|
10
|
+
* `Order from Shake Shack`), its place (`the first one`, `#2`, `1 and 3`) or a reference (`that one`);
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Everything else goes to the model: a question, small talk, a change of mind (`no`, `wait`, `cancel`),
|
|
15
|
+
* a command to the cart, and anything about the account, an order or how the chat works. A pick or an
|
|
16
|
+
* answer goes straight only while the shopper's last turn ended on Agentcard's answer, so a reply to
|
|
17
|
+
* something your model said reaches your model.
|
|
18
|
+
*
|
|
19
|
+
* The rule is tuned for precision over recall: a message that reaches Agentcard by mistake gets a
|
|
20
|
+
* purchase reply to small talk, while one the model reads by mistake costs only the model's call. On
|
|
21
|
+
* the 188 labelled messages in `test/fixtures/direct-messages.json`, it sends none it should not.
|
|
22
|
+
*/
|
|
23
|
+
import { ABOUT, ACKNOWLEDGED, CAPABILITY, CART_COMMAND, CHECKOUT, CHITCHAT, FILLER, asksForDetails, isNewRequest, isPickText, namesStore, plain } from './ack.js';
|
|
24
|
+
/** The longest message that goes straight: a long one is read by the model. */
|
|
25
|
+
const MAX_CHARS = 500;
|
|
26
|
+
/** The steps a shopper answers with their own details or a code. */
|
|
27
|
+
const DETAIL_STEPS = new Set(['shipping_details', 'guest_details', 'verification_code']);
|
|
28
|
+
/** The words of a message: plain, with punctuation, `please` and `thanks` taken out. */
|
|
29
|
+
function wordsOf(text) {
|
|
30
|
+
return plain(text).replace(/[^\p{L}\p{N}\s$'&+./#-]+/gu, ' ').replace(/[!?.,;:]+(?=\s|$)/g, ' ').replace(/[!?,;:]+/g, ' ').replace(/\b(?:please|pls|plz|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
|
|
31
|
+
}
|
|
32
|
+
/** Words a message opens with before it says anything: a greeting or filler. */
|
|
33
|
+
const OPENING = /^(?:(?:hey+|hi+|hello+|yo|hiya|howdy|good (?:morning|afternoon|evening)|ok|okay|so|um|uh|also|and|alright|cool|great)(?: there)?\s+)+/;
|
|
34
|
+
/** A change of mind, a refusal or a stall: never an answer to send on. */
|
|
35
|
+
const TAKEBACK = /^(?:i'?ll think|let me think|thinking|i'?m thinking|let me check|i'?ll check|i'?ll let you know|no|nope|nah|not (?:yet|now|really|sure)|cancel|stop|wait|hold on|hang on|never ?mind|nvm|forget (?:it|that|about it)|start over|nothing|none|neither|later|maybe|idk|i don'?t know|dunno|not sure|hmm+|um+|uh+|lol|lmao|haha+|ok|okay|k|kk|thanks?|ty|cool|nice|great|wow|oh|ah)\b/;
|
|
36
|
+
/** Words that say the message is about something other than buying, wherever they sit. */
|
|
37
|
+
const NOT_SHOPPING = new RegExp([
|
|
38
|
+
'joke', 'weather', 'forecast', 'news', 'poem', 'riddle', 'lyrics', 'translate', 'homework', 'essay', 'coding', 'python', 'javascript',
|
|
39
|
+
'bug', 'error', 'crash(?:ed|es)?', 'broken', 'not working', "doesn'?t work", 'refund', 'return policy', 'password', 'log ?in', 'sign ?in',
|
|
40
|
+
'account', 'api', 'support', 'customer service', 'agent ?card', 'what time', 'time is it', "today'?s date", 'human', 'real person',
|
|
41
|
+
'cancel', 'track(?:ing)?', "where'?s my", 'status', 'receipt', 'invoice', 'unsubscribe', 'privacy', 'kyc', 'identity', 'verify',
|
|
42
|
+
'promo', 'coupon', 'discount code', 'charged', 'charge me', 'balance', 'wallet', 'approve', 'approval', 'link',
|
|
43
|
+
'(?:my|a|the|new|your|payment|credit|debit|virtual|bank|visa|mastercard) card', 'card (?:number|details|on file)',
|
|
44
|
+
]
|
|
45
|
+
.map((word) => `\\b${word}\\b`)
|
|
46
|
+
.join('|'));
|
|
47
|
+
/** A message that opens as a question: the model reads it, unless it asks Agentcard to get something (`can you find me ...`). */
|
|
48
|
+
const QUESTION_START = /^(?:what|whats|what'?s|where|wheres|where'?s|when|why|how|who|whose|which|is|are|was|were|do|does|did|should|shall|will|would|have|has|tell me|explain|any idea|wanna|you)\b/;
|
|
49
|
+
/** A shopping verb and what follows it, after any opening such as `can you`. */
|
|
50
|
+
const SHOP_FRAME = new RegExp('^(?:(?:can|could|would|will) (?:you|u) (?:please )?|please |i\'?d like (?:you )?to |help me )?' +
|
|
51
|
+
'(?:find(?: me| us)?|search(?: for)?|look(?:ing)? for|look up|show me|shop for|buy(?: me)?|order(?: me)?|purchase|get me|get us|grab(?: me)?|pick up|book(?: me)?|reserve|add|' +
|
|
52
|
+
"i want|i need|we need|we want|i'?d like|id like|i would like|i'?m looking for|im looking for|we'?re looking for|i'?m after|im after|" +
|
|
53
|
+
'need|want|can i get|could i get|can i have|could i have|i\'?ll have|send me|get)\\s+(.+)$');
|
|
54
|
+
/** What a shopping verb takes that names nothing to buy: help, a person, a pronoun, the cart, a question. */
|
|
55
|
+
const NOT_AN_ITEM = /^(?:(?:stuff|things|anything|items|products|everything)(?: (?:on|from|at|in|here|there|online)\b.*)?$|help|some help|assistance|someone|somebody|a human|a person|an agent|to (?!(?:buy|order|get|find|book|reserve|purchase|shop|grab|try)\b)\w+|info|information|advice|more info|you|u|me|it|this|that|these|those|them|nothing|out|lost|more|again|a (?:moment|minute|sec|second|break|nap|hug|vacation|friend)|how|what|where|why|when|who|(?:the |my |our )?(?:cart|basket|order|orders|total|purchases?|history|address)|my)\b/;
|
|
56
|
+
/** A verb that needs an object word after it before it names a thing: `add` alone, or `get` with one word, says too little. */
|
|
57
|
+
const MIN_ITEM_CHARS = 3;
|
|
58
|
+
/** Stores a shopper names by brand, so `aa batteries amazon` reads as a product at a store. */
|
|
59
|
+
const STORES = "amazon|walmart|target|best ?buy|costco|home depot|lowe'?s|ebay|etsy|doordash|door dash|uber ?eats|instacart|cvs|walgreens|nike|adidas|sephora|ulta|macy'?s|nordstrom|ikea|wayfair|chewy|whole foods|trader joe'?s|safeway|kroger|old navy|uniqlo|zara|h&m|rei|staples|office depot|barnes (?:&|and) noble|petco|petsmart|aldi|publix|sam'?s club|shein|temu|newegg|lululemon|patagonia|allbirds|glossier|shake shack|chipotle|starbucks|mcdonald'?s|taco bell|five guys|souvla|opentable|resy|1-800-flowers|zappos|kohl'?s|ace hardware|hollister|abercrombie|gap|apple store|dick'?s sporting goods";
|
|
60
|
+
/** A store's own address, such as `livlyclothing.com`. */
|
|
61
|
+
const DOMAIN = '[a-z0-9][a-z0-9-]*\\.(?:com|co|net|org|shop|store|us|uk|ca|io|co\\.uk)';
|
|
62
|
+
/** A thing at a named store: `aa batteries from amazon`, `bows at livlyclothing.com`, `aa batteries amazon`, `tacos near me`. */
|
|
63
|
+
const AT_STORE = new RegExp(`^(?:a |an |some |\\d+ )?[\\p{L}\\p{N}$'&+./-]+(?: [\\p{L}\\p{N}$'&+./-]+){0,9} (?:(?:(?:from|at|on|off) (?:the )?(?:${STORES}|${DOMAIN}))(?: [\\p{L}\\p{N}$'&+./-]+){0,6}|(?:${STORES})|near me)$`, 'u');
|
|
64
|
+
/** Words a thing never opens with: a pronoun or a verb of the chat. */
|
|
65
|
+
const NOT_A_THING = /^(?:i|i'?m|im|i'?ll|ill|i'?ve|ive|we|we'?re|you|u|your|it|its|it'?s|this|that|these|those|they|he|she|my|our|let|lets|let'?s|thank|thanks|just|still|so|not|no|yes|cool|nice|great|love|like|hate|wish|hope|think|guess|really|very|too)\b/;
|
|
66
|
+
/** A table, or a reservation, for a number of people. */
|
|
67
|
+
const TABLE = /^(?:(?:a |an |book (?:a |me a |us a )?)?(?:\w+ )?)?(?:table|reservation|res|booking) for (?:\d{1,2}|one|two|three|four|five|six|seven|eight|ten|twelve)\b/;
|
|
68
|
+
/** A count or size a product is named by: `81 ct`, `30 rolls`, `160ct`, `6 pack`, `12 oz`. */
|
|
69
|
+
const COUNTED = /\b\d+(?:\.\d+)? ?(?:ct|count|pk|pack|packs|rolls?|pcs|pieces|oz|fl oz|ml|lb|lbs|kg|g|gb|tb|mah|inch|in|ft|qt|quart|gallon|gal|capsules|tablets|caplets|gels|bars|cans|bottles)\b/;
|
|
70
|
+
/** Words that tell of something that already happened, not something to get: `returned 3 bottles`, `the 12 pack never came`. */
|
|
71
|
+
const HAPPENED = /\b(?:returned|bought|ordered|got|received|have|had|has|was|were|is|are|did|never|came|arrived|missing|broke|broken|delivered|shipped|yesterday|last)\b/;
|
|
72
|
+
/** A purchase that already happened, which the shopper tells of rather than asks for: `returned my nike shoes yesterday`. */
|
|
73
|
+
const PAST = /\b(?:returned|bought|ordered|received|arrived|came|never|yesterday|last (?:week|time|month|night)|already|refund(?:ed)?|expired)\b/;
|
|
74
|
+
/** A correction of what came before, such as `actually i need AAA not AA`: the model reads it with what it corrects. */
|
|
75
|
+
const CORRECTION = /^(?:actually|instead|no|nah|nope|wait|oh wait|hmm|oops|sorry|i meant|meant)\b|\bnot\b|\binstead\b/;
|
|
76
|
+
/** Words that point at something only the shopper knows, such as `the usual` or `my home address`, or at a step to repeat. */
|
|
77
|
+
const REFERS = /\b(?:whatever|anything|up to you|you pick|you choose|your call|surprise me|dealer'?s choice|usual|same as|as before|like before|like last time|my (?:home|work|office|regular|normal)\b|code|again)\b/;
|
|
78
|
+
/** A complaint or a joke at a price or a store, which asks for nothing: `another $9.99 delivery fee on doordash`, `duracell is a ripoff`. */
|
|
79
|
+
const COMPLAINT = /\b(?:fees?|ripoff|rip off|scam|overpriced|ugh|smh|annoying|terrible|worst|sucks|hate|love that|tbh|lol|lmao|wtf|seriously|bruh)\b/;
|
|
80
|
+
const LINK = /\bhttps?:\/\/\S+|\bwww\.\S+/i;
|
|
81
|
+
/**
|
|
82
|
+
* True when the message asks to buy or book something, read with no context: a shopping verb with what
|
|
83
|
+
* to get, a product link, a product at a named store, or a table. A question, small talk, a pick or an
|
|
84
|
+
* answer, a command to the cart, the shopper's own details and anything about the account read false.
|
|
85
|
+
*/
|
|
86
|
+
export function isProductAsk(text) {
|
|
87
|
+
const raw = String(text ?? '');
|
|
88
|
+
if (raw.length > MAX_CHARS)
|
|
89
|
+
return false;
|
|
90
|
+
const words = wordsOf(raw);
|
|
91
|
+
if (!/[\p{L}]/u.test(words))
|
|
92
|
+
return false;
|
|
93
|
+
const bare = words.replace(OPENING, '');
|
|
94
|
+
if (LINK.test(raw))
|
|
95
|
+
return !QUESTION_START.test(bare) && !NOT_SHOPPING.test(words.replace(/\S*https?:\/\/\S+|\S*www\.\S+/g, ''));
|
|
96
|
+
if (!isNewRequest(raw) || CAPABILITY.test(words) || NOT_SHOPPING.test(words) || PAST.test(words) || CORRECTION.test(words) || REFERS.test(words) || COMPLAINT.test(words))
|
|
97
|
+
return false;
|
|
98
|
+
// A question goes to the model, unless it asks Agentcard to get something: `can you find me usb-c cables?`.
|
|
99
|
+
const framed = SHOP_FRAME.exec(bare);
|
|
100
|
+
if (/\?/.test(raw) && !(framed && /^(?:can|could|would|will) (?:you|u)\b/.test(bare)))
|
|
101
|
+
return false;
|
|
102
|
+
if (QUESTION_START.test(bare))
|
|
103
|
+
return false;
|
|
104
|
+
if (framed) {
|
|
105
|
+
const item = framed[1].trim();
|
|
106
|
+
return item.length >= MIN_ITEM_CHARS && !NOT_AN_ITEM.test(item);
|
|
107
|
+
}
|
|
108
|
+
if (TABLE.test(bare))
|
|
109
|
+
return true;
|
|
110
|
+
// A product named with its count or size, such as `tide pods 81 ct` or `kirkland toilet paper 30 rolls`, with no verb.
|
|
111
|
+
if (COUNTED.test(bare) && !NOT_A_THING.test(bare) && !HAPPENED.test(bare) && bare.split(' ').length <= 10)
|
|
112
|
+
return true;
|
|
113
|
+
return AT_STORE.test(bare) && !NOT_A_THING.test(bare);
|
|
114
|
+
}
|
|
115
|
+
/** A plain yes: to a total, the go-ahead that confirms it. */
|
|
116
|
+
const YES = /^(?:y|ya|yes+|yeah|yea|yep|yup|sure|confirm(?:ed)?|sounds good|looks good|perfect|yes (?:please|place it|go ahead|do it|confirm|that'?s right|correct))$/;
|
|
117
|
+
function isYes(words) {
|
|
118
|
+
if (YES.test(words) || CHECKOUT.test(words))
|
|
119
|
+
return true;
|
|
120
|
+
// A yes, then the go-ahead in other words: `yep go ahead`, `ok do it`, `yes, place the order`, `sure, sounds good`.
|
|
121
|
+
const rest = words.replace(FILLER, '');
|
|
122
|
+
return rest !== words && (rest === '' || YES.test(rest) || CHECKOUT.test(rest));
|
|
123
|
+
}
|
|
124
|
+
/** Words that open a new request or a question rather than a short answer. */
|
|
125
|
+
const REQUEST_START = /^(?:find|search|look|show|get|order|buy|book|can|could|would|will|what|which|how|is|are|do|does|did|i want|i need|please)\b/;
|
|
126
|
+
/** Words that open an objection to giving details: the model answers it. */
|
|
127
|
+
const OBJECTION = /\b(?:expired|wrong|invalid|another|new code|resend|lol|don'?t|do not|won'?t|didn'?t|did not|haven'?t|not|never|no|rather not|why|what|where|how|privacy|safe|secure|instead|different|change)\b/;
|
|
128
|
+
/** Words that weigh a product rather than pick it: `duracell is too expensive`, `energizer instead of these`. */
|
|
129
|
+
const DOUBT = /\b(?:far|close|closest|open|hours|distance|minutes|rating|reviews|not|no|too|expensive|cheaper|pricey|better|worse|than|instead|vs|versus|or|compare|difference|why|isn'?t|aren'?t|don'?t|doesn'?t|hate|meh)\b/;
|
|
130
|
+
const ORDINALS = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7, eighth: 8 };
|
|
131
|
+
/** The places in a list a message names, such as `1 and 3` or `the second one`, in order: empty when it names none. */
|
|
132
|
+
function placesOf(bare, count) {
|
|
133
|
+
const said = bare.replace(/^(?:i'?ll take|i'?ll do|i'?ll go with|go with|let'?s do|lets do|i want|give me|get me|add|the|both|option|options|number|numbers|item|#)\s*/g, ' ');
|
|
134
|
+
const tokens = said.match(/#?\d{1,2}(?:st|nd|rd|th)?|first|second|third|fourth|fifth|sixth|seventh|eighth|last/g) ?? [];
|
|
135
|
+
// The places stand alone, joined by `and` or a comma, with only `the`, `one`, `ones`, `option` or `#` around them.
|
|
136
|
+
const rest = said.replace(/#?\d{1,2}(?:st|nd|rd|th)?|first|second|third|fourth|fifth|sixth|seventh|eighth|last|\band\b|&|\+|,|\bthe\b|\bones?\b|\boptions?\b|\bnumbers?\b|\bitems?\b|#|\bplease\b/g, ' ').trim();
|
|
137
|
+
if (tokens.length === 0 || rest !== '')
|
|
138
|
+
return null;
|
|
139
|
+
const places = tokens.map((token) => (token === 'last' ? count : ORDINALS[token] ?? Number(token.replace(/\D/g, ''))));
|
|
140
|
+
return places.every((place) => place >= 1 && place <= count) ? places : null;
|
|
141
|
+
}
|
|
142
|
+
/** Text as lowercase words with single spaces, as the tool compares a product's name with a tap. */
|
|
143
|
+
function nameWords(text) {
|
|
144
|
+
return String(text ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
145
|
+
}
|
|
146
|
+
/** The pick a message makes of what Agentcard just showed, null when it picks nothing, or false when it counts past the list. */
|
|
147
|
+
function pickOf(raw, bare, context) {
|
|
148
|
+
const products = context.products ?? [];
|
|
149
|
+
const stores = context.stores ?? [];
|
|
150
|
+
const call = (fields) => ({ input: { ask: raw, confirm: false, new_order: false, product_id: null, store_id: null, ...fields }, reason: 'pick' });
|
|
151
|
+
if (stores.length > 0) {
|
|
152
|
+
const said = ` ${nameWords(raw)} `;
|
|
153
|
+
// A store's card names it whole; a store named with the dishes to order from it (`five guys, bacon cheeseburger`) picks it too.
|
|
154
|
+
const named = stores.filter((store) => namesStore(raw, [store.name]) || (nameWords(store.name).length >= 5 && said.includes(` ${nameWords(store.name)} `)));
|
|
155
|
+
if (named.length === 1 && !QUESTION_START.test(bare) && !DOUBT.test(said))
|
|
156
|
+
return call({ store_id: named[0].storeId });
|
|
157
|
+
if (named.length > 0 && (QUESTION_START.test(bare) || DOUBT.test(said)))
|
|
158
|
+
return false;
|
|
159
|
+
if (named.length > 1)
|
|
160
|
+
return call({});
|
|
161
|
+
}
|
|
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)`.
|
|
164
|
+
const said = nameWords(raw);
|
|
165
|
+
const named = products.filter((product) => nameWords(product.name).length >= 12 && said.includes(nameWords(product.name)));
|
|
166
|
+
if (named.length === 1)
|
|
167
|
+
return call({ product_id: named[0].productId });
|
|
168
|
+
if (named.length > 1)
|
|
169
|
+
return call({});
|
|
170
|
+
// A word only one product's name holds, such as its brand: `lets do the coppertop ones`, `duracell 24 count is fine`.
|
|
171
|
+
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));
|
|
174
|
+
if (marked.length === 1 && said.split(' ').length <= 10 && !QUESTION_START.test(bare) && !DOUBT.test(said))
|
|
175
|
+
return call({ product_id: marked[0].productId });
|
|
176
|
+
const places = placesOf(bare, products.length);
|
|
177
|
+
if (places)
|
|
178
|
+
return call(places.length === 1 ? { product_id: products[places[0] - 1].productId } : {});
|
|
179
|
+
// A place past the end of the list picks nothing the shopper saw.
|
|
180
|
+
if (placesOf(bare, Number.MAX_SAFE_INTEGER))
|
|
181
|
+
return false;
|
|
182
|
+
if (/^(?:both|all|all of them|both of them|all three|all 3)$/.test(bare))
|
|
183
|
+
return call({});
|
|
184
|
+
}
|
|
185
|
+
if ((products.length > 0 || stores.length > 0) && isPickText(raw))
|
|
186
|
+
return call({});
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The call a shopper's message makes straight to Agentcard, with no model deciding it, or null when
|
|
191
|
+
* your model decides: see the top of this file for what goes straight. `ask` carries the shopper's
|
|
192
|
+
* words as they sent them, a confirm goes in only for a plain yes to the total Agentcard asked them to
|
|
193
|
+
* confirm, and a pick names its `product_id` or `store_id` only when the message names one exactly.
|
|
194
|
+
*
|
|
195
|
+
* ```ts
|
|
196
|
+
* directCall('find me usb-c cables under $20'); // { input: { ask: 'find me usb-c cables under $20', confirm: false, ... }, reason: 'product_ask' }
|
|
197
|
+
* directCall('what time is it'); // null
|
|
198
|
+
* directCall('yes', { answered: true, nextAction: 'confirm' }); // { input: { ask: 'yes', confirm: true, ... }, reason: 'confirm' }
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export function directCall(shopperText, context = {}) {
|
|
202
|
+
const raw = String(shopperText ?? '').trim();
|
|
203
|
+
if (raw === '' || raw.length > MAX_CHARS)
|
|
204
|
+
return null;
|
|
205
|
+
const ctx = context ?? {};
|
|
206
|
+
const words = wordsOf(raw);
|
|
207
|
+
if (!/[\p{L}\p{N}]/u.test(words))
|
|
208
|
+
return null;
|
|
209
|
+
const bare = words.replace(OPENING, '') || words;
|
|
210
|
+
const plainCall = (reason, confirm = false) => ({ input: { ask: raw, confirm, new_order: false, product_id: null, store_id: null }, reason });
|
|
211
|
+
if (ctx.answered === true && !/\?/.test(raw)) {
|
|
212
|
+
const told = CHITCHAT.test(words) || ABOUT.test(words) || CART_COMMAND.test(words) || CAPABILITY.test(words);
|
|
213
|
+
if (ctx.nextAction === 'confirm' && isYes(words))
|
|
214
|
+
return plainCall('confirm', true);
|
|
215
|
+
// A question, a story of what happened, an objection or a change of mind is the model's to answer, whatever came before.
|
|
216
|
+
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);
|
|
217
|
+
if (!told && !aside && !TAKEBACK.test(words)) {
|
|
218
|
+
const pick = pickOf(raw, bare, ctx);
|
|
219
|
+
if (pick === false)
|
|
220
|
+
return null;
|
|
221
|
+
if (pick)
|
|
222
|
+
return pick;
|
|
223
|
+
// The address, name or code Agentcard asked for: what the shopper sends is the answer, unless it objects.
|
|
224
|
+
const details = (typeof ctx.nextAction === 'string' && DETAIL_STEPS.has(ctx.nextAction)) || asksForDetails(ctx.asked);
|
|
225
|
+
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)
|
|
226
|
+
return plainCall('answer');
|
|
227
|
+
// A short answer to Agentcard's question, such as `medium` after `what size?`, or a yes to `want me to check amazon?`.
|
|
228
|
+
if (/\?/.test(String(ctx.asked ?? '')) && ctx.nextAction !== 'confirm' && !NOT_SHOPPING.test(words)) {
|
|
229
|
+
if (isYes(words))
|
|
230
|
+
return plainCall('answer');
|
|
231
|
+
if (!ACKNOWLEDGED.test(words) && words.split(' ').length <= 4 && !REQUEST_START.test(bare))
|
|
232
|
+
return plainCall('answer');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return isProductAsk(raw) ? plainCall('product_ask') : null;
|
|
237
|
+
}
|
package/dist/events.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* name. The stream (`agentcard.listen`) and a webhook endpoint carry the same types, with the same
|
|
4
4
|
* envelope and the same ids. A test holds this list to the backend's catalog.
|
|
5
5
|
*/
|
|
6
|
-
export declare const AGENTCARD_EVENT_TYPES: readonly [
|
|
6
|
+
export declare const AGENTCARD_EVENT_TYPES: readonly ['card.created', 'card.updated', 'card.closed', 'cardholder.created', 'cardholder.updated', 'transaction.authorized', 'transaction.declined', 'transaction.cleared', 'transaction.voided', 'balance.low', 'cardholder_onboarding_session.completed', 'platform_connect_session.completed', 'merchant.connected', 'order.placed', 'order.confirmed', 'order.failed', 'order.updated', 'purchase.message', 'connection.created', 'connected_card.updated', 'vault.card_stored', 'vault.session_linked', 'vault.session_card_picked', 'vault.payment_permission.updated', 'action.confirmed', 'action.done', 'checkout_authorization.approved', 'checkout_authorization.submitted', 'checkout_authorization.declined', 'checkout_authorization.cancelled', 'checkout_authorization.expired', 'checkout_authorization.duplicate_charge', 'checkout_authorization.refused', 'checkout_authorization.watched', 'checkout_authorization.amount_mismatch', 'checkout_authorization.settled', 'checkout_authorization.settlement_unconfirmed', 'checkout_authorization.outcome_reported', 'card_flow.started', 'card_flow.failed', 'transfer.approved', 'transfer.initiated', 'transfer.completed', 'transfer.failed', 'transfer.released', 'wallet.funded', 'user_wallet.funding_detected', 'user_wallet.funded', 'wallet.balance.low', 'recovery.requested', 'recovery.completed', 'recovery.rejected', 'wallet.withdrawal.initiated', 'wallet.withdrawal.completed', 'wallet.withdrawal.failed', 'identity.verification.updated', 'approval.requested', 'reward.earned', 'reward.reversed', 'wallet_link.opened'];
|
|
7
7
|
/** One event type Agentcard sends. A type Agentcard adds later still reads as its own name. */
|
|
8
8
|
export type AgentcardEventType = (typeof AGENTCARD_EVENT_TYPES)[number];
|
|
9
9
|
/** Whether a filter entry names something Agentcard sends: an event type, a family such as `order.*`, or `*`. */
|
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,13 @@ export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatur
|
|
|
2
2
|
export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
|
|
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 { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
|
|
5
|
-
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, 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, } from './client.js';
|
|
5
|
+
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, } from './client.js';
|
|
6
6
|
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
|
|
7
7
|
export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
|
|
8
|
-
export { buyTool, nextStepPrompt, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, type BuyToolProductAction, productAction, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, type BuyAckMode, } from './buy-tool.js';
|
|
8
|
+
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';
|
|
9
9
|
export { instantAck, instantReaction, ackFor, keepsYes, ACK_REACTION, type AckForOptions } from './ack.js';
|
|
10
|
-
export {
|
|
10
|
+
export { directCall, isProductAsk, type DirectCall, type DirectContext, type DirectReason } from './direct.js';
|
|
11
|
+
export { forModel, forModelError, replyAsWritten, withoutCardList, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, type BuyToolModelStore, } from './model-view.js';
|
|
11
12
|
export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
|
|
12
13
|
export { listenToAgentcard, type AgentcardListener, type ListenOptions, type ListenCursorStore, type StreamFetch, } from './listen.js';
|
|
13
14
|
export { ACTION_LINK_ENDINGS, ACTION_LINK_KINDS, actionLinkId, actionLinkOutcome, isActionLink, type ActionLinkKind, type ActionLinkMessage, type ActionLinkOutcome, } from './action-links.js';
|
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 {
|
|
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';
|
package/dist/model-view.d.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -119,6 +122,13 @@ export interface BuyToolModelView {
|
|
|
119
122
|
* entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
|
|
120
123
|
*/
|
|
121
124
|
export declare function stripLinks(text: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
|
|
127
|
+
* one. A line that pointed at a link ends as a sentence when the answer sent the shopper what it pointed at, a product or a
|
|
128
|
+
* store card or a step's link, and goes when it sent none, so the reply never promises what the shopper does not get. 2026-10-27, chat.opencommerce.sh:
|
|
129
|
+
* "here's the stool on Wayfair:" reached the shopper with nothing after it.
|
|
130
|
+
*/
|
|
131
|
+
export declare function replyWithoutLinks(reply: string, sent: boolean): string;
|
|
122
132
|
/**
|
|
123
133
|
* An amount in its currency's minor unit, as the model reads it: the currency's own minor units place the
|
|
124
134
|
* decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
|
|
@@ -159,10 +169,11 @@ export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): str
|
|
|
159
169
|
export declare function cardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url'>): string | null;
|
|
160
170
|
/**
|
|
161
171
|
* The key of the card a product gets, or null when it gets none: a product with a page at its store,
|
|
162
|
-
* or a flight option, which
|
|
163
|
-
* taps the flight they want (2026-10-20: three flights listed in text, and no card to tap)
|
|
172
|
+
* or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
|
|
173
|
+
* is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
|
|
174
|
+
* and a place's card is where the shopper reads whether it has room.
|
|
164
175
|
*/
|
|
165
|
-
export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight'>): string | null;
|
|
176
|
+
export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight' | 'availability'>): string | null;
|
|
166
177
|
/** How long until `iso`, from `now`, as the shopper reads it: `about 24 hours`, `about 15 minutes`, `no longer`, or null. */
|
|
167
178
|
export declare function expiresIn(iso: string | null | undefined, now?: number): string | null;
|
|
168
179
|
/**
|
|
@@ -179,12 +190,26 @@ export declare function forModel(result: BuyToolResult): BuyToolModelView;
|
|
|
179
190
|
* Null for any other answer, which your model puts in its own words, and for a line the tool wrote
|
|
180
191
|
* itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
|
|
181
192
|
* shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
|
|
193
|
+
* Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
|
|
182
194
|
*
|
|
183
195
|
* ```ts
|
|
184
196
|
* replyAsWritten(result); // 'found two at Amazon. want the first one?'
|
|
185
197
|
* ```
|
|
186
198
|
*/
|
|
187
199
|
export declare function replyAsWritten(result: BuyToolResult): string | null;
|
|
200
|
+
/**
|
|
201
|
+
* Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
|
|
202
|
+
* store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
|
|
203
|
+
* points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
|
|
204
|
+
* words, each further sentence that names another card. What is left is the reply around the list, such as `found
|
|
205
|
+
* these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
|
|
206
|
+
*
|
|
207
|
+
* ```ts
|
|
208
|
+
* withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
|
|
209
|
+
* // 'Which one?'
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
export declare function withoutCardList(reply: string, cardNames: readonly string[]): string;
|
|
188
213
|
/**
|
|
189
214
|
* A thrown error as the model may read it: the same message with every link
|
|
190
215
|
* taken out, and the original error on `cause` for your logs. A TypeError
|
package/dist/model-view.js
CHANGED
|
@@ -59,6 +59,24 @@ export function stripLinks(text) {
|
|
|
59
59
|
.replace(/\n{3,}/g, '\n\n')
|
|
60
60
|
.trim();
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* A line that points at a link with a colon, the link after it on the same line or alone on the next: `here's the stool on
|
|
64
|
+
* Wayfair: https://www.wayfair.com/...`, one in angle brackets, or a bare address with a path or query (`vault.agentcard.sh?token=...`,
|
|
65
|
+
* never a bare `www.walmart.com`, which stripLinks keeps as the store's name). Every such
|
|
66
|
+
* link comes out of what the shopper reads (stripLinks), and the colon would then point at nothing. A Markdown link is not one:
|
|
67
|
+
* stripLinks keeps its label, which the shopper reads after the colon. A question mark that ends the sentence is not a query,
|
|
68
|
+
* as HOST_PATH reads it (`Buy from: glossier.com?`), and a page whose path ends in a bracket keeps it (`stool_(oak)`).
|
|
69
|
+
*/
|
|
70
|
+
const POINTS_AT_LINK = /^([^\n]*?\S)[ \t]*:[ \t]*(?:\n[ \t]*)?<?(?:https?:\/\/|(?:www\.)?(?:[\p{L}\p{N}-]+\.)+\p{L}{2,}(?:[/#]|\?(?![.,;:!?]*(?:[\s<>"'`)\]]|$))))[^\s<>"'`\]]*>?[ \t]*[.!]?[ \t]*$/gimu;
|
|
71
|
+
/**
|
|
72
|
+
* Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
|
|
73
|
+
* one. A line that pointed at a link ends as a sentence when the answer sent the shopper what it pointed at, a product or a
|
|
74
|
+
* store card or a step's link, and goes when it sent none, so the reply never promises what the shopper does not get. 2026-10-27, chat.opencommerce.sh:
|
|
75
|
+
* "here's the stool on Wayfair:" reached the shopper with nothing after it.
|
|
76
|
+
*/
|
|
77
|
+
export function replyWithoutLinks(reply, sent) {
|
|
78
|
+
return stripLinks(String(reply ?? '').replace(POINTS_AT_LINK, (_line, lead) => (sent ? `${lead}.` : '')));
|
|
79
|
+
}
|
|
62
80
|
/** A currency's minor units as Intl knows them: 2 for USD and CAD, 0 for JPY. */
|
|
63
81
|
function minorUnits(format) {
|
|
64
82
|
return format.resolvedOptions().maximumFractionDigits ?? 0;
|
|
@@ -135,13 +153,14 @@ export function cardKey(product) {
|
|
|
135
153
|
}
|
|
136
154
|
/**
|
|
137
155
|
* The key of the card a product gets, or null when it gets none: a product with a page at its store,
|
|
138
|
-
* or a flight option, which
|
|
139
|
-
* taps the flight they want (2026-10-20: three flights listed in text, and no card to tap)
|
|
156
|
+
* or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
|
|
157
|
+
* is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
|
|
158
|
+
* and a place's card is where the shopper reads whether it has room.
|
|
140
159
|
*/
|
|
141
160
|
export function productCardKey(product) {
|
|
142
161
|
if (typeof product.url === 'string' && product.url !== '')
|
|
143
162
|
return cardKey(product);
|
|
144
|
-
return product.flight ? pickId(product) : null;
|
|
163
|
+
return product.flight || product.availability ? pickId(product) : null;
|
|
145
164
|
}
|
|
146
165
|
function cartView(cart) {
|
|
147
166
|
return {
|
|
@@ -159,7 +178,8 @@ function cartView(cart) {
|
|
|
159
178
|
options: (line.options ?? []).map(stripLinks).filter((o) => o !== ''),
|
|
160
179
|
variant: line.variant ? stripLinks(line.variant) || null : null,
|
|
161
180
|
})),
|
|
162
|
-
fees: cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
|
|
181
|
+
fees: cart.serviceFeesCents !== null && cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
|
|
182
|
+
fees_status: cart.serviceFeesStatus,
|
|
163
183
|
fee_lines: (cart.merchantFeeLines ?? []).map((line) => ({ label: stripLinks(line.label), amount: money(line.amountCents, cart.currency) })),
|
|
164
184
|
tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
|
|
165
185
|
total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
|
|
@@ -217,7 +237,7 @@ export function forModel(result) {
|
|
|
217
237
|
status,
|
|
218
238
|
next_action: result.next_action ?? null,
|
|
219
239
|
...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
|
|
220
|
-
reply:
|
|
240
|
+
reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
|
|
221
241
|
cart: result.cart ? cartView(result.cart) : null,
|
|
222
242
|
carts: (result.carts ?? []).map(cartView),
|
|
223
243
|
products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
|
|
@@ -226,6 +246,7 @@ export function forModel(result) {
|
|
|
226
246
|
// A step's ask is one short line: an approval Agentcard places on its own needs no "no need to reply" beside it.
|
|
227
247
|
shopper_need_not_reply: result.shopper_need_not_reply === true && !STEP_ACTIONS.has(result.next_action ?? ''),
|
|
228
248
|
shopper_already_told_looking: result.shopper_already_told_looking === true,
|
|
249
|
+
shopper_already_read_reply: result.shopper_already_read_reply === true,
|
|
229
250
|
link_sent: result.link_sent === true,
|
|
230
251
|
links_sent: result.link_sent === true
|
|
231
252
|
? [...new Set((result.links ?? []).map((link) => link.kind).filter((kind) => kind === 'vault' || kind === 'approval' || kind === 'store_login'))]
|
|
@@ -249,17 +270,140 @@ export function forModel(result) {
|
|
|
249
270
|
* Null for any other answer, which your model puts in its own words, and for a line the tool wrote
|
|
250
271
|
* itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
|
|
251
272
|
* shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
|
|
273
|
+
* Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
|
|
252
274
|
*
|
|
253
275
|
* ```ts
|
|
254
276
|
* replyAsWritten(result); // 'found two at Amazon. want the first one?'
|
|
255
277
|
* ```
|
|
256
278
|
*/
|
|
257
279
|
export function replyAsWritten(result) {
|
|
280
|
+
// The shopper already read every message of it through onText: handing it out again would say it twice.
|
|
281
|
+
if (result.shopper_already_read_reply === true)
|
|
282
|
+
return null;
|
|
258
283
|
// The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
|
|
259
284
|
const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
|
|
260
285
|
const view = forModel(result);
|
|
261
286
|
const reply = view.reply.trim();
|
|
262
|
-
|
|
287
|
+
if (!agentcards || view.status !== 'needs_input' || reply === '')
|
|
288
|
+
return null;
|
|
289
|
+
const cards = [...view.products.filter((product) => product.link_sent).map((product) => product.name), ...view.stores.filter((store) => store.link_sent).map((store) => store.name)];
|
|
290
|
+
// A reply with nothing left once the cards' list comes out is your model's to word.
|
|
291
|
+
return withoutCardList(reply, cards) || null;
|
|
292
|
+
}
|
|
293
|
+
/** A line that opens a list: `- `, `* `, `• `, `1. ` or `1) `. */
|
|
294
|
+
const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
|
|
295
|
+
/** A price, in any of the currencies a store writes one in. */
|
|
296
|
+
const PRICE = /[$£€]\s?\d/;
|
|
297
|
+
/** 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. */
|
|
298
|
+
const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
|
|
299
|
+
/** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
|
|
300
|
+
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;
|
|
301
|
+
/**
|
|
302
|
+
* 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
|
|
303
|
+
* 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.
|
|
304
|
+
*/
|
|
305
|
+
const SAME_UNIT = new Map([['feet', 'ft'], ['foot', 'ft'], ['in', ''], ['inch', ''], ['inches', ''], ['pk', 'pack'], ['ct', 'pack'], ['count', 'pack'], ['pcs', 'pack'], ['pieces', 'pack'], ['lbs', 'lb']]);
|
|
306
|
+
/** A size or a count written as one word, such as `24pack` or `10ft`. */
|
|
307
|
+
const SIZE = /^(\d+)(\p{L}+)$/u;
|
|
308
|
+
/** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
|
|
309
|
+
function wordsOf(text) {
|
|
310
|
+
return text
|
|
311
|
+
.toLowerCase()
|
|
312
|
+
.replace(UNIT, '$1$2')
|
|
313
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
314
|
+
.filter((word) => word !== '')
|
|
315
|
+
.map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* 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
|
|
319
|
+
* text. Null when it names none. A name of one word never counts in prose, where a generic word such as `soap` would.
|
|
320
|
+
* 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.
|
|
321
|
+
*/
|
|
322
|
+
function cardNamed(text, cards, inList) {
|
|
323
|
+
const said = new Set(wordsOf(text));
|
|
324
|
+
for (const [index, name] of cards.entries()) {
|
|
325
|
+
const head = name.slice(0, 6);
|
|
326
|
+
if (!head.every((word) => !/\d/.test(word) || said.has(word)))
|
|
327
|
+
continue;
|
|
328
|
+
const hits = head.filter((word) => said.has(word)).length;
|
|
329
|
+
if (head.length === 1 ? inList && hits === 1 : hits >= Math.max(2, Math.ceil(head.length * 0.6)))
|
|
330
|
+
return index;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
|
|
336
|
+
* store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
|
|
337
|
+
* points at a number the list gave (`the dishwand in #8`), and, past the one or two products a reply recommends in
|
|
338
|
+
* words, each further sentence that names another card. What is left is the reply around the list, such as `found
|
|
339
|
+
* these at amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
|
|
340
|
+
*
|
|
341
|
+
* ```ts
|
|
342
|
+
* withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
|
|
343
|
+
* // 'Which one?'
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
export function withoutCardList(reply, cardNames) {
|
|
347
|
+
const cards = cardNames.map(wordsOf).filter((name) => name.length > 0);
|
|
348
|
+
if (cards.length === 0)
|
|
349
|
+
return reply.trim();
|
|
350
|
+
// 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.
|
|
351
|
+
const opensCard = (text) => {
|
|
352
|
+
const words = wordsOf(text);
|
|
353
|
+
return PRICE.test(text) && cards.some((name) => name.length >= 2 && name.slice(0, 3).every((word, i) => words[i] === word));
|
|
354
|
+
};
|
|
355
|
+
const lines = reply.split('\n');
|
|
356
|
+
const kind = lines.map((line) => {
|
|
357
|
+
if (LIST_ITEM.test(line))
|
|
358
|
+
return cardNamed(line.replace(LIST_ITEM, ''), cards, true) !== null ? 'item' : 'kept';
|
|
359
|
+
return opensCard(line) ? 'named' : 'kept';
|
|
360
|
+
});
|
|
361
|
+
const dropped = kind.map((of) => of !== 'kept');
|
|
362
|
+
// The numbers the dropped list gave, so a number of a product's own, `a #2 Phillips head`, keeps its sentence.
|
|
363
|
+
const numbers = new Set(lines.flatMap((line, i) => (dropped[i] ? [Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1])] : [])).filter((number) => Number.isInteger(number)));
|
|
364
|
+
// 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
|
|
365
|
+
// question on the line after the last item, `Which one?`, is no part of the list.
|
|
366
|
+
const listAfter = (line) => {
|
|
367
|
+
const start = lines.findIndex((after, j) => j > line && after.trim() !== '');
|
|
368
|
+
if (start === -1)
|
|
369
|
+
return [];
|
|
370
|
+
let end = start;
|
|
371
|
+
while (end < lines.length && (LIST_ITEM.test(lines[end]) || kind[end] === 'named'))
|
|
372
|
+
end += 1;
|
|
373
|
+
return dropped.slice(start, end);
|
|
374
|
+
};
|
|
375
|
+
const named = new Set();
|
|
376
|
+
const kept = [];
|
|
377
|
+
lines.forEach((line, i) => {
|
|
378
|
+
if (kind[i] === 'item')
|
|
379
|
+
return;
|
|
380
|
+
let text = line;
|
|
381
|
+
// The line that leads into a list whose every line went loses the sentence that leads in: `Two I'd suggest:` or
|
|
382
|
+
// `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
|
|
383
|
+
const list = listAfter(i);
|
|
384
|
+
if (text.trim().endsWith(':') && list.length > 0 && list.every(Boolean))
|
|
385
|
+
text = text.trim().replace(/(?:^|(?<=[.!?])\s+)[^.!?]*:$/, '');
|
|
386
|
+
const sentences = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
|
|
387
|
+
const place = LIST_NUMBER.exec(sentence);
|
|
388
|
+
if (place && numbers.has(Number(place[1] ?? place[2])))
|
|
389
|
+
return false;
|
|
390
|
+
const card = cardNamed(sentence, cards, false);
|
|
391
|
+
// A list line written without a bullet keeps the sentences its card does not say, such as the question after it.
|
|
392
|
+
if (kind[i] === 'named')
|
|
393
|
+
return card === null && !opensCard(sentence);
|
|
394
|
+
if (card === null || named.has(card))
|
|
395
|
+
return true;
|
|
396
|
+
// A reply recommends one or two products in words; a sentence that names a third is the list again.
|
|
397
|
+
if (named.size >= 2)
|
|
398
|
+
return false;
|
|
399
|
+
named.add(card);
|
|
400
|
+
return true;
|
|
401
|
+
});
|
|
402
|
+
if (kind[i] === 'named' && sentences.length === 0)
|
|
403
|
+
return;
|
|
404
|
+
kept.push(sentences.join(' ').trimEnd());
|
|
405
|
+
});
|
|
406
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
263
407
|
}
|
|
264
408
|
/**
|
|
265
409
|
* 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 [
|
|
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 [
|
|
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.
|
|
3
|
+
"version": "0.15.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
|
},
|