earnrate 0.2.9 → 0.2.11
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 +14 -6
- package/data/CHANGELOG.md +56 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-preferred.yaml +3 -1
- package/data/cards/amex-delta-skymiles-gold.yaml +120 -0
- package/data/cards/amex-delta-skymiles-platinum.yaml +118 -0
- package/data/cards/amex-delta-skymiles-reserve.yaml +80 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +140 -0
- package/data/cards/amex-hilton-honors-surpass.yaml +151 -0
- package/data/cards/amex-hilton-honors.yaml +117 -0
- package/data/cards/amex-marriott-bonvoy-bevy.yaml +116 -0
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +91 -0
- package/data/cards/amex-platinum.yaml +14 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +13 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +13 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +16 -0
- package/data/cards/capital-one-quicksilver.yaml +80 -0
- package/data/cards/chase-aeroplan.yaml +28 -0
- package/data/cards/chase-ihg-one-rewards.yaml +1 -1
- package/data/cards/chase-marriott-bonvoy-bold.yaml +123 -0
- package/data/cards/chase-marriott-bonvoy-boundless.yaml +76 -0
- package/data/cards/chase-sapphire-reserve.yaml +29 -0
- package/data/cards/chase-united-business.yaml +10 -0
- package/data/cards/chase-united-club-business.yaml +19 -0
- package/data/cards/chase-united-club.yaml +31 -0
- package/data/cards/chase-united-explorer.yaml +19 -0
- package/data/cards/chase-united-gateway.yaml +18 -0
- package/data/cards/chase-united-quest.yaml +20 -1
- package/data/cards/citi-aadvantage-business.yaml +12 -1
- package/data/cards/citi-aadvantage-platinum-select.yaml +12 -1
- package/data/cards/discover-it-cash-back.yaml +169 -0
- package/data/currencies.yaml +26 -0
- package/data/issuers.yaml +3 -0
- package/data/merchants/overrides.yaml +24 -2
- package/data/merchants/seed.yaml +40 -6
- package/dist/engine/cheat-sheet.js +3 -0
- package/dist/engine/explore.js +8 -4
- package/dist/engine/milestones.js +10 -2
- package/dist/engine/recommend.js +42 -16
- package/dist/engine/rule-text.js +12 -4
- package/dist/format/recommendation.js +8 -2
- package/dist/format/wallet.js +6 -2
- package/dist/mcp/prompts.js +90 -0
- package/dist/mcp/schemas.js +3 -0
- package/dist/mcp/server.js +19 -1
- package/dist/mcp/tools/account.js +5 -4
- package/dist/mcp/tools/answers.js +7 -0
- package/dist/mcp/tools/wallet.js +1 -0
- package/package.json +1 -1
package/dist/engine/recommend.js
CHANGED
|
@@ -11,6 +11,7 @@ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, e
|
|
|
11
11
|
import { cardValuation } from "./valuation.js";
|
|
12
12
|
export const STALE_AFTER_DAYS = 180;
|
|
13
13
|
// The engine only tells the U.S. from everywhere else.
|
|
14
|
+
export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
|
|
14
15
|
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
15
16
|
// Any purchase outside the U.S., when no country is named: ZZ is a code ISO
|
|
16
17
|
// leaves unassigned, and nothing prints it.
|
|
@@ -87,6 +88,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
87
88
|
perDollar,
|
|
88
89
|
fee,
|
|
89
90
|
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
|
|
91
|
+
rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
|
|
90
92
|
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
|
|
91
93
|
});
|
|
92
94
|
}
|
|
@@ -111,7 +113,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
111
113
|
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
112
114
|
}
|
|
113
115
|
for (const hit of entry.milestones)
|
|
114
|
-
lines.push(reachedText(card, hit));
|
|
116
|
+
lines.push(reachedText(card, hit, valuation.centsPerUnit));
|
|
115
117
|
if (valuation.pooledFrom !== null) {
|
|
116
118
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
117
119
|
const heldPartners = wallet.cards
|
|
@@ -122,7 +124,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
122
124
|
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
123
125
|
}
|
|
124
126
|
if (currency !== 'cash') {
|
|
125
|
-
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.
|
|
127
|
+
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
|
|
126
128
|
}
|
|
127
129
|
return lines;
|
|
128
130
|
}
|
|
@@ -131,7 +133,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
131
133
|
const { best, nominal } = evaluation;
|
|
132
134
|
const warnings = [];
|
|
133
135
|
if (entry.fee > 0)
|
|
134
|
-
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.
|
|
136
|
+
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
135
137
|
if (isForeign(purchase) && card.foreign_transaction_fee === null)
|
|
136
138
|
warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
|
|
137
139
|
const capLabel = (e) => e.cap
|
|
@@ -184,11 +186,19 @@ function toRanked(catalog, wallet, entry, purchase) {
|
|
|
184
186
|
rate: evaluation.best.rate,
|
|
185
187
|
currency: valuation.currency,
|
|
186
188
|
centsPerUnit: valuation.centsPerUnit,
|
|
187
|
-
effectiveReturnPct: entry.
|
|
189
|
+
effectiveReturnPct: entry.rateReturnPct,
|
|
188
190
|
foreignFee: entry.fee > 0 ? entry.fee : null,
|
|
189
191
|
earned: purchase.amount === null
|
|
190
192
|
? null
|
|
191
|
-
: Math.round(purchase.amount * entry.
|
|
193
|
+
: Math.round(purchase.amount * entry.rateReturnPct) / 100,
|
|
194
|
+
milestoneBonus: entry.milestones.length === 0
|
|
195
|
+
? null
|
|
196
|
+
: {
|
|
197
|
+
points: milestonePoints(entry.milestones),
|
|
198
|
+
dollars: roundCents((milestonePoints(entry.milestones) *
|
|
199
|
+
entry.valuation.centsPerUnit) /
|
|
200
|
+
100),
|
|
201
|
+
},
|
|
192
202
|
capped: evaluation.best.cap !== null,
|
|
193
203
|
reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
|
|
194
204
|
warnings: warningsFor(catalog, entry, purchase),
|
|
@@ -210,11 +220,13 @@ function tieNote(entries) {
|
|
|
210
220
|
tied.some((e) => e.evaluation.best.cap !== null)
|
|
211
221
|
? 'because its rate has no cap'
|
|
212
222
|
: 'by a fixed order; either works';
|
|
213
|
-
return `${names} also ${verb} ${pct(winner.
|
|
223
|
+
return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
214
224
|
}
|
|
215
225
|
// Finds the closest point inside a currency's value range where the winner
|
|
216
226
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
217
227
|
// cents-per-point value, so each pair of cards flips at most once.
|
|
228
|
+
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
229
|
+
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
218
230
|
function sensitivity(catalog, entries) {
|
|
219
231
|
const [winner, ...rest] = entries;
|
|
220
232
|
if (!winner)
|
|
@@ -235,13 +247,13 @@ function sensitivity(catalog, entries) {
|
|
|
235
247
|
if (w.range && wRate > 0) {
|
|
236
248
|
const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
|
|
237
249
|
if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
|
|
238
|
-
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, w.currency)}
|
|
250
|
+
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
|
|
239
251
|
}
|
|
240
252
|
}
|
|
241
253
|
if (o.range && oRate > 0) {
|
|
242
254
|
const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
|
|
243
255
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
244
|
-
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)}
|
|
256
|
+
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
|
|
245
257
|
}
|
|
246
258
|
}
|
|
247
259
|
}
|
|
@@ -256,7 +268,19 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
256
268
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
257
269
|
}
|
|
258
270
|
else if (merchant.category === 'other') {
|
|
259
|
-
|
|
271
|
+
// A card whose rule names this store by name (Audible on Amex's
|
|
272
|
+
// streaming list) earns its bonus there anyway.
|
|
273
|
+
const naming = entries.flatMap((e) => {
|
|
274
|
+
const rule = e.evaluation.best.rule;
|
|
275
|
+
return rule &&
|
|
276
|
+
merchant.merchantId !== null &&
|
|
277
|
+
(rule.match.merchants ?? []).includes(merchant.merchantId)
|
|
278
|
+
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
279
|
+
: [];
|
|
280
|
+
});
|
|
281
|
+
assumptions.push(naming.length === 0
|
|
282
|
+
? `Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`
|
|
283
|
+
: `Assumes ${merchant.resolvedName} falls outside the usual bonus categories, except on cards that name it: ${listed(naming)}.`);
|
|
260
284
|
}
|
|
261
285
|
else {
|
|
262
286
|
assumptions.push(codesAs(catalog, merchant));
|
|
@@ -274,7 +298,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
274
298
|
if (values !== null)
|
|
275
299
|
assumptions.push(values);
|
|
276
300
|
if (isForeign(purchase))
|
|
277
|
-
assumptions.push(
|
|
301
|
+
assumptions.push(FOREIGN_ASSUMPTION);
|
|
278
302
|
for (const e of entries)
|
|
279
303
|
for (const hit of e.milestones)
|
|
280
304
|
assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
|
|
@@ -314,9 +338,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
314
338
|
const twin = alts.find((e) => e.card.id !== winner.card.id &&
|
|
315
339
|
alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
|
|
316
340
|
if (alt.card.id !== winner.card.id)
|
|
317
|
-
warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.
|
|
341
|
+
warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.rateReturnPct)}).`);
|
|
318
342
|
else if (twin)
|
|
319
|
-
warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.
|
|
343
|
+
warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.rateReturnPct)}).`);
|
|
320
344
|
}
|
|
321
345
|
}
|
|
322
346
|
if (!winner) {
|
|
@@ -335,7 +359,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
335
359
|
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
336
360
|
: undefined;
|
|
337
361
|
if (alt)
|
|
338
|
-
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.
|
|
362
|
+
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
|
|
339
363
|
}
|
|
340
364
|
else if (merchant.accepts === null &&
|
|
341
365
|
(winner.card.network === 'amex' || winner.card.network === 'discover')) {
|
|
@@ -360,7 +384,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
360
384
|
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
361
385
|
.entries[0];
|
|
362
386
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
363
|
-
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.
|
|
387
|
+
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
|
|
364
388
|
}
|
|
365
389
|
}
|
|
366
390
|
const open = bonusPick(catalog, wallet, entries, purchase.date);
|
|
@@ -376,7 +400,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
376
400
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
377
401
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
378
402
|
if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
|
|
379
|
-
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.
|
|
403
|
+
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
|
|
380
404
|
}
|
|
381
405
|
const chosen = bonus?.entry ?? winner;
|
|
382
406
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
@@ -393,7 +417,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
393
417
|
const daysLeft = status.daysLeft ?? 0;
|
|
394
418
|
const costHere = ties || top.earned === null || first.earned === null
|
|
395
419
|
? null
|
|
396
|
-
: roundCents(first.earned
|
|
420
|
+
: roundCents(first.earned +
|
|
421
|
+
(first.milestoneBonus?.dollars ?? 0) -
|
|
422
|
+
(top.earned + (top.milestoneBonus?.dollars ?? 0)));
|
|
397
423
|
bonusResult = {
|
|
398
424
|
cardId: top.cardId,
|
|
399
425
|
checkoutLabel: top.checkoutLabel,
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -34,10 +34,18 @@ export function ruleParts(catalog, rule) {
|
|
|
34
34
|
if (rule.match.choice !== undefined)
|
|
35
35
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
36
36
|
const merchants = rule.match.merchants ?? [];
|
|
37
|
-
// A long list of one kind of store reads as "select streaming
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
37
|
+
// A long list of mostly one kind of store reads as "select streaming
|
|
38
|
+
// services": Amex's streaming list also names Audible, which isn't one.
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
for (const m of merchants) {
|
|
41
|
+
const category = findMerchant(catalog, m)?.category;
|
|
42
|
+
if (category)
|
|
43
|
+
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
46
|
+
const merchantParts = merchants.length > 4 &&
|
|
47
|
+
kind !== undefined &&
|
|
48
|
+
count >= merchants.length * 0.75
|
|
41
49
|
? [`select ${categoryPhrase(catalog, kind)}`]
|
|
42
50
|
: merchants.map((m) => merchantName(catalog, m));
|
|
43
51
|
const parts = [
|
|
@@ -30,7 +30,10 @@ function cardLines(cards, style, bonusPick) {
|
|
|
30
30
|
const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
|
|
31
31
|
const lines = [];
|
|
32
32
|
cards.forEach((c) => {
|
|
33
|
-
const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}
|
|
33
|
+
const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
|
|
34
|
+
(c.milestoneBonus === null
|
|
35
|
+
? ''
|
|
36
|
+
: ` + ${money(c.milestoneBonus.dollars)} spend bonus`);
|
|
34
37
|
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
35
38
|
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
36
39
|
for (const r of c.reasoning)
|
|
@@ -53,9 +56,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
53
56
|
const noCard = noCardText(rec.merchant.resolvedName);
|
|
54
57
|
if (rec.decision && top) {
|
|
55
58
|
const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
|
|
59
|
+
const milestone = top.milestoneBonus === null
|
|
60
|
+
? ''
|
|
61
|
+
: `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
|
|
56
62
|
const confidence = rec.decision.confidence;
|
|
57
63
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
58
|
-
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
|
|
64
|
+
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
|
|
59
65
|
if (rec.bonus && rec.notes[0])
|
|
60
66
|
lines.push(rec.notes[0]);
|
|
61
67
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -36,7 +36,7 @@ export function formatWalletSummary(summary) {
|
|
|
36
36
|
for (const cap of c.caps)
|
|
37
37
|
lines.push(cap.remaining === null
|
|
38
38
|
? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
|
|
39
|
-
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
|
|
39
|
+
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${cap.period === 'cardmember_year' ? 'this cardmember year' : `for ${cap.currentPeriod ?? ''}`}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
|
|
40
40
|
for (const m of c.milestones)
|
|
41
41
|
lines.push(` Spend bonus (${m.id}): ${m.text}`);
|
|
42
42
|
if (c.anniversary !== null)
|
|
@@ -76,7 +76,11 @@ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
|
|
|
76
76
|
messages.push([
|
|
77
77
|
`Spend on ${cardId} this year: ${money(first.after)}.`,
|
|
78
78
|
...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
|
|
79
|
-
...(next
|
|
79
|
+
...(next
|
|
80
|
+
? [
|
|
81
|
+
`${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
|
|
82
|
+
]
|
|
83
|
+
: []),
|
|
80
84
|
].join(' '));
|
|
81
85
|
}
|
|
82
86
|
return messages.join(' ');
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Ready-made questions a client can offer: the user's question, then what
|
|
3
|
+
// to call and how to answer. Rates and card facts come only from the tools.
|
|
4
|
+
export const PROMPTS = ['which-card', 'cheat-sheet', 'this-quarter', 'set-up'];
|
|
5
|
+
const FROM_SAVED_OR_ALL = "the user's saved cards if there are any, otherwise every card earnrate knows";
|
|
6
|
+
const ask = (question, guidance) => ({
|
|
7
|
+
messages: [
|
|
8
|
+
{
|
|
9
|
+
role: 'user',
|
|
10
|
+
content: {
|
|
11
|
+
type: 'text',
|
|
12
|
+
text: `${question}\n\n${guidance.join(' ')}`,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
const dollars = (amount) => (/^\d/.test(amount) ? `$${amount}` : amount);
|
|
18
|
+
export function registerPrompts(server, deps, ctx) {
|
|
19
|
+
server.registerPrompt('which-card', {
|
|
20
|
+
title: 'Which credit card should I use?',
|
|
21
|
+
description: 'The card that earns the most at a store or for a kind of purchase, with the reason. From your saved cards, or every card if you have none saved.',
|
|
22
|
+
argsSchema: z.object({
|
|
23
|
+
store: z
|
|
24
|
+
.string()
|
|
25
|
+
.trim()
|
|
26
|
+
.min(1)
|
|
27
|
+
.describe('The store, or the kind of purchase: Costco, groceries'),
|
|
28
|
+
amount: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('How much you are spending, in US dollars'),
|
|
32
|
+
country: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Where you are paying, if not the U.S.: FR, Japan'),
|
|
36
|
+
}),
|
|
37
|
+
}, (args) => {
|
|
38
|
+
const amount = args.amount?.trim() || null;
|
|
39
|
+
const country = args.country?.trim() || null;
|
|
40
|
+
const call = [
|
|
41
|
+
`merchant ${JSON.stringify(args.store)}`,
|
|
42
|
+
...(amount === null
|
|
43
|
+
? []
|
|
44
|
+
: [`amount ${JSON.stringify(amount)} as a number of US dollars`]),
|
|
45
|
+
...(country === null
|
|
46
|
+
? []
|
|
47
|
+
: [`country as the two-letter code for ${JSON.stringify(country)}`]),
|
|
48
|
+
];
|
|
49
|
+
return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}?`, [
|
|
50
|
+
`Call earnrate's best_card_for_purchase with ${call.join(', ')}.`,
|
|
51
|
+
`It answers from ${FROM_SAVED_OR_ALL}, so don't ask for their cards first.`,
|
|
52
|
+
'Say which card to use and why, with the assumptions and warnings it gives.',
|
|
53
|
+
...(country === null
|
|
54
|
+
? [
|
|
55
|
+
'If the user mentions paying abroad or in another currency, ask where, or pass `country` as its two-letter code.',
|
|
56
|
+
]
|
|
57
|
+
: []),
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
server.registerPrompt('cheat-sheet', {
|
|
61
|
+
title: 'Which card for everyday spending?',
|
|
62
|
+
description: 'The card to use for each everyday kind of purchase right now, from your saved cards or every card.',
|
|
63
|
+
}, () => ask('Which card should I use for everyday spending?', [
|
|
64
|
+
"Call earnrate's cheat_sheet and show the card for each kind of purchase, with its notes.",
|
|
65
|
+
`It uses ${FROM_SAVED_OR_ALL}.`,
|
|
66
|
+
]));
|
|
67
|
+
server.registerPrompt('this-quarter', {
|
|
68
|
+
title: 'What should I do with my cards this quarter?',
|
|
69
|
+
description: 'Bonuses to activate, categories to pick, caps running low and welcome bonuses to finish, from your saved cards.',
|
|
70
|
+
}, () => ask('What should I do with my cards this quarter?', [
|
|
71
|
+
"Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first.",
|
|
72
|
+
ctx.walletUse,
|
|
73
|
+
'If no cards are saved, say in a sentence or two what saving them adds (reminders to activate bonuses, pick categories, and watch caps and welcome bonuses), without pressing the user to do it, and offer the cheat sheet from every card instead.',
|
|
74
|
+
]));
|
|
75
|
+
server.registerPrompt('set-up', {
|
|
76
|
+
title: 'Set up my cards',
|
|
77
|
+
description: 'Save the cards you hold, by name, with their category picks, memberships and activations.',
|
|
78
|
+
}, () => ask('Help me set up my cards in earnrate.', [
|
|
79
|
+
'Ask which cards the user has; their names are enough. Never ask for card numbers, expiry dates or security codes.',
|
|
80
|
+
...(deps.savedWallets === 'account'
|
|
81
|
+
? [
|
|
82
|
+
"Never ask for the recovery code either; it's typed only on earnrate's page.",
|
|
83
|
+
]
|
|
84
|
+
: []),
|
|
85
|
+
'Find each card with search_cards and check the match with the user, then save it with add_card.',
|
|
86
|
+
ctx.walletUse,
|
|
87
|
+
'When a card needs category picks, a membership like Amazon Prime, activations or its anniversary, ask rather than guessing, and save the answers with update_card or set_memberships.',
|
|
88
|
+
'When done, call list_wallet and show its to-do list.',
|
|
89
|
+
]));
|
|
90
|
+
}
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -40,6 +40,9 @@ const RankedCardSchema = z.object({
|
|
|
40
40
|
effectiveReturnPct: z.number(),
|
|
41
41
|
earned: z.number().nullable(),
|
|
42
42
|
foreignFee: z.number().nullable(),
|
|
43
|
+
milestoneBonus: z
|
|
44
|
+
.object({ points: z.number(), dollars: z.number() })
|
|
45
|
+
.nullable(),
|
|
43
46
|
capped: z.boolean(),
|
|
44
47
|
reasoning: z.array(z.string()),
|
|
45
48
|
warnings: z.array(z.string()),
|
package/dist/mcp/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
|
|
|
2
2
|
import { MCP_SERVER_NAME } from "../brand.js";
|
|
3
3
|
import { makeContext } from "./context.js";
|
|
4
4
|
import { makeInputs } from "./inputs.js";
|
|
5
|
+
import { registerPrompts } from "./prompts.js";
|
|
5
6
|
import { registerAccountTools } from "./tools/account.js";
|
|
6
7
|
import { registerAnswerTools } from "./tools/answers.js";
|
|
7
8
|
import { registerWalletTools } from "./tools/wallet.js";
|
|
@@ -45,11 +46,28 @@ The wallet lists cards by product id, never card numbers; never ask for card num
|
|
|
45
46
|
|
|
46
47
|
${WHICH_WALLET[savedWallets]} The earnrate extension on the user's computer and the hosted earnrate connector keep separate wallets with the same tools. If both are connected, say which wallet an answer comes from, and ask the user which one to use before saving or recording anything.`;
|
|
47
48
|
export function createEarnrateServer(deps) {
|
|
48
|
-
const server = new McpServer({
|
|
49
|
+
const server = new McpServer({
|
|
50
|
+
name: MCP_SERVER_NAME,
|
|
51
|
+
version: deps.version,
|
|
52
|
+
websiteUrl: 'https://earnrate.dev',
|
|
53
|
+
icons: [
|
|
54
|
+
{
|
|
55
|
+
src: 'https://earnrate.dev/icon-192.png',
|
|
56
|
+
mimeType: 'image/png',
|
|
57
|
+
sizes: ['192x192'],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
src: 'https://earnrate.dev/favicon.svg',
|
|
61
|
+
mimeType: 'image/svg+xml',
|
|
62
|
+
sizes: ['any'],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}, { instructions: instructions(deps.savedWallets) });
|
|
49
66
|
const ctx = makeContext(deps);
|
|
50
67
|
const inputs = makeInputs(deps.catalog);
|
|
51
68
|
registerAnswerTools(server, deps, ctx, inputs);
|
|
52
69
|
registerWalletTools(server, deps, ctx, inputs);
|
|
70
|
+
registerPrompts(server, deps, ctx);
|
|
53
71
|
if (deps.savedWallets === 'account')
|
|
54
72
|
registerAccountTools(server, deps.catalog, deps.store, ctx);
|
|
55
73
|
return server;
|
|
@@ -26,6 +26,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
26
26
|
annotations: {
|
|
27
27
|
title: 'Export the wallet',
|
|
28
28
|
readOnlyHint: true,
|
|
29
|
+
destructiveHint: false,
|
|
29
30
|
openWorldHint: false,
|
|
30
31
|
},
|
|
31
32
|
}, async () => {
|
|
@@ -82,7 +83,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
82
83
|
});
|
|
83
84
|
server.registerTool('delete_wallet', {
|
|
84
85
|
title: 'Delete the wallet',
|
|
85
|
-
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every
|
|
86
|
+
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
|
|
86
87
|
inputSchema: z.object({
|
|
87
88
|
confirm: z
|
|
88
89
|
.literal(true)
|
|
@@ -121,7 +122,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
121
122
|
content: [
|
|
122
123
|
{
|
|
123
124
|
type: 'text',
|
|
124
|
-
text: "Deleted the saved wallet and its recovery code, and signed out every
|
|
125
|
+
text: "Deleted the saved wallet and its recovery code, and signed out every app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
|
|
125
126
|
},
|
|
126
127
|
],
|
|
127
128
|
structuredContent: { deleted: true },
|
|
@@ -129,7 +130,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
129
130
|
});
|
|
130
131
|
server.registerTool('switch_wallet', {
|
|
131
132
|
title: 'Switch wallets',
|
|
132
|
-
description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another
|
|
133
|
+
description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
|
|
133
134
|
inputSchema: z.object({}),
|
|
134
135
|
outputSchema: z.object({ disconnected: z.boolean() }),
|
|
135
136
|
annotations: {
|
|
@@ -145,7 +146,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
145
146
|
content: [
|
|
146
147
|
{
|
|
147
148
|
type: 'text',
|
|
148
|
-
text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other
|
|
149
|
+
text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other accounts connected to this wallet through the same app are signed out too.",
|
|
149
150
|
},
|
|
150
151
|
],
|
|
151
152
|
structuredContent: { disconnected: true },
|
|
@@ -54,6 +54,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
54
54
|
annotations: {
|
|
55
55
|
title: 'Best card for a purchase',
|
|
56
56
|
readOnlyHint: true,
|
|
57
|
+
destructiveHint: false,
|
|
57
58
|
openWorldHint: deps.llm !== null,
|
|
58
59
|
},
|
|
59
60
|
}, async ({ merchant, amount, channel, date, country, category, cards, memberships, valuation, }) => {
|
|
@@ -127,6 +128,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
127
128
|
annotations: {
|
|
128
129
|
title: 'Resolve a merchant',
|
|
129
130
|
readOnlyHint: true,
|
|
131
|
+
destructiveHint: false,
|
|
130
132
|
openWorldHint: deps.llm !== null,
|
|
131
133
|
},
|
|
132
134
|
}, async ({ merchant, channel, category }) => {
|
|
@@ -166,6 +168,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
166
168
|
annotations: {
|
|
167
169
|
title: 'Compare welcome offers',
|
|
168
170
|
readOnlyHint: true,
|
|
171
|
+
destructiveHint: false,
|
|
169
172
|
openWorldHint: false,
|
|
170
173
|
},
|
|
171
174
|
}, async (args) => {
|
|
@@ -219,6 +222,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
219
222
|
annotations: {
|
|
220
223
|
title: 'Best cards for a purchase',
|
|
221
224
|
readOnlyHint: true,
|
|
225
|
+
destructiveHint: false,
|
|
222
226
|
openWorldHint: false,
|
|
223
227
|
},
|
|
224
228
|
}, async (args) => {
|
|
@@ -266,6 +270,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
266
270
|
annotations: {
|
|
267
271
|
title: 'Simulate a year of spending',
|
|
268
272
|
readOnlyHint: true,
|
|
273
|
+
destructiveHint: false,
|
|
269
274
|
openWorldHint: false,
|
|
270
275
|
},
|
|
271
276
|
}, async (args) => {
|
|
@@ -349,6 +354,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
349
354
|
annotations: {
|
|
350
355
|
title: 'Cheat sheet',
|
|
351
356
|
readOnlyHint: true,
|
|
357
|
+
destructiveHint: false,
|
|
352
358
|
openWorldHint: false,
|
|
353
359
|
},
|
|
354
360
|
}, async (args) => {
|
|
@@ -391,6 +397,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
391
397
|
annotations: {
|
|
392
398
|
title: 'Search cards',
|
|
393
399
|
readOnlyHint: true,
|
|
400
|
+
destructiveHint: false,
|
|
394
401
|
openWorldHint: false,
|
|
395
402
|
},
|
|
396
403
|
}, async ({ query }) => {
|
package/dist/mcp/tools/wallet.js
CHANGED