@agent-cards/commerce-sdk 0.16.0 → 0.17.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 +4 -4
- package/dist/ack.d.ts +14 -4
- package/dist/ack.js +29 -3
- package/dist/buy-tool.d.ts +16 -7
- package/dist/buy-tool.js +270 -21
- package/dist/client.d.ts +8 -0
- package/dist/client.js +4 -1
- package/dist/definition.js +1 -0
- package/dist/direct.d.ts +11 -1
- package/dist/direct.js +34 -7
- package/dist/messages.d.ts +27 -6
- package/dist/messages.js +68 -8
- package/dist/model-view.d.ts +5 -2
- package/dist/model-view.js +192 -23
- package/dist/sentences.d.ts +13 -0
- package/dist/sentences.js +28 -0
- package/dist/texting.d.ts +2 -0
- package/dist/texting.js +6 -1
- package/package.json +1 -1
package/dist/direct.js
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* - a pick of what Agentcard just showed, by a card's tap (`I'll take the KODAK AA (24 Pack) ($13.49)`,
|
|
10
10
|
* `Order from Shake Shack`), its place (`the first one`, `#2`, `1 and 3`) or a reference (`that one`);
|
|
11
11
|
* - the answer to the question Agentcard just asked: the address, name or code it asked for, a short
|
|
12
|
-
* answer such as `medium` or `size 9`, or a plain yes to the total it asked the shopper to confirm
|
|
12
|
+
* answer such as `medium` or `size 9`, or a plain yes to the total it asked the shopper to confirm;
|
|
13
|
+
* - a price limit for the products Agentcard just showed: `only options under $20`, `under 20`, `cheaper ones`.
|
|
14
|
+
* Agentcard holds its cards to the limit and shows the ones inside it again as cards; your model, answering
|
|
15
|
+
* from the list it saw, named two products with no card to tap (2026-10-27).
|
|
13
16
|
*
|
|
14
17
|
* Everything else goes to the model: a question, small talk, a change of mind (`no`, `wait`, `cancel`),
|
|
15
18
|
* a command to the cart, and anything about the account, an order or how the chat works. A pick or an
|
|
@@ -21,6 +24,11 @@
|
|
|
21
24
|
* the 188 labelled messages in `test/fixtures/direct-messages.json`, it sends none it should not.
|
|
22
25
|
*/
|
|
23
26
|
import { ABOUT, ACKNOWLEDGED, CAPABILITY, CART_COMMAND, CHECKOUT, CHITCHAT, FILLER, asksForDetails, isNewRequest, isPickText, namesStore, plain } from './ack.js';
|
|
27
|
+
/**
|
|
28
|
+
* A price limit put on what was just shown: `under $20`, `under 20`, `less than 15 bucks`, `between 10 and 20`, `$20 or
|
|
29
|
+
* less`, `over $50`, `cheaper ones`, `menos de 20`. Only whether the message states one: Agentcard reads the limit itself.
|
|
30
|
+
*/
|
|
31
|
+
const PRICE_REFINE = /(?:\b(?:under|below|less than|cheaper than|up to|at most|no more than|max(?:imum)?|between|over|above|more than|at least|menos de|hasta|entre)\s+\$?\s?\d)|\$\s?\d+(?:\.\d{1,2})?\s*(?:or (?:less|under|below|more)|and (?:under|below|up)|max|tops|\+)|\b\d+\s*(?:dollars|bucks)\b|\b(?:cheaper|less expensive|more affordable|lower[- ]priced)\b(?!\s+than\b)|\bm[aá]s barat/;
|
|
24
32
|
/** The longest message that goes straight: a long one is read by the model. */
|
|
25
33
|
const MAX_CHARS = 500;
|
|
26
34
|
/** The steps a shopper answers with their own details or a code. */
|
|
@@ -143,9 +151,17 @@ function placesOf(bare, count) {
|
|
|
143
151
|
function nameWords(text) {
|
|
144
152
|
return String(text ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
145
153
|
}
|
|
154
|
+
/** A card's tap as an app writes it, `I'll take the <name> (<price>)`: the name in it, or null for any other message. */
|
|
155
|
+
const TAP = /^(?:i'?ll (?:take|have|get)|i want|add)\s+(?:the\s+)?(.+?)\s*\(\s*(?:[$€£]\s?\d[\d,]*(?:\.\d{1,2})?|from [$€£]\s?\d[\d,]*(?:\.\d{1,2})?)\s*\)\s*[.!]?$/i;
|
|
156
|
+
function tappedName(raw) {
|
|
157
|
+
const name = TAP.exec(raw.trim())?.[1]?.trim();
|
|
158
|
+
return name ? name : null;
|
|
159
|
+
}
|
|
146
160
|
/** The pick a message makes of what Agentcard just showed, null when it picks nothing, or false when it counts past the list. */
|
|
147
161
|
function pickOf(raw, bare, context) {
|
|
148
162
|
const products = context.products ?? [];
|
|
163
|
+
// A pick by name reads every card the shopper can tap; a place reads `products`, in the order the answer showed them.
|
|
164
|
+
const byName = context.tappable?.length ? context.tappable : products;
|
|
149
165
|
const stores = context.stores ?? [];
|
|
150
166
|
const call = (fields) => ({ input: { ask: raw, confirm: false, new_order: false, product_id: null, store_id: null, ...fields }, reason: 'pick' });
|
|
151
167
|
if (stores.length > 0) {
|
|
@@ -159,18 +175,26 @@ function pickOf(raw, bare, context) {
|
|
|
159
175
|
if (named.length > 1)
|
|
160
176
|
return call({});
|
|
161
177
|
}
|
|
162
|
-
if (
|
|
163
|
-
// A card's tap names the product whole
|
|
178
|
+
if (byName.length > 0) {
|
|
179
|
+
// A card's tap names the product whole, `I'll take the Burrito Bowl ($15.15)`: the product of exactly that name is the pick,
|
|
180
|
+
// never the Burrito whose one word it shares, and a tap that names no product here (a card this list does not hold), or two
|
|
181
|
+
// alike, sends none, so Agentcard reads the words against every card it showed.
|
|
182
|
+
const tap = tappedName(raw);
|
|
183
|
+
if (tap !== null) {
|
|
184
|
+
const exact = byName.filter((product) => [nameWords(tap), nameWords(`the ${tap}`)].includes(nameWords(product.name)));
|
|
185
|
+
return call(exact.length === 1 ? { product_id: exact[0].productId } : {});
|
|
186
|
+
}
|
|
187
|
+
// A product's whole name inside the message: `I want the KODAK AA Alkaline Batteries (24 Pack)`.
|
|
164
188
|
const said = nameWords(raw);
|
|
165
|
-
const named =
|
|
189
|
+
const named = byName.filter((product) => nameWords(product.name).length >= 12 && said.includes(nameWords(product.name)));
|
|
166
190
|
if (named.length === 1)
|
|
167
191
|
return call({ product_id: named[0].productId });
|
|
168
192
|
if (named.length > 1)
|
|
169
193
|
return call({});
|
|
170
194
|
// A word only one product's name holds, such as its brand: `lets do the coppertop ones`, `duracell 24 count is fine`.
|
|
171
195
|
const words = new Set(said.split(' '));
|
|
172
|
-
const owners = (word) =>
|
|
173
|
-
const marked =
|
|
196
|
+
const owners = (word) => byName.filter((product) => nameWords(product.name).split(' ').includes(word));
|
|
197
|
+
const marked = byName.filter((product) => nameWords(product.name).split(' ').some((word) => word.length >= 4 && !/^\d/.test(word) && words.has(word) && owners(word).length === 1));
|
|
174
198
|
if (marked.length === 1 && said.split(' ').length <= 10 && !QUESTION_START.test(bare) && !DOUBT.test(said))
|
|
175
199
|
return call({ product_id: marked[0].productId });
|
|
176
200
|
const places = placesOf(bare, products.length);
|
|
@@ -182,7 +206,7 @@ function pickOf(raw, bare, context) {
|
|
|
182
206
|
if (/^(?:both|all|all of them|both of them|all three|all 3)$/.test(bare))
|
|
183
207
|
return call({});
|
|
184
208
|
}
|
|
185
|
-
if ((
|
|
209
|
+
if ((byName.length > 0 || stores.length > 0) && isPickText(raw))
|
|
186
210
|
return call({});
|
|
187
211
|
return null;
|
|
188
212
|
}
|
|
@@ -220,6 +244,9 @@ export function directCall(shopperText, context = {}) {
|
|
|
220
244
|
return null;
|
|
221
245
|
if (pick)
|
|
222
246
|
return pick;
|
|
247
|
+
// A price limit on the products just shown: Agentcard shows the ones inside it again, as cards.
|
|
248
|
+
if ((ctx.products ?? []).length > 0 && PRICE_REFINE.test(words) && words.split(' ').length <= 15)
|
|
249
|
+
return plainCall('product_ask');
|
|
223
250
|
// The address, name or code Agentcard asked for: what the shopper sends is the answer, unless it objects.
|
|
224
251
|
const details = (typeof ctx.nextAction === 'string' && DETAIL_STEPS.has(ctx.nextAction)) || asksForDetails(ctx.asked);
|
|
225
252
|
if (details && !OBJECTION.test(words) && !REQUEST_START.test(bare) && !QUESTION_START.test(bare) && !PAST.test(words) && !/\b(?:same as|on file|you have|the usual|ur|your)\b/.test(words) && !/^(?:resend|send|again|still)\b/.test(bare) && !NOT_SHOPPING.test(words) && words.split(' ').length <= 40)
|
package/dist/messages.d.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Joins each message that leads in with a colon (`two options came in under $20:`) to the message after it, so a shopper
|
|
3
|
+
* never reads a bubble that ends on a colon: the lead-in and its first item or list arrive as one message, a line apart.
|
|
4
|
+
* `add` returns the message to send, or null while it holds a lead-in for the next one. `end` returns what a held lead-in
|
|
5
|
+
* leaves once nothing followed it, such as when every item it led into went because the cards show them: the text less
|
|
6
|
+
* its colon sentence (withoutLeadIn), or null when nothing is left. On 2026-10-27 a chat showed "two options came in
|
|
7
|
+
* under $20:" as a bubble of its own, above the item it led into.
|
|
6
8
|
*/
|
|
9
|
+
export declare function leadInJoiner(): {
|
|
10
|
+
add(message: string): string | null;
|
|
11
|
+
end(): string | null;
|
|
12
|
+
};
|
|
7
13
|
/**
|
|
8
14
|
* The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
|
|
9
15
|
* out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
|
|
10
|
-
* lines it holds, and so does a list whose items blank lines separate.
|
|
16
|
+
* lines it holds, and so does a list whose items blank lines separate. A paragraph that ends with a
|
|
17
|
+
* colon goes with the paragraph after it, one line apart, and loses its colon sentence when nothing
|
|
18
|
+
* follows it, so no message ends on a colon.
|
|
11
19
|
*
|
|
12
20
|
* ```ts
|
|
13
21
|
* toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
|
|
22
|
+
* toMessages('two came in under $20:\n\n• Everydaze, $8.99'); // ['two came in under $20:\n• Everydaze, $8.99']
|
|
14
23
|
* ```
|
|
15
24
|
*/
|
|
16
25
|
export declare function toMessages(text: string): string[];
|
|
26
|
+
/**
|
|
27
|
+
* The text's paragraphs as `toMessages` cuts them, before any lead-in joins the paragraph after it: the units Agentcard
|
|
28
|
+
* and a model write, and what a count of the messages a shopper read is checked against, paragraph for paragraph.
|
|
29
|
+
*/
|
|
30
|
+
export declare function paragraphsOf(text: string): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Messages in order, each one that leads in with a colon joined to the ones after it by one joiner across them all
|
|
33
|
+
* (leadInJoiner), so a lead-in that is a whole message of its own, as one of Agentcard's `messages` can be, still goes
|
|
34
|
+
* with the message after it, and a lead-in nothing follows at the very end loses its colon sentences.
|
|
35
|
+
*/
|
|
36
|
+
export declare function joinLeadIns(messages: readonly string[]): string[];
|
|
17
37
|
/**
|
|
18
38
|
* How long someone texting takes to type `message`, in milliseconds: 300, plus 12 a character, at most
|
|
19
39
|
* 1,500. With `pace: true`, `messageStream` and `buy.turn` wait this long between two messages, so a
|
|
@@ -67,7 +87,8 @@ export interface MessageStreamOptions {
|
|
|
67
87
|
* moment the blank line that ends it arrives, whatever the pieces split it into, and goes to
|
|
68
88
|
* `onMessage` then, or, after the first, once its typing pause since the last one passed (`pace`). A
|
|
69
89
|
* paragraph inside a list waits for the first characters of the line after the blank, which say
|
|
70
|
-
* whether the list goes on
|
|
90
|
+
* whether the list goes on, and a paragraph that ends with a colon waits for the paragraph after it,
|
|
91
|
+
* so the two go out as one message. `end` hands over the last message and waits for every send.
|
|
71
92
|
*
|
|
72
93
|
* ```ts
|
|
73
94
|
* const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
|
package/dist/messages.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
|
|
3
3
|
* own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
|
|
4
4
|
* paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
|
|
5
|
-
* items a blank line separates.
|
|
5
|
+
* items a blank line separates. A paragraph that leads in with a colon never stands alone: it goes out
|
|
6
|
+
* with the one after it (leadInJoiner).
|
|
6
7
|
*/
|
|
8
|
+
import { leadsIn, withoutLeadIn } from './sentences.js';
|
|
7
9
|
/** A line holding nothing but spaces or tabs. */
|
|
8
10
|
const BLANK = /^[ \t]*$/;
|
|
9
11
|
/** A list item: `-`, `*`, `+` or the bullet `•`, or a number followed by `.` or `)`, then a space or the line's end. */
|
|
@@ -30,6 +32,29 @@ function closesFence(line, open) {
|
|
|
30
32
|
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
31
33
|
return !!match && match[1][0] === open[0] && match[1].length >= open.length;
|
|
32
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Joins each message that leads in with a colon (`two options came in under $20:`) to the message after it, so a shopper
|
|
37
|
+
* never reads a bubble that ends on a colon: the lead-in and its first item or list arrive as one message, a line apart.
|
|
38
|
+
* `add` returns the message to send, or null while it holds a lead-in for the next one. `end` returns what a held lead-in
|
|
39
|
+
* leaves once nothing followed it, such as when every item it led into went because the cards show them: the text less
|
|
40
|
+
* its colon sentence (withoutLeadIn), or null when nothing is left. On 2026-10-27 a chat showed "two options came in
|
|
41
|
+
* under $20:" as a bubble of its own, above the item it led into.
|
|
42
|
+
*/
|
|
43
|
+
export function leadInJoiner() {
|
|
44
|
+
let held = null;
|
|
45
|
+
return {
|
|
46
|
+
add(message) {
|
|
47
|
+
const whole = held === null ? message : `${held}\n${message}`;
|
|
48
|
+
held = leadsIn(whole) ? whole : null;
|
|
49
|
+
return held === null ? whole : null;
|
|
50
|
+
},
|
|
51
|
+
end() {
|
|
52
|
+
const rest = held === null ? '' : withoutLeadIn(held);
|
|
53
|
+
held = null;
|
|
54
|
+
return rest || null;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
33
58
|
/**
|
|
34
59
|
* Reads text line by line and closes each message once nothing later can change it. It keeps only
|
|
35
60
|
* the message being built, so text fed in pieces costs what the text costs once.
|
|
@@ -102,19 +127,47 @@ class Scanner {
|
|
|
102
127
|
/**
|
|
103
128
|
* The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
|
|
104
129
|
* out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
|
|
105
|
-
* lines it holds, and so does a list whose items blank lines separate.
|
|
130
|
+
* lines it holds, and so does a list whose items blank lines separate. A paragraph that ends with a
|
|
131
|
+
* colon goes with the paragraph after it, one line apart, and loses its colon sentence when nothing
|
|
132
|
+
* follows it, so no message ends on a colon.
|
|
106
133
|
*
|
|
107
134
|
* ```ts
|
|
108
135
|
* toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
|
|
136
|
+
* toMessages('two came in under $20:\n\n• Everydaze, $8.99'); // ['two came in under $20:\n• Everydaze, $8.99']
|
|
109
137
|
* ```
|
|
110
138
|
*/
|
|
111
139
|
export function toMessages(text) {
|
|
112
|
-
|
|
113
|
-
|
|
140
|
+
return joinLeadIns(paragraphsOf(text));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The text's paragraphs as `toMessages` cuts them, before any lead-in joins the paragraph after it: the units Agentcard
|
|
144
|
+
* and a model write, and what a count of the messages a shopper read is checked against, paragraph for paragraph.
|
|
145
|
+
*/
|
|
146
|
+
export function paragraphsOf(text) {
|
|
147
|
+
const paragraphs = [];
|
|
148
|
+
const scanner = new Scanner((paragraph) => paragraphs.push(paragraph));
|
|
114
149
|
for (const line of String(text ?? '').replace(/\r\n?/g, '\n').split('\n'))
|
|
115
150
|
scanner.line(line);
|
|
116
151
|
scanner.end();
|
|
117
|
-
return
|
|
152
|
+
return paragraphs;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Messages in order, each one that leads in with a colon joined to the ones after it by one joiner across them all
|
|
156
|
+
* (leadInJoiner), so a lead-in that is a whole message of its own, as one of Agentcard's `messages` can be, still goes
|
|
157
|
+
* with the message after it, and a lead-in nothing follows at the very end loses its colon sentences.
|
|
158
|
+
*/
|
|
159
|
+
export function joinLeadIns(messages) {
|
|
160
|
+
const leads = leadInJoiner();
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const message of messages) {
|
|
163
|
+
const ready = leads.add(message);
|
|
164
|
+
if (ready)
|
|
165
|
+
out.push(ready);
|
|
166
|
+
}
|
|
167
|
+
const rest = leads.end();
|
|
168
|
+
if (rest)
|
|
169
|
+
out.push(rest);
|
|
170
|
+
return out;
|
|
118
171
|
}
|
|
119
172
|
/** The pause before a message, as someone texting takes it: 300 ms, plus 12 ms a character, at most 1.5 seconds. */
|
|
120
173
|
const TYPING_BASE_MS = 300;
|
|
@@ -171,7 +224,8 @@ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
171
224
|
* moment the blank line that ends it arrives, whatever the pieces split it into, and goes to
|
|
172
225
|
* `onMessage` then, or, after the first, once its typing pause since the last one passed (`pace`). A
|
|
173
226
|
* paragraph inside a list waits for the first characters of the line after the blank, which say
|
|
174
|
-
* whether the list goes on
|
|
227
|
+
* whether the list goes on, and a paragraph that ends with a colon waits for the paragraph after it,
|
|
228
|
+
* so the two go out as one message. `end` hands over the last message and waits for every send.
|
|
175
229
|
*
|
|
176
230
|
* ```ts
|
|
177
231
|
* const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
|
|
@@ -247,10 +301,15 @@ export function messageStream(options) {
|
|
|
247
301
|
});
|
|
248
302
|
}
|
|
249
303
|
};
|
|
250
|
-
|
|
304
|
+
// A paragraph that leads in with a colon waits for the one after it, so the two go out as one message.
|
|
305
|
+
const leads = leadInJoiner();
|
|
306
|
+
const send = (message) => {
|
|
307
|
+
if (!message)
|
|
308
|
+
return;
|
|
251
309
|
queue.push(message);
|
|
252
310
|
kick();
|
|
253
|
-
}
|
|
311
|
+
};
|
|
312
|
+
const scanner = new Scanner((paragraph) => send(leads.add(paragraph)));
|
|
254
313
|
return {
|
|
255
314
|
push(delta) {
|
|
256
315
|
if (ended)
|
|
@@ -273,6 +332,7 @@ export function messageStream(options) {
|
|
|
273
332
|
scanner.line(line);
|
|
274
333
|
open = '';
|
|
275
334
|
scanner.end();
|
|
335
|
+
send(leads.end());
|
|
276
336
|
}
|
|
277
337
|
while (running)
|
|
278
338
|
await running;
|
package/dist/model-view.d.ts
CHANGED
|
@@ -242,8 +242,11 @@ export interface ShownCard {
|
|
|
242
242
|
* store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
|
|
243
243
|
* points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
|
|
244
244
|
* are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
|
|
245
|
-
* further sentence that names another card.
|
|
246
|
-
*
|
|
245
|
+
* further sentence that names another card. A list written on one line goes the same way, item by item (inlineList),
|
|
246
|
+
* and so does a list line written without a bullet, whole, up to the question on it. What is left is the reply around
|
|
247
|
+
* the list, such as `found these at amazon.` and `which one do you want?`. A reply whose cards went out names none of
|
|
248
|
+
* them twice over. A list that keeps an item for a product no card shows keeps its lead-in, and the two read as one
|
|
249
|
+
* message (toMessages), so the shopper never reads `two options came in under $20:` on its own.
|
|
247
250
|
*
|
|
248
251
|
* Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
|
|
249
252
|
* a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
|
package/dist/model-view.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { SENTENCE_END, lastSentenceStart } from './sentences.js';
|
|
1
|
+
import { SENTENCE_END, lastSentenceStart, leadsIn, withoutLeadIn } from './sentences.js';
|
|
2
|
+
import { toMessages } from './messages.js';
|
|
2
3
|
/** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
|
|
3
4
|
const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
|
|
4
5
|
const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
|
|
@@ -373,6 +374,18 @@ export function replyAsWritten(result) {
|
|
|
373
374
|
const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
|
|
374
375
|
/** A price, in any of the currencies a store writes one in. */
|
|
375
376
|
const PRICE = /[$£€]\s?\d/;
|
|
377
|
+
/**
|
|
378
|
+
* A mark that numbers or bullets an item in the middle of a line, such as the `2)` in `1) a mug; 2) a soap`, or a `•`.
|
|
379
|
+
* A number counts only in a run that goes 1, 2, 3, so a size such as `(Pack of 12) and` marks nothing.
|
|
380
|
+
*/
|
|
381
|
+
const INLINE_MARK = /(?<=^|[\s:;,(])(?:(\d{1,2})([.)])|•)[ \t]+/g;
|
|
382
|
+
/**
|
|
383
|
+
* A sentence that asks the shopper something: a question, `let me know ...` or `tell me ...` anywhere in it, as in
|
|
384
|
+
* `amazon has the best price, so let me know how many you want.`, or one that opens with `pick ...` or `reply ...`.
|
|
385
|
+
*/
|
|
386
|
+
const ASKS = /\?[\s"'*_)\]]*$|\b(?:let me know|tell me)\b|^[\s*_"'(]*(?:just (?:say|tell|reply|pick)|reply|pick|choose)\b/i;
|
|
387
|
+
/** A clause that turns from a product to the shopper, after a comma, a semicolon or a dash: `, which one would you like?`. */
|
|
388
|
+
const ASKING_CLAUSE = /(?:[,;]|\s[-–])\s+(?=(?:(?:and|or|so|but)\s+)?(?:which|what|want|would|do|does|should|shall|can|could|how|any|let me know|tell me|just)\b)/gi;
|
|
376
389
|
/** 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. */
|
|
377
390
|
const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
|
|
378
391
|
/**
|
|
@@ -503,13 +516,123 @@ function cardsNamed(text, cards, inList) {
|
|
|
503
516
|
.filter(({ hit }) => hit.size === 0 || !saidOnce(hit) || !found.some((other) => other.hit.size > hit.size && [...hit].every((word) => other.hit.has(word))))
|
|
504
517
|
.map(({ index }) => index);
|
|
505
518
|
}
|
|
519
|
+
/** The sentences of a text from `from` on, each with the place in the text where it starts. */
|
|
520
|
+
function sentencesAt(text, from) {
|
|
521
|
+
const out = [];
|
|
522
|
+
let at = from;
|
|
523
|
+
for (const part of text.slice(from).split(SENTENCE_END)) {
|
|
524
|
+
const start = text.indexOf(part, at);
|
|
525
|
+
if (part !== '')
|
|
526
|
+
out.push({ text: part, at: start });
|
|
527
|
+
at = start + part.length;
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Where the clause that asks the shopper starts in a sentence that also gives a product, as `which one would you like?`
|
|
533
|
+
* does in `2) Mini Soaps Gift Box ($58.00), which one would you like?`, or null when the sentence asks nothing apart.
|
|
534
|
+
*/
|
|
535
|
+
function askingClause(sentence) {
|
|
536
|
+
if (!ASKS.test(sentence))
|
|
537
|
+
return null;
|
|
538
|
+
for (const turn of sentence.matchAll(ASKING_CLAUSE)) {
|
|
539
|
+
const rest = sentence.slice(turn.index + turn[0].length);
|
|
540
|
+
if (ASKS.test(rest) && !PRICE.test(rest))
|
|
541
|
+
return turn.index + turn[0].length;
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/** Where the sentence that holds `at` starts: right after the sentence before it, or at the line's start. */
|
|
546
|
+
function sentenceStart(line, at) {
|
|
547
|
+
return lastSentenceStart(line.slice(0, at));
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Where a list on one line stops, given where its last item starts: past that item's first period, through the
|
|
551
|
+
* sentences that only describe it, to the first that asks the shopper or names a card itself, so `8.4 Fl. Oz.` never
|
|
552
|
+
* cuts the item short. A question the item's own sentence asks after a comma, `..., which one?`, stays out of it.
|
|
553
|
+
*/
|
|
554
|
+
function inlineEnd(line, from, cards) {
|
|
555
|
+
const [own, ...rest] = sentencesAt(line, from);
|
|
556
|
+
const clause = own ? askingClause(own.text) : null;
|
|
557
|
+
if (own && clause !== null)
|
|
558
|
+
return own.at + clause;
|
|
559
|
+
const stop = rest.find((sentence) => ASKS.test(sentence.text) || cardsNamed(sentence.text, cards, false).length > 0);
|
|
560
|
+
return stop ? stop.at : line.length;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* A list a reply writes on one line, mid-sentence: `i found a few options: 1) a mug ($19.00); 2) a gift set ($115.00);
|
|
564
|
+
* 3) a soap box ($58.00). which one?`. Its items are numbered `1)` or `1.`, bulleted with `•`, or set apart by `;` with
|
|
565
|
+
* each naming a card, after a colon or three of them at least. The list may open the line, `1) a mug; 2) a candle`, and
|
|
566
|
+
* ends where inlineEnd says. `lead` is where the sentence that leads into the list starts, `end` where the list stops.
|
|
567
|
+
* Null when the line holds no such list.
|
|
568
|
+
*/
|
|
569
|
+
function inlineList(line, cards) {
|
|
570
|
+
const marks = [...line.matchAll(INLINE_MARK)];
|
|
571
|
+
// A numbered run, 1, 2, 3, each marked the same way, or two or more bullets.
|
|
572
|
+
const run = [];
|
|
573
|
+
for (const mark of marks) {
|
|
574
|
+
if (mark[1] !== undefined && Number(mark[1]) === run.length + 1 && (run.length === 0 || run[0][2] === mark[2]))
|
|
575
|
+
run.push(mark);
|
|
576
|
+
}
|
|
577
|
+
const bullets = marks.filter((mark) => mark[1] === undefined);
|
|
578
|
+
const chosen = run.length >= 2 ? run : bullets.length >= 2 ? bullets : [];
|
|
579
|
+
if (chosen.length >= 2) {
|
|
580
|
+
const last = chosen[chosen.length - 1];
|
|
581
|
+
const end = inlineEnd(line, last.index + last[0].length, cards);
|
|
582
|
+
const items = chosen.map((mark, k) => {
|
|
583
|
+
const start = mark.index;
|
|
584
|
+
const raw = line.slice(start, k + 1 < chosen.length ? chosen[k + 1].index : end);
|
|
585
|
+
return { start, raw, body: raw.slice(mark[0].length), number: mark[1] === undefined ? null : Number(mark[1]) };
|
|
586
|
+
});
|
|
587
|
+
return { lead: sentenceStart(line, items[0].start), items, end };
|
|
588
|
+
}
|
|
589
|
+
// Parts set apart by `;` run from the colon that leads into them, found by its place rather than by sentences, so a
|
|
590
|
+
// name such as `8.4 Fl. Oz.` before the first `;` stays whole.
|
|
591
|
+
const semicolon = line.indexOf(';');
|
|
592
|
+
if (semicolon === -1)
|
|
593
|
+
return null;
|
|
594
|
+
const colon = line.lastIndexOf(':', semicolon);
|
|
595
|
+
const led = colon !== -1 && !/[?!]/.test(line.slice(colon, semicolon));
|
|
596
|
+
const opens = led ? colon + 1 : sentenceStart(line, semicolon);
|
|
597
|
+
// The list's last `;` is the last one with an item on its right and no question between, found before the list's end
|
|
598
|
+
// is, so a sentence inside an item, `The Lavender Candle ships free;`, never ends the list early, and a `;` in a
|
|
599
|
+
// question after it, `which one would you like; or should i search again?`, is no part of it. An item names a card
|
|
600
|
+
// the list has not named yet, or names one again with a price, `Travel Mug ($19.00)`; a part that names a listed
|
|
601
|
+
// card with no price is a recommendation after the list, `i'd pick Travel Mug; Lavender Candle is nicer.`, and stays.
|
|
602
|
+
let last = semicolon;
|
|
603
|
+
for (let next = line.indexOf(';', last + 1); next !== -1; next = line.indexOf(';', last + 1)) {
|
|
604
|
+
const after = line.indexOf(';', next + 1);
|
|
605
|
+
const asks = sentencesAt(line.slice(0, next), last + 1).some((sentence) => ASKS.test(sentence.text));
|
|
606
|
+
const listed = new Set(cardsNamed(line.slice(opens, next), cards, true));
|
|
607
|
+
const text = line.slice(next + 1, after === -1 ? line.length : after);
|
|
608
|
+
const part = cardsNamed(text, cards, true);
|
|
609
|
+
const item = part.some((index) => !listed.has(index)) || (part.length > 0 && PRICE.test(sentencesAt(text, 0)[0]?.text ?? ''));
|
|
610
|
+
if (asks || !item)
|
|
611
|
+
break;
|
|
612
|
+
last = next;
|
|
613
|
+
}
|
|
614
|
+
const end = inlineEnd(line, last + 1, cards);
|
|
615
|
+
const parts = line.slice(opens, end).replace(/[\s,;]+$/, '').split(';');
|
|
616
|
+
if ((!led && parts.length < 3) || !parts.every((part) => cardsNamed(part, cards, true).length > 0))
|
|
617
|
+
return null;
|
|
618
|
+
let start = opens;
|
|
619
|
+
const items = parts.map((part) => {
|
|
620
|
+
const item = { start, raw: part, body: part, number: null };
|
|
621
|
+
start += part.length + 1;
|
|
622
|
+
return item;
|
|
623
|
+
});
|
|
624
|
+
return { lead: led ? sentenceStart(line, colon) : opens, items, end };
|
|
625
|
+
}
|
|
506
626
|
/**
|
|
507
627
|
* Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
|
|
508
628
|
* store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
|
|
509
629
|
* points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
|
|
510
630
|
* are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
|
|
511
|
-
* further sentence that names another card.
|
|
512
|
-
*
|
|
631
|
+
* further sentence that names another card. A list written on one line goes the same way, item by item (inlineList),
|
|
632
|
+
* and so does a list line written without a bullet, whole, up to the question on it. What is left is the reply around
|
|
633
|
+
* the list, such as `found these at amazon.` and `which one do you want?`. A reply whose cards went out names none of
|
|
634
|
+
* them twice over. A list that keeps an item for a product no card shows keeps its lead-in, and the two read as one
|
|
635
|
+
* message (toMessages), so the shopper never reads `two options came in under $20:` on its own.
|
|
513
636
|
*
|
|
514
637
|
* Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
|
|
515
638
|
* a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
|
|
@@ -528,25 +651,61 @@ export function withoutCardList(reply, cardNames) {
|
|
|
528
651
|
const words = wordsOf(text);
|
|
529
652
|
return PRICE.test(text) && cards.some((card) => card.words.length >= 2 && card.words.slice(0, 3).every((word, i) => words[i] === word));
|
|
530
653
|
};
|
|
531
|
-
|
|
654
|
+
// The numbers the dropped list gave, so a number of a product's own, `a #2 Phillips head`, keeps its sentence.
|
|
655
|
+
const numbers = new Set();
|
|
656
|
+
// A list a line writes inline goes the way a list of lines does: each item that names a card, and the sentence that
|
|
657
|
+
// led into the list once every item went. An item for a product no card shows stays, with the lead, and so does a
|
|
658
|
+
// list that opens the line, `1) a mug; 2) a candle; 3) a soap box`, item by item rather than as one list line.
|
|
659
|
+
const unlisted = (line) => {
|
|
660
|
+
const list = inlineList(line, cards);
|
|
661
|
+
const gone = list?.items.map((item) => cardsNamed(item.body, cards, true).length > 0) ?? [];
|
|
662
|
+
if (!list || !gone.some(Boolean))
|
|
663
|
+
return line;
|
|
664
|
+
list.items.forEach((item, k) => {
|
|
665
|
+
if (gone[k] && item.number !== null)
|
|
666
|
+
numbers.add(item.number);
|
|
667
|
+
});
|
|
668
|
+
const after = line.slice(list.end).trimStart();
|
|
669
|
+
if (gone.every(Boolean))
|
|
670
|
+
return `${line.slice(0, list.lead).trimEnd()} ${after}`.trim();
|
|
671
|
+
const left = list.items.filter((_, k) => !gone[k]).map((item) => item.raw.trim().replace(/[\s,;]*(?:\b(?:and|or))?[\s,;]*$/i, ''));
|
|
672
|
+
const close = /[.!?]$/.test(left[left.length - 1]) ? '' : '.';
|
|
673
|
+
return [line.slice(0, list.items[0].start).trimEnd(), `${left.join('; ')}${close}`, after].filter((part) => part !== '').join(' ');
|
|
674
|
+
};
|
|
675
|
+
const lines = reply.split('\n').map(unlisted);
|
|
532
676
|
const kind = lines.map((line) => {
|
|
533
677
|
if (LIST_ITEM.test(line))
|
|
534
678
|
return cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept';
|
|
535
679
|
return opensCard(line) ? 'named' : 'kept';
|
|
536
680
|
});
|
|
537
681
|
const dropped = kind.map((of) => of !== 'kept');
|
|
538
|
-
|
|
539
|
-
|
|
682
|
+
lines.forEach((line, i) => {
|
|
683
|
+
const number = dropped[i] ? Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1]) : NaN;
|
|
684
|
+
if (Number.isInteger(number))
|
|
685
|
+
numbers.add(number);
|
|
686
|
+
});
|
|
540
687
|
// 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
|
|
541
|
-
// question on the line after the last item, `Which one?`, is no part of the list.
|
|
688
|
+
// question on the line after the last item, `Which one?`, is no part of the list. A blank line between two list lines
|
|
689
|
+
// is no end of it, as toMessages reads one, so a list that keeps an item past the blank keeps its lead.
|
|
690
|
+
const listLine = (j) => LIST_ITEM.test(lines[j]) || kind[j] === 'named';
|
|
542
691
|
const listAfter = (line) => {
|
|
543
692
|
const start = lines.findIndex((after, j) => j > line && after.trim() !== '');
|
|
544
693
|
if (start === -1)
|
|
545
694
|
return [];
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
695
|
+
const list = [];
|
|
696
|
+
for (let j = start; j < lines.length; j += 1) {
|
|
697
|
+
if (listLine(j)) {
|
|
698
|
+
list.push(dropped[j]);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (lines[j].trim() !== '')
|
|
702
|
+
break;
|
|
703
|
+
// A blank line: the list goes on when the next line with words on it is a list line.
|
|
704
|
+
const next = lines.findIndex((after, k) => k > j && after.trim() !== '');
|
|
705
|
+
if (next === -1 || !listLine(next))
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
return list;
|
|
550
709
|
};
|
|
551
710
|
const named = new Set();
|
|
552
711
|
const kept = [];
|
|
@@ -557,34 +716,44 @@ export function withoutCardList(reply, cardNames) {
|
|
|
557
716
|
// The line that leads into a list whose every line went loses the sentence that leads in: `Two I'd suggest:` or
|
|
558
717
|
// `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
|
|
559
718
|
const list = listAfter(i);
|
|
560
|
-
if (text
|
|
561
|
-
text =
|
|
562
|
-
|
|
719
|
+
if (leadsIn(text) && list.length > 0 && list.every(Boolean))
|
|
720
|
+
text = withoutLeadIn(text);
|
|
721
|
+
// A list line written without a bullet goes whole, with what trails its card's price (`$16.12. amazon, and the
|
|
722
|
+
// cheapest multipack.`), up to the sentence that asks the shopper something, which stays with what follows it. A
|
|
723
|
+
// question its card's own sentence asks after a comma, `..., $16.12, want it?`, stays too.
|
|
724
|
+
let asked = false;
|
|
725
|
+
const sentences = text.split(SENTENCE_END).flatMap((sentence) => {
|
|
563
726
|
const place = LIST_NUMBER.exec(sentence);
|
|
564
727
|
if (place && numbers.has(Number(place[1] ?? place[2])))
|
|
565
|
-
return
|
|
728
|
+
return [];
|
|
566
729
|
const found = cardsNamed(sentence, cards, false);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
730
|
+
if (kind[i] === 'named') {
|
|
731
|
+
const card = found.length > 0 || opensCard(sentence);
|
|
732
|
+
asked ||= ASKS.test(sentence);
|
|
733
|
+
if (!card)
|
|
734
|
+
return asked ? [sentence] : [];
|
|
735
|
+
const clause = asked ? askingClause(sentence) : null;
|
|
736
|
+
return clause === null ? [] : [sentence.slice(clause)];
|
|
737
|
+
}
|
|
570
738
|
if (found.length === 0)
|
|
571
|
-
return
|
|
739
|
+
return [sentence];
|
|
572
740
|
// `other 6-packs are ...` and `there's also ...` recommend nothing: they are the list again, in a sentence.
|
|
573
741
|
if (MORE_OF_LIST.test(sentence))
|
|
574
|
-
return
|
|
742
|
+
return [];
|
|
575
743
|
const fresh = found.filter((card) => !named.has(card));
|
|
576
744
|
// A reply recommends one or two products in words; a sentence that names a third is the list again.
|
|
577
745
|
if (named.size + fresh.length > 2)
|
|
578
|
-
return
|
|
746
|
+
return [];
|
|
579
747
|
for (const card of fresh)
|
|
580
748
|
named.add(card);
|
|
581
|
-
return
|
|
749
|
+
return [sentence];
|
|
582
750
|
});
|
|
583
751
|
if (kind[i] === 'named' && sentences.length === 0)
|
|
584
752
|
return;
|
|
585
753
|
kept.push(sentences.join(' ').trimEnd());
|
|
586
754
|
});
|
|
587
|
-
|
|
755
|
+
// A lead-in kept for a list that lost an item to a card goes with what is left of the list, as one message (toMessages).
|
|
756
|
+
return toMessages(kept.join('\n')).join('\n\n');
|
|
588
757
|
}
|
|
589
758
|
/**
|
|
590
759
|
* A thrown error as the model may read it: the same message with every link
|
package/dist/sentences.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
1
|
export declare const SENTENCE_END: RegExp;
|
|
2
2
|
/** Where the last sentence of `text` begins: 0 when the text is one sentence. */
|
|
3
3
|
export declare function lastSentenceStart(text: string): number;
|
|
4
|
+
/** True when `text` ends with a colon that leads into what follows it, so it never stands alone as a message. */
|
|
5
|
+
export declare function leadsIn(text: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* The text less every sentence at its end that leads in with a colon, for lead-ins nothing follows: the list they led
|
|
8
|
+
* into went (its cards show it), and a colon would point at nothing. Lead-ins in a row go together, so `two things:`
|
|
9
|
+
* above `first:` leaves no colon behind. A text that leads into nothing stays as it is.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* withoutLeadIn('found these at amazon. two came in under $20:'); // 'found these at amazon.'
|
|
13
|
+
* withoutLeadIn('two things:\nfirst:'); // ''
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function withoutLeadIn(text: string): string;
|
package/dist/sentences.js
CHANGED
|
@@ -27,3 +27,31 @@ export function lastSentenceStart(text) {
|
|
|
27
27
|
start = end.index + end[0].length;
|
|
28
28
|
return start;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* A text that leads into what comes after it with a colon, as `two options came in under $20:` or `here are a few:` does,
|
|
32
|
+
* a closing quote or bracket allowed after the colon.
|
|
33
|
+
*/
|
|
34
|
+
const LEAD_IN = /:["')\]]*$/;
|
|
35
|
+
/** True when `text` ends with a colon that leads into what follows it, so it never stands alone as a message. */
|
|
36
|
+
export function leadsIn(text) {
|
|
37
|
+
return LEAD_IN.test(String(text ?? '').trim());
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The text less every sentence at its end that leads in with a colon, for lead-ins nothing follows: the list they led
|
|
41
|
+
* into went (its cards show it), and a colon would point at nothing. Lead-ins in a row go together, so `two things:`
|
|
42
|
+
* above `first:` leaves no colon behind. A text that leads into nothing stays as it is.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* withoutLeadIn('found these at amazon. two came in under $20:'); // 'found these at amazon.'
|
|
46
|
+
* withoutLeadIn('two things:\nfirst:'); // ''
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function withoutLeadIn(text) {
|
|
50
|
+
let rest = String(text ?? '').trim();
|
|
51
|
+
while (rest !== '' && leadsIn(rest)) {
|
|
52
|
+
const at = rest.lastIndexOf('\n') + 1;
|
|
53
|
+
const last = rest.slice(at);
|
|
54
|
+
rest = (rest.slice(0, at) + last.slice(0, lastSentenceStart(last))).trim();
|
|
55
|
+
}
|
|
56
|
+
return rest;
|
|
57
|
+
}
|
package/dist/texting.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface ReplyAsWrittenOptions {
|
|
|
41
41
|
* 6"), and Agentcard and `directCall` read that number against the list. Null for any other answer, which your model
|
|
42
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
43
|
* `onText` (`shopper_already_read_reply`). `{ textingStyle: true }` hands it over in the texting style (`textingStyle`).
|
|
44
|
+
* A paragraph that ends with a colon, such as `two options came in under $20:`, shares its message with the paragraph
|
|
45
|
+
* after it, and one that nothing follows loses its colon sentence, so the shopper never reads a message ending on a colon.
|
|
44
46
|
*
|
|
45
47
|
* ```ts
|
|
46
48
|
* replyAsWritten(result); // 'which bread do you want?\n\n• Sourdough\n• Whole wheat'
|