@agent-cards/commerce-sdk 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
3
+ * own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
4
+ * paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
5
+ * items a blank line separates.
6
+ */
7
+ /** A line holding nothing but spaces or tabs. */
8
+ const BLANK = /^[ \t]*$/;
9
+ /** A list item: `-`, `*` or `+`, or a number followed by `.` or `)`, then a space or the line's end. */
10
+ const LIST_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
11
+ /** A list item whose marker a space already follows, the only kind a line still being written is sure to stay. */
12
+ const LIST_ITEM_SPACED = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/;
13
+ /** A line indented under a list item: two spaces or a tab, then text. */
14
+ const INDENTED = /^(?: {2,}|\t)\S/;
15
+ /** The start of a line that can still grow into a list item or an indented line once more text arrives. */
16
+ const MAYBE_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)]?)?$|^[ \t]*$/;
17
+ /** A code fence: three or more backticks or tildes, indented by at most three spaces. */
18
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
19
+ /** How the line after a blank line inside a list reads: it continues the list, it ends it, or it is too short to tell. */
20
+ function afterListBlank(line, complete) {
21
+ // `1.` alone is an item once the line ends there, and the start of `1.5 cups` while it is still arriving.
22
+ if ((complete ? LIST_ITEM : LIST_ITEM_SPACED).test(line) || INDENTED.test(line))
23
+ return 'continues';
24
+ if (!complete && MAYBE_ITEM.test(line))
25
+ return 'unknown';
26
+ return 'ends';
27
+ }
28
+ /** True when `line` closes the fence `open` opened: the same character, at least as many times, and nothing after. */
29
+ function closesFence(line, open) {
30
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
31
+ return !!match && match[1][0] === open[0] && match[1].length >= open.length;
32
+ }
33
+ /**
34
+ * Reads text line by line and closes each message once nothing later can change it. It keeps only
35
+ * the message being built, so text fed in pieces costs what the text costs once.
36
+ */
37
+ class Scanner {
38
+ onClose;
39
+ current = [];
40
+ fence = null;
41
+ inList = false;
42
+ blankInList = false;
43
+ constructor(onClose) {
44
+ this.onClose = onClose;
45
+ }
46
+ close() {
47
+ const message = this.current.join('\n').trim();
48
+ this.current = [];
49
+ this.inList = false;
50
+ this.blankInList = false;
51
+ if (message)
52
+ this.onClose(message);
53
+ }
54
+ /** One whole line. */
55
+ line(line) {
56
+ if (this.fence !== null) {
57
+ this.current.push(line);
58
+ if (closesFence(line, this.fence))
59
+ this.fence = null;
60
+ return;
61
+ }
62
+ if (BLANK.test(line)) {
63
+ if (this.current.length === 0)
64
+ return;
65
+ // A blank line ends the paragraph, unless it sits between two items of one list.
66
+ if (this.inList)
67
+ this.blankInList = true;
68
+ else
69
+ this.close();
70
+ return;
71
+ }
72
+ if (this.blankInList) {
73
+ if (afterListBlank(line, true) === 'ends')
74
+ this.close();
75
+ else
76
+ this.current.push('');
77
+ this.blankInList = false;
78
+ }
79
+ this.current.push(line);
80
+ if (LIST_ITEM.test(line))
81
+ this.inList = true;
82
+ else if (this.current.length === 1)
83
+ this.inList = false;
84
+ const open = FENCE.exec(line);
85
+ if (open)
86
+ this.fence = open[1];
87
+ }
88
+ /**
89
+ * The start of a line still being written. After a blank line inside a list, its first characters
90
+ * can already say the list ended, and the list goes out as soon as they do. Only the start counts:
91
+ * no marker, indent or number runs past 16 characters.
92
+ */
93
+ partial(start) {
94
+ if (this.fence === null && this.blankInList && afterListBlank(start.slice(0, 16), false) === 'ends')
95
+ this.close();
96
+ }
97
+ /** The text is whole: the message being built closes. */
98
+ end() {
99
+ this.close();
100
+ }
101
+ }
102
+ /**
103
+ * The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
104
+ * 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.
106
+ *
107
+ * ```ts
108
+ * toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
109
+ * ```
110
+ */
111
+ export function toMessages(text) {
112
+ const messages = [];
113
+ const scanner = new Scanner((message) => messages.push(message));
114
+ for (const line of String(text ?? '').replace(/\r\n?/g, '\n').split('\n'))
115
+ scanner.line(line);
116
+ scanner.end();
117
+ return messages;
118
+ }
119
+ /**
120
+ * Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
121
+ * `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
122
+ * paragraph inside a list waits for the first characters of the line after the blank, which say
123
+ * whether the list goes on. `end` hands over the last message and waits for every send.
124
+ *
125
+ * ```ts
126
+ * const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
127
+ * stream.on('text', (delta) => paragraphs.push(delta));
128
+ * await stream.done();
129
+ * await paragraphs.end();
130
+ * ```
131
+ */
132
+ export function messageStream(options) {
133
+ const onMessage = options?.onMessage;
134
+ if (typeof onMessage !== 'function')
135
+ throw new TypeError('Pass onMessage as a function that takes each message.');
136
+ // The line still being written. A trailing \r waits here too, in case its \n is in the next piece.
137
+ let open = '';
138
+ let ended = false;
139
+ let failure = null;
140
+ // Messages waiting for the send before them. A send that returns no promise goes out at once.
141
+ const queue = [];
142
+ let running = null;
143
+ const drain = () => {
144
+ while (queue.length > 0 && !failure) {
145
+ const message = queue.shift();
146
+ let sent;
147
+ try {
148
+ sent = onMessage(message);
149
+ }
150
+ catch (error) {
151
+ failure = { error };
152
+ break;
153
+ }
154
+ if (sent && typeof sent.then === 'function') {
155
+ return Promise.resolve(sent).then(() => drain() ?? undefined, (error) => {
156
+ failure = { error };
157
+ });
158
+ }
159
+ }
160
+ return null;
161
+ };
162
+ const kick = () => {
163
+ if (running)
164
+ return;
165
+ const pending = drain();
166
+ if (pending) {
167
+ running = pending.then(() => {
168
+ running = null;
169
+ kick();
170
+ });
171
+ }
172
+ };
173
+ const scanner = new Scanner((message) => {
174
+ queue.push(message);
175
+ kick();
176
+ });
177
+ return {
178
+ push(delta) {
179
+ if (ended)
180
+ throw new Error('This message stream has ended. Start a new one for the next text.');
181
+ if (typeof delta !== 'string' || delta === '')
182
+ return;
183
+ const text = open + delta;
184
+ const cut = text.endsWith('\r') ? text.length - 1 : text.length;
185
+ const lines = text.slice(0, cut).replace(/\r\n?/g, '\n').split('\n');
186
+ open = (lines.pop() ?? '') + text.slice(cut);
187
+ for (const line of lines)
188
+ scanner.line(line);
189
+ // A held \r is a line ending still arriving, not text that could end a list.
190
+ scanner.partial(open.endsWith('\r') ? open.slice(0, -1) : open);
191
+ },
192
+ async end() {
193
+ if (!ended) {
194
+ ended = true;
195
+ for (const line of open.replace(/\r\n?/g, '\n').split('\n'))
196
+ scanner.line(line);
197
+ open = '';
198
+ scanner.end();
199
+ }
200
+ while (running)
201
+ await running;
202
+ if (failure)
203
+ throw failure.error;
204
+ },
205
+ };
206
+ }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * What the model reads: the buy tool's answer with every link taken out. A
3
+ * tool runner puts a tool's result straight into the model's context, and a
4
+ * model that holds a link writes it into the chat. So a link never enters
5
+ * this view: `sendLink` delivers it, and the model reads `link_sent` for the
6
+ * Vault and approval links, and `link_sent` on each product and
7
+ * `product_links_sent` for the products' store pages.
8
+ */
9
+ import type { BuyNextAction } from './client.js';
1
10
  import type { BuyToolProduct, BuyToolResult, BuyToolStatus } from './buy-tool.js';
