@agent-cards/commerce-sdk 0.18.4 → 0.18.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/dist/action-links.js +1 -0
- package/dist/app-instructions.d.ts +17 -0
- package/dist/app-instructions.generated.d.ts +9 -0
- package/dist/app-instructions.generated.js +10 -0
- package/dist/app-instructions.js +22 -0
- package/dist/buy-tool.d.ts +21 -2
- package/dist/buy-tool.js +150 -67
- package/dist/client.d.ts +84 -11
- package/dist/client.js +101 -15
- package/dist/definition.js +2 -1
- package/dist/direct.d.ts +8 -0
- package/dist/direct.js +13 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/message.d.ts +2 -1
- package/dist/model-view.d.ts +29 -4
- package/dist/model-view.js +93 -13
- package/dist/plan.d.ts +41 -0
- package/dist/plan.js +34 -0
- package/dist/shopper-message.d.ts +12 -0
- package/dist/shopper-message.js +10 -0
- package/dist/store-wait.d.ts +27 -0
- package/dist/store-wait.js +41 -0
- package/dist/trip-ask.d.ts +1 -1
- package/dist/trip-ask.js +4 -4
- package/dist/turn-record.d.ts +2 -2
- package/dist/turn-record.js +3 -3
- package/examples/anthropic.mjs +3 -2
- package/package.json +4 -3
package/dist/model-view.js
CHANGED
|
@@ -196,12 +196,42 @@ function cartView(cart) {
|
|
|
196
196
|
/** What an estimated total leaves out: tax, and shipping or delivery, less what the store's own fee lines already carry
|
|
197
197
|
* (a 1-800-Flowers estimate holds its Florist Delivery and Sales Tax). Null when the lines carry both. */
|
|
198
198
|
function leftOut(lines) {
|
|
199
|
+
return leftOutWords(leftOutParts(lines), 1);
|
|
200
|
+
}
|
|
201
|
+
function leftOutParts(lines) {
|
|
199
202
|
const parts = [];
|
|
200
203
|
if (!lines.some((l) => /\btax\b/i.test(l.label)))
|
|
201
204
|
parts.push('tax');
|
|
202
205
|
if (!lines.some((l) => /\b(?:deliver(?:y|ies)?|shipping)\b/i.test(l.label)))
|
|
203
206
|
parts.push('shipping or delivery');
|
|
204
|
-
return parts
|
|
207
|
+
return parts;
|
|
208
|
+
}
|
|
209
|
+
function leftOutWords(parts, stores) {
|
|
210
|
+
return parts.length ? `${parts.join(' and ')}, which the ${stores === 1 ? 'store adds' : 'stores add'} at checkout` : null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The carts' grand totals, one per currency, summed from each cart's `totalCents`, which already holds its tip, fees and
|
|
214
|
+
* tax: a display string is never parsed. Carts in two currencies are never added together.
|
|
215
|
+
*/
|
|
216
|
+
export function grandTotals(carts) {
|
|
217
|
+
const groups = new Map();
|
|
218
|
+
for (const cart of carts) {
|
|
219
|
+
const code = typeof cart.currency === 'string' && cart.currency.trim() !== '' ? cart.currency.trim().toUpperCase() : null;
|
|
220
|
+
groups.set(code, [...(groups.get(code) ?? []), cart]);
|
|
221
|
+
}
|
|
222
|
+
return [...groups].map(([currency, group]) => {
|
|
223
|
+
const cents = group.reduce((sum, cart) => sum + cart.totalCents, 0);
|
|
224
|
+
const estimates = group.filter((cart) => cart.totalIsEstimate === true);
|
|
225
|
+
const parts = new Set(estimates.flatMap((cart) => leftOutParts(cart.merchantFeeLines ?? [])));
|
|
226
|
+
return {
|
|
227
|
+
amount: money(cents, currency),
|
|
228
|
+
amount_cents: cents,
|
|
229
|
+
currency,
|
|
230
|
+
is_estimate: estimates.length > 0,
|
|
231
|
+
leaves_out: leftOutWords(['tax', 'shipping or delivery'].filter((part) => parts.has(part)), estimates.length),
|
|
232
|
+
stores: group.length,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
205
235
|
}
|
|
206
236
|
/** How long until `iso`, from `now`, as the shopper reads it: `about 24 hours`, `about 15 minutes`, `no longer`, or null. */
|
|
207
237
|
export function expiresIn(iso, now = Date.now()) {
|
|
@@ -267,6 +297,7 @@ export function forModel(result) {
|
|
|
267
297
|
store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
|
|
268
298
|
? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
|
|
269
299
|
: null,
|
|
300
|
+
grand_totals: grandTotals(result.carts ?? []),
|
|
270
301
|
...(yesCarriedView(result) ? { yes_carried: yesCarriedView(result) } : {}),
|
|
271
302
|
};
|
|
272
303
|
}
|
|
@@ -393,6 +424,17 @@ const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
|
|
|
393
424
|
* `there's also ...`, `you could also ...`. One that names a card is the list, whatever the reply recommended before it.
|
|
394
425
|
*/
|
|
395
426
|
const MORE_OF_LIST = /^[\s*_"'(]*(?:(?:and|plus|but|or)\s+)?(?:others?\b[^.!?:]{0,40}?\b(?:are|is|include|includes|would be)\b|there(?:'s|’s|\s+is|\s+are)\s+also\b|(?:you|we)\s+(?:could|can|might)\s+also\b)/i;
|
|
427
|
+
/**
|
|
428
|
+
* A sentence whose subject is the list's items, named by nothing but a pointer back at them and followed by its verb:
|
|
429
|
+
* `both are ...`, `these ship ...`, `the two cost ...`, `all three come ...`, `they're ...`. A pointer that leads a
|
|
430
|
+
* noun, `Both Visa and Mastercard are accepted.`, `Both taste and price matter.` or `Either way, ...`, points at no item and never
|
|
431
|
+
* matches: a word read as the verb but joined to another by `and` or `or` is a noun.
|
|
432
|
+
*/
|
|
433
|
+
const POINTS_BACK = /^[\s*_"'(]*(?:(?:and|but|so|plus)\s+)?(?:both|these|those|either|neither|they|the (?:two|three|four|five)|all (?:two|three|four|five|of (?:them|these|those))|each of (?:them|these|those))(?:\s+of\s+(?:them|these|those))?(?:'re|’re|\s+(?:(?:also|all|only|each|usually|both)\s+)?(?:are|is|were|was|have|has|had|come|comes|ship|ships|cost|costs|run|runs|sell|sells|look|looks|work|works|seem|seems|can|could|will|would|should|might|do|does|offer|offers|include|includes|take|takes|use|uses|weigh|weighs|arrive|arrives|carry|carries|fit|fits|need|needs|sit|sits|taste|tastes|smell|smells|feel|feels|make|makes|go|goes|pair|pairs|brew|brews|roast|roasts|stay|stays|last|lasts|hold|holds|don['’]t|doesn['’]t|didn['’]t|aren['’]t|isn['’]t|won['’]t|can['’]t|wouldn['’]t)\b(?!\s+(?:and|or)\b))/i;
|
|
434
|
+
/** How far a line is set in, a tab counting as four spaces, so a nested list line reads as deeper than its item. */
|
|
435
|
+
function indentOf(line) {
|
|
436
|
+
return (/^[ \t]*/.exec(line)?.[0] ?? '').replace(/\t/g, ' ').length;
|
|
437
|
+
}
|
|
396
438
|
/** An amount a text gives after a currency sign, such as `$22.89` or `¥1,331`. */
|
|
397
439
|
const AMOUNT = /[$£€¥]\s?(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/g;
|
|
398
440
|
/** Words that tell no product from another: a card's brand or its distinctive words are never one of these. */
|
|
@@ -629,9 +671,11 @@ function inlineList(line, cards) {
|
|
|
629
671
|
* points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
|
|
630
672
|
* are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
|
|
631
673
|
* further sentence that names another card. A list written on one line goes the same way, item by item (inlineList),
|
|
632
|
-
* and so does a list line written without a bullet, whole, up to the question on it.
|
|
633
|
-
*
|
|
634
|
-
*
|
|
674
|
+
* and so does a list line written without a bullet, whole, up to the question on it. The lines set in under a list
|
|
675
|
+
* line that goes, its details such as ` - ships from London`, go with it, and once the list went, a sentence that only
|
|
676
|
+
* points back at it, `both are priced in foreign currency`, goes when the cards show another number than it counts.
|
|
677
|
+
* What is left is the reply around the list, such as `found these at amazon.` and `which one do you want?`. A reply
|
|
678
|
+
* whose cards went out names none of them twice over. A list that keeps an item for a product no card shows keeps its lead-in, and the two read as one
|
|
635
679
|
* message (toMessages), so the shopper never reads `two options came in under $20:` on its own.
|
|
636
680
|
*
|
|
637
681
|
* Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
|
|
@@ -653,10 +697,14 @@ export function withoutCardList(reply, cardNames) {
|
|
|
653
697
|
};
|
|
654
698
|
// The numbers the dropped list gave, so a number of a product's own, `a #2 Phillips head`, keeps its sentence.
|
|
655
699
|
const numbers = new Set();
|
|
700
|
+
// How many items of a list written inline went: with the list lines that went, the count a pointer such as `these` means.
|
|
701
|
+
let inlineGone = 0;
|
|
702
|
+
// The lines whose inline list went whole: the lines set in under one describe its items, and go with them.
|
|
703
|
+
const inlineEmptied = new Set();
|
|
656
704
|
// A list a line writes inline goes the way a list of lines does: each item that names a card, and the sentence that
|
|
657
705
|
// led into the list once every item went. An item for a product no card shows stays, with the lead, and so does a
|
|
658
706
|
// list that opens the line, `1) a mug; 2) a candle; 3) a soap box`, item by item rather than as one list line.
|
|
659
|
-
const unlisted = (line) => {
|
|
707
|
+
const unlisted = (line, index) => {
|
|
660
708
|
const list = inlineList(line, cards);
|
|
661
709
|
const gone = list?.items.map((item) => cardsNamed(item.body, cards, true).length > 0) ?? [];
|
|
662
710
|
if (!list || !gone.some(Boolean))
|
|
@@ -665,6 +713,9 @@ export function withoutCardList(reply, cardNames) {
|
|
|
665
713
|
if (gone[k] && item.number !== null)
|
|
666
714
|
numbers.add(item.number);
|
|
667
715
|
});
|
|
716
|
+
inlineGone += gone.filter(Boolean).length;
|
|
717
|
+
if (gone.every(Boolean))
|
|
718
|
+
inlineEmptied.add(index);
|
|
668
719
|
const after = line.slice(list.end).trimStart();
|
|
669
720
|
if (gone.every(Boolean))
|
|
670
721
|
return `${line.slice(0, list.lead).trimEnd()} ${after}`.trim();
|
|
@@ -672,22 +723,37 @@ export function withoutCardList(reply, cardNames) {
|
|
|
672
723
|
const close = /[.!?]$/.test(left[left.length - 1]) ? '' : '.';
|
|
673
724
|
return [line.slice(0, list.items[0].start).trimEnd(), `${left.join('; ')}${close}`, after].filter((part) => part !== '').join(' ');
|
|
674
725
|
};
|
|
675
|
-
const
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
726
|
+
const written = reply.split('\n');
|
|
727
|
+
const lines = written.map(unlisted);
|
|
728
|
+
// A line set in deeper than a list line that names a card is that item's detail, such as ` - ships from London`
|
|
729
|
+
// under `- Square Mile Red Brick, £13.50`: it goes with its item (`under`), since what it describes is the card.
|
|
730
|
+
let item = null;
|
|
731
|
+
const kind = lines.map((line, i) => {
|
|
732
|
+
if (inlineEmptied.has(i)) {
|
|
733
|
+
// A list nested under an item already dropped keeps that item's depth, so the lines after it still go with their item.
|
|
734
|
+
const at = indentOf(written[i]);
|
|
735
|
+
if (item === null || at <= item)
|
|
736
|
+
item = at;
|
|
737
|
+
return 'kept';
|
|
738
|
+
}
|
|
739
|
+
if (line.trim() === '')
|
|
740
|
+
return 'kept';
|
|
741
|
+
if (item !== null && indentOf(line) > item)
|
|
742
|
+
return 'under';
|
|
743
|
+
const of = LIST_ITEM.test(line) ? (cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept') : opensCard(line) ? 'named' : 'kept';
|
|
744
|
+
item = of === 'kept' ? null : indentOf(line);
|
|
745
|
+
return of;
|
|
680
746
|
});
|
|
681
747
|
const dropped = kind.map((of) => of !== 'kept');
|
|
682
748
|
lines.forEach((line, i) => {
|
|
683
|
-
const number = dropped[i] ? Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1]) : NaN;
|
|
749
|
+
const number = dropped[i] && kind[i] !== 'under' ? Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1]) : NaN;
|
|
684
750
|
if (Number.isInteger(number))
|
|
685
751
|
numbers.add(number);
|
|
686
752
|
});
|
|
687
753
|
// The list a line leads into: the lines from the next one with words on it to the first that is not a list line, so a
|
|
688
754
|
// question on the line after the last item, `Which one?`, is no part of the list. A blank line between two list lines
|
|
689
755
|
// is no end of it, as toMessages reads one, so a list that keeps an item past the blank keeps its lead.
|
|
690
|
-
const listLine = (j) => LIST_ITEM.test(lines[j]) || kind[j] === 'named';
|
|
756
|
+
const listLine = (j) => LIST_ITEM.test(lines[j]) || kind[j] === 'named' || kind[j] === 'under';
|
|
691
757
|
const listAfter = (line) => {
|
|
692
758
|
const start = lines.findIndex((after, j) => j > line && after.trim() !== '');
|
|
693
759
|
if (start === -1)
|
|
@@ -709,8 +775,20 @@ export function withoutCardList(reply, cardNames) {
|
|
|
709
775
|
};
|
|
710
776
|
const named = new Set();
|
|
711
777
|
const kept = [];
|
|
778
|
+
// Once a list went, a sentence that only points back at its items, `both are priced in foreign currency` or `these ship
|
|
779
|
+
// free`, reads against the cards. It stays when it counts what the cards show, `both` beside two cards, and goes when the
|
|
780
|
+
// cards show another number, as eight cards beside `both` do: the shopper cannot tell which two it means.
|
|
781
|
+
const listed = kind.filter((of) => of === 'item' || of === 'named').length + inlineGone;
|
|
782
|
+
const pointsElsewhere = (sentence) => {
|
|
783
|
+
const pointer = POINTS_BACK.exec(sentence);
|
|
784
|
+
if (!pointer || listed === 0)
|
|
785
|
+
return false;
|
|
786
|
+
const counted = /\b(both|either|neither|two|three|four|five)\b/i.exec(pointer[0])?.[1]?.toLowerCase();
|
|
787
|
+
const count = counted ? { both: 2, either: 2, neither: 2, two: 2, three: 3, four: 4, five: 5 }[counted] : listed;
|
|
788
|
+
return count !== cards.length;
|
|
789
|
+
};
|
|
712
790
|
lines.forEach((line, i) => {
|
|
713
|
-
if (kind[i] === 'item')
|
|
791
|
+
if (kind[i] === 'item' || kind[i] === 'under')
|
|
714
792
|
return;
|
|
715
793
|
let text = line;
|
|
716
794
|
// The line that leads into a list whose every line went loses the sentence that leads in: `Two I'd suggest:` or
|
|
@@ -727,6 +805,8 @@ export function withoutCardList(reply, cardNames) {
|
|
|
727
805
|
if (place && numbers.has(Number(place[1] ?? place[2])))
|
|
728
806
|
return [];
|
|
729
807
|
const found = cardsNamed(sentence, cards, false);
|
|
808
|
+
if (found.length === 0 && !ASKS.test(sentence) && !/:\s*$/.test(sentence) && pointsElsewhere(sentence))
|
|
809
|
+
return [];
|
|
730
810
|
if (kind[i] === 'named') {
|
|
731
811
|
const card = found.length > 0 || opensCard(sentence);
|
|
732
812
|
asked ||= ASKS.test(sentence);
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A checkout plan on a purchase answer: the items a shopper chose to book together, booked in the order they depend on each
|
|
3
|
+
* other on one approval, such as a trip's flight first, then its hotel and car. The same object rides `plan.updated`, so a
|
|
4
|
+
* tracker redraws from either. Null on an answer with no plan, and on an older API.
|
|
5
|
+
*/
|
|
6
|
+
export type BuyPlanStatus = 'preparing' | 'running' | 'done' | 'partly_done' | 'stopped';
|
|
7
|
+
export type BuyPlanItemState = 'draft' | 'quoted' | 'approved' | 'booking' | 'confirmed' | 'pending' | 'needs_input' | 'failed' | 'skipped' | 'cancelled';
|
|
8
|
+
export interface BuyPlanItem {
|
|
9
|
+
/** The item's key in its plan: `flight`, `hotel`, `car`. */
|
|
10
|
+
key: string;
|
|
11
|
+
kind: string;
|
|
12
|
+
state: BuyPlanItemState;
|
|
13
|
+
name: string;
|
|
14
|
+
/** What the shopper pays for it: the confirmed price once booked, the quote before. */
|
|
15
|
+
price_cents: number | null;
|
|
16
|
+
/** A changed price waiting for the shopper's answer, or null. */
|
|
17
|
+
asked_cents: number | null;
|
|
18
|
+
/** The supplier's confirmation number once booked. */
|
|
19
|
+
confirmation: string | null;
|
|
20
|
+
/** The keys of the items it books after. */
|
|
21
|
+
depends_on: string[];
|
|
22
|
+
/** Why it waits or did not book, such as `price_changed` or `approval_required`, or null. */
|
|
23
|
+
reason: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface BuyPlan {
|
|
26
|
+
id: string;
|
|
27
|
+
status: BuyPlanStatus;
|
|
28
|
+
/** Moves on every change: keep the highest one seen. */
|
|
29
|
+
version: number;
|
|
30
|
+
currency: string;
|
|
31
|
+
total_cents: number;
|
|
32
|
+
charged_cents: number;
|
|
33
|
+
/** When the items waiting on a paused one are let go, or null. */
|
|
34
|
+
hold_until: string | null;
|
|
35
|
+
items: BuyPlanItem[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The plan an answer carries, or null when it carries none or one this version cannot read: a status or an item state this
|
|
39
|
+
* version does not know, a missing total, or an item it cannot read. A tracker never shows a guessed state or a zero total.
|
|
40
|
+
*/
|
|
41
|
+
export declare function planOf(value: unknown): BuyPlan | null;
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
2
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
3
|
+
const text = (v) => (typeof v === 'string' ? v : null);
|
|
4
|
+
const STATUSES = new Set(['preparing', 'running', 'done', 'partly_done', 'stopped']);
|
|
5
|
+
const STATES = new Set(['draft', 'quoted', 'approved', 'booking', 'confirmed', 'pending', 'needs_input', 'failed', 'skipped', 'cancelled']);
|
|
6
|
+
function itemOf(i) {
|
|
7
|
+
if (!isRecord(i) || !text(i.key) || !text(i.state) || !STATES.has(i.state))
|
|
8
|
+
return null;
|
|
9
|
+
return {
|
|
10
|
+
key: i.key, kind: text(i.kind) ?? '', state: i.state, name: text(i.name) ?? '',
|
|
11
|
+
price_cents: num(i.price_cents), asked_cents: num(i.asked_cents), confirmation: text(i.confirmation),
|
|
12
|
+
depends_on: Array.isArray(i.depends_on) ? i.depends_on.filter((d) => typeof d === 'string') : [], reason: text(i.reason),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The plan an answer carries, or null when it carries none or one this version cannot read: a status or an item state this
|
|
17
|
+
* version does not know, a missing total, or an item it cannot read. A tracker never shows a guessed state or a zero total.
|
|
18
|
+
*/
|
|
19
|
+
export function planOf(value) {
|
|
20
|
+
if (!isRecord(value) || !text(value.id) || !Array.isArray(value.items) || !STATUSES.has(text(value.status) ?? ''))
|
|
21
|
+
return null;
|
|
22
|
+
const total = num(value.total_cents);
|
|
23
|
+
const charged = num(value.charged_cents);
|
|
24
|
+
const version = num(value.version);
|
|
25
|
+
if (total === null || charged === null || version === null)
|
|
26
|
+
return null;
|
|
27
|
+
const items = value.items.map(itemOf);
|
|
28
|
+
if (items.some((i) => i === null))
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
id: value.id, status: value.status, version, currency: text(value.currency) ?? 'USD',
|
|
32
|
+
total_cents: total, charged_cents: charged, hold_until: text(value.hold_until), items: items,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -72,6 +72,12 @@ export interface ShopperMessage {
|
|
|
72
72
|
externalUserId: string | null;
|
|
73
73
|
/** The buy conversation the event belongs to, when Agentcard names one. */
|
|
74
74
|
conversationId: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Your own id for the chat or thread that conversation opened under (the buy tool's `threadId`, POST /buy `thread_id`):
|
|
77
|
+
* the chat to post `text` into. Null for a purchase that opened under none, and for a message about the shopper alone,
|
|
78
|
+
* such as a saved card.
|
|
79
|
+
*/
|
|
80
|
+
threadId: string | null;
|
|
75
81
|
/** Ready to send to the shopper as is. */
|
|
76
82
|
text: string;
|
|
77
83
|
/** For your agent: the facts of what happened, marked as written by the system and not typed by the shopper. */
|
|
@@ -97,6 +103,12 @@ export interface ShopperMessage {
|
|
|
97
103
|
/** Unix seconds. */
|
|
98
104
|
created: number;
|
|
99
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether a chat reads this message: one about a purchase opened under the chat's thread, matched both ways, so the chat
|
|
108
|
+
* of a tool built without `threadId` reads the purchases opened under none and never a chat's, or one about the shopper
|
|
109
|
+
* alone, which names neither a thread nor a conversation. Internal: `buyTool` and the client's kept messages share it.
|
|
110
|
+
*/
|
|
111
|
+
export declare function messageInChat(message: Pick<ShopperMessage, 'threadId' | 'conversationId'>, threadId?: string): boolean;
|
|
100
112
|
/**
|
|
101
113
|
* The message an event carries for a shopper, or null when it carries none: an event type that is
|
|
102
114
|
* not a shopper event, an order event that names no shopper, or a delivery stage the package does
|
package/dist/shopper-message.js
CHANGED
|
@@ -89,6 +89,7 @@ function orderBase(event, type) {
|
|
|
89
89
|
userId,
|
|
90
90
|
externalUserId: id(data.external_user_id) ?? null,
|
|
91
91
|
conversationId: id(data.conversation_id) ?? null,
|
|
92
|
+
threadId: id(data.thread_id) ?? null,
|
|
92
93
|
clientId: id(connection.client_id) ?? null,
|
|
93
94
|
livemode: event.livemode,
|
|
94
95
|
created: event.created,
|
|
@@ -180,6 +181,14 @@ function readOrderUpdated(event) {
|
|
|
180
181
|
order,
|
|
181
182
|
};
|
|
182
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Whether a chat reads this message: one about a purchase opened under the chat's thread, matched both ways, so the chat
|
|
186
|
+
* of a tool built without `threadId` reads the purchases opened under none and never a chat's, or one about the shopper
|
|
187
|
+
* alone, which names neither a thread nor a conversation. Internal: `buyTool` and the client's kept messages share it.
|
|
188
|
+
*/
|
|
189
|
+
export function messageInChat(message, threadId) {
|
|
190
|
+
return (message.threadId || undefined) === (threadId || undefined) || (!message.threadId && !message.conversationId);
|
|
191
|
+
}
|
|
183
192
|
/**
|
|
184
193
|
* The message an event carries for a shopper, or null when it carries none: an event type that is
|
|
185
194
|
* not a shopper event, an order event that names no shopper, or a delivery stage the package does
|
|
@@ -198,6 +207,7 @@ export function readShopperMessage(event) {
|
|
|
198
207
|
...message,
|
|
199
208
|
type: 'purchase.message',
|
|
200
209
|
conversationId: id(event.data.conversation_id) ?? null,
|
|
210
|
+
threadId: id(event.data.thread_id) ?? null,
|
|
201
211
|
advances: NEXT_STEP.has(message.kind),
|
|
202
212
|
order: purchaseOrder(message.kind, message.facts, event.created),
|
|
203
213
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { BuyToolProduct } from './buy-tool.js';
|
|
2
|
+
/**
|
|
3
|
+
* What happens to a store that answers after the reply: `stream` sends its cards after the reply, in the same turn, as
|
|
4
|
+
* the card events every streamed turn carries; `hold` keeps them out of the turn, so the reply is the turn's last word.
|
|
5
|
+
*/
|
|
6
|
+
export type BuyLateResults = 'stream' | 'hold';
|
|
7
|
+
/** The store wait of a tool or a turn. */
|
|
8
|
+
export interface BuyStoreWait {
|
|
9
|
+
/** How long, in milliseconds, the reply waits for store searches before Agentcard writes it. */
|
|
10
|
+
storeWaitMs?: number;
|
|
11
|
+
/** What happens to a store that answers after the reply. */
|
|
12
|
+
lateResults?: BuyLateResults;
|
|
13
|
+
}
|
|
14
|
+
/** The request fields a store wait sends, none when it names nothing. */
|
|
15
|
+
export interface BuyStoreWaitFields {
|
|
16
|
+
store_wait_ms?: number;
|
|
17
|
+
late_results?: BuyLateResults;
|
|
18
|
+
}
|
|
19
|
+
/** Checks a store wait passed to `buyTool` (`where` is empty) or to a turn (`where` reads ' to the turn'), and throws a TypeError for one that cannot work. */
|
|
20
|
+
export declare function checkStoreWait(wait: BuyStoreWait, where?: string): void;
|
|
21
|
+
/** The fields a request carries for a turn's store wait: the turn's own, else the tool's. */
|
|
22
|
+
export declare function storeWaitFields(turn: BuyStoreWait, tool: BuyStoreWait): BuyStoreWaitFields;
|
|
23
|
+
/**
|
|
24
|
+
* The answer's products with the cards of a store that answered after the reply added at the end, each once: the answer
|
|
25
|
+
* can list them already, when the turn's last word came after them, and a late card it lists is left as the answer has it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function withLateCards(products: readonly BuyToolProduct[], late: readonly BuyToolProduct[]): BuyToolProduct[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a reply waits for store searches, and what happens to a store that answers after it: the `storeWaitMs` and
|
|
3
|
+
* `lateResults` options of `buyTool` and `buy.turn`, sent on every request as `store_wait_ms` and `late_results`. Left
|
|
4
|
+
* out, nothing is sent and Agentcard keeps its own wait.
|
|
5
|
+
*/
|
|
6
|
+
import { pickId, productCardKey } from './model-view.js';
|
|
7
|
+
/** Checks a store wait passed to `buyTool` (`where` is empty) or to a turn (`where` reads ' to the turn'), and throws a TypeError for one that cannot work. */
|
|
8
|
+
export function checkStoreWait(wait, where = '') {
|
|
9
|
+
const ms = wait.storeWaitMs;
|
|
10
|
+
if (ms !== undefined && (typeof ms !== 'number' || !Number.isInteger(ms) || ms < 0)) {
|
|
11
|
+
throw new TypeError(`Pass storeWaitMs${where} as a whole number of milliseconds, 0 or more, or leave it out.`);
|
|
12
|
+
}
|
|
13
|
+
const late = wait.lateResults;
|
|
14
|
+
if (late !== undefined && late !== 'stream' && late !== 'hold') {
|
|
15
|
+
throw new TypeError(`Pass lateResults${where} as 'stream' or 'hold', or leave it out.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The fields a request carries for a turn's store wait: the turn's own, else the tool's. */
|
|
19
|
+
export function storeWaitFields(turn, tool) {
|
|
20
|
+
const ms = turn.storeWaitMs ?? tool.storeWaitMs;
|
|
21
|
+
const late = turn.lateResults ?? tool.lateResults;
|
|
22
|
+
return { ...(ms !== undefined ? { store_wait_ms: ms } : {}), ...(late !== undefined ? { late_results: late } : {}) };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The answer's products with the cards of a store that answered after the reply added at the end, each once: the answer
|
|
26
|
+
* can list them already, when the turn's last word came after them, and a late card it lists is left as the answer has it.
|
|
27
|
+
*/
|
|
28
|
+
export function withLateCards(products, late) {
|
|
29
|
+
// A product with no link-free id is told apart by its page, so two stores' offers of one title stay two.
|
|
30
|
+
const keyOf = (product) => pickId(product) ?? productCardKey(product) ?? `name:${product.name}`;
|
|
31
|
+
const held = new Set(products.map(keyOf));
|
|
32
|
+
const added = [];
|
|
33
|
+
for (const product of late) {
|
|
34
|
+
const key = keyOf(product);
|
|
35
|
+
if (held.has(key))
|
|
36
|
+
continue;
|
|
37
|
+
held.add(key);
|
|
38
|
+
added.push(product);
|
|
39
|
+
}
|
|
40
|
+
return [...products, ...added];
|
|
41
|
+
}
|
package/dist/trip-ask.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* an ask goes straight only with a shopping verb (`book a flight to Loreto`), as before.
|
|
14
14
|
*
|
|
15
15
|
* `inCapabilities` on: the tool's description says it plans and books trips, flights and hotels, tells the model to call it
|
|
16
|
-
* for a trip ask with no dates or airport, and
|
|
16
|
+
* for a trip ask with no dates or airport, and leads with a whole trip when the shopper asks what it can do for travel. Off: the
|
|
17
17
|
* description is the text it was before, word for word.
|
|
18
18
|
*/
|
|
19
19
|
export declare const TRAVEL: {
|
package/dist/trip-ask.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* an ask goes straight only with a shopping verb (`book a flight to Loreto`), as before.
|
|
14
14
|
*
|
|
15
15
|
* `inCapabilities` on: the tool's description says it plans and books trips, flights and hotels, tells the model to call it
|
|
16
|
-
* for a trip ask with no dates or airport, and
|
|
16
|
+
* for a trip ask with no dates or airport, and leads with a whole trip when the shopper asks what it can do for travel. Off: the
|
|
17
17
|
* description is the text it was before, word for word.
|
|
18
18
|
*/
|
|
19
19
|
export const TRAVEL = { goStraight: true, inCapabilities: true };
|
|
@@ -52,9 +52,9 @@ export function withoutTripVerb(text) {
|
|
|
52
52
|
return TRAVEL.goStraight && isTripAsk(text) ? text.replace(TRIP_VERB, '') : text;
|
|
53
53
|
}
|
|
54
54
|
const OPENING_LINE = 'Buy what this shopper asks for at real stores.';
|
|
55
|
-
const TRAVEL_LINES = 'You also plan and book trips: flights and
|
|
56
|
-
const CAPABILITY_END = '
|
|
57
|
-
const CAPABILITY_TRAVEL = 'Name
|
|
55
|
+
const TRAVEL_LINES = 'You also plan and book trips: flights, hotels and rental cars. When the shopper asks to plan a trip, or for flights or a hotel, such as "help me plan a trip to Loreto" or "flights and hotels in loreto", call this tool with their words, even when they name no dates and no place they leave from: Agentcard asks them for what it needs.';
|
|
56
|
+
const CAPABILITY_END = 'Name no other store unless a result showed it.';
|
|
57
|
+
const CAPABILITY_TRAVEL = 'Name whole trips among the kinds of things you can do. When the shopper asks what you can do for travel, lead with planning a whole trip, the flights, a hotel and a rental car together, then name the parts they can book on their own: flights, hotels and rental cars.';
|
|
58
58
|
/** The tool's description with travel named, when `inCapabilities` is on (or `on` says so); the description as given when off. */
|
|
59
59
|
export function withTravel(description, on = TRAVEL.inCapabilities) {
|
|
60
60
|
if (!on)
|
package/dist/turn-record.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
* Whether a turn keeps its record and a dropped stream reads it back.
|
|
8
|
-
*
|
|
7
|
+
* Whether a turn keeps its record and a dropped stream reads it back. When it is off, `turnStream` streams without
|
|
8
|
+
* saving and `streamTurn` reports a drop as the error it was; `recover: false` does the same for the one call it is passed to.
|
|
9
9
|
*/
|
|
10
10
|
export declare const TURN_RECOVERY_ON: boolean;
|
|
11
11
|
/** One event of a turn, as its server-sent frame carries it: `event: <event>` and `data: <JSON of data>`. */
|
package/dist/turn-record.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
* Whether a turn keeps its record and a dropped stream reads it back.
|
|
8
|
-
*
|
|
7
|
+
* Whether a turn keeps its record and a dropped stream reads it back. When it is off, `turnStream` streams without
|
|
8
|
+
* saving and `streamTurn` reports a drop as the error it was; `recover: false` does the same for the one call it is passed to.
|
|
9
9
|
*/
|
|
10
|
-
export const TURN_RECOVERY_ON =
|
|
10
|
+
export const TURN_RECOVERY_ON = true;
|
|
11
11
|
/** A turn id the browser makes up front: 8 to 64 letters, digits, `-` or `_`. Check one before using it in a key. */
|
|
12
12
|
export function isTurnId(value) {
|
|
13
13
|
return typeof value === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(value);
|
package/examples/anthropic.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Sandbox credentials place no real order. SHOPPER_ID is your own id for the shopper you play.
|
|
7
7
|
import readline from 'node:readline';
|
|
8
8
|
import Anthropic from '@anthropic-ai/sdk';
|
|
9
|
-
import { AgentcardClient, buyTool } from '@agent-cards/commerce-sdk';
|
|
9
|
+
import { AgentcardClient, appInstructions, buyTool } from '@agent-cards/commerce-sdk';
|
|
10
10
|
|
|
11
11
|
const agentcard = new AgentcardClient({
|
|
12
12
|
clientId: process.env.AGENTCARD_CLIENT_ID ?? '',
|
|
@@ -33,7 +33,8 @@ for await (const line of terminal) {
|
|
|
33
33
|
const runner = anthropic.beta.messages.toolRunner({
|
|
34
34
|
model: 'claude-opus-5-5',
|
|
35
35
|
max_tokens: 16000,
|
|
36
|
-
|
|
36
|
+
// Agentcard's recommended instructions: what it can do and how to use the buy tool. A terminal shows plain text.
|
|
37
|
+
system: appInstructions({ markdown: false }),
|
|
37
38
|
tools: [buy],
|
|
38
39
|
messages: [...messages, { role: 'user', content: said }],
|
|
39
40
|
// When a safety classifier declines a request, Anthropic answers it with a fallback model instead.
|
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.6",
|
|
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",
|
|
@@ -27,11 +27,12 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"_comment_build": "Zero runtime dependencies: node:crypto is the one import, and the buy tool calls the global fetch. TypeScript is pinned, not installed, as in @agent-cards/sdk. types/node.d.ts declares the two node:crypto functions the source calls, so the build needs no @types package and the published declarations name no Node type; FetchLike names AbortSignal, which every runtime with fetch declares.",
|
|
30
|
-
"build": "npx -y -p typescript@5.9.3 tsc",
|
|
30
|
+
"build": "node scripts/app-instructions.mjs && npx -y -p typescript@5.9.3 tsc",
|
|
31
31
|
"prepublishOnly": "pnpm build",
|
|
32
32
|
"test": "pnpm build && node --test test/*.test.mjs",
|
|
33
33
|
"verify:pack": "node scripts/verify-pack.mjs",
|
|
34
|
-
"_comment_pack": "verify:pack lists what npm would publish (npm pack --dry-run) and fails on a missing manifest target, README or example, or on a test, source, shim or build-cache file; then it loads the built root and checks the names the README imports. The release workflow runs it after the build."
|
|
34
|
+
"_comment_pack": "verify:pack lists what npm would publish (npm pack --dry-run) and fails on a missing manifest target, README or example, or on a test, source, shim or build-cache file; then it loads the built root and checks the names the README imports. The release workflow runs it after the build.",
|
|
35
|
+
"_comment_instructions": "The build first copies the instructions for an app's own model (appInstructions) from apps/agent-voice/kb/app.md into src/app-instructions.generated.ts, which git ignores, so the published text always matches the voice package at the commit it was built from."
|
|
35
36
|
},
|
|
36
37
|
"keywords": [
|
|
37
38
|
"agentcard",
|