@agent-cards/commerce-sdk 0.2.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/listen.js ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Hear every Agentcard event without a webhook endpoint. Your server opens one connection to
3
+ * Agentcard with your company credentials, and Agentcard streams your company's events down it:
4
+ * no public URL, no webhook secret, no signature to check. Each event is the envelope a webhook
5
+ * endpoint would receive, with the same id.
6
+ *
7
+ * The listener reconnects on its own, resumes after the last event it handled, skips an event it
8
+ * already handled, and falls back to asking every few seconds when the stream is not available.
9
+ * Shopper messages go through the same reader as `agentcardWebhook` (`readShopperMessage`), so
10
+ * `onMessage` gets the same ready-to-send text.
11
+ */
12
+ import { isRecord } from './verify.js';
13
+ import { isStale, readShopperMessage } from './shopper-message.js';
14
+ import { isEventPattern, matchesEventPattern } from './events.js';
15
+ import { AgentcardApiError } from './client.js';
16
+ /** How many handled event ids the listener remembers, the oldest forgotten first. */
17
+ const HANDLED_EVENTS_KEPT = 10_000;
18
+ /** Tries for one event's callback before the listener reports it and moves on. */
19
+ const CALLBACK_ATTEMPTS = 5;
20
+ const IDLE_SAVE_MS = 60_000;
21
+ const MAX_BACKOFF_MS = 30_000;
22
+ /**
23
+ * How far behind its cursor Agentcard hands events over again after a resume, in case one
24
+ * committed late, unless the stream says otherwise. The saved cursor carries the id of every event
25
+ * handled within this window and a margin, so a new process skips each of them.
26
+ */
27
+ const DEFAULT_REPLAY_MS = 10_000;
28
+ const REPLAY_MARGIN_MS = 5_000;
29
+ /** The time in a cursor, `<ms>_<row>`, or null for a cursor of another shape. */
30
+ const cursorTime = (cursor) => {
31
+ const m = cursor ? /^(\d{1,15})_/.exec(cursor) : null;
32
+ return m ? Number(m[1]) : null;
33
+ };
34
+ /** Streams in a row that answered but never said ready before `auto` polls instead: a proxy that buffers the stream, or closes it. */
35
+ const STREAM_ATTEMPTS_BEFORE_POLLING = 3;
36
+ /** Raised when the stream is not served here, so `auto` polls instead. */
37
+ class StreamUnavailable extends Error {
38
+ }
39
+ /** Raised when the stream answered but closed, or went silent, before Agentcard said it was ready. */
40
+ class StreamNotEstablished extends Error {
41
+ }
42
+ const sleep = (ms, signal) => new Promise((resolve) => {
43
+ if (signal.aborted)
44
+ return resolve();
45
+ const timer = setTimeout(done, ms);
46
+ function done() {
47
+ clearTimeout(timer);
48
+ signal.removeEventListener('abort', done);
49
+ resolve();
50
+ }
51
+ signal.addEventListener('abort', done);
52
+ });
53
+ function parseJson(text) {
54
+ try {
55
+ return JSON.parse(text);
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /** An event envelope as Agentcard sends it, or null for anything else. */
62
+ function eventOf(value) {
63
+ if (!isRecord(value) || typeof value.id !== 'string' || typeof value.type !== 'string' || !isRecord(value.data))
64
+ return null;
65
+ return {
66
+ id: value.id,
67
+ type: value.type,
68
+ created: typeof value.created === 'number' ? value.created : 0,
69
+ livemode: value.livemode === true,
70
+ data: value.data,
71
+ };
72
+ }
73
+ /** The Agentcard error a refused read answered with. */
74
+ async function refusal(res) {
75
+ const body = parseJson(await res.text().catch(() => ''));
76
+ const error = isRecord(body) && isRecord(body.error) ? body.error : {};
77
+ const code = typeof error.code === 'string' ? error.code : `http_${res.status}`;
78
+ const message = typeof error.message === 'string' ? error.message : `Agentcard answered ${res.status}.`;
79
+ return new AgentcardApiError(res.status, code, message, isRecord(body) ? body : undefined);
80
+ }
81
+ /**
82
+ * Listen to every event Agentcard sends your company, over one connection your server opens.
83
+ * Build `agentcard` with your company's client credentials. Returns at once; `ready` resolves once
84
+ * the listener reaches Agentcard. Each event runs its callback once, in order, and the next event
85
+ * waits for it: a callback that throws runs again, up to five times, before the listener reports
86
+ * it and moves on. Run one listener per company and mode: two listeners each hear every event.
87
+ */
88
+ export function listenToAgentcard(agentcard, opts) {
89
+ if (!agentcard || typeof agentcard.bearer !== 'function')
90
+ throw new TypeError('Pass the AgentcardClient built with your company credentials.');
91
+ if (!agentcard.company)
92
+ throw new TypeError('Listening takes your company credentials: build the client with clientId and clientSecret.');
93
+ if (opts === null || typeof opts !== 'object')
94
+ throw new TypeError('Pass { onMessage } or { onEvent }.');
95
+ if (opts.onMessage === undefined && opts.onEvent === undefined)
96
+ throw new TypeError('Pass onMessage, onEvent, or both.');
97
+ for (const name of ['onMessage', 'onEvent', 'onError']) {
98
+ if (opts[name] !== undefined && typeof opts[name] !== 'function')
99
+ throw new TypeError(`${name} must be a function.`);
100
+ }
101
+ const filter = opts.events === undefined ? null : [...opts.events];
102
+ if (filter !== null && (filter.length === 0 || filter.some((p) => typeof p !== 'string' || !isEventPattern(p)))) {
103
+ throw new TypeError('events lists event types Agentcard sends, families such as order.*, or *. Leave it out for every event.');
104
+ }
105
+ const transportOpt = opts.transport ?? 'auto';
106
+ if (!['auto', 'stream', 'poll'].includes(transportOpt))
107
+ throw new TypeError("transport is 'auto', 'stream' or 'poll'.");
108
+ const fetchFn = (opts.fetch ?? globalThis.fetch);
109
+ if (typeof fetchFn !== 'function')
110
+ throw new TypeError('Pass fetch: this runtime has no global fetch. Node 18 and later have one.');
111
+ const pollIntervalMs = opts.pollIntervalMs ?? 2_000;
112
+ const heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? 45_000;
113
+ const pollTimeoutMs = opts.pollTimeoutMs ?? 30_000;
114
+ const stop = new AbortController();
115
+ let transport = transportOpt === 'poll' ? 'poll' : 'stream';
116
+ let cursor = null;
117
+ let savedCursor = null;
118
+ let lastSave = 0;
119
+ let resolveReady;
120
+ const ready = new Promise((resolve) => {
121
+ resolveReady = resolve;
122
+ });
123
+ const report = (error, event) => {
124
+ try {
125
+ const reported = opts.onError ? opts.onError(error, event) : console.error('Agentcard listener:', error);
126
+ if (reported && typeof reported.then === 'function')
127
+ reported.then(undefined, () => { });
128
+ }
129
+ catch {
130
+ // A failing reporter never stops the listener.
131
+ }
132
+ };
133
+ const finished = new Set();
134
+ /** The ids handled last, saved with the cursor, so a new process skips what Agentcard hands it again. */
135
+ /** Each handled id with the cursor time it came with, which is never earlier than the event's own place. */
136
+ const recent = [];
137
+ let replayMs = DEFAULT_REPLAY_MS;
138
+ const finishAt = (id, at) => {
139
+ finished.add(id);
140
+ if (finished.size > HANDLED_EVENTS_KEPT)
141
+ finished.delete(finished.values().next().value);
142
+ recent.push({ id, at: at ?? Date.now() });
143
+ if (recent.length > HANDLED_EVENTS_KEPT)
144
+ recent.shift();
145
+ };
146
+ /**
147
+ * What the store keeps: Agentcard's cursor, then the id of every event a resume from it could hand
148
+ * over again, as `<cursor>.<id>,<id>`. An id older than the replay window behind the cursor is dropped.
149
+ */
150
+ const stored = (next) => {
151
+ const at = cursorTime(next);
152
+ if (at !== null) {
153
+ const keepFrom = at - replayMs - REPLAY_MARGIN_MS;
154
+ while (recent.length > 0 && recent[0].at < keepFrom)
155
+ recent.shift();
156
+ }
157
+ return recent.length > 0 ? `${next}.${recent.map((r) => r.id).join(',')}` : next;
158
+ };
159
+ const save = async (next, force) => {
160
+ cursor = next;
161
+ const value = stored(next);
162
+ if (!opts.cursor || value === savedCursor)
163
+ return;
164
+ if (!force && Date.now() - lastSave < IDLE_SAVE_MS)
165
+ return;
166
+ try {
167
+ await opts.cursor.save(value);
168
+ savedCursor = value;
169
+ lastSave = Date.now();
170
+ }
171
+ catch (error) {
172
+ report(error);
173
+ }
174
+ };
175
+ /**
176
+ * Runs one event's callback, and tries again when it throws, up to five times in all. Answers
177
+ * false when the listener closed with the callback still failing: the event stays unfinished and
178
+ * nothing saves past it, so the next process runs it again.
179
+ */
180
+ const handle = async (event, at) => {
181
+ if (finished.has(event.id))
182
+ return true;
183
+ const finish = (id) => {
184
+ finishAt(id, at);
185
+ return true;
186
+ };
187
+ if (filter !== null && !filter.some((p) => matchesEventPattern(event.type, p)))
188
+ return finish(event.id);
189
+ let message = null;
190
+ try {
191
+ message = readShopperMessage(event);
192
+ }
193
+ catch (error) {
194
+ // A message the package cannot read reads the same way every time, so it is reported once.
195
+ report(error, event);
196
+ }
197
+ // A message whose link or window closed before it arrived reaches no shopper, and the event still reaches onEvent.
198
+ const forShopper = message && message.externalUserId !== null && !isStale(message) ? message : null;
199
+ for (let attempt = 1;; attempt++) {
200
+ try {
201
+ if (opts.onMessage && forShopper?.externalUserId)
202
+ await opts.onMessage(forShopper.externalUserId, forShopper, event);
203
+ else if (opts.onEvent)
204
+ await opts.onEvent(event);
205
+ return finish(event.id);
206
+ }
207
+ catch (error) {
208
+ if (attempt >= CALLBACK_ATTEMPTS) {
209
+ report(error, event);
210
+ return finish(event.id);
211
+ }
212
+ // Closing mid-failure leaves the event for the next process, which has the tries left.
213
+ if (stop.signal.aborted) {
214
+ report(error, event);
215
+ return false;
216
+ }
217
+ await sleep(1_000 * 2 ** (attempt - 1), stop.signal);
218
+ }
219
+ }
220
+ };
221
+ const query = (after, extra = {}) => {
222
+ const params = new URLSearchParams(extra);
223
+ if (after)
224
+ params.set('after', after);
225
+ if (filter)
226
+ params.set('types', filter.join(','));
227
+ const text = params.toString();
228
+ return text ? `?${text}` : '';
229
+ };
230
+ /** One authorized GET: a 401 renews the token once. */
231
+ const get = async (path, accept, signal) => {
232
+ let token = await agentcard.bearer();
233
+ for (let attempt = 0;; attempt++) {
234
+ const res = await fetchFn(`${agentcard.baseUrl}${path}`, { method: 'GET', headers: { authorization: `Bearer ${token}`, accept }, signal });
235
+ if (res.status !== 401 || attempt > 0)
236
+ return res;
237
+ await res.text().catch(() => '');
238
+ token = await agentcard.bearer(token);
239
+ }
240
+ };
241
+ /** A refused cursor: Agentcard no longer keeps it, so the listener reports it and starts from now. */
242
+ const dropCursor = async (error) => {
243
+ report(error);
244
+ cursor = null;
245
+ };
246
+ /** One stream connection, read to its end. Resolves when the server asks to reconnect or the connection drops. */
247
+ const streamOnce = async () => {
248
+ const conn = new AbortController();
249
+ const abort = () => conn.abort();
250
+ stop.signal.addEventListener('abort', abort);
251
+ let silence;
252
+ let wentSilent = false;
253
+ const arm = () => {
254
+ if (silence)
255
+ clearTimeout(silence);
256
+ silence = setTimeout(() => {
257
+ wentSilent = true;
258
+ abort();
259
+ }, heartbeatTimeoutMs);
260
+ };
261
+ let opened = false;
262
+ try {
263
+ arm();
264
+ let res;
265
+ try {
266
+ res = await get(`/api/v2/events/stream${query(cursor)}`, 'text/event-stream', conn.signal);
267
+ }
268
+ catch (error) {
269
+ // No answer within the heartbeat timeout is a stream held back on its way, not a lost network.
270
+ if (wentSilent && !stop.signal.aborted)
271
+ throw new StreamNotEstablished('The event stream sent nothing before Agentcard said it was ready.');
272
+ throw error;
273
+ }
274
+ if (res.status === 400 || res.status === 410) {
275
+ const error = await refusal(res);
276
+ if (error.code === 'invalid_cursor' || error.code === 'cursor_expired') {
277
+ await dropCursor(error);
278
+ return 'reconnect';
279
+ }
280
+ throw error;
281
+ }
282
+ const type = res.headers.get('content-type') ?? '';
283
+ if (res.status === 404 || res.status === 405 || (res.ok && !type.includes('text/event-stream'))) {
284
+ await res.text().catch(() => '');
285
+ throw new StreamUnavailable(`Agentcard answered the stream with ${res.status} ${type || 'and no content type'}.`);
286
+ }
287
+ if (!res.ok || !res.body)
288
+ throw await refusal(res);
289
+ const reader = res.body.getReader();
290
+ const decoder = new TextDecoder();
291
+ let buffer = '';
292
+ for (;;) {
293
+ let chunk;
294
+ try {
295
+ chunk = await reader.read();
296
+ }
297
+ catch (error) {
298
+ if (!opened && !stop.signal.aborted)
299
+ throw new StreamNotEstablished('The event stream went silent before Agentcard said it was ready.');
300
+ throw error;
301
+ }
302
+ const { done, value } = chunk;
303
+ // A connection that ends before Agentcard said it is ready counts as a failure, so it backs off.
304
+ if (done) {
305
+ if (!opened)
306
+ throw new StreamNotEstablished('The event stream closed before Agentcard said it was ready.');
307
+ return 'dropped';
308
+ }
309
+ arm();
310
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n');
311
+ let cut;
312
+ while ((cut = buffer.indexOf('\n\n')) >= 0) {
313
+ const block = buffer.slice(0, cut);
314
+ buffer = buffer.slice(cut + 2);
315
+ let id;
316
+ let name = 'message';
317
+ const data = [];
318
+ for (const line of block.split('\n')) {
319
+ if (line === '' || line.startsWith(':'))
320
+ continue;
321
+ const colon = line.indexOf(':');
322
+ const field = colon < 0 ? line : line.slice(0, colon);
323
+ const val = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
324
+ if (field === 'id')
325
+ id = val;
326
+ else if (field === 'event')
327
+ name = val;
328
+ else if (field === 'data')
329
+ data.push(val);
330
+ }
331
+ if (name === 'ready') {
332
+ opened = true;
333
+ resolveReady();
334
+ const announced = parseJson(data.join('\n'));
335
+ if (isRecord(announced) && typeof announced.replay_seconds === 'number' && announced.replay_seconds > 0) {
336
+ replayMs = Math.max(DEFAULT_REPLAY_MS, announced.replay_seconds * 1000);
337
+ }
338
+ if (id)
339
+ await save(id, false);
340
+ }
341
+ else if (name === 'heartbeat') {
342
+ if (id)
343
+ await save(id, false);
344
+ }
345
+ else if (name === 'reconnect') {
346
+ if (id)
347
+ await save(id, false);
348
+ await reader.cancel().catch(() => { });
349
+ return 'reconnect';
350
+ }
351
+ else if (name === 'message' || name === 'replay') {
352
+ // A replay is an event Agentcard hands over again after a resume, in case it committed late; an id already handled runs nothing.
353
+ const event = eventOf(parseJson(data.join('\n')));
354
+ if (event) {
355
+ // A callback that runs long is not silence on the stream.
356
+ if (silence)
357
+ clearTimeout(silence);
358
+ const handled = await handle(event, cursorTime(id));
359
+ arm();
360
+ // An event the shutdown left unfinished saves no cursor past it.
361
+ if (!handled)
362
+ return 'dropped';
363
+ }
364
+ if (id)
365
+ await save(id, true);
366
+ }
367
+ if (stop.signal.aborted)
368
+ return 'dropped';
369
+ }
370
+ }
371
+ }
372
+ finally {
373
+ if (silence)
374
+ clearTimeout(silence);
375
+ stop.signal.removeEventListener('abort', abort);
376
+ conn.abort();
377
+ }
378
+ };
379
+ /** One page of a polling read, given up after `pollTimeoutMs` so a stalled read never stops the listener. */
380
+ const readPage = async () => {
381
+ const conn = new AbortController();
382
+ const abort = () => conn.abort();
383
+ stop.signal.addEventListener('abort', abort);
384
+ const deadline = setTimeout(abort, pollTimeoutMs);
385
+ try {
386
+ const res = await get(`/api/v2/events${query(cursor, { limit: '100' })}`, 'application/json', conn.signal);
387
+ return { status: res.status, ok: res.ok, text: await res.text() };
388
+ }
389
+ finally {
390
+ clearTimeout(deadline);
391
+ stop.signal.removeEventListener('abort', abort);
392
+ }
393
+ };
394
+ /** One polling read, every page of it. */
395
+ const pollOnce = async () => {
396
+ for (;;) {
397
+ const res = await readPage();
398
+ if (!res.ok) {
399
+ const error = await refusal({ status: res.status, text: async () => res.text });
400
+ if (error.code === 'invalid_cursor' || error.code === 'cursor_expired')
401
+ return dropCursor(error);
402
+ throw error;
403
+ }
404
+ const body = parseJson(res.text);
405
+ if (!isRecord(body) || !Array.isArray(body.data))
406
+ throw new AgentcardApiError(res.status, 'invalid_answer', 'Agentcard answered the event read with a body the listener cannot read.');
407
+ resolveReady();
408
+ for (const item of body.data) {
409
+ if (stop.signal.aborted)
410
+ return;
411
+ const event = isRecord(item) ? eventOf(item.event) : null;
412
+ const itemCursor = isRecord(item) && typeof item.cursor === 'string' ? item.cursor : null;
413
+ // An event the shutdown left unfinished saves no cursor past it.
414
+ if (event && !(await handle(event, cursorTime(itemCursor))))
415
+ return;
416
+ if (itemCursor)
417
+ await save(itemCursor, true);
418
+ }
419
+ if (typeof body.cursor === 'string')
420
+ await save(body.cursor, body.data.length > 0);
421
+ if (body.has_more !== true)
422
+ return;
423
+ }
424
+ };
425
+ const loop = (async () => {
426
+ if (opts.cursor) {
427
+ try {
428
+ const loaded = await opts.cursor.load();
429
+ if (typeof loaded === 'string' && loaded !== '') {
430
+ savedCursor = loaded;
431
+ const dot = loaded.indexOf('.');
432
+ cursor = dot < 0 ? loaded : loaded.slice(0, dot);
433
+ for (const id of dot < 0 ? [] : loaded.slice(dot + 1).split(','))
434
+ if (/^[A-Za-z0-9_]{1,64}$/.test(id))
435
+ finishAt(id, cursorTime(cursor));
436
+ }
437
+ }
438
+ catch (error) {
439
+ report(error);
440
+ }
441
+ }
442
+ let failures = 0;
443
+ let unestablished = 0;
444
+ while (!stop.signal.aborted) {
445
+ try {
446
+ if (transport === 'stream') {
447
+ const ended = await streamOnce();
448
+ failures = 0;
449
+ unestablished = 0;
450
+ if (ended === 'reconnect')
451
+ continue;
452
+ }
453
+ else {
454
+ await pollOnce();
455
+ failures = 0;
456
+ await sleep(pollIntervalMs, stop.signal);
457
+ continue;
458
+ }
459
+ }
460
+ catch (error) {
461
+ if (stop.signal.aborted)
462
+ break;
463
+ if (error instanceof StreamUnavailable && transportOpt === 'auto') {
464
+ transport = 'poll';
465
+ continue;
466
+ }
467
+ failures++;
468
+ report(error);
469
+ if (error instanceof StreamNotEstablished && transportOpt === 'auto' && ++unestablished >= STREAM_ATTEMPTS_BEFORE_POLLING) {
470
+ // Something between here and Agentcard holds the stream back; the same events come by polling, from the same cursor.
471
+ transport = 'poll';
472
+ continue;
473
+ }
474
+ }
475
+ if (stop.signal.aborted)
476
+ break;
477
+ // A dropped connection or a failed read waits a little longer each time, up to 30 seconds.
478
+ const backoff = Math.min(MAX_BACKOFF_MS, 1_000 * 2 ** Math.max(0, failures - 1));
479
+ await sleep(failures === 0 ? 250 : backoff * (0.5 + Math.random() / 2), stop.signal);
480
+ }
481
+ resolveReady();
482
+ })();
483
+ return {
484
+ ready,
485
+ get transport() {
486
+ return transport;
487
+ },
488
+ get cursor() {
489
+ return cursor;
490
+ },
491
+ async close() {
492
+ stop.abort();
493
+ await loop;
494
+ },
495
+ };
496
+ }
package/dist/message.d.ts CHANGED
@@ -9,13 +9,15 @@ import { type AgentcardEvent } from './verify.js';
9
9
  * - `order_placed`: the shopper approved, and Agentcard placed the order.
10
10
  * - `order_failed`: the order failed after the approval, and nothing more happens for that approval.
11
11
  * - `order_not_placed_yet`: the shopper approved, and the order is not placed yet.
12
+ * - `order_unconfirmed`: the store's reply to the order was lost, so the order may exist; Agentcard is confirming it.
12
13
  * - `approval_expiring`: the approval link expires soon; `text` carries the link.
13
14
  * - `approval_expired`: the approval expired before the shopper answered, and nothing was charged.
14
15
  * - `card_link_unfinished`: the shopper has not added a card from their card link; `text` carries the link again.
15
16
  * - `card_link_expired`: the card link expired before the shopper added a card.
16
17
  * - `cart_idle`: a cart sat without being sent for approval; `facts.nudgeNumber` counts the nudges.
18
+ * - `card_added`: the shopper saved a card from the card link your app sent, and can check out with it.
17
19
  */
18
- export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'approval_expiring' | 'approval_expired' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle';
20
+ export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'order_unconfirmed' | 'approval_expiring' | 'approval_expired' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle' | 'card_added';
19
21
  /** The message's values one by one, for a company that writes its own copy. A fact that does not apply is absent. */
20
22
  export interface PurchaseFacts {
21
23
  merchantName?: string;
@@ -20,7 +20,11 @@ export interface BuyToolModelCart {
20
20
  total: string;
21
21
  }
22
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;
23
25
  name: string;
26
+ /** The store that sells it, by name, never a link: a product with no store is not shown to the model. */
27
+ store: string;
24
28
  price: string | null;
25
29
  /** True when the shopper holds this product's store page, which `sendLink` delivered beside the reply. */
26
30
  link_sent: boolean;
@@ -39,12 +43,22 @@ export interface BuyToolModelView {
39
43
  products: BuyToolModelProduct[];
40
44
  approval_expires_at: string | null;
41
45
  shopper_need_not_reply: boolean;
46
+ /** 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. */
47
+ shopper_already_told_looking: boolean;
42
48
  /** True when the shopper already has the link this answer needed. */
43
49
  link_sent: boolean;
44
50
  /** How many store pages of the products above the shopper holds, 0 when none went out. */
45
51
  product_links_sent: number;
52
+ /** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
53
+ pick: {
54
+ product_id: string | null;
55
+ in_cart: boolean;
56
+ } | null;
46
57
  }
47
- /** The text with every link taken out: a Markdown link keeps its label, and a bare address goes entirely. */
58
+ /**
59
+ * The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
60
+ * entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
61
+ */
48
62
  export declare function stripLinks(text: string): string;
49
63
  /**
50
64
  * An amount in its currency's minor unit, as the model reads it: the currency's own minor units place the
@@ -53,14 +67,20 @@ export declare function stripLinks(text: string): string;
53
67
  */
54
68
  export declare function money(minor: number | null | undefined, currency: string | null | undefined): string | null;
55
69
  /**
56
- * The products the model reads: the first twenty that keep a name once links
57
- * are taken out. The tool sends store pages for these same twenty, so the shopper
58
- * never holds a product link the model cannot name.
70
+ * The products the model reads: the first twenty that keep a name and a store
71
+ * once links are taken out, so the model never offers a product without saying
72
+ * where it is sold. The tool sends store pages for these same twenty, so the
73
+ * shopper never holds a product link the model cannot name.
59
74
  */
60
75
  export declare function shownProducts(products: readonly BuyToolProduct[] | null | undefined): BuyToolProduct[];
76
+ /**
77
+ * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
78
+ * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
79
+ */
80
+ export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): string | null;
61
81
  /**
62
82
  * The model's view of one answer: the status, Agentcard's reply, the newest
63
- * cart and every cart on the table, the products by name and price, and
83
+ * cart and every cart on the table, the products by id, name and price, and
64
84
  * whether the shopper has each link. A status this version does not know
65
85
  * reads as `needs_input`.
66
86
  */
@@ -10,15 +10,40 @@ const HOST_PATH = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}[/?#][^\s<>"'`)\]]*/gi;
10
10
  * A bare host on a common top-level domain, such as `agentcard.sh` or `amazon.com`, which a chat app
11
11
  * turns into a link. The list keeps words with a dot, such as `Node.js`, and an email address's host.
12
12
  */
13
- const BARE_HOST = /(?<![@\w.-])(?:[a-z0-9-]+\.)+(?:com|net|org|io|co|sh|ai|app|dev|me|us|uk|ca|de|fr|es|it|nl|eu|au|in|jp|br|mx|info|biz|shop|store|site|online|link|page|xyz|ly|gg|to|tv|fm|so)(?![\w-])/gi;
14
- /** The text with every link taken out: a Markdown link keeps its label, and a bare address goes entirely. */
13
+ const BARE_HOST = /(?<![@\w.-])(?:[a-z0-9-]+\.)+(?:com|net|org|io|co|sh|ai|app|dev|me|us|uk|ca|de|fr|es|it|nl|eu|au|in|jp|br|mx|info|biz|shop|store|site|online|link|page|xyz|ly|gg|to|tv|fm|so)(?:\.[a-z]{2})?(?![\w-])/gi;
14
+ /** The labels of a domain ending under a country's (amazon.co.uk), which name no store. */
15
+ const SECOND_LEVEL = new Set(['co', 'com', 'org', 'net', 'ac', 'gov', 'edu']);
16
+ /** Labels before a store's own that name a part of its site: shop.nike.com is Nike's, ca.mavi.com is Mavi's. */
17
+ const SITE_PART = new Set(['www', 'shop', 'store', 'stores', 'm', 'mobile', 'en', 'us', 'ca', 'uk']);
18
+ /**
19
+ * The name a bare address stands for, as the model reads a store in `reply`: walmart.com reads Walmart, shop.nike.com
20
+ * Nike, amazon.co.uk Amazon. Agentcard names a store by its domain, and a removed domain left the model with "Buy it at
21
+ * for $54.00" and no store to tell the shopper.
22
+ */
23
+ function siteName(host) {
24
+ const labels = host.toLowerCase().split('.').filter(Boolean);
25
+ labels.pop();
26
+ if (labels.length > 1 && SECOND_LEVEL.has(labels[labels.length - 1]))
27
+ labels.pop();
28
+ const name = labels.find((label, i) => i === labels.length - 1 || !(SITE_PART.has(label) || label.length <= 2)) ?? '';
29
+ return name ? name[0].toUpperCase() + name.slice(1) : '';
30
+ }
31
+ /** A www address: the site's name when it is bare (the closing mark it ends a sentence with stays), nothing when it carries a path. */
32
+ function wwwName(address) {
33
+ const [, host, closing] = /^(.*?)([.,;:!?]*)$/.exec(address) ?? [];
34
+ return host && !/[/?#]/.test(host) ? siteName(host) + (closing ?? '') : '';
35
+ }
36
+ /**
37
+ * The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
38
+ * entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
39
+ */
15
40
  export function stripLinks(text) {
16
41
  return String(text ?? '')
17
42
  .replace(MARKDOWN_LINK, '$1')
18
43
  .replace(SCHEME_URL, '')
19
- .replace(WWW_URL, '')
44
+ .replace(WWW_URL, wwwName)
20
45
  .replace(HOST_PATH, '')
21
- .replace(BARE_HOST, '')
46
+ .replace(BARE_HOST, siteName)
22
47
  .replace(/\(\s*\)|\[\s*\]|<\s*>/g, '')
23
48
  .replace(/[ \t]+([.,;:!?])/g, '$1')
24
49
  .replace(/[ \t]{2,}/g, ' ')
@@ -60,12 +85,22 @@ export function money(minor, currency) {
60
85
  return format.format(minor / 10 ** minorUnits(format));
61
86
  }
62
87
  /**
63
- * The products the model reads: the first twenty that keep a name once links
64
- * are taken out. The tool sends store pages for these same twenty, so the shopper
65
- * never holds a product link the model cannot name.
88
+ * The products the model reads: the first twenty that keep a name and a store
89
+ * once links are taken out, so the model never offers a product without saying
90
+ * where it is sold. The tool sends store pages for these same twenty, so the
91
+ * shopper never holds a product link the model cannot name.
66
92
  */
67
93
  export function shownProducts(products) {
68
- return (products ?? []).filter((product) => stripLinks(product.name) !== '').slice(0, MAX_PRODUCTS);
94
+ return (products ?? []).filter((product) => stripLinks(product.name) !== '' && stripLinks(product.store ?? '') !== '').slice(0, MAX_PRODUCTS);
95
+ }
96
+ /**
97
+ * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
98
+ * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
99
+ */
100
+ export function pickId(product) {
101
+ if (typeof product.ref === 'string' && product.ref !== '' && stripLinks(product.ref) === product.ref)
102
+ return product.ref;
103
+ return typeof product.id === 'string' && product.id !== '' && stripLinks(product.id) === product.id ? product.id : null;
69
104
  }
70
105
  function cartView(cart) {
71
106
  return {
@@ -78,7 +113,7 @@ function cartView(cart) {
78
113
  }
79
114
  /**
80
115
  * 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
116
+ * cart and every cart on the table, the products by id, name and price, and
82
117
  * whether the shopper has each link. A status this version does not know
83
118
  * reads as `needs_input`.
84
119
  */
@@ -92,12 +127,15 @@ export function forModel(result) {
92
127
  reply: stripLinks(result.reply),
93
128
  cart: result.cart ? cartView(result.cart) : null,
94
129
  carts: (result.carts ?? []).map(cartView),
95
- products: products.map((product) => ({ name: stripLinks(product.name), price: money(product.priceCents, product.currency), link_sent: held(product) })),
130
+ products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: money(product.priceCents, product.currency), link_sent: held(product) })),
96
131
  approval_expires_at: typeof result.approval_expires_at === 'string' ? stripLinks(result.approval_expires_at) || null : null,
97
132
  shopper_need_not_reply: result.shopper_need_not_reply === true,
133
+ shopper_already_told_looking: result.shopper_already_told_looking === true,
98
134
  link_sent: result.link_sent === true,
99
135
  // Two products on one page share one link, so the count is of pages.
100
136
  product_links_sent: new Set(products.filter(held).map((product) => product.url)).size,
137
+ // The id the model sent, never the catalog id, which is a store page for a retail product.
138
+ pick: result.pick ? { product_id: pickId({ id: result.pick.product_id, ref: result.pick.ref }), in_cart: result.pick.in_cart === true } : null,
101
139
  };
102
140
  }
103
141
  /**