2
11
  export interface BuyToolModelCart {
3
12
  store: string;
@@ -11,6 +20,8 @@ export interface BuyToolModelCart {
11
20
  total: string;
12
21
  }
13
22
  export interface BuyToolModelProduct {
23
+ /** What the model passes as `product_id` when the shopper picks this product: Agentcard's ref, never a link. Null when there is none. */
24
+ product_id: string | null;
14
25
  name: string;
15
26
  price: string | null;
16
27
  /** True when the shopper holds this product's store page, which `sendLink` delivered beside the reply. */
@@ -19,6 +30,8 @@ export interface BuyToolModelProduct {
19
30
  /** The buy tool's answer as the model reads it. */
20
31
  export interface BuyToolModelView {
21
32
  status: BuyToolStatus;
33
+ /** The one thing to ask the shopper for in this message, or null when nothing waits on them. Never ask for a later step. */
34
+ next_action: BuyNextAction | null;
22
35
  /** Agentcard's answer for the shopper, with every link taken out. */
23
36
  reply: string;
24
37
  /** The newest cart on the table. */
@@ -32,6 +45,11 @@ export interface BuyToolModelView {
32
45
  link_sent: boolean;
33
46
  /** How many store pages of the products above the shopper holds, 0 when none went out. */
34
47
  product_links_sent: number;
48
+ /** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
49
+ pick: {
50
+ product_id: string | null;
51
+ in_cart: boolean;
52
+ } | null;
35
53
  }
36
54
  /** The text with every link taken out: a Markdown link keeps its label, and a bare address goes entirely. */
37
55
  export declare function stripLinks(text: string): string;
@@ -47,9 +65,14 @@ export declare function money(minor: number | null | undefined, currency: string
47
65
  * never holds a product link the model cannot name.
48
66
  */
49
67
  export declare function shownProducts(products: readonly BuyToolProduct[] | null | undefined): BuyToolProduct[];
68
+ /**
69
+ * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
70
+ * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
71
+ */
72
+ export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): string | null;
50
73
  /**
51
74
  * The model's view of one answer: the status, Agentcard's reply, the newest
52
- * cart and every cart on the table, the products by name and price, and
75
+ * cart and every cart on the table, the products by id, name and price, and
53
76
  * whether the shopper has each link. A status this version does not know
54
77
  * reads as `needs_input`.
55
78
  */
@@ -1,5 +1,5 @@
1
1
  const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
2
- /** The model sees the first twenty products the search found, the cap the backend's catalog keeps for a turn. */
2
+ /** At most twenty products reach the model. Agentcard's /buy answer lists three, so this cap holds only for an older answer. */
3
3
  const MAX_PRODUCTS = 20;
4
4
  const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
5
5
  const SCHEME_URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`)\]]*/gi;
@@ -67,6 +67,15 @@ export function money(minor, currency) {
67
67
  export function shownProducts(products) {
68
68
  return (products ?? []).filter((product) => stripLinks(product.name) !== '').slice(0, MAX_PRODUCTS);
69
69
  }
70
+ /**
71
+ * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
72
+ * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
73
+ */
74
+ export function pickId(product) {
75
+ if (typeof product.ref === 'string' && product.ref !== '' && stripLinks(product.ref) === product.ref)
76
+ return product.ref;
77
+ return typeof product.id === 'string' && product.id !== '' && stripLinks(product.id) === product.id ? product.id : null;
78
+ }
70
79
  function cartView(cart) {
71
80
  return {
72
81
  store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
@@ -78,7 +87,7 @@ function cartView(cart) {
78
87
  }
79
88
  /**
80
89
  * The model's view of one answer: the status, Agentcard's reply, the newest
81
- * cart and every cart on the table, the products by name and price, and
90
+ * cart and every cart on the table, the products by id, name and price, and
82
91
  * whether the shopper has each link. A status this version does not know
83
92
  * reads as `needs_input`.
84
93
  */
@@ -88,15 +97,18 @@ export function forModel(result) {
88
97
  const held = (product) => product.linkSent === true && typeof product.url === 'string' && product.url !== '';
89
98
  return {
90
99
  status,
100
+ next_action: result.next_action ?? null,
91
101
  reply: stripLinks(result.reply),
92
102
  cart: result.cart ? cartView(result.cart) : null,
93
103
  carts: (result.carts ?? []).map(cartView),
94
- products: products.map((product) => ({ name: stripLinks(product.name), price: money(product.priceCents, product.currency), link_sent: held(product) })),
104
+ products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), price: money(product.priceCents, product.currency), link_sent: held(product) })),
95
105
  approval_expires_at: typeof result.approval_expires_at === 'string' ? stripLinks(result.approval_expires_at) || null : null,
96
106
  shopper_need_not_reply: result.shopper_need_not_reply === true,
97
107
  link_sent: result.link_sent === true,
98
108
  // Two products on one page share one link, so the count is of pages.
99
109
  product_links_sent: new Set(products.filter(held).map((product) => product.url)).size,
110
+ // The id the model sent, never the catalog id, which is a store page for a retail product.
111
+ pick: result.pick ? { product_id: pickId({ id: result.pick.product_id, ref: result.pick.ref }), in_cart: result.pick.in_cart === true } : null,
100
112
  };
101
113
  }
102
114
  /**
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Every Agentcard event a shopper should hear about, read as one message for that shopper's chat.
3
+ * `purchase.message` carries the text Agentcard wrote. `order.confirmed` and `order.updated` carry
4
+ * facts only, so the package writes their text. Every other event type is not a shopper message.
5
+ */
6
+ import { type AgentcardEvent } from './verify.js';
7
+ import { type PurchaseFacts, type PurchaseMessageKind } from './message.js';
8
+ /**
9
+ * The event types that carry a message for a shopper. Subscribe your endpoint to each by name:
10
+ * a catch-all `*` alone does not turn `purchase.message` on.
11
+ */
12
+ export declare const SHOPPER_EVENT_TYPES: readonly ["purchase.message", "order.confirmed", "order.updated"];
13
+ /** One of the event types that carry a message for a shopper. */
14
+ export type ShopperEventType = (typeof SHOPPER_EVENT_TYPES)[number];
15
+ /**
16
+ * The delivery stage of an order, as `order.updated` names it. A stage Agentcard adds later reads
17
+ * as its own name.
18
+ */
19
+ export type OrderStatus = 'confirmed' | 'courier_assigned' | 'courier_at_store' | 'ready_for_pickup' | 'picked_up' | 'arriving' | 'delivered' | 'canceled';
20
+ /**
21
+ * The moment a shopper message is about. The `purchase.message` kinds, then:
22
+ * - `order_confirmed`: the retailer accepted the order on its side (`order.confirmed`).
23
+ * - `order_updated`: the order reached a new delivery stage, named in `facts.orderStatus` (`order.updated`).
24
+ */
25
+ export type ShopperMessageKind = PurchaseMessageKind | 'order_confirmed' | 'order_updated';
26
+ /** The values of a shopper message one by one. A fact that does not apply is absent. */
27
+ export interface ShopperFacts extends PurchaseFacts {
28
+ /** The retailer's own order number, as on its confirmation and account page. */
29
+ retailerOrderId?: string;
30
+ /** The retailer's quoted delivery window, in its own words. */
31
+ deliveryWindow?: string;
32
+ /** The order's delivery stage. */
33
+ orderStatus?: OrderStatus | (string & {});
34
+ /** The stage before this one. */
35
+ previousOrderStatus?: OrderStatus | (string & {});
36
+ /** The store's own line for the stage, such as "Your Dasher is nearby". */
37
+ statusTitle?: string;
38
+ /** The earliest and the latest arrival the store estimates, as ISO 8601 timestamps. */
39
+ arrivalEarliest?: string;
40
+ arrivalLatest?: string;
41
+ /** When the order was delivered, as an ISO 8601 timestamp. */
42
+ deliveredAt?: string;
43
+ /** Why the store canceled the order, in its own word. */
44
+ canceledReason?: string;
45
+ }
46
+ /** A message for one shopper's chat, read from any event that carries one. */
47
+ export interface ShopperMessage {
48
+ /** The event id. Agentcard delivers at least once: handle each id once. */
49
+ eventId: string;
50
+ /** The event type the message arrived as. */
51
+ type: ShopperEventType;
52
+ /**
53
+ * The moment the message is about: one of the kinds above, or a kind Agentcard adds later, read
54
+ * as its own name. Give a `switch` over it a default branch that sends `text`.
55
+ */
56
+ kind: ShopperMessageKind | (string & {});
57
+ /** The shopper's Agentcard user id. */
58
+ userId: string;
59
+ /** Your own id for the shopper, when you gave Agentcard one: the chat to post `text` into. */
60
+ externalUserId: string | null;
61
+ /** The buy conversation the event belongs to, when Agentcard names one. */
62
+ conversationId: string | null;
63
+ /** Ready to send to the shopper as is. */
64
+ text: string;
65
+ /** For your agent: the facts of what happened, marked as written by the system and not typed by the shopper. */
66
+ note: string;
67
+ facts: ShopperFacts;
68
+ /** The OAuth client id of the app the shopper connected through, when the event names it. */
69
+ clientId: string | null;
70
+ /** False for a sandbox event. */
71
+ livemode: boolean;
72
+ /** Unix seconds. */
73
+ created: number;
74
+ }
75
+ /**
76
+ * The message an event carries for a shopper, or null when it carries none: an event type that is
77
+ * not a shopper event, an order event that names no shopper, or a delivery stage the package does
78
+ * not know and the store gave no line for. Pass the event `verifyAgentcardWebhook` returned.
79
+ * Throws a TypeError when a `purchase.message` lacks its kind, user_id or text.
80
+ */
81
+ export declare function readShopperMessage(event: AgentcardEvent): ShopperMessage | null;
82
+ /**
83
+ * Whether the message stopped holding before `now` (unix milliseconds, default the system clock):
84
+ * the link or the window its text names has closed. Drop a stale message instead of sending it.
85
+ */
86
+ export declare function isStale(message: Pick<ShopperMessage, 'facts'>, now?: number): boolean;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Every Agentcard event a shopper should hear about, read as one message for that shopper's chat.
3
+ * `purchase.message` carries the text Agentcard wrote. `order.confirmed` and `order.updated` carry
4
+ * facts only, so the package writes their text. Every other event type is not a shopper message.
5
+ */
6
+ import { isRecord } from './verify.js';
7
+ import { readPurchaseMessage } from './message.js';
8
+ /**
9
+ * The event types that carry a message for a shopper. Subscribe your endpoint to each by name:
10
+ * a catch-all `*` alone does not turn `purchase.message` on.
11
+ */
12
+ export const SHOPPER_EVENT_TYPES = ['purchase.message', 'order.confirmed', 'order.updated'];
13
+ const SYSTEM_LINE = 'This note was generated by the system, not typed by the user.';
14
+ function text(value, max = 200) {
15
+ if (typeof value !== 'string')
16
+ return undefined;
17
+ // One line, so a value never breaks the sentence it sits in.
18
+ const line = value.replace(/\s+/g, ' ').trim();
19
+ return line === '' ? undefined : line.slice(0, max);
20
+ }
21
+ /** An id exactly as Agentcard sent it, so a lookup by it finds the same record the other events name. */
22
+ function id(value) {
23
+ return typeof value === 'string' && value !== '' ? value : undefined;
24
+ }
25
+ function count(value) {
26
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
27
+ }
28
+ /** Facts without the values that do not apply. */
29
+ function present(facts) {
30
+ return Object.fromEntries(Object.entries(facts).filter(([, value]) => value !== undefined));
31
+ }
32
+ /**
33
+ * The store as the shopper knows it, or null when the event names a roster of stores rather than
34
+ * one: a retail order names every retailer it can reach.
35
+ */
36
+ function storeOf(data) {
37
+ if (data.merchant === 'retail')
38
+ return null;
39
+ return text(data.merchant_name, 64) ?? null;
40
+ }
41
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
42
+ /** "Your DoorDash order is almost there." for each stage, or null for a stage the package does not know. */
43
+ function stageText(status, store, reason, title) {
44
+ const order = store ? `your ${store} order` : 'your order';
45
+ switch (status) {
46
+ case 'confirmed':
47
+ return store ? `${store} accepted your order.` : 'The store accepted your order.';
48
+ case 'courier_assigned':
49
+ return `A driver is assigned to ${order}.`;
50
+ case 'courier_at_store':
51
+ return `The driver is at the store for ${order}.`;
52
+ case 'ready_for_pickup':
53
+ return `${capitalize(order)} is ready for pickup.`;
54
+ case 'picked_up':
55
+ return `${capitalize(order)} is picked up and on its way.`;
56
+ case 'arriving':
57
+ return `${capitalize(order)} is almost there.`;
58
+ case 'delivered':
59
+ return `${capitalize(order)} was delivered.`;
60
+ case 'canceled':
61
+ return `${capitalize(order)} was canceled${reason ? `: ${reason}` : ''}.`;
62
+ default:
63
+ return title ? `${store ? `${store}: ` : ''}${title}` : null;
64
+ }
65
+ }
66
+ function orderBase(event, type) {
67
+ const data = event.data;
68
+ const userId = id(data.user_id);
69
+ if (!userId)
70
+ return null;
71
+ const connection = isRecord(data.connection) ? data.connection : {};
72
+ return {
73
+ data,
74
+ store: storeOf(data),
75
+ base: {
76
+ eventId: event.id,
77
+ type,
78
+ userId,
79
+ externalUserId: id(data.external_user_id) ?? null,
80
+ conversationId: id(data.conversation_id) ?? null,
81
+ clientId: id(connection.client_id) ?? null,
82
+ livemode: event.livemode,
83
+ created: event.created,
84
+ },
85
+ };
86
+ }
87
+ function readOrderConfirmed(event) {
88
+ const read = orderBase(event, 'order.confirmed');
89
+ if (!read)
90
+ return null;
91
+ const { data, store, base } = read;
92
+ const retailerOrderId = text(data.retailer_order_id, 80);
93
+ const deliveryWindow = text(data.delivery_window, 80);
94
+ const facts = present({
95
+ merchantName: store ?? undefined,
96
+ totalCents: count(data.final_total_cents) ?? count(data.charged_cents),
97
+ currency: text(data.currency, 8),
98
+ orderId: id(data.order_id),
99
+ retailerOrderId,
100
+ deliveryWindow,
101
+ });
102
+ const who = store ? `${store} confirmed your order` : 'The store confirmed your order';
103
+ return {
104
+ ...base,
105
+ kind: 'order_confirmed',
106
+ text: `${who}${retailerOrderId ? `, order number ${retailerOrderId}` : ''}.${deliveryWindow ? ` Expected delivery: ${deliveryWindow}.` : ''}`,
107
+ note: `[event: the store accepted this user's order${store ? ` at ${store}` : ''} on its side, outside this conversation.` +
108
+ `${retailerOrderId ? ` The store's order number is ${retailerOrderId}.` : ''}` +
109
+ `${deliveryWindow ? ` The store quotes delivery ${deliveryWindow}.` : ''} ${SYSTEM_LINE}]`,
110
+ facts,
111
+ };
112
+ }
113
+ function readOrderUpdated(event) {
114
+ const read = orderBase(event, 'order.updated');
115
+ if (!read)
116
+ return null;
117
+ const { data, store, base } = read;
118
+ const status = text(data.status, 40);
119
+ if (!status)
120
+ return null;
121
+ const statusTitle = text(data.status_title, 120);
122
+ const canceledReason = text(data.canceled_reason, 120);
123
+ const message = stageText(status, store, canceledReason, statusTitle);
124
+ if (!message)
125
+ return null;
126
+ const arrival = isRecord(data.estimated_arrival) ? data.estimated_arrival : {};
127
+ const previous = text(data.previous_status, 40);
128
+ return {
129
+ ...base,
130
+ kind: 'order_updated',
131
+ text: message,
132
+ note: `[event: this user's order${store ? ` at ${store}` : ''} moved to the stage ${status}` +
133
+ `${previous ? ` from ${previous}` : ''}, outside this conversation.` +
134
+ `${statusTitle ? ` The store says: ${statusTitle}.` : ''}${canceledReason ? ` The store's reason: ${canceledReason}.` : ''} ${SYSTEM_LINE}]`,
135
+ facts: present({
136
+ merchantName: store ?? undefined,
137
+ orderId: id(data.order_id),
138
+ orderStatus: status,
139
+ previousOrderStatus: previous,
140
+ statusTitle,
141
+ arrivalEarliest: text(arrival.earliest, 40),
142
+ arrivalLatest: text(arrival.latest, 40),
143
+ deliveredAt: text(data.delivered_at, 40),
144
+ canceledReason,
145
+ }),
146
+ };
147
+ }
148
+ /**
149
+ * The message an event carries for a shopper, or null when it carries none: an event type that is
150
+ * not a shopper event, an order event that names no shopper, or a delivery stage the package does
151
+ * not know and the store gave no line for. Pass the event `verifyAgentcardWebhook` returned.
152
+ * Throws a TypeError when a `purchase.message` lacks its kind, user_id or text.
153
+ */
154
+ export function readShopperMessage(event) {
155
+ if (event.type === 'order.confirmed')
156
+ return readOrderConfirmed(event);
157
+ if (event.type === 'order.updated')
158
+ return readOrderUpdated(event);
159
+ const message = readPurchaseMessage(event);
160
+ if (!message)
161
+ return null;
162
+ return {
163
+ ...message,
164
+ type: 'purchase.message',
165
+ conversationId: id(event.data.conversation_id) ?? null,
166
+ };
167
+ }
168
+ /**
169
+ * Whether the message stopped holding before `now` (unix milliseconds, default the system clock):
170
+ * the link or the window its text names has closed. Drop a stale message instead of sending it.
171
+ */
172
+ export function isStale(message, now = Date.now()) {
173
+ const until = message.facts.validUntil ? Date.parse(message.facts.validUntil) : Number.NaN;
174
+ return Number.isFinite(until) && until <= now;
175
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.1.0",
4
- "description": "Give your agent one tool that buys for your shoppers at real stores, and hear what happened to each purchase from a signed webhook.",
3
+ "version": "0.5.0",
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.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",