earnrate 0.2.14 → 0.2.16

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.
Files changed (45) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +12 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  5. package/data/cards/discover-it-cash-back.yaml +1 -1
  6. package/data/cards/venmo-credit-card.yaml +1 -1
  7. package/data/categories.yaml +10 -0
  8. package/dist/cli/args.js +30 -1
  9. package/dist/cli/import.js +14 -11
  10. package/dist/cli/run.js +10 -1
  11. package/dist/cli/todo.js +3 -2
  12. package/dist/cli/wallet.js +49 -16
  13. package/dist/engine/cheat-sheet.js +13 -10
  14. package/dist/engine/checklist.js +5 -10
  15. package/dist/engine/credits.js +2 -2
  16. package/dist/engine/evaluate.js +1 -1
  17. package/dist/engine/explore.js +7 -3
  18. package/dist/engine/intro.js +1 -5
  19. package/dist/engine/memberships.js +3 -7
  20. package/dist/engine/offers.js +3 -5
  21. package/dist/engine/periods.js +5 -0
  22. package/dist/engine/profiles.js +2 -1
  23. package/dist/engine/recommend.js +19 -17
  24. package/dist/engine/record.js +1 -1
  25. package/dist/engine/rule-text.js +71 -23
  26. package/dist/engine/setup.js +4 -0
  27. package/dist/engine/text.js +23 -6
  28. package/dist/engine/wallet-change.js +5 -5
  29. package/dist/engine/wallet-summary.js +32 -23
  30. package/dist/format/card-text.js +6 -5
  31. package/dist/format/cards.js +2 -2
  32. package/dist/format/explore.js +2 -2
  33. package/dist/format/wallet-yaml.js +2 -2
  34. package/dist/format/wallet.js +5 -5
  35. package/dist/import/analyze.js +13 -1
  36. package/dist/mcp/context.js +2 -3
  37. package/dist/mcp/inputs.js +11 -3
  38. package/dist/mcp/schemas.js +2 -0
  39. package/dist/mcp/server.js +0 -2
  40. package/dist/mcp/tools/account.js +50 -42
  41. package/dist/mcp/tools/answers.js +8 -4
  42. package/dist/mcp/tools/wallet.js +205 -148
  43. package/dist/mcp/wallet-api.js +78 -0
  44. package/dist/schema/card.js +6 -10
  45. package/package.json +2 -1
@@ -1,8 +1,7 @@
1
- import { earnText } from "../engine/text.js";
2
1
  // Card names, fees and requirements as earnrate.dev and its static search
3
2
  // pages show them. Node-free: the site bundles it.
4
3
  import { membershipName, membershipText } from "../engine/memberships.js";
