earnrate 0.2.4 → 0.2.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 +18 -15
- package/data/CHANGELOG.md +79 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +23 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +50 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +19 -2
- package/data/cards/chase-ihg-one-rewards.yaml +15 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +17 -4
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +6 -9
- package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +16 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +23 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/issuers.yaml +32 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +25 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +11 -6
- package/dist/cli/explore.js +17 -23
- package/dist/cli/import.js +10 -10
- package/dist/cli/offers.js +19 -8
- package/dist/cli/run.js +31 -20
- package/dist/cli/wallet.js +50 -5
- package/dist/data/file-store.js +9 -7
- package/dist/data/integrity.js +22 -2
- package/dist/data/load.js +13 -2
- package/dist/data/store.js +13 -22
- package/dist/data/wallet-edit.js +108 -8
- package/dist/engine/cheat-sheet.js +15 -19
- package/dist/engine/compare.js +10 -0
- package/dist/engine/evaluate.js +2 -4
- package/dist/engine/explore.js +13 -56
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +18 -5
- package/dist/engine/periods.js +18 -0
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +23 -37
- package/dist/engine/setup.js +28 -40
- package/dist/engine/simulate.js +11 -10
- package/dist/engine/text.js +49 -23
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +26 -11
- package/dist/engine/wallet-summary.js +85 -31
- package/dist/format/card-text.js +19 -36
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +15 -16
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +6 -6
- package/dist/format/wallet.js +5 -2
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +61 -13
- package/dist/mcp/inputs.js +16 -7
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/server.js +8 -1
- package/dist/mcp/tools/account.js +45 -17
- package/dist/mcp/tools/answers.js +84 -29
- package/dist/mcp/tools/wallet.js +109 -22
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +8 -12
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
package/dist/format/explore.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { membershipNote } from "../engine/memberships.js";
|
|
2
|
-
import {
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
.map((m) => ` + ${wholeUsd(m.annualFee)} for ${m.name.replace(/^an? /, '')}`)
|
|
1
|
+
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
+
import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
3
|
+
const membershipFees = (memberships) => memberships
|
|
4
|
+
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
6
5
|
.join('');
|
|
7
6
|
const TOP_BEST = 10;
|
|
8
|
-
export function formatExploration(r, all) {
|
|
7
|
+
export function formatExploration(r, catalog, all) {
|
|
9
8
|
const m = r.merchant;
|
|
10
9
|
const title = m.source === 'category'
|
|
11
10
|
? m.resolvedName.toLowerCase()
|
|
@@ -18,13 +17,13 @@ export function formatExploration(r, all) {
|
|
|
18
17
|
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
19
18
|
const lines = [`Best cards for ${title}:`, ''];
|
|
20
19
|
shown.forEach((e, i) => {
|
|
21
|
-
const earned = e.earned === null ? '' : ` ${
|
|
20
|
+
const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
|
|
22
21
|
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
23
22
|
for (const c of e.conditions)
|
|
24
23
|
lines.push(` ${c}`);
|
|
25
24
|
const upgrade = e.withMembership[0];
|
|
26
25
|
if (upgrade)
|
|
27
|
-
lines.push(` With ${membershipName(upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
26
|
+
lines.push(` With ${membershipName(catalog, upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
28
27
|
for (const w of e.warnings)
|
|
29
28
|
lines.push(` ! ${w}`);
|
|
30
29
|
});
|
|
@@ -36,8 +35,8 @@ export function formatExploration(r, all) {
|
|
|
36
35
|
return `${lines.join('\n')}\n`;
|
|
37
36
|
}
|
|
38
37
|
function picksText(picks) {
|
|
39
|
-
const parts = Object.entries(picks).map(([choice, options]) => `${choice}
|
|
40
|
-
return parts.length === 0 ? '' : ` (
|
|
38
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words).join(' and ')} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
|
|
39
|
+
return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
|
|
41
40
|
}
|
|
42
41
|
function cardNamer(catalog) {
|
|
43
42
|
return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
@@ -60,7 +59,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
60
59
|
return 'No cards match.\n';
|
|
61
60
|
const top = all ? 20 : TOP_SIMULATED;
|
|
62
61
|
const lines = [
|
|
63
|
-
`A year of this spending: ${
|
|
62
|
+
`A year of this spending: ${wholeDollars(first.spend)}.`,
|
|
64
63
|
'',
|
|
65
64
|
'Best single cards (rewards minus annual fee, per year):',
|
|
66
65
|
];
|
|
@@ -68,13 +67,13 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
68
67
|
const card = s.cards[0];
|
|
69
68
|
if (!card)
|
|
70
69
|
return;
|
|
71
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${
|
|
70
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
|
|
72
71
|
});
|
|
73
72
|
const pairs = r.pairs.slice(0, top);
|
|
74
73
|
if (pairs.length > 0) {
|
|
75
74
|
lines.push('', 'Best two-card pairs:');
|
|
76
75
|
pairs.forEach((s, i) => {
|
|
77
|
-
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${
|
|
76
|
+
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
|
|
78
77
|
if (s.alternatives.length > 0)
|
|
79
78
|
lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
|
|
80
79
|
for (const split of splitText(s, name))
|
|
@@ -96,7 +95,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
96
95
|
export function formatAdditions(r, catalog, all) {
|
|
97
96
|
const name = cardNamer(catalog);
|
|
98
97
|
const lines = [
|
|
99
|
-
`Your wallet earns about ${
|
|
98
|
+
`Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
|
|
100
99
|
];
|
|
101
100
|
for (const split of splitText(r.current, name))
|
|
102
101
|
lines.push(` ${split}`);
|
|
@@ -107,7 +106,7 @@ export function formatAdditions(r, catalog, all) {
|
|
|
107
106
|
else {
|
|
108
107
|
lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
|
|
109
108
|
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
110
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${
|
|
109
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
|
|
111
110
|
});
|
|
112
111
|
}
|
|
113
112
|
const notes = new Set([
|
|
@@ -118,6 +117,6 @@ export function formatAdditions(r, catalog, all) {
|
|
|
118
117
|
]);
|
|
119
118
|
if (notes.size > 0)
|
|
120
119
|
lines.push('', ...notes);
|
|
121
|
-
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
|
|
120
|
+
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
|
|
122
121
|
return `${lines.join('\n')}\n`;
|
|
123
122
|
}
|
package/dist/format/offers.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { offerTerms } from "../engine/offers.js";
|
|
2
|
+
import { capitalize, feeText, money, shortDate, wholeDollars, } from "../engine/text.js";
|
|
2
3
|
const TOP = 10;
|
|
3
|
-
function reward(o, currencyName) {
|
|
4
|
-
const amount = o.dollars !== null
|
|
5
|
-
? money(o.dollars)
|
|
6
|
-
: `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
|
-
return o.varies ? `Up to ${amount}` : amount;
|
|
8
|
-
}
|
|
9
4
|
export function formatOffers(c, currencyName, all) {
|
|
10
5
|
if (c.offers.length === 0) {
|
|
11
6
|
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
@@ -15,19 +10,19 @@ export function formatOffers(c, currencyName, all) {
|
|
|
15
10
|
const lines = [];
|
|
16
11
|
shown.forEach((o, i) => {
|
|
17
12
|
const weeks = (o.months * 52) / 12;
|
|
18
|
-
const pace = o.spend > 0 ? ` (about ${
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
: 'with no spending required';
|
|
22
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
23
|
-
lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
13
|
+
const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
|
|
14
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
15
|
+
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
24
16
|
if (o.ineligible)
|
|
25
17
|
lines.push(` ${o.ineligible}`);
|
|
26
18
|
if (o.note)
|
|
27
19
|
lines.push(` ${o.note}`);
|
|
28
20
|
});
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
const ruledOut = c.offers.filter((o) => o.ineligible !== null && !shown.includes(o));
|
|
22
|
+
if (ruledOut.length > 0)
|
|
23
|
+
lines.push(` ${String(ruledOut.length)} more left out as likely not eligible with a card you hold: ${ruledOut.map((o) => o.cardName).join(', ')}.`);
|
|
24
|
+
if (c.offers.length > shown.length + ruledOut.length)
|
|
25
|
+
lines.push(` + ${String(c.offers.length - shown.length - ruledOut.length)} more (--all lists them)`);
|
|
31
26
|
if (c.withoutOffer.length > 0)
|
|
32
27
|
lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
|
|
33
28
|
lines.push('', ...c.notes);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { money, pct } from "../engine/text.js";
|
|
1
|
+
import { capitalize, dollarsAndCents, money, pct } from "../engine/text.js";
|
|
2
|
+
import { noCardText } from "../engine/recommend.js";
|
|
2
3
|
export const plain = (_format, text) => text;
|
|
3
4
|
const TOP = 3;
|
|
4
|
-
const cents = (n) => `$${n.toFixed(2)}`;
|
|
5
5
|
const label = (c) => c.checkoutLabel ?? c.cardName;
|
|
6
6
|
function header(rec, purchase) {
|
|
7
7
|
const m = rec.merchant;
|
|
@@ -30,7 +30,7 @@ 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 ? '' : ` ${
|
|
33
|
+
const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
|
|
34
34
|
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
35
35
|
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
36
36
|
for (const r of c.reasoning)
|
|
@@ -50,12 +50,12 @@ function excludedLines(excluded) {
|
|
|
50
50
|
export function formatRecommendation(rec, purchase, { all = false, style = plain, notices = [], } = {}) {
|
|
51
51
|
const lines = [header(rec, purchase), ''];
|
|
52
52
|
const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId) ?? rec.ranked[0];
|
|
53
|
-
const noCard =
|
|
53
|
+
const noCard = noCardText(rec.merchant.resolvedName);
|
|
54
54
|
if (rec.decision && top) {
|
|
55
|
-
const earned = top.earned === null ? '' : `, about ${
|
|
55
|
+
const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
|
|
56
56
|
const confidence = rec.decision.confidence;
|
|
57
57
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
58
|
-
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${
|
|
58
|
+
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
|
|
59
59
|
if (rec.bonus && rec.notes[0])
|
|
60
60
|
lines.push(rec.notes[0]);
|
|
61
61
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -40,9 +40,12 @@ export function formatWalletSummary(summary) {
|
|
|
40
40
|
return lines.join('\n');
|
|
41
41
|
});
|
|
42
42
|
const memberships = summary.memberships.length > 0
|
|
43
|
-
? `Memberships: ${summary.memberships.join(', ')}.`
|
|
43
|
+
? `Memberships: ${summary.memberships.map((id, i) => `${summary.membershipNames[i] ?? id} (${id})`).join(', ')}.`
|
|
44
44
|
: 'Memberships: none saved.';
|
|
45
|
-
|
|
45
|
+
const values = summary.pointValues.length > 0
|
|
46
|
+
? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
|
|
47
|
+
: "Point values: earnrate's defaults.";
|
|
48
|
+
return [...cards, memberships, values].join('\n');
|
|
46
49
|
}
|
|
47
50
|
export function formatBooking(amount, cardId, cap, bonus) {
|
|
48
51
|
const messages = [];
|
package/dist/import/analyze.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
2
|
+
import { HALF_CENT } from "../engine/compare.js";
|
|
2
3
|
import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
3
4
|
import { periodKey } from "../engine/periods.js";
|
|
4
5
|
import { recommend } from "../engine/recommend.js";
|
|
@@ -155,7 +156,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
155
156
|
if (topBooking)
|
|
156
157
|
simulated = applyBooking(simulated, topBooking);
|
|
157
158
|
const gain = (top.earned ?? 0) - (used.earned ?? 0);
|
|
158
|
-
if (top.cardId !== cardId && gain >
|
|
159
|
+
if (top.cardId !== cardId && gain > HALF_CENT) {
|
|
159
160
|
const key = `${name}|${cardId}|${top.cardId}`;
|
|
160
161
|
const miss = missed.get(key) ?? {
|
|
161
162
|
merchant: name,
|
package/dist/mcp/context.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { walletProblems } from "../data/integrity.js";
|
|
2
2
|
import { EMPTY_WALLET, walletFromCards } from "../engine/transient.js";
|
|
3
|
+
import { capitalize } from "../engine/text.js";
|
|
3
4
|
import { formatNotes } from "../format/cards.js";
|
|
4
|
-
import {
|
|
5
|
+
import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
|
|
5
6
|
import { defined } from "./inputs.js";
|
|
6
7
|
export const fail = (text) => ({
|
|
7
8
|
content: [{ type: 'text', text }],
|
|
@@ -15,9 +16,22 @@ export function makeContext(deps) {
|
|
|
15
16
|
const theWallet = deps.savedWallets === 'file'
|
|
16
17
|
? `the wallet at ${deps.store.where}`
|
|
17
18
|
: 'the saved wallet';
|
|
19
|
+
// Which wallet this server keeps, for replies and tool descriptions. The
|
|
20
|
+
// local extension and the hosted connector can both be connected at once,
|
|
21
|
+
// each with its own wallet, and their tools are otherwise identical.
|
|
22
|
+
const walletName = deps.savedWallets === 'file'
|
|
23
|
+
? `the wallet file on this computer (${deps.store.where})`
|
|
24
|
+
: 'your hosted earnrate wallet';
|
|
25
|
+
const walletUse = deps.savedWallets === 'file'
|
|
26
|
+
? "It uses the wallet file on this computer, not the hosted earnrate connector's wallet."
|
|
27
|
+
: "It uses the user's hosted earnrate wallet, not a wallet file on their computer.";
|
|
28
|
+
const startHint = 'Answers cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.';
|
|
29
|
+
const noCards = deps.savedWallets === 'file'
|
|
30
|
+
? `The wallet at ${deps.store.where} has no cards yet. ${startHint}`
|
|
31
|
+
: `The user's saved wallet has no cards yet. ${startHint}`;
|
|
18
32
|
const noWallet = deps.savedWallets === 'file'
|
|
19
33
|
? `There's no saved wallet yet (${deps.store.where}). Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`
|
|
20
|
-
:
|
|
34
|
+
: noCards;
|
|
21
35
|
// Read on every call, so edits to the wallet apply without a restart.
|
|
22
36
|
async function wallet() {
|
|
23
37
|
let loaded;
|
|
@@ -32,21 +46,21 @@ export function makeContext(deps) {
|
|
|
32
46
|
}
|
|
33
47
|
const problems = walletProblems(loaded, catalog);
|
|
34
48
|
return problems.length > 0
|
|
35
|
-
? `${
|
|
49
|
+
? `${capitalize(theWallet)} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
|
|
36
50
|
: loaded;
|
|
37
51
|
}
|
|
38
|
-
// No saved wallet is the normal state, not a
|
|
39
|
-
// empty one, with a note on how to
|
|
52
|
+
// No saved wallet, or one with no cards, is the normal state, not a
|
|
53
|
+
// failure: it reads as an empty one, with a note on how to add cards.
|
|
40
54
|
async function readableWallet() {
|
|
41
55
|
const w = await wallet();
|
|
42
56
|
if (typeof w !== 'string')
|
|
43
|
-
return { wallet: w, start: null };
|
|
57
|
+
return { wallet: w, start: w.cards.length === 0 ? noCards : null };
|
|
44
58
|
return w === noWallet ? { wallet: EMPTY_WALLET, start: w } : w;
|
|
45
59
|
}
|
|
46
60
|
// The caller's category is free and comes before the paid Claude fallback;
|
|
47
61
|
// neither ever overrides a merchant earnrate already knows.
|
|
48
62
|
async function resolve(merchant, channel, category) {
|
|
49
|
-
const known =
|
|
63
|
+
const known = resolvePlace(catalog, merchant, channel);
|
|
50
64
|
if (known.source !== 'none' || category !== null || !deps.llm) {
|
|
51
65
|
return { merchant: withCallerCategory(known, category), notices: [] };
|
|
52
66
|
}
|
|
@@ -54,15 +68,34 @@ export function makeContext(deps) {
|
|
|
54
68
|
}
|
|
55
69
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
56
70
|
// neither (null), answers cover every card earnrate knows.
|
|
71
|
+
// The point values and memberships the user saved apply to every answer,
|
|
72
|
+
// not just ones from their saved cards. A wallet that can't be read has
|
|
73
|
+
// none.
|
|
74
|
+
async function saved() {
|
|
75
|
+
try {
|
|
76
|
+
const wallet = await deps.store.load();
|
|
77
|
+
return {
|
|
78
|
+
valuations: wallet?.valuations ?? {},
|
|
79
|
+
memberships: wallet?.memberships ?? [],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { valuations: {}, memberships: [] };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
57
86
|
async function cardsFor(cards, memberships) {
|
|
58
|
-
if (cards && cards.length > 0)
|
|
59
|
-
|
|
87
|
+
if (cards && cards.length > 0) {
|
|
88
|
+
const named = walletFromCards(catalog, {
|
|
60
89
|
cards: cards.map(({ cardId, ...rest }) => ({
|
|
61
90
|
id: cardId,
|
|
62
91
|
...defined(rest),
|
|
63
92
|
})),
|
|
64
|
-
|
|
93
|
+
memberships: memberships ?? (await saved()).memberships,
|
|
65
94
|
}, deps.today());
|
|
95
|
+
return typeof named === 'string'
|
|
96
|
+
? named
|
|
97
|
+
: { ...named, valuations: (await saved()).valuations };
|
|
98
|
+
}
|
|
66
99
|
const read = await readableWallet();
|
|
67
100
|
if (typeof read === 'string')
|
|
68
101
|
return read;
|
|
@@ -82,8 +115,8 @@ export function makeContext(deps) {
|
|
|
82
115
|
const text = [
|
|
83
116
|
message,
|
|
84
117
|
output.cardIds.length > 0
|
|
85
|
-
? `
|
|
86
|
-
:
|
|
118
|
+
? `Saved in ${walletName}, which has ${String(output.cardIds.length)} ${output.cardIds.length === 1 ? 'card' : 'cards'}.`
|
|
119
|
+
: `Saved in ${walletName}, which has no cards now; answers cover every card.`,
|
|
87
120
|
...(notes ? [formatNotes(result.notes)] : []),
|
|
88
121
|
].join('\n');
|
|
89
122
|
return {
|
|
@@ -92,5 +125,20 @@ export function makeContext(deps) {
|
|
|
92
125
|
};
|
|
93
126
|
}
|
|
94
127
|
const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
95
|
-
|
|
128
|
+
// The first line of an answer from the user's own cards: which ones.
|
|
129
|
+
const fromCards = (named) => named !== undefined && named.length > 0
|
|
130
|
+
? 'For the cards named in this conversation.\n'
|
|
131
|
+
: `From ${walletName}.\n`;
|
|
132
|
+
return {
|
|
133
|
+
walletName,
|
|
134
|
+
fromCards,
|
|
135
|
+
walletUse,
|
|
136
|
+
wallet,
|
|
137
|
+
readableWallet,
|
|
138
|
+
resolve,
|
|
139
|
+
cardsFor,
|
|
140
|
+
saved,
|
|
141
|
+
changed,
|
|
142
|
+
cardName,
|
|
143
|
+
};
|
|
96
144
|
}
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
3
|
-
import { VALUATION_PROFILES } from "../engine/profiles.js";
|
|
3
|
+
import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
|
|
4
4
|
import { knownMemberships } from "../engine/setup.js";
|
|
5
5
|
import { IsoDate } from "../schema/common.js";
|
|
6
6
|
export const Merchant = z
|
|
7
7
|
.string()
|
|
8
8
|
.min(1)
|
|
9
|
-
.describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com".');
|
|
9
|
+
.describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com", or a kind of place, e.g. "restaurants" or "gas station".');
|
|
10
10
|
export const ChannelInput = z
|
|
11
11
|
.enum(['online', 'in_store'])
|
|
12
12
|
.optional()
|
|
@@ -112,6 +112,8 @@ export function makeInputs(catalog) {
|
|
|
112
112
|
.enum(categoryIds)
|
|
113
113
|
.optional()
|
|
114
114
|
.describe(`Only for a merchant earnrate may not know, and only if you're confident what kind of business it is: its spending category, e.g. grocery.supermarket for a supermarket, dining for a restaurant, gas for a gas station. Ignored when earnrate knows the merchant. Leave it out if unsure. Categories: ${categoryList}.`);
|
|
115
|
+
// record_purchase can't skip an unknown merchant's caps, so it asks.
|
|
116
|
+
const RecordCategoryInput = CategoryInput.describe(`For a merchant earnrate doesn't know, its spending category, e.g. dining for a restaurant: without one the purchase isn't recorded, since its caps can't be told. If nobody knows, other records it toward a welcome bonus without touching any cap. Ignored when earnrate knows the merchant. Categories: ${categoryList}.`);
|
|
115
117
|
const CatalogCategory = z
|
|
116
118
|
.enum(categoryIds)
|
|
117
119
|
.describe(`A spending category: ${categoryList}.`);
|
|
@@ -128,11 +130,12 @@ export function makeInputs(catalog) {
|
|
|
128
130
|
valuation: z
|
|
129
131
|
.enum(['flat', 'points-heavy'])
|
|
130
132
|
.optional()
|
|
131
|
-
.describe('
|
|
133
|
+
.describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
|
|
132
134
|
};
|
|
133
135
|
const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
|
|
134
136
|
return {
|
|
135
137
|
CategoryInput,
|
|
138
|
+
RecordCategoryInput,
|
|
136
139
|
CatalogCategory,
|
|
137
140
|
NamedCards,
|
|
138
141
|
NamedMemberships,
|
|
@@ -141,8 +144,12 @@ export function makeInputs(catalog) {
|
|
|
141
144
|
AllMemberships,
|
|
142
145
|
};
|
|
143
146
|
}
|
|
144
|
-
// The catalog filters as exploreCards and simulate take them.
|
|
145
|
-
|
|
147
|
+
// The catalog filters as exploreCards and simulate take them. Memberships or
|
|
148
|
+
// a valuation profile asked for win over the user's saved ones.
|
|
149
|
+
export const settings = (args, saved = {
|
|
150
|
+
valuations: {},
|
|
151
|
+
memberships: [],
|
|
152
|
+
}) => ({
|
|
146
153
|
filters: {
|
|
147
154
|
...ALL_PERSONAL_CARDS,
|
|
148
155
|
kind: args.kind ?? 'any',
|
|
@@ -150,6 +157,8 @@ export const settings = (args) => ({
|
|
|
150
157
|
business: args.business ?? false,
|
|
151
158
|
issuer: args.issuer ?? null,
|
|
152
159
|
},
|
|
153
|
-
memberships: args.memberships ??
|
|
154
|
-
valuations: args.valuation
|
|
160
|
+
memberships: args.memberships ?? saved.memberships,
|
|
161
|
+
valuations: args.valuation
|
|
162
|
+
? VALUATION_PROFILES[args.valuation]
|
|
163
|
+
: saved.valuations,
|
|
155
164
|
});
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -78,6 +78,14 @@ const RecommendationSchema = z.object({
|
|
|
78
78
|
});
|
|
79
79
|
export const WalletSummarySchema = z.object({
|
|
80
80
|
memberships: z.array(z.string()),
|
|
81
|
+
membershipNames: z.array(z.string()),
|
|
82
|
+
pointValues: z.array(z.object({
|
|
83
|
+
currency: z.string(),
|
|
84
|
+
name: z.string(),
|
|
85
|
+
centsPerUnit: z.number(),
|
|
86
|
+
redemption: z.string().nullable(),
|
|
87
|
+
})),
|
|
88
|
+
started: z.string().nullable(),
|
|
81
89
|
cards: z.array(z.object({
|
|
82
90
|
cardId: z.string(),
|
|
83
91
|
cardName: z.string(),
|
|
@@ -99,6 +107,7 @@ export const WalletSummarySchema = z.object({
|
|
|
99
107
|
})
|
|
100
108
|
.nullable(),
|
|
101
109
|
currency: z.string(),
|
|
110
|
+
currencyName: z.string(),
|
|
102
111
|
centsPerUnit: z.number(),
|
|
103
112
|
lastVerified: z.string(),
|
|
104
113
|
verifiedBy: z.string(),
|
package/dist/mcp/server.js
CHANGED
|
@@ -23,6 +23,7 @@ export const SAVED_WALLET_TOOLS = [
|
|
|
23
23
|
'update_card',
|
|
24
24
|
'remove_card',
|
|
25
25
|
'set_memberships',
|
|
26
|
+
'set_point_values',
|
|
26
27
|
'export_wallet',
|
|
27
28
|
'import_wallet',
|
|
28
29
|
'delete_wallet',
|
|
@@ -32,11 +33,17 @@ const SAVING = {
|
|
|
32
33
|
file: 'Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools.',
|
|
33
34
|
account: "The user signed in to earnrate when they connected it, to an anonymous saved wallet (no email or name). It may have no cards yet; answers then cover every card. Save cards with add_card only when the user agrees. The wallet can change outside this conversation when the user signs in again on earnrate's page, so read it with list_wallet rather than from memory. To use a different wallet or start a new one, call switch_wallet; the user then connects on earnrate's page with that wallet's recovery code. Never ask for the recovery code in the conversation; it's typed only on earnrate's page. To change the code, the user opens https://mcp.earnrate.dev/change-code. earnrate.dev and the earnrate command line keep their own separate wallets; export_wallet and import_wallet move one between them.",
|
|
34
35
|
};
|
|
36
|
+
const WHICH_WALLET = {
|
|
37
|
+
file: "This server's saved wallet is a file on the user's computer.",
|
|
38
|
+
account: "This server's saved wallet is hosted by earnrate, not a file on the user's computer.",
|
|
39
|
+
};
|
|
35
40
|
const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
|
|
36
41
|
|
|
37
42
|
Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
|
|
38
43
|
|
|
39
|
-
The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes
|
|
44
|
+
The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
|
|
45
|
+
|
|
46
|
+
${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.`;
|
|
40
47
|
export function createEarnrateServer(deps) {
|
|
41
48
|
const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: instructions(deps.savedWallets) });
|
|
42
49
|
const ctx = makeContext(deps);
|
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { emptyWallet } from "../../data/store.js";
|
|
2
3
|
import { parseWalletYaml, walletYaml } from "../../format/wallet-yaml.js";
|
|
4
|
+
import { listed } from "../../engine/text.js";
|
|
5
|
+
import { savedSettingsText } from "../../engine/wallet-summary.js";
|
|
3
6
|
import { fail } from "../context.js";
|
|
4
7
|
import { WalletChangeSchema } from "../schemas.js";
|
|
5
8
|
// A saved wallet on the hosted server has no file to copy or delete, so
|
|
6
9
|
// these do it.
|
|
7
10
|
export function registerAccountTools(server, catalog, store, ctx) {
|
|
11
|
+
const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
|
|
12
|
+
// Everything a saved wallet holds, in words; empty when there's nothing.
|
|
13
|
+
const contents = (w) => [
|
|
14
|
+
...(w.cards.length > 0
|
|
15
|
+
? [
|
|
16
|
+
`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`,
|
|
17
|
+
]
|
|
18
|
+
: []),
|
|
19
|
+
...savedSettingsText(catalog, w),
|
|
20
|
+
];
|
|
8
21
|
server.registerTool('export_wallet', {
|
|
9
22
|
title: 'Export the wallet',
|
|
10
|
-
description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, cap usage and bonus progress. Never card numbers.',
|
|
23
|
+
description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage and bonus progress. Never card numbers.',
|
|
11
24
|
inputSchema: z.object({}),
|
|
12
25
|
outputSchema: z.object({ yaml: z.string() }),
|
|
13
|
-
annotations: {
|
|
26
|
+
annotations: {
|
|
27
|
+
title: 'Export the wallet',
|
|
28
|
+
readOnlyHint: true,
|
|
29
|
+
openWorldHint: false,
|
|
30
|
+
},
|
|
14
31
|
}, async () => {
|
|
15
32
|
const w = await ctx.wallet();
|
|
16
33
|
if (typeof w === 'string')
|
|
@@ -23,7 +40,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
23
40
|
});
|
|
24
41
|
server.registerTool('import_wallet', {
|
|
25
42
|
title: 'Import a wallet',
|
|
26
|
-
description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If
|
|
43
|
+
description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If anything is already saved (cards, memberships or point values), it refuses unless replace is true: confirm with the user first.',
|
|
27
44
|
inputSchema: z.object({
|
|
28
45
|
yaml: z
|
|
29
46
|
.string()
|
|
@@ -33,10 +50,11 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
33
50
|
replace: z
|
|
34
51
|
.boolean()
|
|
35
52
|
.optional()
|
|
36
|
-
.describe('true to replace
|
|
53
|
+
.describe('true to replace what the wallet already holds, after the user confirms.'),
|
|
37
54
|
}),
|
|
38
55
|
outputSchema: WalletChangeSchema,
|
|
39
56
|
annotations: {
|
|
57
|
+
title: 'Import a wallet',
|
|
40
58
|
readOnlyHint: false,
|
|
41
59
|
destructiveHint: true,
|
|
42
60
|
idempotentHint: true,
|
|
@@ -55,17 +73,16 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
55
73
|
return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
|
|
56
74
|
current = null;
|
|
57
75
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
: `Imported a wallet with ${cards(parsed.cards.length)}.`, { notes: false });
|
|
76
|
+
const before = current === null ? [] : contents(current);
|
|
77
|
+
if (before.length > 0 && replace !== true)
|
|
78
|
+
return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
|
|
79
|
+
return ctx.changed(await store.replace(parsed), before.length > 0
|
|
80
|
+
? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
|
|
81
|
+
: `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
|
|
65
82
|
});
|
|
66
83
|
server.registerTool('delete_wallet', {
|
|
67
84
|
title: 'Delete the wallet',
|
|
68
|
-
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude 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 and
|
|
85
|
+
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude 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.",
|
|
69
86
|
inputSchema: z.object({
|
|
70
87
|
confirm: z
|
|
71
88
|
.literal(true)
|
|
@@ -75,20 +92,30 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
75
92
|
.int()
|
|
76
93
|
.nonnegative()
|
|
77
94
|
.describe('How many cards the wallet held when the user confirmed, from list_wallet: 0 for an empty wallet.'),
|
|
95
|
+
saved: z
|
|
96
|
+
.number()
|
|
97
|
+
.int()
|
|
98
|
+
.nonnegative()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('How many memberships and point values list_wallet showed, counted together: 0 if none.'),
|
|
78
101
|
}),
|
|
79
102
|
outputSchema: z.object({ deleted: z.boolean() }),
|
|
80
103
|
annotations: {
|
|
104
|
+
title: 'Delete the wallet',
|
|
81
105
|
readOnlyHint: false,
|
|
82
106
|
destructiveHint: true,
|
|
83
107
|
idempotentHint: true,
|
|
84
108
|
openWorldHint: false,
|
|
85
109
|
},
|
|
86
|
-
}, async ({ cards }) => {
|
|
110
|
+
}, async ({ cards, saved }) => {
|
|
87
111
|
// An unreadable wallet can still be deleted: the user confirmed it.
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
const loaded = await store.load().catch(() => undefined);
|
|
113
|
+
if (loaded !== undefined) {
|
|
114
|
+
const current = loaded ?? emptyWallet();
|
|
115
|
+
const settings = savedSettingsText(catalog, current);
|
|
116
|
+
if (current.cards.length !== cards || settings.length !== (saved ?? 0))
|
|
117
|
+
return fail(`This saved wallet holds ${listed(contents(current)) || 'nothing'}, not what the user confirmed (${cardsText(cards)}${saved === undefined ? '' : ` and ${String(saved)} memberships and point values`}); it may have changed, or be a different wallet. Show it with list_wallet, confirm again, and pass cards: ${String(current.cards.length)} and saved: ${String(settings.length)}.`);
|
|
118
|
+
}
|
|
92
119
|
await store.erase();
|
|
93
120
|
return {
|
|
94
121
|
content: [
|
|
@@ -106,6 +133,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
106
133
|
inputSchema: z.object({}),
|
|
107
134
|
outputSchema: z.object({ disconnected: z.boolean() }),
|
|
108
135
|
annotations: {
|
|
136
|
+
title: 'Switch wallets',
|
|
109
137
|
readOnlyHint: false,
|
|
110
138
|
destructiveHint: false,
|
|
111
139
|
idempotentHint: true,
|