@agent-cards/commerce-sdk 0.8.0 → 0.10.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 +117 -8
- package/dist/ack.d.ts +44 -2
- package/dist/ack.js +219 -10
- package/dist/adapters.d.ts +3 -3
- package/dist/adapters.js +2 -1
- package/dist/buy-tool.d.ts +190 -13
- package/dist/buy-tool.js +544 -91
- package/dist/client.d.ts +71 -3
- package/dist/client.js +79 -1
- package/dist/definition.d.ts +14 -6
- package/dist/definition.js +31 -12
- package/dist/handler.d.ts +6 -0
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/listen.d.ts +2 -0
- package/dist/listen.js +4 -1
- package/dist/messages.d.ts +33 -2
- package/dist/messages.js +82 -5
- package/dist/model-view.d.ts +48 -3
- package/dist/model-view.js +40 -3
- package/package.json +1 -1
package/dist/messages.js
CHANGED
|
@@ -116,9 +116,60 @@ export function toMessages(text) {
|
|
|
116
116
|
scanner.end();
|
|
117
117
|
return messages;
|
|
118
118
|
}
|
|
119
|
+
/** The pause before a message, as someone texting takes it: 300 ms, plus 12 ms a character, at most 1.5 seconds. */
|
|
120
|
+
const TYPING_BASE_MS = 300;
|
|
121
|
+
const TYPING_MS_PER_CHARACTER = 12;
|
|
122
|
+
const TYPING_MAX_MS = 1_500;
|
|
119
123
|
/**
|
|
120
|
-
*
|
|
121
|
-
* `
|
|
124
|
+
* How long someone texting takes to type `message`, in milliseconds: 300, plus 12 a character, at most
|
|
125
|
+
* 1,500. `messageStream` and `buy.turn` wait this long between two messages by default, so a model that
|
|
126
|
+
* writes three paragraphs in 100 ms still delivers them one at a time.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* typingDelay('which one do you want?'); // 564
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export function typingDelay(message) {
|
|
133
|
+
return Math.min(TYPING_MAX_MS, TYPING_BASE_MS + TYPING_MS_PER_CHARACTER * [...String(message ?? '')].length);
|
|
134
|
+
}
|
|
135
|
+
/** The pause for each message `pace` asks for, or null for none. Throws a TypeError for a pace that cannot work. */
|
|
136
|
+
export function paceOf(pace) {
|
|
137
|
+
if (pace === undefined || pace === true)
|
|
138
|
+
return typingDelay;
|
|
139
|
+
if (pace === false)
|
|
140
|
+
return null;
|
|
141
|
+
if (typeof pace !== 'function')
|
|
142
|
+
throw new TypeError('Pass pace as true, false, or a function that returns the pause before a message in milliseconds.');
|
|
143
|
+
return (message) => {
|
|
144
|
+
let ms;
|
|
145
|
+
try {
|
|
146
|
+
ms = Number(pace(message));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// A pace function that fails costs only the pause.
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** A typing hook that never throws: one that fails costs nothing but the indicator. */
|
|
156
|
+
export function typingHook(onTyping) {
|
|
157
|
+
if (onTyping !== undefined && typeof onTyping !== 'function')
|
|
158
|
+
throw new TypeError('Pass onTyping as a function that takes true when a pause starts and false when its message goes out.');
|
|
159
|
+
return (typing) => {
|
|
160
|
+
try {
|
|
161
|
+
onTyping?.(typing);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// The app's indicator failing never stops a message.
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
169
|
+
/**
|
|
170
|
+
* Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph is ready the
|
|
171
|
+
* moment the blank line that ends it arrives, whatever the pieces split it into, and goes to
|
|
172
|
+
* `onMessage` then, or, after the first, once its typing pause since the last one passed (`pace`). A
|
|
122
173
|
* paragraph inside a list waits for the first characters of the line after the blank, which say
|
|
123
174
|
* whether the list goes on. `end` hands over the last message and waits for every send.
|
|
124
175
|
*
|
|
@@ -133,16 +184,38 @@ export function messageStream(options) {
|
|
|
133
184
|
const onMessage = options?.onMessage;
|
|
134
185
|
if (typeof onMessage !== 'function')
|
|
135
186
|
throw new TypeError('Pass onMessage as a function that takes each message.');
|
|
187
|
+
const pauseOf = paceOf(options.pace);
|
|
188
|
+
const typing = typingHook(options.onTyping);
|
|
136
189
|
// The line still being written. A trailing \r waits here too, in case its \n is in the next piece.
|
|
137
190
|
let open = '';
|
|
138
191
|
let ended = false;
|
|
139
192
|
let failure = null;
|
|
140
|
-
// Messages waiting for the send before them. A send that returns no promise goes out at once.
|
|
193
|
+
// Messages waiting for the send before them, or for their pause. A send that returns no promise goes out at once.
|
|
141
194
|
const queue = [];
|
|
142
195
|
let running = null;
|
|
196
|
+
// When the last message went out, and whether the app was told a pause started.
|
|
197
|
+
let lastAt = null;
|
|
198
|
+
let paused = false;
|
|
199
|
+
// The pause chosen for the message at the head of the queue: chosen once, so a pace that varies never makes it wait twice.
|
|
200
|
+
let headPause = null;
|
|
143
201
|
const drain = () => {
|
|
144
202
|
while (queue.length > 0 && !failure) {
|
|
145
|
-
const message = queue
|
|
203
|
+
const message = queue[0];
|
|
204
|
+
if (pauseOf && lastAt !== null) {
|
|
205
|
+
headPause ??= pauseOf(message);
|
|
206
|
+
const wait = lastAt + headPause - Date.now();
|
|
207
|
+
if (wait > 0) {
|
|
208
|
+
if (!paused)
|
|
209
|
+
typing(true);
|
|
210
|
+
paused = true;
|
|
211
|
+
return sleep(wait).then(() => drain() ?? undefined);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
queue.shift();
|
|
215
|
+
headPause = null;
|
|
216
|
+
if (paused)
|
|
217
|
+
typing(false);
|
|
218
|
+
paused = false;
|
|
146
219
|
let sent;
|
|
147
220
|
try {
|
|
148
221
|
sent = onMessage(message);
|
|
@@ -152,10 +225,14 @@ export function messageStream(options) {
|
|
|
152
225
|
break;
|
|
153
226
|
}
|
|
154
227
|
if (sent && typeof sent.then === 'function') {
|
|
155
|
-
return Promise.resolve(sent).then(() =>
|
|
228
|
+
return Promise.resolve(sent).then(() => {
|
|
229
|
+
lastAt = Date.now();
|
|
230
|
+
return drain() ?? undefined;
|
|
231
|
+
}, (error) => {
|
|
156
232
|
failure = { error };
|
|
157
233
|
});
|
|
158
234
|
}
|
|
235
|
+
lastAt = Date.now();
|
|
159
236
|
}
|
|
160
237
|
return null;
|
|
161
238
|
};
|
package/dist/model-view.d.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* model that holds a link writes it into the chat. So a link never enters
|
|
5
5
|
* this view: `sendLink` delivers it, and the model reads `link_sent` for the
|
|
6
6
|
* Vault and approval links, and `link_sent` on each product and
|
|
7
|
-
* `product_links_sent` for the products' store pages
|
|
7
|
+
* `product_links_sent` for the products' store pages, and `link_sent` on each
|
|
8
|
+
* store and `store_links_sent` for the pages of the stores to pick from.
|
|
8
9
|
*/
|
|
9
10
|
import type { BuyNextAction } from './client.js';
|
|
10
|
-
import type { BuyToolProduct, BuyToolResult, BuyToolStatus } from './buy-tool.js';
|
|
11
|
+
import type { BuyToolProduct, BuyToolResult, BuyToolStatus, BuyToolStore } from './buy-tool.js';
|
|
11
12
|
export interface BuyToolModelCart {
|
|
12
13
|
store: string;
|
|
13
14
|
items: Array<{
|
|
@@ -16,8 +17,17 @@ export interface BuyToolModelCart {
|
|
|
16
17
|
price: string | null;
|
|
17
18
|
}>;
|
|
18
19
|
fees: string | null;
|
|
20
|
+
/** The store's own fee and tax lines inside `fees`, by the store's labels. Empty when it itemized none. */
|
|
21
|
+
fee_lines: Array<{
|
|
22
|
+
label: string;
|
|
23
|
+
amount: string;
|
|
24
|
+
}>;
|
|
19
25
|
tip: string | null;
|
|
20
26
|
total: string;
|
|
27
|
+
/** True when the store has not priced its fees and tax yet: `total` is an estimate, never the final amount. */
|
|
28
|
+
total_is_estimate: boolean;
|
|
29
|
+
/** The most the shopper's card can be held for this cart: above `total` at a store that prices its final at checkout, `total` itself for a flight, which holds exactly that; else null. */
|
|
30
|
+
max_charge: string | null;
|
|
21
31
|
}
|
|
22
32
|
export interface BuyToolModelProduct {
|
|
23
33
|
/** What the model passes as `product_id` when the shopper picks this product: Agentcard's ref, never a link. Null when there is none. */
|
|
@@ -29,6 +39,15 @@ export interface BuyToolModelProduct {
|
|
|
29
39
|
/** True when the shopper holds this product's store page, which `sendLink` delivered beside the reply. */
|
|
30
40
|
link_sent: boolean;
|
|
31
41
|
}
|
|
42
|
+
export interface BuyToolModelStore {
|
|
43
|
+
/** What the model passes as `store_id` when the shopper picks this store: Agentcard's id, never a link. */
|
|
44
|
+
store_id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
/** The delivery estimate in minutes, or null when the store showed none. */
|
|
47
|
+
eta_minutes: number | null;
|
|
48
|
+
/** True when the shopper holds this store's page, which `sendLink` delivered beside the reply. */
|
|
49
|
+
link_sent: boolean;
|
|
50
|
+
}
|
|
32
51
|
/** The buy tool's answer as the model reads it. */
|
|
33
52
|
export interface BuyToolModelView {
|
|
34
53
|
status: BuyToolStatus;
|
|
@@ -47,13 +66,22 @@ export interface BuyToolModelView {
|
|
|
47
66
|
shopper_already_told_looking: boolean;
|
|
48
67
|
/** True when the shopper already has the link this answer needed. */
|
|
49
68
|
link_sent: boolean;
|
|
50
|
-
/** How many
|
|
69
|
+
/** How many of the products above the shopper holds as cards, each with its name, price and picture; 0 when none went out. */
|
|
51
70
|
product_links_sent: number;
|
|
52
71
|
/** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
|
|
53
72
|
pick: {
|
|
54
73
|
product_id: string | null;
|
|
55
74
|
in_cart: boolean;
|
|
56
75
|
} | null;
|
|
76
|
+
/** The stores the reply presents to choose from, in the order it names them. */
|
|
77
|
+
stores: BuyToolModelStore[];
|
|
78
|
+
/** How many store pages of the stores above the shopper holds, 0 when none went out. */
|
|
79
|
+
store_links_sent: number;
|
|
80
|
+
/** The store this call's `store_id` (or an answer such as `the second one`) picked, and whether Agentcard selected it; null when none was picked. */
|
|
81
|
+
store_pick: {
|
|
82
|
+
store_id: string;
|
|
83
|
+
selected: boolean;
|
|
84
|
+
} | null;
|
|
57
85
|
}
|
|
58
86
|
/**
|
|
59
87
|
* The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
|
|
@@ -73,11 +101,28 @@ export declare function money(minor: number | null | undefined, currency: string
|
|
|
73
101
|
* shopper never holds a product link the model cannot name.
|
|
74
102
|
*/
|
|
75
103
|
export declare function shownProducts(products: readonly BuyToolProduct[] | null | undefined): BuyToolProduct[];
|
|
104
|
+
/**
|
|
105
|
+
* The stores the model reads: each one whose id and name hold no link, so the model never reads a
|
|
106
|
+
* link and never offers a store without its name. The tool sends pages for these same stores.
|
|
107
|
+
*/
|
|
108
|
+
export declare function shownStores(stores: readonly BuyToolStore[] | null | undefined): BuyToolStore[];
|
|
76
109
|
/**
|
|
77
110
|
* The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
|
|
78
111
|
* it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
|
|
79
112
|
*/
|
|
80
113
|
export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): string | null;
|
|
114
|
+
/**
|
|
115
|
+
* What tells one product card from another: the product's link-free id, else its page. A restaurant gives
|
|
116
|
+
* every menu item the store's one page, so a card per page sent one card for a reply that offered three
|
|
117
|
+
* dishes (2026-10-20, Suavecito Birria & Tacos on DoorDash). Each product the model reads gets its own card.
|
|
118
|
+
*/
|
|
119
|
+
export declare function cardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url'>): string | null;
|
|
120
|
+
/**
|
|
121
|
+
* The key of the card a product gets, or null when it gets none: a product with a page at its store,
|
|
122
|
+
* or a flight option, which has no page, with an id a pick takes. A flight's card is how the shopper
|
|
123
|
+
* taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
|
|
124
|
+
*/
|
|
125
|
+
export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight'>): string | null;
|
|
81
126
|
/**
|
|
82
127
|
* The model's view of one answer: the status, Agentcard's reply, the newest
|
|
83
128
|
* cart and every cart on the table, the products by id, name and price, and
|
package/dist/model-view.js
CHANGED
|
@@ -93,6 +93,13 @@ export function money(minor, currency) {
|
|
|
93
93
|
export function shownProducts(products) {
|
|
94
94
|
return (products ?? []).filter((product) => stripLinks(product.name) !== '' && stripLinks(product.store ?? '') !== '').slice(0, MAX_PRODUCTS);
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* The stores the model reads: each one whose id and name hold no link, so the model never reads a
|
|
98
|
+
* link and never offers a store without its name. The tool sends pages for these same stores.
|
|
99
|
+
*/
|
|
100
|
+
export function shownStores(stores) {
|
|
101
|
+
return (stores ?? []).filter((store) => store.id !== '' && stripLinks(store.id) === store.id && stripLinks(store.name) !== '');
|
|
102
|
+
}
|
|
96
103
|
/**
|
|
97
104
|
* The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
|
|
98
105
|
* it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
|
|
@@ -102,13 +109,35 @@ export function pickId(product) {
|
|
|
102
109
|
return product.ref;
|
|
103
110
|
return typeof product.id === 'string' && product.id !== '' && stripLinks(product.id) === product.id ? product.id : null;
|
|
104
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* What tells one product card from another: the product's link-free id, else its page. A restaurant gives
|
|
114
|
+
* every menu item the store's one page, so a card per page sent one card for a reply that offered three
|
|
115
|
+
* dishes (2026-10-20, Suavecito Birria & Tacos on DoorDash). Each product the model reads gets its own card.
|
|
116
|
+
*/
|
|
117
|
+
export function cardKey(product) {
|
|
118
|
+
return pickId(product) ?? (typeof product.url === 'string' && product.url !== '' ? product.url : null);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The key of the card a product gets, or null when it gets none: a product with a page at its store,
|
|
122
|
+
* or a flight option, which has no page, with an id a pick takes. A flight's card is how the shopper
|
|
123
|
+
* taps the flight they want (2026-10-20: three flights listed in text, and no card to tap).
|
|
124
|
+
*/
|
|
125
|
+
export function productCardKey(product) {
|
|
126
|
+
if (typeof product.url === 'string' && product.url !== '')
|
|
127
|
+
return cardKey(product);
|
|
128
|
+
return product.flight ? pickId(product) : null;
|
|
129
|
+
}
|
|
105
130
|
function cartView(cart) {
|
|
106
131
|
return {
|
|
107
132
|
store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
|
|
108
133
|
items: cart.items.map((line) => ({ name: stripLinks(line.name), qty: line.qty, price: money(line.priceCents, cart.currency) })),
|
|
109
134
|
fees: cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
|
|
135
|
+
fee_lines: (cart.merchantFeeLines ?? []).map((line) => ({ label: stripLinks(line.label), amount: money(line.amountCents, cart.currency) })),
|
|
110
136
|
tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
|
|
111
137
|
total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
|
|
138
|
+
total_is_estimate: cart.totalIsEstimate === true,
|
|
139
|
+
// Agentcard names a ceiling equal to the total only for a hold of exactly the total, as a flight's.
|
|
140
|
+
max_charge: typeof cart.approvedCeilingCents === 'number' && cart.approvedCeilingCents > 0 && cart.approvedCeilingCents >= cart.totalCents ? money(cart.approvedCeilingCents, cart.currency) : null,
|
|
112
141
|
};
|
|
113
142
|
}
|
|
114
143
|
/**
|
|
@@ -120,7 +149,9 @@ function cartView(cart) {
|
|
|
120
149
|
export function forModel(result) {
|
|
121
150
|
const status = typeof result.status === 'string' && MODEL_STATUSES.has(result.status) ? result.status : 'needs_input';
|
|
122
151
|
const products = shownProducts(result.products);
|
|
123
|
-
const held = (product) => product.linkSent === true &&
|
|
152
|
+
const held = (product) => product.linkSent === true && productCardKey(product) !== null;
|
|
153
|
+
const stores = shownStores(result.stores);
|
|
154
|
+
const heldStore = (store) => store.linkSent === true && typeof store.url === 'string' && store.url !== '';
|
|
124
155
|
return {
|
|
125
156
|
status,
|
|
126
157
|
next_action: result.next_action ?? null,
|
|
@@ -132,10 +163,16 @@ export function forModel(result) {
|
|
|
132
163
|
shopper_need_not_reply: result.shopper_need_not_reply === true,
|
|
133
164
|
shopper_already_told_looking: result.shopper_already_told_looking === true,
|
|
134
165
|
link_sent: result.link_sent === true,
|
|
135
|
-
//
|
|
136
|
-
product_links_sent: new Set(products.filter(held).map(
|
|
166
|
+
// A card per product, so two dishes on a restaurant's one page are two cards.
|
|
167
|
+
product_links_sent: new Set(products.filter(held).map(productCardKey)).size,
|
|
137
168
|
// The id the model sent, never the catalog id, which is a store page for a retail product.
|
|
138
169
|
pick: result.pick ? { product_id: pickId({ id: result.pick.product_id, ref: result.pick.ref }), in_cart: result.pick.in_cart === true } : null,
|
|
170
|
+
stores: stores.map((store) => ({ store_id: store.id, name: stripLinks(store.name), eta_minutes: store.etaMinutes, link_sent: heldStore(store) })),
|
|
171
|
+
store_links_sent: new Set(stores.filter(heldStore).map((store) => store.url)).size,
|
|
172
|
+
// A store id with a link in it is never read back to the model.
|
|
173
|
+
store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
|
|
174
|
+
? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
|
|
175
|
+
: null,
|
|
139
176
|
};
|
|
140
177
|
}
|
|
141
178
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/commerce-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|