5
- import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
4
+ import { capSentence, earnText, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
6
5
  // An issuer's name as people know it, from data/issuers.yaml.
7
6
  export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
8
7
  export function cardText(catalog) {
@@ -16,9 +15,11 @@ export function cardText(catalog) {
16
15
  .replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
17
16
  .replace(/ Card by /, ' by '));
18
17
  }
19
- function feeLabel(id) {
18
+ // `membership: false` leaves out a membership the card needs, for lists
19
+ // whose own conditions say it only when it applies.
20
+ function feeLabel(id, { membership = true } = {}) {
20
21
  const card = cards.get(id);
21
- const m = card?.requires_membership;
22
+ const m = membership ? card?.requires_membership : undefined;
22
23
  const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
23
24
  const fee = card?.annual_fee;
24
25
  if (fee === undefined)
@@ -42,7 +43,7 @@ export function cardText(catalog) {
42
43
  if (until !== null)
43
44
  lines.push(`This rate runs through ${until}`);
44
45
  if (r.cap)
45
- lines.push(`Up to ${wholeDollars(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
46
+ lines.push(capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency)));
46
47
  return lines;
47
48
  }
48
49
  const memberName = (id) => membershipName(catalog, id);
@@ -5,7 +5,7 @@ export function formatCardInfo(catalog, card) {
5
5
  ? ''
6
6
  : card.annualFee === 0
7
7
  ? ', no annual fee'
8
- : `, ${money(card.annualFee)} a year`;
8
+ : `, ${money(card.annualFee)} a year${card.firstYearFee === null ? '' : `, ${card.firstYearFee === 0 ? 'waived' : money(card.firstYearFee)} the first year`}`;
9
9
  const needs = [
10
10
  ...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
11
11
  ...(card.requiresMembership
@@ -24,7 +24,7 @@ export function formatCardInfo(catalog, card) {
24
24
  ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
25
25
  ...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
26
26
  ...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
27
- ...card.credits.map((c) => `merchant credit ${c.id}: ${c.text}`),
27
+ ...card.credits.map((c) => `statement credit ${c.id}: ${c.text}`),
28
28
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
29
29
  foreignFeeText(card.foreignTransactionFee).toLowerCase(),
30
30
  ];
@@ -1,5 +1,5 @@
1
1
  import { membershipName, membershipNote, } from "../engine/memberships.js";
2
- import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
2
+ import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
3
3
  const membershipFees = (memberships) => memberships
4
4
  .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
5
5
  .join('');
@@ -51,7 +51,7 @@ export function formatExploration(r, catalog, all) {
51
51
  return `${lines.join('\n')}\n`;
52
52
  }
53
53
  function picksText(picks) {
54
- const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words).join(' and ')} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
54
+ const parts = Object.entries(picks).map(([choice, options]) => `${listed(options.map(words))} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
55
55
  return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
56
56
  }
57
57
  function cardNamer(catalog) {
@@ -25,8 +25,8 @@ function compact(wallet) {
25
25
  cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
26
26
  };
27
27
  }
28
- // Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
29
- // date, not a string.
28
+ // Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
29
+ // 2026-10-15 as a date, not a string.
30
30
  function quotedYaml(value) {
31
31
  const doc = new Document(value);
32
32
  visit(doc, {
@@ -48,10 +48,10 @@ export function formatWalletSummary(summary) {
48
48
  else if (c.intro?.state === 'no-date')
49
49
  lines.push(` Has ${introAdjective(c.intro.days)} rates for new accounts; give its opening date if it opened ${introSince(c.intro.days)}.`);
50
50
  else if (c.opened !== null)
51
- lines.push(` Opened ${c.opened}.`);
51
+ lines.push(` Opened ${shortDate(c.opened)}.`);
52
52
  const b = c.bonus;
53
53
  if (b?.state === 'open' && b.spend !== null)
54
- lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
54
+ lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline === null ? 'by its deadline' : shortDate(b.deadline)}.`);
55
55
  else if (b)
56
56
  lines.push(` Welcome bonus: ${b.state}.`);
57
57
  return lines.join('\n');
@@ -65,19 +65,19 @@ export function formatWalletSummary(summary) {
65
65
  return [...cards, memberships, values].join('\n');
66
66
  }
67
67
  export function formatBooking(amount, name, // the card's short name
68
- cap, bonus, milestones = [], credit = null) {
68
+ cap, bonus, milestones = [], credit = null, capLabel = null) {
69
69
  const messages = [];
70
70
  if (credit)
71
71
  messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${possessive(name)} ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
72
72
  if (cap) {
73
73
  const left = Math.max(0, cap.capAmount - cap.after);
74
- messages.push(`Recorded ${money(amount)} on ${name} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
74
+ messages.push(`Recorded ${money(amount)} on ${name} toward its ${capLabel ?? cap.capKey} cap: ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
75
75
  }
76
76
  if (bonus) {
77
77
  const left = Math.max(0, bonus.spend - bonus.after);
78
78
  messages.push(left === 0
79
79
  ? `Welcome bonus spend met on ${name}: ${money(bonus.after)} of ${money(bonus.spend)}.`
80
- : `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
80
+ : `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline === null ? 'the deadline' : shortDate(bonus.deadline)}.`);
81
81
  }
82
82
  // Every milestone counts the same purchases, so one line covers them.
83
83
  const first = milestones[0];
@@ -4,7 +4,7 @@ import { capByKey, categoryMatches } from "../engine/evaluate.js";
4
4
  import { milestoneProgress } from "../engine/milestones.js";
5
5
  import { periodKey } from "../engine/periods.js";
6
6
  import { recommend } from "../engine/recommend.js";
7
- import { applyBooking, capBooking } from "../engine/record.js";
7
+ import { applyBooking, applyCreditBooking, capBooking, creditBooking, } from "../engine/record.js";
8
8
  import { roundCents } from "../engine/text.js";
9
9
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
10
10
  import { cleanDescriptor } from "./statement.js";
@@ -55,6 +55,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
55
55
  const missed = new Map();
56
56
  const coding = new Map();
57
57
  const bookings = [];
58
+ const credits = [];
58
59
  let outsideCurrentPeriod = 0;
59
60
  let alreadyImported = 0;
60
61
  let purchases = 0;
@@ -145,6 +146,16 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
145
146
  outsideCurrentPeriod++;
146
147
  }
147
148
  }
149
+ if (line.amount > 0 && !alreadyRecorded(entry, 'credit')) {
150
+ const credit = creditBooking(catalog, current, recorded, merchant);
151
+ const held = current.cards.find((c) => c.id === cardId);
152
+ if (credit &&
153
+ periodKey(credit.periodKind, today, held?.anniversary ?? null) ===
154
+ credit.period) {
155
+ credits.push({ ...entry, booking: credit });
156
+ current = applyCreditBooking(current, credit);
157
+ }
158
+ }
148
159
  if (line.amount < 0) {
149
160
  refunds += -line.amount;
150
161
  continue;
@@ -244,6 +255,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
244
255
  .sort((a, b) => b.best - b.earned - (a.best - a.earned)),
245
256
  coding: [...coding.values()].sort((a, b) => b.count - a.count),
246
257
  bookings,
258
+ credits,
247
259
  outsideCurrentPeriod,
248
260
  alreadyImported,
249
261
  bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
@@ -72,9 +72,8 @@ export function makeContext(deps) {
72
72
  }
73
73
  // Cards named in the conversation come first, then the saved wallet; with
74
74
  // neither (null), answers cover every card earnrate knows.
75
- // The point values and memberships the user saved apply to every answer,
76
- // not just ones from their saved cards. A wallet that can't be read has
77
- // none.
75
+ // Saved point values and memberships apply to every answer, not just ones
76
+ // from saved cards. A wallet that can't be read has none.
78
77
  async function saved() {
79
78
  try {
80
79
  const wallet = await deps.store.load();
@@ -49,6 +49,14 @@ const NamedCard = z.object({
49
49
  .array(z.string())
50
50
  .optional()
51
51
  .describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
52
+ enrolled: z
53
+ .array(z.string())
54
+ .optional()
55
+ .describe("Statement credits the user has enrolled in with the issuer, as credit ids from search_cards. A credit that needs enrolling counts only when it's here. Ask; never assume."),
56
+ opened: z
57
+ .string()
58
+ .optional()
59
+ .describe('The day the account opened (YYYY-MM-DD), for a card with a first-year rate. Optional; without it the card is ranked at its standard rates.'),
52
60
  });
53
61
  const cardFields = {
54
62
  checkoutLabel: z
@@ -80,15 +88,15 @@ const cardFields = {
80
88
  milestoneSpent: z
81
89
  .record(z.string(), z.number().nonnegative())
82
90
  .optional()
83
- .describe('Dollars already spent on the card this year, for its spend milestones (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
91
+ .describe('Dollars already spent on the card this year, for its spend bonuses (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
84
92
  enrolled: z
85
93
  .array(z.string())
86
94
  .optional()
87
- .describe('Merchant credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
95
+ .describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
88
96
  creditUsed: z
89
97
  .record(z.string(), z.number().nonnegative())
90
98
  .optional()
91
- .describe('Dollars of a merchant credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
99
+ .describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
92
100
  bonus: z
93
101
  .object({
94
102
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
@@ -298,6 +298,7 @@ export const CardSearchSchema = z.object({
298
298
  network: CardNetwork,
299
299
  currency: z.string(),
300
300
  annualFee: z.number().nullable(),
301
+ firstYearFee: z.number().nullable(),
301
302
  foreignTransactionFee: z.number().nullable(),
302
303
  business: z.boolean(),
303
304
  onlyAt: z.array(z.string()).nullable(),
@@ -412,6 +413,7 @@ export const ExplorationSchema = z.object({
412
413
  conditions: z.array(z.string()),
413
414
  evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
414
415
  withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
416
+ needsMembership: z.string().nullable(),
415
417
  })),
416
418
  excluded: z.array(ExcludedCardSchema),
417
419
  assumptions: z.array(z.string()),
@@ -6,7 +6,6 @@ import { registerPrompts } from "./prompts.js";
6
6
  import { registerAccountTools } from "./tools/account.js";
7
7
  import { registerAnswerTools } from "./tools/answers.js";
8
8
  import { registerWalletTools } from "./tools/wallet.js";
9
- // The tools that answer without a saved wallet.
10
9
  export const ANSWER_TOOLS = [
11
10
  'best_card_for_purchase',
12
11
  'best_cards_for',
@@ -16,7 +15,6 @@ export const ANSWER_TOOLS = [
16
15
  'resolve_merchant',
17
16
  'search_cards',
18
17
  ];
19
- // The tools that read or change a saved wallet.
20
18
  export const SAVED_WALLET_TOOLS = [
21
19
  'list_wallet',
22
20
  'record_purchase',
@@ -5,22 +5,59 @@ import { listed } from "../../engine/text.js";
5
5
  import { savedSettingsText } from "../../engine/wallet-summary.js";
6
6
  import { fail } from "../context.js";
7
7
  import { WalletChangeSchema } from "../schemas.js";
8
+ const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
9
+ // Everything a saved wallet holds, in words; empty when there's nothing.
10
+ const contentsOf = (catalog, w) => [
11
+ ...(w.cards.length > 0
12
+ ? [`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`]
13
+ : []),
14
+ ...savedSettingsText(catalog, w),
15
+ ];
16
+ const ImportInput = z.object({
17
+ yaml: z
18
+ .string()
19
+ .min(1)
20
+ .max(100_000)
21
+ .describe('The wallet.yaml text, as the user gave it.'),
22
+ replace: z
23
+ .boolean()
24
+ .optional()
25
+ .describe('true to replace what the wallet already holds, after the user confirms.'),
26
+ });
27
+ export function importHandler(catalog, store, ctx) {
28
+ const contents = (w) => contentsOf(catalog, w);
29
+ return {
30
+ input: ImportInput,
31
+ run: async ({ yaml, replace }) => {
32
+ const parsed = parseWalletYaml(yaml, catalog);
33
+ if (typeof parsed === 'string')
34
+ return fail(parsed);
35
+ let current;
36
+ try {
37
+ current = await store.load();
38
+ }
39
+ catch (e) {
40
+ if (replace !== true)
41
+ 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.`);
42
+ current = null;
43
+ }
44
+ const before = current === null ? [] : contents(current);
45
+ if (before.length > 0 && replace !== true)
46
+ return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
47
+ return ctx.changed(await store.replace(parsed), before.length > 0
48
+ ? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
49
+ : `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
50
+ },
51
+ };
52
+ }
8
53
  // A saved wallet on the hosted server has no file to copy or delete, so
9
54
  // these do it.
10
55
  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
- ];
56
+ const contents = (w) => contentsOf(catalog, w);
57
+ const importWallet = importHandler(catalog, store, ctx);
21
58
  server.registerTool('export_wallet', {
22
59
  title: 'Export the wallet',
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, bonus progress and yearly spending toward spend bonuses. Never card numbers.',
60
+ description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in and used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
24
61
  inputSchema: z.object({}),
25
62
  outputSchema: z.object({ yaml: z.string() }),
26
63
  annotations: {
@@ -42,17 +79,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
42
79
  server.registerTool('import_wallet', {
43
80
  title: 'Import a wallet',
44
81
  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.',
45
- inputSchema: z.object({
46
- yaml: z
47
- .string()
48
- .min(1)
49
- .max(100_000)
50
- .describe('The wallet.yaml text, as the user gave it.'),
51
- replace: z
52
- .boolean()
53
- .optional()
54
- .describe('true to replace what the wallet already holds, after the user confirms.'),
55
- }),
82
+ inputSchema: importWallet.input,
56
83
  outputSchema: WalletChangeSchema,
57
84
  annotations: {
58
85
  title: 'Import a wallet',
@@ -61,26 +88,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
61
88
  idempotentHint: true,
62
89
  openWorldHint: false,
63
90
  },
64
- }, async ({ yaml, replace }) => {
65
- const parsed = parseWalletYaml(yaml, catalog);
66
- if (typeof parsed === 'string')
67
- return fail(parsed);
68
- let current;
69
- try {
70
- current = await store.load();
71
- }
72
- catch (e) {
73
- if (replace !== true)
74
- 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.`);
75
- current = null;
76
- }
77
- const before = current === null ? [] : contents(current);
78
- if (before.length > 0 && replace !== true)
79
- return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
80
- return ctx.changed(await store.replace(parsed), before.length > 0
81
- ? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
82
- : `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
83
- });
91
+ }, (args) => importWallet.run(args));
84
92
  server.registerTool('delete_wallet', {
85
93
  title: 'Delete the wallet',
86
94
  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.",
@@ -34,7 +34,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
34
34
  const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
35
35
  server.registerTool('best_card_for_purchase', {
36
36
  title: 'Best card for a purchase',
37
- description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A merchant statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
37
+ description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
38
38
  inputSchema: z.object({
39
39
  merchant: Merchant,
40
40
  amount: z
@@ -166,6 +166,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
166
166
  .optional()
167
167
  .describe('Include cards already in the wallet.'),
168
168
  cards: NamedCards,
169
+ memberships: NamedMemberships,
169
170
  }),
170
171
  outputSchema: OfferComparisonSchema,
171
172
  annotations: {
@@ -175,10 +176,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
175
176
  openWorldHint: false,
176
177
  },
177
178
  }, async (args) => {
178
- const wallet = await ctx.cardsFor(args.cards, undefined);
179
+ const wallet = await ctx.cardsFor(args.cards, args.memberships);
179
180
  if (typeof wallet === 'string')
180
181
  return fail(wallet);
181
- const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
182
+ const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
183
+ const base = args.memberships
184
+ ? { ...found, memberships: args.memberships }
185
+ : found;
182
186
  const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
183
187
  kind: args.kind ?? 'any',
184
188
  noAnnualFee: args.noAnnualFee ?? false,
@@ -391,7 +395,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
391
395
  });
392
396
  server.registerTool('search_cards', {
393
397
  title: 'Search cards',
394
- description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet, and what it needs set up: category picks and their options, memberships, activations. Leave the query out to list every card. US cards only.',
398
+ description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee (and any first-year waiver), foreign transaction fee, whether it\'s already in the wallet, what it needs set up (category picks and their options, memberships, activations), its yearly spend bonuses, first-year rates and statement credits, each with the id update_card takes. Leave the query out to list every card. US cards only.',
395
399
  inputSchema: z.object({
396
400
  query: z
397
401
  .string()