@agent-cards/commerce-sdk 0.18.11 → 0.18.14
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/buy-tool.d.ts +8 -0
- package/dist/buy-tool.js +44 -4
- package/dist/client.d.ts +8 -0
- package/dist/client.js +34 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/list-marks.d.ts +13 -0
- package/dist/list-marks.js +40 -0
- package/dist/model-view.d.ts +5 -3
- package/dist/model-view.js +17 -7
- package/dist/search-groups.d.ts +70 -0
- package/dist/search-groups.js +60 -0
- package/dist/store-pages.d.ts +30 -0
- package/dist/store-pages.js +92 -0
- package/package.json +1 -1
package/dist/buy-tool.d.ts
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import type { BuyCardSize } from './card-size.js';
|
|
35
35
|
import type { BuyPlan } from './plan.js';
|
|
36
36
|
import type { BuyMerchantStatusUpdate, BuySearchStatus } from './search-status.js';
|
|
37
|
+
import { type BuyGroupsUpdate } from './search-groups.js';
|
|
37
38
|
import type { BuyFreshness, BuyRecheck, BuyStoreCheckUpdate } from './client.js';
|
|
38
39
|
import type { BuyFulfilledBy } from './fulfilled-by.js';
|
|
39
40
|
import { AgentcardClient, type BuyCart, type BuyCartBuilding, type BuyChoice, type BuyCodeRequest, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type BuyTurnStatus, type BuyAvailability, type BuyStorePick, type BuyTurn, type FetchLike } from './client.js';
|
|
@@ -421,6 +422,11 @@ export interface BuyToolOptions extends BuyStoreWait {
|
|
|
421
422
|
* search starts, then what it found. One that throws costs nothing but the call to it.
|
|
422
423
|
*/
|
|
423
424
|
onMerchantStatus?: (update: BuyMerchantStatusUpdate) => void | Promise<void>;
|
|
425
|
+
/**
|
|
426
|
+
* Hears every group of a call's products each time one changes, and once more from the answer (`BuyGroupsUpdate`), each
|
|
427
|
+
* group's `product_ids` naming the cards' `productId`s. One that throws costs nothing but the call to it.
|
|
428
|
+
*/
|
|
429
|
+
onGroups?: (update: BuyGroupsUpdate) => void | Promise<void>;
|
|
424
430
|
/**
|
|
425
431
|
* Hears each store behind a card while Agentcard checks its price and stock, then the store's answer
|
|
426
432
|
* (`BuyStoreCheckUpdate`): `cardId` is the card's `productId`. One that throws costs nothing but the call to it.
|
|
@@ -662,6 +668,8 @@ export interface BuyToolTurnOptions extends BuyStoreWait {
|
|
|
662
668
|
onAvailability?: BuyToolOptions['onAvailability'];
|
|
663
669
|
/** Hears each store this turn's searches ask, as the tool's `onMerchantStatus` does. */
|
|
664
670
|
onMerchantStatus?: BuyToolOptions['onMerchantStatus'];
|
|
671
|
+
/** Hears the groups of this turn's products, as the tool's `onGroups` does. */
|
|
672
|
+
onGroups?: BuyToolOptions['onGroups'];
|
|
665
673
|
/** Hears each store behind this turn's cards as it is checked, as the tool's `onStoreCheck` does. */
|
|
666
674
|
onStoreCheck?: BuyToolOptions['onStoreCheck'];
|
|
667
675
|
/** Hears this turn's re-check of the shopper's quotes, as the tool's `onRecheck` does. */
|
package/dist/buy-tool.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { groupsOf } from './search-groups.js';
|
|
1
2
|
import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
|
|
2
3
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
3
4
|
import { directCall, isProductAsk } from './direct.js';
|
|
4
5
|
import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
5
6
|
import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
|
|
7
|
+
import { onlyListMarks } from './list-marks.js';
|
|
6
8
|
import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
|
|
7
9
|
import { isStale, messageInChat } from './shopper-message.js';
|
|
8
10
|
import { actionLinkId, isActionLink } from './action-links.js';
|
|
@@ -777,7 +779,7 @@ export function buyTool(options) {
|
|
|
777
779
|
if (o.stream !== undefined && o.stream !== false)
|
|
778
780
|
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
779
781
|
const onProgress = o.onProgress;
|
|
780
|
-
for (const key of ['onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onStoreCheck', 'onRecheck', 'onFreshness']) {
|
|
782
|
+
for (const key of ['onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onGroups', 'onStoreCheck', 'onRecheck', 'onFreshness']) {
|
|
781
783
|
if (o[key] !== undefined && typeof o[key] !== 'function')
|
|
782
784
|
throw new TypeError(`Pass ${key} as a function, or leave it out.`);
|
|
783
785
|
}
|
|
@@ -859,6 +861,7 @@ export function buyTool(options) {
|
|
|
859
861
|
onFound: o.onFound,
|
|
860
862
|
onAvailability: o.onAvailability,
|
|
861
863
|
onMerchantStatus: o.onMerchantStatus,
|
|
864
|
+
onGroups: o.onGroups,
|
|
862
865
|
onStoreCheck: o.onStoreCheck,
|
|
863
866
|
onRecheck: o.onRecheck,
|
|
864
867
|
onFreshness: o.onFreshness,
|
|
@@ -1664,7 +1667,14 @@ export function buyTool(options) {
|
|
|
1664
1667
|
// A step link held for the message that introduces it goes out before this call hands over any of its own.
|
|
1665
1668
|
explicit?.release?.();
|
|
1666
1669
|
const withdrawalsAtStart = withdrawals;
|
|
1667
|
-
const
|
|
1670
|
+
const parsedInput = parseBuyToolInput(raw);
|
|
1671
|
+
// A tapped turn's first call that names no product and no store picks the tapped card (TurnState.tapped).
|
|
1672
|
+
const tappedId = explicit?.tapped ?? null;
|
|
1673
|
+
if (explicit)
|
|
1674
|
+
explicit.tapped = null;
|
|
1675
|
+
const input = tappedId && !parsedInput.confirm && !parsedInput.new_order && !parsedInput.product_id && !parsedInput.store_id
|
|
1676
|
+
? { ...parsedInput, product_id: tappedId }
|
|
1677
|
+
: parsedInput;
|
|
1668
1678
|
// With carts at more than one store waiting, a new order would drop every one of them: "cancel the
|
|
1669
1679
|
// reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
|
|
1670
1680
|
// The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
|
|
@@ -1783,7 +1793,9 @@ export function buyTool(options) {
|
|
|
1783
1793
|
let heldTexts = [];
|
|
1784
1794
|
/** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
|
|
1785
1795
|
const showMessage = async (message, texts, conversationId) => {
|
|
1786
|
-
|
|
1796
|
+
// A message of list marks or punctuation alone, such as `1.; 2.`, says nothing, and the shopper never reads it (list-marks.ts).
|
|
1797
|
+
if (!onlyListMarks(message))
|
|
1798
|
+
await speak(messageForShopper(message, leadIn, t.texting), { conversationId });
|
|
1787
1799
|
leadIn = leadsIntoList(message);
|
|
1788
1800
|
told.push(...texts);
|
|
1789
1801
|
};
|
|
@@ -1995,6 +2007,21 @@ export function buyTool(options) {
|
|
|
1995
2007
|
},
|
|
1996
2008
|
}
|
|
1997
2009
|
: {}),
|
|
2010
|
+
...(t.onGroups
|
|
2011
|
+
? {
|
|
2012
|
+
onGroups: async (update) => {
|
|
2013
|
+
// The answer's groups go out once the call has its answer, below, whether it streamed or not.
|
|
2014
|
+
if (update.final)
|
|
2015
|
+
return;
|
|
2016
|
+
try {
|
|
2017
|
+
await t.onGroups({ ...update, groups: update.groups.map((g) => ({ ...g, product_ids: g.product_ids.map((id) => cardOfId.get(id) ?? id) })) });
|
|
2018
|
+
}
|
|
2019
|
+
catch {
|
|
2020
|
+
// The app's display failing never fails the turn.
|
|
2021
|
+
}
|
|
2022
|
+
},
|
|
2023
|
+
}
|
|
2024
|
+
: {}),
|
|
1998
2025
|
...(t.onStoreCheck
|
|
1999
2026
|
? {
|
|
2000
2027
|
onStoreCheck: async (update) => {
|
|
@@ -2138,6 +2165,17 @@ export function buyTool(options) {
|
|
|
2138
2165
|
}
|
|
2139
2166
|
}
|
|
2140
2167
|
ack?.cancel();
|
|
2168
|
+
// The answer's groups, each product named by the card it goes out as, as `pickId` names a card.
|
|
2169
|
+
if (t.onGroups && turn?.catalog) {
|
|
2170
|
+
const cardOf = new Map(turn.catalog.items.map((item) => [item.id, pickId({ id: item.id, ref: item.ref }) ?? item.id]));
|
|
2171
|
+
const groups = groupsOf(turn).map(({ products: _, ...g }) => ({ ...g, product_ids: g.product_ids.map((id) => cardOf.get(id) ?? id) }));
|
|
2172
|
+
try {
|
|
2173
|
+
await t.onGroups({ conversationId: turn.conversation_id || null, groups, final: true });
|
|
2174
|
+
}
|
|
2175
|
+
catch {
|
|
2176
|
+
// The app's display failing never fails the call.
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2141
2179
|
const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
|
|
2142
2180
|
await t.ackOut;
|
|
2143
2181
|
// A newer message from the shopper stopped this turn, and its own turn answers: this one ends here and says nothing
|
|
@@ -2393,7 +2431,7 @@ export function buyTool(options) {
|
|
|
2393
2431
|
const own = options ?? {};
|
|
2394
2432
|
if (typeof own !== 'object')
|
|
2395
2433
|
throw new TypeError('Pass the turn options as an object, or leave them out.');
|
|
2396
|
-
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onStoreCheck', 'onRecheck', 'onFreshness', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
2434
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onGroups', 'onStoreCheck', 'onRecheck', 'onFreshness', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
2397
2435
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
2398
2436
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
2399
2437
|
}
|
|
@@ -2478,6 +2516,7 @@ export function buyTool(options) {
|
|
|
2478
2516
|
onFound: own.onFound ?? o.onFound,
|
|
2479
2517
|
onAvailability: own.onAvailability ?? o.onAvailability,
|
|
2480
2518
|
onMerchantStatus: own.onMerchantStatus ?? o.onMerchantStatus,
|
|
2519
|
+
onGroups: own.onGroups ?? o.onGroups,
|
|
2481
2520
|
onStoreCheck: own.onStoreCheck ?? o.onStoreCheck,
|
|
2482
2521
|
onRecheck: own.onRecheck ?? o.onRecheck,
|
|
2483
2522
|
onFreshness: own.onFreshness ?? o.onFreshness,
|
|
@@ -2587,6 +2626,7 @@ export function buyTool(options) {
|
|
|
2587
2626
|
const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
|
|
2588
2627
|
// A tap names its card by the id its link carried, whatever answer showed it: the pick goes straight with that id.
|
|
2589
2628
|
const tappedId = typeof options?.tapped?.productId === 'string' && options.tapped.productId.trim() ? options.tapped.productId.trim() : null;
|
|
2629
|
+
state.tapped = tappedId;
|
|
2590
2630
|
// A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
|
|
2591
2631
|
// straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
|
|
2592
2632
|
const direct = !directOn || running > 0
|
package/dist/client.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { type BuyTrip } from './trip.js';
|
|
12
12
|
import { type BuyPlan } from './plan.js';
|
|
13
13
|
import { type BuyMerchantStatusUpdate, type BuySearchStatus } from './search-status.js';
|
|
14
|
+
import { type BuyGroup, type BuyGroupsUpdate } from './search-groups.js';
|
|
14
15
|
import { type BuyFulfilledBy } from './fulfilled-by.js';
|
|
15
16
|
import { type BuyCardSize } from './card-size.js';
|
|
16
17
|
import { type AgentcardListener, type ListenOptions } from './listen.js';
|
|
@@ -196,6 +197,11 @@ export interface BuyStreamHandlers {
|
|
|
196
197
|
* follow one store, keep the updates `isMerchantStatusAt(update, 'walmart.com')` accepts. The answer's `search` holds every store.
|
|
197
198
|
*/
|
|
198
199
|
onMerchantStatus?: (update: BuyMerchantStatusUpdate) => void | Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* Every group of the turn's products (BuyGroup), each time one changes, and once more from the answer. An answer with no
|
|
202
|
+
* groups comes as one group holding every product, as `groupsOf` reads it. Draw one grid per group, in the order given.
|
|
203
|
+
*/
|
|
204
|
+
onGroups?: (update: BuyGroupsUpdate) => void | Promise<void>;
|
|
199
205
|
/**
|
|
200
206
|
* Agentcard reads the shopper's quotes again: on their return after a gap, or before a yes to a price older than its
|
|
201
207
|
* window. Comes with `phase: 'started'`, the line to show at once and every item `checking`, then `phase: 'finished'` with
|
|
@@ -557,6 +563,8 @@ export interface BuyCatalog {
|
|
|
557
563
|
merchant_name: string;
|
|
558
564
|
items: BuyCatalogItem[];
|
|
559
565
|
presented: boolean;
|
|
566
|
+
/** The groups `items` come in, one per store or item asked for (BuyGroup); absent from an older API. `groupsOf` reads them. */
|
|
567
|
+
groups?: BuyGroup[];
|
|
560
568
|
}
|
|
561
569
|
/** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
|
|
562
570
|
export interface BuyPick {
|
package/dist/client.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { tripOf } from './trip.js';
|
|
12
12
|
import { planOf } from './plan.js';
|
|
13
13
|
import { merchantStatusOf, searchOf } from './search-status.js';
|
|
14
|
+
import { groupListOf, groupOf, groupsOf } from './search-groups.js';
|
|
14
15
|
import { isRecord } from './verify.js';
|
|
15
16
|
import { failedPartyOf, fulfilledByField } from './fulfilled-by.js';
|
|
16
17
|
import { cardSizeField } from './card-size.js';
|
|
@@ -25,7 +26,7 @@ export const STATUS_WORDING = Object.freeze({
|
|
|
25
26
|
searching_wider: 'Checking more stores for this…',
|
|
26
27
|
});
|
|
27
28
|
/** The handlers that ask Agentcard to stream a turn. */
|
|
28
|
-
const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability', 'onReplyWritten', 'onMerchantStatus', 'onRecheck', 'onFreshness', 'onStoreCheck'];
|
|
29
|
+
const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability', 'onReplyWritten', 'onMerchantStatus', 'onGroups', 'onRecheck', 'onFreshness', 'onStoreCheck'];
|
|
29
30
|
const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
|
|
30
31
|
export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
|
|
31
32
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
@@ -299,7 +300,8 @@ function catalogOf(v) {
|
|
|
299
300
|
if (!isRecord(v))
|
|
300
301
|
return null;
|
|
301
302
|
const items = catalogItemsOf(v.items);
|
|
302
|
-
|
|
303
|
+
const groups = groupListOf(v.groups);
|
|
304
|
+
return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true, ...(groups ? { groups } : {}) };
|
|
303
305
|
}
|
|
304
306
|
/** A catalog block's products, as `items` or `more` lists them. */
|
|
305
307
|
function catalogItemsOf(v) {
|
|
@@ -596,6 +598,11 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
596
598
|
delivering = delivering.then(() => hand()).catch(() => { });
|
|
597
599
|
};
|
|
598
600
|
let failure = null;
|
|
601
|
+
// The turn's groups so far, in the order they first came; a group sent again takes its own place.
|
|
602
|
+
let groups = [];
|
|
603
|
+
// Whether a `group` event already carried the turn's final groups, which an answer with no catalog (a search that found
|
|
604
|
+
// nothing, a failed turn) can only say that way; the envelope's groups are the same, so they are not handed on twice.
|
|
605
|
+
let groupsFinal = false;
|
|
599
606
|
try {
|
|
600
607
|
await eachEvent(res, (event, text) => {
|
|
601
608
|
const data = parseJson(text);
|
|
@@ -606,6 +613,12 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
606
613
|
const named = conversationId;
|
|
607
614
|
if (event === 'envelope') {
|
|
608
615
|
envelope = data;
|
|
616
|
+
const catalog = catalogOf(data.catalog);
|
|
617
|
+
if (stream.onGroups && catalog && !groupsFinal) {
|
|
618
|
+
// An answer whose search found nothing still comes, as one `empty` group, so the app hears the search ended.
|
|
619
|
+
const final = groupsOf({ catalog, search: searchOf(data.search) }).map(({ products: _, ...g }) => g);
|
|
620
|
+
deliver(() => stream.onGroups({ conversationId: named, groups: final, final: true }));
|
|
621
|
+
}
|
|
609
622
|
return true;
|
|
610
623
|
}
|
|
611
624
|
if (event === 'progress' && typeof data.stage === 'string' && PROGRESS_STAGES.has(data.stage) && stream.onProgress) {
|
|
@@ -694,6 +707,25 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
694
707
|
deliver(() => stream.onStoreCheck(update));
|
|
695
708
|
}
|
|
696
709
|
}
|
|
710
|
+
else if (event === 'group' && stream.onGroups) {
|
|
711
|
+
// One group as it changes, or the whole list; an event with neither is read past.
|
|
712
|
+
const changed = Array.isArray(data.groups) ? groupListOf(data.groups) ?? [] : [groupOf(data.group)].flatMap((g) => g ?? []);
|
|
713
|
+
if (!changed.length)
|
|
714
|
+
return;
|
|
715
|
+
const final = data.final === true;
|
|
716
|
+
// A final list is the turn's whole answer: it replaces the groups held, whose ids it may have split into one per ask.
|
|
717
|
+
if (final && Array.isArray(data.groups))
|
|
718
|
+
groups = changed;
|
|
719
|
+
else {
|
|
720
|
+
for (const g of changed) {
|
|
721
|
+
const at = groups.findIndex((held) => held.id === g.id);
|
|
722
|
+
groups = at < 0 ? [...groups, g] : groups.map((held, i) => (i === at ? g : held));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const now = groups;
|
|
726
|
+
groupsFinal ||= final;
|
|
727
|
+
deliver(() => stream.onGroups({ conversationId: named, groups: now, final }));
|
|
728
|
+
}
|
|
697
729
|
else if (event === 'merchant' && stream.onMerchantStatus) {
|
|
698
730
|
const update = merchantStatusOf(data);
|
|
699
731
|
if (update)
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, type
|
|
|
4
4
|
export { ORDER_STEPS, updateOrder, arrivalText, type ShopperOrder, type OrderStep, type OrderPathStep, type OrderStepState, type ArrivalTextOptions, } from './order.js';
|
|
5
5
|
export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
|
|
6
6
|
export { searchAt, isMerchantStatusAt, merchantStatusLine, searchStatusLines, type BuySearchStatus, type BuyMerchantStatus, type BuyMerchantSearchStatus, type BuyMerchantStatusUpdate, } from './search-status.js';
|
|
7
|
+
export { groupsOf, type BuyGroup, type BuyGroupKind, type BuyGroupStatus, type BuyGroupWithProducts, type BuyGroupsSource, type BuyGroupsUpdate, } from './search-groups.js';
|
|
7
8
|
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyStoreCheckUpdate, type BuyFoundUpdate, type BuyTurnStatus, STATUS_WORDING, type BuySegmentUpdate, type BuyRecheck, type BuyFreshness, type BuyFreshnessState, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyLastAnswer, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyCartDiscount, type BuyCartHold, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyFlightParty, type BuyPaymentSource, type BuyConversation, type BuyLatestReply, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyCodeRequest, type BuyChoiceGroup, type BuyChoiceDelivery, type BuyChoiceOption, } from './client.js';
|
|
8
9
|
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
|
|
9
10
|
export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, } fr
|
|
|
4
4
|
export { ORDER_STEPS, updateOrder, arrivalText, } from './order.js';
|
|
5
5
|
export { agentcardWebhook, purchaseAgentWebhook, } from './handler.js';
|
|
6
6
|
export { searchAt, isMerchantStatusAt, merchantStatusLine, searchStatusLines, } from './search-status.js';
|
|
7
|
+
export { groupsOf, } from './search-groups.js';
|
|
7
8
|
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, STATUS_WORDING, } from './client.js';
|
|
8
9
|
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
|
|
9
10
|
export { toMessages, messageStream, typingDelay, } from './messages.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list mark with nothing after it is no message. When a reply's list items were only links or photos, such as
|
|
3
|
+
* `1. ; 2. `, taking the links out (stripLinks) left the marks
|
|
4
|
+
* alone, and on 2026-11-04 a shopper on chat.opencommerce.sh read a message that said only `1.; 2.`. These read a text for
|
|
5
|
+
* marks a removed item emptied, so an item that keeps its words keeps its number, and a mark with none goes.
|
|
6
|
+
*/
|
|
7
|
+
/** True when a message holds list marks or punctuation and nothing else, so it says nothing to the shopper. */
|
|
8
|
+
export declare function onlyListMarks(text: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The text without the list marks an item no longer follows, and without a line left with marks and punctuation alone.
|
|
11
|
+
* A line that was blank stays blank, and every other line stays as written.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withoutEmptyMarks(text: string): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list mark with nothing after it is no message. When a reply's list items were only links or photos, such as
|
|
3
|
+
* `1. ; 2. `, taking the links out (stripLinks) left the marks
|
|
4
|
+
* alone, and on 2026-11-04 a shopper on chat.opencommerce.sh read a message that said only `1.; 2.`. These read a text for
|
|
5
|
+
* marks a removed item emptied, so an item that keeps its words keeps its number, and a mark with none goes.
|
|
6
|
+
*/
|
|
7
|
+
/** A list mark as a reply writes one: `1.`, `2)`, or a bullet. */
|
|
8
|
+
const MARK = String.raw `(?:\d{1,2}[.)]|[•*+-])`;
|
|
9
|
+
/** A line, or a whole message, of list marks and punctuation alone, with no word or number of an item. */
|
|
10
|
+
const ONLY_MARKS = new RegExp(String.raw `^(?:\s|[.,;:!?…·]|${MARK})*$`, 'u');
|
|
11
|
+
/** True when a message holds list marks or punctuation and nothing else, so it says nothing to the shopper. */
|
|
12
|
+
export function onlyListMarks(text) {
|
|
13
|
+
return /\S/.test(text) && ONLY_MARKS.test(text);
|
|
14
|
+
}
|
|
15
|
+
// An emptied mark that opens a list, at the line's start or after the colon that leads into it, before another mark
|
|
16
|
+
// (`1.; 2. Twfbrm`, `Options: 1.; 2. Travel mug`); one between two semicolons or at the line's end after one (`Bentgo; 2.;
|
|
17
|
+
// 3. Twfbrm`, `Bentgo; 2.`). A number after a colon with nothing after it stays, since `Size: 9.` gives a value, and so does a
|
|
18
|
+
// number inside a sentence, `size 9, 10.`.
|
|
19
|
+
const LEADING_EMPTY = new RegExp(String.raw `(^[ \t]*|:[ \t]*)(?:${MARK}[ \t;,]*)+(?=\d{1,2}[.)][ \t]|•[ \t])`, 'gm');
|
|
20
|
+
const BETWEEN_EMPTY = new RegExp(String.raw `[ \t]*;[ \t]*${MARK}[ \t]*(?=;|$)`, 'gm');
|
|
21
|
+
// A list a colon leads into whose every item went, `Options: 1.; 2.`: its numbers go and the lead-in keeps its colon. A
|
|
22
|
+
// single number after a colon, `Size: 9.`, has no `;` to mark it as a list, and stays.
|
|
23
|
+
const EMPTY_AFTER_COLON = new RegExp(String.raw `:[ \t]*${MARK}(?:[ \t]*;[ \t]*${MARK})+[ \t;]*$`, 'gm');
|
|
24
|
+
/**
|
|
25
|
+
* The text without the list marks an item no longer follows, and without a line left with marks and punctuation alone.
|
|
26
|
+
* A line that was blank stays blank, and every other line stays as written.
|
|
27
|
+
*/
|
|
28
|
+
export function withoutEmptyMarks(text) {
|
|
29
|
+
return text
|
|
30
|
+
.split('\n')
|
|
31
|
+
.flatMap((line) => {
|
|
32
|
+
if (!/\S/.test(line))
|
|
33
|
+
return [line];
|
|
34
|
+
const kept = line.replace(LEADING_EMPTY, '$1').replace(EMPTY_AFTER_COLON, ':').replace(BETWEEN_EMPTY, '');
|
|
35
|
+
return ONLY_MARKS.test(kept) ? [] : [kept];
|
|
36
|
+
})
|
|
37
|
+
.join('\n')
|
|
38
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
package/dist/model-view.d.ts
CHANGED
|
@@ -184,7 +184,8 @@ export interface BuyToolModelView {
|
|
|
184
184
|
};
|
|
185
185
|
}
|
|
186
186
|
/**
|
|
187
|
-
* The text with every link taken out: a Markdown link keeps its label
|
|
187
|
+
* The text with every link taken out: a Markdown link keeps its label unless the label only names the link
|
|
188
|
+
* (store-pages.ts LINK_WORD_LINKS), a Markdown image goes whole, an address with a path or a scheme goes
|
|
188
189
|
* entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
|
|
189
190
|
*/
|
|
190
191
|
export declare function stripLinks(text: string): string;
|
|
@@ -192,9 +193,10 @@ export declare function stripLinks(text: string): string;
|
|
|
192
193
|
* Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
|
|
193
194
|
* one. A line that pointed at a link ends as a sentence when the answer sent the shopper what it pointed at, a product or a
|
|
194
195
|
* store card or a step's link, and goes when it sent none, so the reply never promises what the shopper does not get. 2026-10-27, chat.opencommerce.sh:
|
|
195
|
-
* "here's the stool on Wayfair:" reached the shopper with nothing after it.
|
|
196
|
+
* "here's the stool on Wayfair:" reached the shopper with nothing after it. The page of a card the answer sent, `pages`,
|
|
197
|
+
* stays as written.
|
|
196
198
|
*/
|
|
197
|
-
export declare function replyWithoutLinks(reply: string, sent: boolean): string;
|
|
199
|
+
export declare function replyWithoutLinks(reply: string, sent: boolean, pages?: readonly (string | null | undefined)[]): string;
|
|
198
200
|
/**
|
|
199
201
|
* An amount in its currency's minor unit, as the model reads it: the currency's own minor units place the
|
|
200
202
|
* decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
|
package/dist/model-view.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SENTENCE_END, lastSentenceStart, leadsIn, withoutLeadIn } from './sentences.js';
|
|
2
2
|
import { toMessages } from './messages.js';
|
|
3
|
+
import { GONE, LINK_WORD_LINKS, MARKDOWN_IMAGE, holdStorePages, namesOnlyALink, withoutGoneLines } from './store-pages.js';
|
|
4
|
+
import { withoutEmptyMarks } from './list-marks.js';
|
|
3
5
|
/** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
|
|
4
6
|
const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
|
|
5
7
|
const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
|
|
@@ -44,11 +46,15 @@ function wwwName(address) {
|
|
|
44
46
|
return host && !/[/?#]/.test(host) ? siteName(host) + (closing ?? '') : '';
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
|
-
* The text with every link taken out: a Markdown link keeps its label
|
|
49
|
+
* The text with every link taken out: a Markdown link keeps its label unless the label only names the link
|
|
50
|
+
* (store-pages.ts LINK_WORD_LINKS), a Markdown image goes whole, an address with a path or a scheme goes
|
|
48
51
|
* entirely, and a bare address reads as the site's name (siteName), so the store a reply names stays in it.
|
|
49
52
|
*/
|
|
50
53
|
export function stripLinks(text) {
|
|
51
|
-
|
|
54
|
+
// A list item that was only a link leaves its mark behind, and the mark goes with it (list-marks.ts).
|
|
55
|
+
return withoutEmptyMarks(withoutGoneLines(String(text ?? '')
|
|
56
|
+
.replace(MARKDOWN_IMAGE, GONE)
|
|
57
|
+
.replace(LINK_WORD_LINKS, GONE))
|
|
52
58
|
.replace(MARKDOWN_LINK, '$1')
|
|
53
59
|
.replace(SCHEME_URL, '')
|
|
54
60
|
.replace(WWW_URL, wwwName)
|
|
@@ -59,7 +65,7 @@ export function stripLinks(text) {
|
|
|
59
65
|
.replace(/[ \t]{2,}/g, ' ')
|
|
60
66
|
.replace(/[ \t]+$/gm, '')
|
|
61
67
|
.replace(/\n{3,}/g, '\n\n')
|
|
62
|
-
.trim();
|
|
68
|
+
.trim());
|
|
63
69
|
}
|
|
64
70
|
/**
|
|
65
71
|
* A line that points at a link with a colon, the link after it on the same line or alone on the next: `here's the stool on
|
|
@@ -74,10 +80,14 @@ const POINTS_AT_LINK = /^([^\n]*?\S)[ \t]*:[ \t]*(?:\n[ \t]*)?<?(?:https?:\/\/|(
|
|
|
74
80
|
* Agentcard's reply as the shopper and the model read it, with every link taken out (stripLinks) and no line left pointing at
|
|
75
81
|
* one. A line that pointed at a link ends as a sentence when the answer sent the shopper what it pointed at, a product or a
|
|
76
82
|
* store card or a step's link, and goes when it sent none, so the reply never promises what the shopper does not get. 2026-10-27, chat.opencommerce.sh:
|
|
77
|
-
* "here's the stool on Wayfair:" reached the shopper with nothing after it.
|
|
83
|
+
* "here's the stool on Wayfair:" reached the shopper with nothing after it. The page of a card the answer sent, `pages`,
|
|
84
|
+
* stays as written.
|
|
78
85
|
*/
|
|
79
|
-
export function replyWithoutLinks(reply, sent) {
|
|
80
|
-
|
|
86
|
+
export function replyWithoutLinks(reply, sent, pages = []) {
|
|
87
|
+
// A card's own store page stays where the reply wrote it (store-pages.ts), and a line whose words only named a link goes with it.
|
|
88
|
+
// A bold label keeps its colon outside the bold (`**Photo:** <address>` reads `**Photo**: <address>`), so it reads as a label.
|
|
89
|
+
const { text, restore } = holdStorePages(String(reply ?? '').replace(/(\*\*|__)([^*_\n]+?):\1/g, '$1$2$1:'), pages);
|
|
90
|
+
return restore(stripLinks(withoutGoneLines(text.replace(POINTS_AT_LINK, (_line, lead) => (sent && !namesOnlyALink(lead) ? `${lead}.` : GONE)))));
|
|
81
91
|
}
|
|
82
92
|
/** A currency's minor units as Intl knows them: 2 for USD and CAD, 0 for JPY. */
|
|
83
93
|
function minorUnits(format) {
|
|
@@ -301,7 +311,7 @@ export function forModel(result) {
|
|
|
301
311
|
question: result.choice.question === null ? null : stripLinks(result.choice.question),
|
|
302
312
|
groups: result.choice.groups.map((group) => ({ name: stripLinks(group.name), required: group.required, options: group.options.map((option) => `${stripLinks(option.label)}${option.price_delta_cents ? ` (+$${(option.price_delta_cents / 100).toFixed(2)})` : ''}`) })),
|
|
303
313
|
} } : {}),
|
|
304
|
-
reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
|
|
314
|
+
reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true, [...products.filter(held).map((product) => product.url), ...stores.filter(heldStore).map((store) => store.url)]),
|
|
305
315
|
cart: result.cart ? cartView(result.cart, result.cart_building?.store) : null,
|
|
306
316
|
carts: (result.carts ?? []).map((cart) => cartView(cart, result.cart_building?.store)),
|
|
307
317
|
products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), store: stripLinks(product.store ?? ''), price: productPrice(product), link_sent: held(product) })),
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The groups an answer's products come in: one per store or item the shopper asked for, such as a vacuum at Amazon and a
|
|
3
|
+
* laundry hamper at Target, each with its own stores, status and products. The answer's `catalog.groups` carries them beside
|
|
4
|
+
* the flat `catalog.items`, which every group's `product_ids` point into; a streamed turn's `group` events carry each group
|
|
5
|
+
* as it changes. `groupsOf` reads them, and reads an answer with no groups as one group holding every product, so an app
|
|
6
|
+
* draws a grid per group whatever the answer carries.
|
|
7
|
+
*/
|
|
8
|
+
import { type BuyMerchantStatus } from './search-status.js';
|
|
9
|
+
import type { BuyCatalog, BuyCatalogItem } from './client.js';
|
|
10
|
+
/** What a group holds: products, or one part of a trip. */
|
|
11
|
+
export type BuyGroupKind = 'product' | 'flight' | 'stay' | 'car' | 'event' | 'table';
|
|
12
|
+
/**
|
|
13
|
+
* Where a group stands. `loading`: its searches still run. `ready`: it has products to show. `empty`: its searches
|
|
14
|
+
* answered with none. `failed`: its searches failed. `needs`: it waits for details the shopper has not given, in `missing`.
|
|
15
|
+
*/
|
|
16
|
+
export type BuyGroupStatus = 'loading' | 'ready' | 'empty' | 'failed' | 'needs';
|
|
17
|
+
/** One group of an answer's products, as Agentcard sends it. */
|
|
18
|
+
export interface BuyGroup {
|
|
19
|
+
/** The group's id, the same on every update of the group within a turn. */
|
|
20
|
+
id: string;
|
|
21
|
+
kind: BuyGroupKind;
|
|
22
|
+
/** The group's heading, to show the shopper: `Vacuums at Amazon`. */
|
|
23
|
+
label: string;
|
|
24
|
+
/** The part of the shopper's message this group answers: `a vacuum on Amazon`. */
|
|
25
|
+
ask: string;
|
|
26
|
+
/** Each store the group searched, in the order its search started. */
|
|
27
|
+
stores: BuyMerchantStatus[];
|
|
28
|
+
status: BuyGroupStatus;
|
|
29
|
+
/** `full`: draw the group's products as a grid. `summary`: draw only its `summary` line, which the shopper taps to see them. */
|
|
30
|
+
display: 'full' | 'summary';
|
|
31
|
+
/** The ids of the group's products in `catalog.items`, in the order to show them. */
|
|
32
|
+
product_ids: string[];
|
|
33
|
+
/** One line for the group, such as `Also at Home Depot from $89`; absent when Agentcard wrote none. */
|
|
34
|
+
summary?: string;
|
|
35
|
+
/** What the group assumed that the shopper did not say, such as `arrival Dec 24`; absent when it assumed nothing. */
|
|
36
|
+
assumes?: string;
|
|
37
|
+
/** The details a `needs` group waits for, such as `pickup time`; absent otherwise. */
|
|
38
|
+
missing?: string[];
|
|
39
|
+
}
|
|
40
|
+
/** A group with its products read from the answer's catalog, in the group's order. */
|
|
41
|
+
export interface BuyGroupWithProducts extends BuyGroup {
|
|
42
|
+
products: BuyCatalogItem[];
|
|
43
|
+
}
|
|
44
|
+
/** The groups of a running turn, every time one of them changes. */
|
|
45
|
+
export interface BuyGroupsUpdate {
|
|
46
|
+
conversationId: string | null;
|
|
47
|
+
/** Every group the turn has so far, in the shopper's order. */
|
|
48
|
+
groups: BuyGroup[];
|
|
49
|
+
/** True for the groups the finished answer carries, the last update of the turn; false while its searches run. */
|
|
50
|
+
final: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** One group as Agentcard sent it, or null for one with no id or a kind or status this version does not know. */
|
|
53
|
+
export declare function groupOf(v: unknown): BuyGroup | null;
|
|
54
|
+
/** A list of groups as Agentcard sent it, or undefined when it sent none. */
|
|
55
|
+
export declare function groupListOf(v: unknown): BuyGroup[] | undefined;
|
|
56
|
+
/** The parts of an answer `groupsOf` reads: a `BuyTurn`, or any answer that carries its catalog and search. */
|
|
57
|
+
export interface BuyGroupsSource {
|
|
58
|
+
catalog: BuyCatalog | null;
|
|
59
|
+
search?: {
|
|
60
|
+
final: boolean;
|
|
61
|
+
merchants: BuyMerchantStatus[];
|
|
62
|
+
} | null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The answer's groups, each with its products read from the catalog in the group's order; an id the catalog does not hold
|
|
66
|
+
* is left out. An answer with no groups reads as one `product` group holding every product, `full`, with the answer's
|
|
67
|
+
* stores: `loading` while a store's search still runs, `ready` with products, `empty` without. An answer with no catalog
|
|
68
|
+
* reads as no groups. It reads the answer alone, so a stored answer read back later gives the groups the live turn gave.
|
|
69
|
+
*/
|
|
70
|
+
export declare function groupsOf(answer: BuyGroupsSource): BuyGroupWithProducts[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The groups an answer's products come in: one per store or item the shopper asked for, such as a vacuum at Amazon and a
|
|
3
|
+
* laundry hamper at Target, each with its own stores, status and products. The answer's `catalog.groups` carries them beside
|
|
4
|
+
* the flat `catalog.items`, which every group's `product_ids` point into; a streamed turn's `group` events carry each group
|
|
5
|
+
* as it changes. `groupsOf` reads them, and reads an answer with no groups as one group holding every product, so an app
|
|
6
|
+
* draws a grid per group whatever the answer carries.
|
|
7
|
+
*/
|
|
8
|
+
import { searchOf } from './search-status.js';
|
|
9
|
+
const KINDS = new Set(['product', 'flight', 'stay', 'car', 'event', 'table']);
|
|
10
|
+
const STATUSES = new Set(['loading', 'ready', 'empty', 'failed', 'needs']);
|
|
11
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
12
|
+
const text = (v) => (typeof v === 'string' && v.trim() !== '' ? v : null);
|
|
13
|
+
const texts = (v) => (Array.isArray(v) ? v.filter((s) => text(s) !== null) : []);
|
|
14
|
+
/** One group as Agentcard sent it, or null for one with no id or a kind or status this version does not know. */
|
|
15
|
+
export function groupOf(v) {
|
|
16
|
+
if (!isRecord(v))
|
|
17
|
+
return null;
|
|
18
|
+
const id = text(v.id);
|
|
19
|
+
if (!id || typeof v.kind !== 'string' || !KINDS.has(v.kind) || typeof v.status !== 'string' || !STATUSES.has(v.status))
|
|
20
|
+
return null;
|
|
21
|
+
const stores = searchOf({ search_id: id, merchants: Array.isArray(v.stores) ? v.stores : [] })?.merchants ?? [];
|
|
22
|
+
const summary = text(v.summary);
|
|
23
|
+
const assumes = text(v.assumes);
|
|
24
|
+
const missing = texts(v.missing);
|
|
25
|
+
return {
|
|
26
|
+
id, kind: v.kind, label: text(v.label) ?? '', ask: text(v.ask) ?? '', stores, status: v.status,
|
|
27
|
+
display: v.display === 'summary' ? 'summary' : 'full', product_ids: texts(v.product_ids),
|
|
28
|
+
...(summary ? { summary } : {}), ...(assumes ? { assumes } : {}), ...(missing.length ? { missing } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** A list of groups as Agentcard sent it, or undefined when it sent none. */
|
|
32
|
+
export function groupListOf(v) {
|
|
33
|
+
if (!Array.isArray(v))
|
|
34
|
+
return undefined;
|
|
35
|
+
const groups = v.flatMap((g) => groupOf(g) ?? []);
|
|
36
|
+
return groups.length ? groups : undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The answer's groups, each with its products read from the catalog in the group's order; an id the catalog does not hold
|
|
40
|
+
* is left out. An answer with no groups reads as one `product` group holding every product, `full`, with the answer's
|
|
41
|
+
* stores: `loading` while a store's search still runs, `ready` with products, `empty` without. An answer with no catalog
|
|
42
|
+
* reads as no groups. It reads the answer alone, so a stored answer read back later gives the groups the live turn gave.
|
|
43
|
+
*/
|
|
44
|
+
export function groupsOf(answer) {
|
|
45
|
+
const catalog = answer.catalog;
|
|
46
|
+
if (!catalog)
|
|
47
|
+
return [];
|
|
48
|
+
const byId = new Map(catalog.items.map((item) => [item.id, item]));
|
|
49
|
+
// Read again here, so an answer kept as JSON and read back later, or a raw body, gives the same groups as the live turn.
|
|
50
|
+
const groups = groupListOf(catalog.groups);
|
|
51
|
+
if (groups) {
|
|
52
|
+
return groups.map((g) => ({ ...g, products: g.product_ids.flatMap((id) => byId.get(id) ?? []) }));
|
|
53
|
+
}
|
|
54
|
+
const search = answer.search ?? null;
|
|
55
|
+
const status = search && !search.final ? 'loading' : catalog.items.length ? 'ready' : 'empty';
|
|
56
|
+
return [{
|
|
57
|
+
id: 'all', kind: 'product', label: catalog.merchant_name, ask: '', stores: search?.merchants ?? [], status, display: 'full',
|
|
58
|
+
product_ids: catalog.items.map((item) => item.id), products: catalog.items,
|
|
59
|
+
}];
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store pages a reply may keep, and the words that only name a link.
|
|
3
|
+
*
|
|
4
|
+
* Every link comes out of what the shopper reads (model-view.ts stripLinks), with one exception: the page at the store of a
|
|
5
|
+
* product or store card this answer sent, written as the card carries it. The shopper asked for it, and it is the real page
|
|
6
|
+
* at the store. A referral or tracking address (buy.trychannel3.com) is never one, and neither is a photo's address.
|
|
7
|
+
*
|
|
8
|
+
* 2026-11-02, chat.opencommerce.sh (conv_b1eb3d1508196604653217c0): a reply about a rack at Amazon ended
|
|
9
|
+
* "Link: https://www.amazon.com/dp/B0BS765YQL" and "Photo: https://m.media-amazon.com/...jpg", and the shopper read "link."
|
|
10
|
+
* and "photo." as a message of its own. The card's page now stays, and a line that named only a removed link goes.
|
|
11
|
+
*/
|
|
12
|
+
/** True when `text` holds nothing but words that name a link, so it goes with the link. */
|
|
13
|
+
export declare function namesOnlyALink(text: string): boolean;
|
|
14
|
+
/** A Markdown image. A chat shows its alt text, never the picture. */
|
|
15
|
+
export declare const MARKDOWN_IMAGE: RegExp;
|
|
16
|
+
/** Markdown links whose labels only name the link, at the end of a line or making up all of it, with the marks between them. A sentence's own closing mark before them stays. */
|
|
17
|
+
export declare const LINK_WORD_LINKS: RegExp;
|
|
18
|
+
/**
|
|
19
|
+
* The reply with each address of a card's store page held aside, and the function that puts them back once every other link
|
|
20
|
+
* is gone. A Markdown link to such a page reads as its label then the address, or the address alone when its label only named
|
|
21
|
+
* it. An address that only shares a page's host is not that page and comes out as before.
|
|
22
|
+
*/
|
|
23
|
+
export declare function holdStorePages(reply: string, pages: readonly (string | null | undefined)[]): {
|
|
24
|
+
text: string;
|
|
25
|
+
restore: (text: string) => string;
|
|
26
|
+
};
|
|
27
|
+
/** What a removed line leaves in its place until its newline goes with it (withoutGoneLines). */
|
|
28
|
+
export declare const GONE = "\u2064";
|
|
29
|
+
/** The text with every line that held only a removed link taken out with its newline, so no paragraph break is left where it was. */
|
|
30
|
+
export declare function withoutGoneLines(text: string): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store pages a reply may keep, and the words that only name a link.
|
|
3
|
+
*
|
|
4
|
+
* Every link comes out of what the shopper reads (model-view.ts stripLinks), with one exception: the page at the store of a
|
|
5
|
+
* product or store card this answer sent, written as the card carries it. The shopper asked for it, and it is the real page
|
|
6
|
+
* at the store. A referral or tracking address (buy.trychannel3.com) is never one, and neither is a photo's address.
|
|
7
|
+
*
|
|
8
|
+
* 2026-11-02, chat.opencommerce.sh (conv_b1eb3d1508196604653217c0): a reply about a rack at Amazon ended
|
|
9
|
+
* "Link: https://www.amazon.com/dp/B0BS765YQL" and "Photo: https://m.media-amazon.com/...jpg", and the shopper read "link."
|
|
10
|
+
* and "photo." as a message of its own. The card's page now stays, and a line that named only a removed link goes.
|
|
11
|
+
*/
|
|
12
|
+
/** A label that only names the link it stood for: `link`, `photo`, `here's the link`. Once the link is gone it says nothing. */
|
|
13
|
+
const LINK_WORDS = String.raw `(?:here(?:'s|\s+is)?\s+)?(?:(?:the|a|an|this|its|product|store)\s+)*(?:links?|photos?|pics?|pictures?|images?|urls?|pages?|listings?|here)`;
|
|
14
|
+
const ONLY_LINK_WORDS = new RegExp(String.raw `^\s*[-*•]?\s*${LINK_WORDS}\s*[.:!]?\s*$`, 'i');
|
|
15
|
+
/** True when `text` holds nothing but words that name a link, so it goes with the link. */
|
|
16
|
+
export function namesOnlyALink(text) {
|
|
17
|
+
return ONLY_LINK_WORDS.test(String(text ?? '').replace(/\*\*|__/g, ''));
|
|
18
|
+
}
|
|
19
|
+
/** A Markdown image. A chat shows its alt text, never the picture. */
|
|
20
|
+
export const MARKDOWN_IMAGE = /!\[[^\]\n]*\]\([^)\n]*\)/g;
|
|
21
|
+
/** Markdown links whose labels only name the link, at the end of a line or making up all of it, with the marks between them. A sentence's own closing mark before them stays. */
|
|
22
|
+
export const LINK_WORD_LINKS = new RegExp(String.raw `(?:[ \t]*(?:[,;|&·•-]|\band\b|\bor\b)?[ \t]*\[[ \t]*(?:\*\*|__)?${LINK_WORDS}(?:\*\*|__)?[ \t]*\]\([^)\n]*\)[.,;:!]?)+[ \t]*$`, 'gim');
|
|
23
|
+
/** Hosts that only count a click and send the shopper on: never the store's own page. */
|
|
24
|
+
const TRACKING_HOSTS = ['trychannel3.com'];
|
|
25
|
+
/** A page as compared here: host without `www.`, path without a trailing slash, and its query. Null for an address that is no web page. */
|
|
26
|
+
function pageKey(address) {
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(address);
|
|
29
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
30
|
+
return null;
|
|
31
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, '');
|
|
32
|
+
if (TRACKING_HOSTS.some((tracking) => host === tracking || host.endsWith(`.${tracking}`)))
|
|
33
|
+
return null;
|
|
34
|
+
return `${host}${url.pathname.replace(/\/+$/, '')}${url.search}`;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A full address in a reply, and the closing marks of its sentence after it. */
|
|
41
|
+
const ADDRESS = /https?:\/\/[^\s<>"'`)\]]+/gi;
|
|
42
|
+
/** A Markdown link with a full address. */
|
|
43
|
+
const MARKDOWN_ADDRESS = /\[([^\]\n]*)\]\((https?:\/\/[^)\s]+)\)/gi;
|
|
44
|
+
/** The mark that holds a kept page's place while every other link comes out: invisible, and no link pattern reads it. */
|
|
45
|
+
const HOLD = (i) => `${i}`;
|
|
46
|
+
const HELD = /(\d+)/g;
|
|
47
|
+
/**
|
|
48
|
+
* The reply with each address of a card's store page held aside, and the function that puts them back once every other link
|
|
49
|
+
* is gone. A Markdown link to such a page reads as its label then the address, or the address alone when its label only named
|
|
50
|
+
* it. An address that only shares a page's host is not that page and comes out as before.
|
|
51
|
+
*/
|
|
52
|
+
export function holdStorePages(reply, pages) {
|
|
53
|
+
const keys = new Set(pages.map((page) => (typeof page === 'string' ? pageKey(page) : null)).filter((key) => key !== null));
|
|
54
|
+
const held = [];
|
|
55
|
+
const hold = (address) => {
|
|
56
|
+
const key = pageKey(address);
|
|
57
|
+
if (key === null || !keys.has(key))
|
|
58
|
+
return null;
|
|
59
|
+
held.push(address);
|
|
60
|
+
return HOLD(held.length - 1);
|
|
61
|
+
};
|
|
62
|
+
if (keys.size === 0)
|
|
63
|
+
return { text: reply, restore: (text) => text };
|
|
64
|
+
/** A Markdown link to a held page reads as its label then the page, or the page alone when its label only named it. */
|
|
65
|
+
const labelled = (label, mark) => (namesOnlyALink(label) || label.trim() === '' ? mark : `${label.trim()}: ${mark}`);
|
|
66
|
+
// Each card's page exactly as the card carries it goes first, in a Markdown link or on its own, so a page whose address
|
|
67
|
+
// holds a bracket, such as `stool_(oak)`, is held whole before MARKDOWN_ADDRESS and ADDRESS, which stop at a bracket.
|
|
68
|
+
const exact = [...new Set(pages.filter((page) => typeof page === 'string' && pageKey(page) !== null))].sort((a, b) => b.length - a.length);
|
|
69
|
+
const text = exact
|
|
70
|
+
.reduce((out, page) => {
|
|
71
|
+
const quoted = page.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
72
|
+
const inLink = out.replace(new RegExp(String.raw `\[([^\]\n]*)\]\(${quoted}\)`, 'g'), (_whole, label) => labelled(label, HOLD(held.push(page) - 1)));
|
|
73
|
+
// The page alone, never the start of a longer address: what follows it ends the address or the sentence.
|
|
74
|
+
return inLink.replace(new RegExp(String.raw `${quoted}(?=$|[\s<>"'\x60\]]|[.,;:!?)]+(?:\s|$))`, 'g'), () => HOLD(held.push(page) - 1));
|
|
75
|
+
}, reply)
|
|
76
|
+
.replace(MARKDOWN_ADDRESS, (whole, label, address) => {
|
|
77
|
+
const mark = hold(address);
|
|
78
|
+
return mark === null ? whole : labelled(label, mark);
|
|
79
|
+
})
|
|
80
|
+
.replace(ADDRESS, (whole) => {
|
|
81
|
+
const [, address, closing] = /^(.*?)([.,;:!?]*)$/.exec(whole) ?? [];
|
|
82
|
+
const mark = address ? hold(address) : null;
|
|
83
|
+
return mark === null ? whole : `${mark}${closing ?? ''}`;
|
|
84
|
+
});
|
|
85
|
+
return { text, restore: (out) => out.replace(HELD, (_mark, i) => held[Number(i)] ?? '') };
|
|
86
|
+
}
|
|
87
|
+
/** What a removed line leaves in its place until its newline goes with it (withoutGoneLines). */
|
|
88
|
+
export const GONE = '\u2064';
|
|
89
|
+
/** The text with every line that held only a removed link taken out with its newline, so no paragraph break is left where it was. */
|
|
90
|
+
export function withoutGoneLines(text) {
|
|
91
|
+
return text.replace(/^[ \t]*\u2064[ \t]*(?:\n|$)/gm, '').replace(/[ \t]*\u2064/g, '');
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/commerce-sdk",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.14",
|
|
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
|
"homepage": "https://opencommerce.sh",
|
|
6
6
|
"type": "module",
|