earnrate 0.1.4 → 0.2.0

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.
@@ -3,26 +3,46 @@ import { z } from 'zod';
3
3
  import { MCP_SERVER_NAME } from "../brand.js";
4
4
  import { formatCardInfo, formatNotes } from "../cli/cards.js";
5
5
  import { formatRecommendation } from "../cli/format.js";
6
- import { formatCheatSheet } from "../cli/cheatsheet.js";
7
- import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../cli/explore.js";
8
- import { formatOffers } from "../cli/offers.js";
6
+ import { formatCheatSheet } from "../format/cheat-sheet.js";
7
+ import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
8
+ import { formatOffers } from "../format/offers.js";
9
+ import { parseWalletYaml, walletYaml } from "../format/wallet-yaml.js";
9
10
  import { walletProblems } from "../data/integrity.js";
10
- import { loadWallet } from "../data/load.js";
11
- import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
12
- import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
13
- import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
14
- import { cheatSheet } from "../engine/cheat-sheet.js";
11
+ import { catalogCheatSheet, cheatSheet } from "../engine/cheat-sheet.js";
15
12
  import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
13
+ import { walletFromCards } from "../engine/transient.js";
16
14
  import { compareOffers } from "../engine/offers.js";
17
15
  import { VALUATION_PROFILES } from "../engine/profiles.js";
18
- import { capBooking } from "../engine/record.js";
19
16
  import { recommend } from "../engine/recommend.js";
20
- import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
17
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, TYPICAL_SPEND_NOTE, typicalSpendLines, } from "../engine/simulate.js";
21
18
  import { knownMemberships, searchCards } from "../engine/setup.js";
22
19
  import { walletSummary } from "../engine/wallet-summary.js";
23
20
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
24
21
  import { IsoDate } from "../schema/common.js";
25
- import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, SpendingSimulationSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
22
+ import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, PurchaseAnswerSchema, SpendingSimulationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
23
+ // The tools that answer without a saved wallet.
24
+ export const ANSWER_TOOLS = [
25
+ 'best_card_for_purchase',
26
+ 'best_cards_for',
27
+ 'simulate_spending',
28
+ 'compare_welcome_offers',
29
+ 'cheat_sheet',
30
+ 'resolve_merchant',
31
+ 'search_cards',
32
+ ];
33
+ // The tools that read or change a saved wallet. The remote server asks for
34
+ // sign-in before running one.
35
+ export const SAVED_WALLET_TOOLS = [
36
+ 'list_wallet',
37
+ 'record_purchase',
38
+ 'add_card',
39
+ 'update_card',
40
+ 'remove_card',
41
+ 'set_memberships',
42
+ 'export_wallet',
43
+ 'import_wallet',
44
+ 'delete_wallet',
45
+ ];
26
46
  const Merchant = z
27
47
  .string()
28
48
  .min(1)
@@ -32,33 +52,42 @@ const ChannelInput = z
32
52
  .optional()
33
53
  .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
34
54
  const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
35
- const INSTRUCTIONS = `earnrate picks which of the user's credit cards earns the most for a purchase. best_cards_for and simulate_spending compare every card earnrate knows and need no wallet, for questions like "what's the best grocery card?" or "which cards fit my spending?". Its wallet lists the cards they hold by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
55
+ const SAVING = {
56
+ none: "This server doesn't save wallets: pass the cards each time. For a saved wallet with cap and welcome-bonus tracking, the user can install earnrate locally (earnrate.dev).",
57
+ 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.',
58
+ account: "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. Saved wallets are anonymous: the first wallet tool asks the user to open an earnrate page, where they start a wallet (it shows a recovery code to keep; no email or name) or reconnect one with their code. Never ask for the recovery code in the conversation; it's typed only on that page. To change the code, the user opens https://mcp.earnrate.dev/change-code.",
59
+ };
60
+ 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
61
 
37
- If a tool says there's no wallet yet, ask the user which cards they have, find each with search_cards, and add it with add_card. add_card and update_card reply with what a card still needs, such as category picks, memberships like Amazon Prime, or activated bonus categories. Ask the user about those and set them with update_card or set_memberships.`;
62
+ Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
63
+
64
+ The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.`;
38
65
  const fail = (text) => ({
39
66
  content: [{ type: 'text', text }],
40
67
  isError: true,
41
68
  });
42
69
  const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
43
70
  export function createEarnrateServer(deps) {
44
- const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
71
+ const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS(deps.savedWallets) });
45
72
  const { catalog } = deps;
46
- const noWallet = `There's no wallet yet (${deps.walletFile}), which is normal before the user adds a card. Ask the user which credit cards they have, find each one with search_cards, and add it with add_card. best_cards_for and simulate_spending work without a wallet.`;
47
- // Read on every call, so edits to the wallet file apply without a restart.
73
+ // Tools that need a saved wallet; a server without saved wallets drops them.
74
+ const walletTools = [];
75
+ const noWallet = `There's no wallet yet (${deps.store.where}), which is normal before the user adds a card. Ask the user which credit cards they have, find each one with search_cards, and add it with add_card. best_cards_for and simulate_spending work without a wallet.`;
76
+ // Read on every call, so edits to the wallet apply without a restart.
48
77
  async function wallet() {
49
78
  let loaded;
50
79
  try {
51
- loaded = await loadWallet(deps.walletFile);
80
+ const found = await deps.store.load();
81
+ if (found === null)
82
+ return noWallet;
83
+ loaded = found;
52
84
  }
53
85
  catch (e) {
54
- if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
55
- return noWallet;
56
- }
57
- return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
86
+ return `Couldn't read the wallet at ${deps.store.where}: ${e instanceof Error ? e.message : String(e)}`;
58
87
  }
59
88
  const problems = walletProblems(loaded, catalog);
60
89
  return problems.length > 0
61
- ? `The wallet at ${deps.walletFile} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
90
+ ? `The wallet at ${deps.store.where} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
62
91
  : loaded;
63
92
  }
64
93
  // No wallet yet is the first-run state, not a failure: read-only tools
@@ -85,9 +114,50 @@ export function createEarnrateServer(deps) {
85
114
  .enum(ids)
86
115
  .optional()
87
116
  .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: ${catalog.categories.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
117
+ const namedMembershipIds = knownMemberships(catalog);
118
+ const NamedCardsInput = z
119
+ .array(z.object({
120
+ cardId: z.string().min(1).describe('A card id from search_cards.'),
121
+ picks: z
122
+ .record(z.string(), z.array(z.string()).min(1))
123
+ .optional()
124
+ .describe("Bonus categories the user picked, for cards that have them: choice id → option ids, from search_cards. Ask; don't guess."),
125
+ activated: z
126
+ .array(z.string())
127
+ .optional()
128
+ .describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
129
+ }))
130
+ .optional()
131
+ .describe("Cards the user named in this conversation, to answer for just those cards without saving anything. Leave out to use the user's saved wallet if they have one; with neither, the answer covers every card earnrate knows.");
132
+ const NamedMembershipsInput = z
133
+ .array(z.enum(namedMembershipIds))
134
+ .optional()
135
+ .describe("Memberships the user has, such as amazon-prime, when they're named in the conversation rather than saved.");
136
+ const ALL_CARDS_NOTE = "The user hasn't named their cards, so this covers every card earnrate knows. That's a complete answer. If they want the best of their own cards, they can name them (pass them as `cards`); saving a wallet is optional.";
137
+ // Cards named in the conversation come first, then the saved wallet; with
138
+ // neither, read tools answer from every card earnrate knows.
139
+ async function sourceFor(cards, memberships) {
140
+ if (cards && cards.length > 0) {
141
+ const w = walletFromCards(catalog, {
142
+ cards: cards.map((c) => ({
143
+ id: c.cardId,
144
+ ...(c.picks ? { picks: c.picks } : {}),
145
+ ...(c.activated ? { activated: c.activated } : {}),
146
+ })),
147
+ ...(memberships ? { memberships } : {}),
148
+ }, deps.today());
149
+ return typeof w === 'string' ? w : { wallet: w, named: true };
150
+ }
151
+ const read = await readableWallet();
152
+ if (typeof read === 'string')
153
+ return read;
154
+ return read.start !== null
155
+ ? { wallet: null }
156
+ : { wallet: read.wallet, named: false };
157
+ }
88
158
  server.registerTool('best_card_for_purchase', {
89
159
  title: 'Best card for a purchase',
90
- description: "Picks the card in the user's wallet that earns the most rewards for a purchase, and explains why. Call it before paying. `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout. Also returns every usable card ranked with reasons, cards the merchant won't take, the assumptions made, warnings, and when a different point valuation would change the pick. Never needs or returns card numbers.",
160
+ description: "Which 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. Never needs or returns card numbers.",
91
161
  inputSchema: z.object({
92
162
  merchant: Merchant,
93
163
  amount: z
@@ -98,17 +168,18 @@ export function createEarnrateServer(deps) {
98
168
  channel: ChannelInput,
99
169
  date: DateInput,
100
170
  category: CategoryInput,
171
+ cards: NamedCardsInput,
172
+ memberships: NamedMembershipsInput,
101
173
  }),
102
- outputSchema: RecommendationSchema,
174
+ outputSchema: PurchaseAnswerSchema,
103
175
  annotations: {
104
176
  readOnlyHint: true,
105
177
  openWorldHint: deps.llm !== null,
106
178
  },
107
- }, async ({ merchant, amount, channel, date, category }) => {
108
- const read = await readableWallet();
109
- if (typeof read === 'string')
110
- return fail(read);
111
- const w = read.wallet;
179
+ }, async ({ merchant, amount, channel, date, category, cards, memberships, }) => {
180
+ const source = await sourceFor(cards, memberships);
181
+ if (typeof source === 'string')
182
+ return fail(source);
112
183
  const purchase = {
113
184
  merchant,
114
185
  amount: amount ?? null,
@@ -117,20 +188,35 @@ export function createEarnrateServer(deps) {
117
188
  country: 'US',
118
189
  };
119
190
  const resolved = await resolve(merchant, purchase.channel, category ?? null);
120
- const rec = recommend(catalog, w, purchase, resolved.merchant);
121
- if (read.start !== null) {
122
- const output = {
123
- ...rec,
124
- warnings: [read.start, ...resolved.notices],
125
- };
191
+ if (source.wallet === null) {
192
+ const explored = exploreCards(catalog, {
193
+ merchant,
194
+ category: null,
195
+ amount: purchase.amount,
196
+ channel: purchase.channel,
197
+ date: purchase.date,
198
+ }, { ...settings({}), memberships: memberships ?? [] }, resolved.merchant);
199
+ const allCards = { ...explored, ranked: explored.ranked.slice(0, 10) };
200
+ const empty = recommend(catalog, { valuations: {}, memberships: [], cards: [] }, purchase, resolved.merchant);
126
201
  return {
127
- content: [{ type: 'text', text: read.start }],
128
- structuredContent: output,
202
+ content: [
203
+ {
204
+ type: 'text',
205
+ text: `${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
206
+ },
207
+ ],
208
+ structuredContent: {
209
+ ...empty,
210
+ warnings: [ALL_CARDS_NOTE, ...resolved.notices],
211
+ allCards,
212
+ },
129
213
  };
130
214
  }
215
+ const rec = recommend(catalog, source.wallet, purchase, resolved.merchant);
131
216
  const output = {
132
217
  ...rec,
133
218
  warnings: [...rec.warnings, ...resolved.notices],
219
+ allCards: null,
134
220
  };
135
221
  return {
136
222
  content: [
@@ -164,7 +250,7 @@ export function createEarnrateServer(deps) {
164
250
  structuredContent: m,
165
251
  };
166
252
  });
167
- server.registerTool('list_wallet', {
253
+ walletTools.push(server.registerTool('list_wallet', {
168
254
  title: 'List wallet',
169
255
  description: "Lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, and when each card's data was last verified.",
170
256
  outputSchema: WalletSummarySchema,
@@ -186,7 +272,9 @@ export function createEarnrateServer(deps) {
186
272
  const chosen = c.chosen
187
273
  ? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
188
274
  : '';
189
- const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
275
+ const onlyAt = c.onlyAt
276
+ ? `; works only at ${c.onlyAt.join(', ')}`
277
+ : '';
190
278
  const rotation = c.rotationGap === null
191
279
  ? ''
192
280
  : `; rotating categories for ${c.rotationGap} not in earnrate yet`;
@@ -202,7 +290,7 @@ export function createEarnrateServer(deps) {
202
290
  content: [{ type: 'text', text: lines.join('\n') }],
203
291
  structuredContent: summary,
204
292
  };
205
- });
293
+ }));
206
294
  server.registerTool('compare_welcome_offers', {
207
295
  title: 'Compare welcome offers',
208
296
  description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
@@ -223,14 +311,15 @@ export function createEarnrateServer(deps) {
223
311
  .boolean()
224
312
  .optional()
225
313
  .describe('Include cards already in the wallet.'),
314
+ cards: NamedCardsInput,
226
315
  }),
227
316
  outputSchema: OfferComparisonSchema,
228
317
  annotations: { readOnlyHint: true, openWorldHint: false },
229
318
  }, async (args) => {
230
- const read = await readableWallet();
231
- if (typeof read === 'string')
232
- return fail(read);
233
- const comparison = compareOffers(catalog, read.wallet, {
319
+ const source = await sourceFor(args.cards, undefined);
320
+ if (typeof source === 'string')
321
+ return fail(source);
322
+ const comparison = compareOffers(catalog, source.wallet ?? { valuations: {}, memberships: [], cards: [] }, {
234
323
  kind: args.kind ?? 'any',
235
324
  noAnnualFee: args.noAnnualFee ?? false,
236
325
  business: args.business ?? false,
@@ -317,7 +406,7 @@ export function createEarnrateServer(deps) {
317
406
  });
318
407
  server.registerTool('simulate_spending', {
319
408
  title: 'Simulate a year of spending',
320
- description: 'Spends the user\'s monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. With compareToWallet, it starts from the user\'s wallet and ranks the cards that would add the most to it. Use it for "which card fits my spending?" or "is it worth adding a card?". Ask for rough monthly amounts if the user hasn\'t given them. It leaves out welcome bonuses.',
409
+ description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
321
410
  inputSchema: z.object({
322
411
  spending: z
323
412
  .array(z.object({
@@ -325,33 +414,42 @@ export function createEarnrateServer(deps) {
325
414
  category: CatalogCategory.optional(),
326
415
  monthly: z.number().positive().describe('US dollars a month.'),
327
416
  }))
328
- .min(1)
329
- .describe('Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco).'),
417
+ .optional()
418
+ .describe("Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco). Optional: leave out to use typical amounts, and don't press the user for numbers they haven't offered."),
419
+ cards: NamedCardsInput,
330
420
  compareToWallet: z
331
421
  .boolean()
332
422
  .optional()
333
- .describe("Rank the cards that would add the most to the user's wallet."),
423
+ .describe("Rank the cards that would add the most to the user's saved wallet."),
334
424
  ...CatalogFilters,
335
425
  }),
336
426
  outputSchema: SpendingSimulationSchema,
337
427
  annotations: { readOnlyHint: true, openWorldHint: false },
338
428
  }, async (args) => {
339
- if (args.spending.some((l) => l.merchant === undefined && l.category === undefined))
429
+ const given = args.spending ?? [];
430
+ if (given.some((l) => l.merchant === undefined && l.category === undefined))
340
431
  return fail('Each spending line needs a merchant or a category.');
341
- const lines = resolveSpendLines(catalog, args.spending.map((l) => ({
342
- merchant: l.merchant ?? null,
343
- category: l.merchant === undefined ? (l.category ?? null) : null,
344
- monthly: l.monthly,
345
- })));
432
+ const lines = given.length === 0
433
+ ? typicalSpendLines(catalog)
434
+ : resolveSpendLines(catalog, given.map((l) => ({
435
+ merchant: l.merchant ?? null,
436
+ category: l.merchant === undefined ? (l.category ?? null) : null,
437
+ monthly: l.monthly,
438
+ })));
346
439
  const unknown = lines.filter((l) => l.merchant.source === 'none');
347
440
  if (unknown.length > 0)
348
441
  return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
442
+ const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
349
443
  const options = { ...settings(args), start: deps.today() };
350
- const read = args.compareToWallet ? await readableWallet() : null;
351
- if (typeof read === 'string')
352
- return fail(read);
353
- if (read && read.start === null) {
354
- const w = read.wallet;
444
+ const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
445
+ args.compareToWallet === true;
446
+ const source = wantsTheirs
447
+ ? await sourceFor(args.cards, args.memberships)
448
+ : null;
449
+ if (typeof source === 'string')
450
+ return fail(source);
451
+ if (source && source.wallet !== null) {
452
+ const w = source.wallet;
355
453
  const valued = args.valuation
356
454
  ? {
357
455
  ...w,
@@ -362,11 +460,11 @@ export function createEarnrateServer(deps) {
362
460
  }
363
461
  : w;
364
462
  const result = simulateAdditions(catalog, valued, lines, options);
463
+ const text = formatAdditions(result, catalog, false);
365
464
  return {
366
- content: [
367
- { type: 'text', text: formatAdditions(result, catalog, false) },
368
- ],
465
+ content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
369
466
  structuredContent: {
467
+ notes,
370
468
  singles: [],
371
469
  pairs: [],
372
470
  current: result.current,
@@ -376,16 +474,14 @@ export function createEarnrateServer(deps) {
376
474
  },
377
475
  };
378
476
  }
477
+ if (source)
478
+ notes.push(ALL_CARDS_NOTE);
379
479
  const result = simulateCatalog(catalog, lines, options);
380
480
  const text = formatCatalogSimulation(result, catalog, false);
381
481
  return {
382
- content: [
383
- {
384
- type: 'text',
385
- text: read?.start ? `${read.start}\n\n${text}` : text,
386
- },
387
- ],
482
+ content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
388
483
  structuredContent: {
484
+ notes,
389
485
  singles: result.singles.slice(0, 10),
390
486
  pairs: result.pairs.slice(0, 5),
391
487
  current: null,
@@ -395,26 +491,37 @@ export function createEarnrateServer(deps) {
395
491
  });
396
492
  server.registerTool('cheat_sheet', {
397
493
  title: 'Cheat sheet',
398
- description: "The card in the user's wallet to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else, with caps, picks and activations as the wallet has them. Use it when the user asks which card to use for what, or wants a quick reference for the quarter. For one specific store, call best_card_for_purchase instead. Pass on the notes: they say what the wallet still needs, like this quarter's picks.",
399
- inputSchema: z.object({ date: DateInput }),
494
+ description: "The card to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else. With no cards given and no saved wallet, it picks from every card earnrate knows (`scope: all cards`, each row noting the card's fee and conditions). With cards named in the conversation or a saved wallet, it picks from the user's own cards, with caps, picks and activations as they are. For one specific store, call best_card_for_purchase instead. Pass on the notes.",
495
+ inputSchema: z.object({
496
+ date: DateInput,
497
+ cards: NamedCardsInput,
498
+ memberships: NamedMembershipsInput,
499
+ }),
400
500
  outputSchema: CheatSheetSchema,
401
501
  annotations: { readOnlyHint: true, openWorldHint: false },
402
502
  }, async (args) => {
403
- const read = await readableWallet();
404
- if (typeof read === 'string')
405
- return fail(read);
406
- const sheet = cheatSheet(catalog, read.wallet, args.date ?? deps.today());
407
- if (read.start !== null)
503
+ const source = await sourceFor(args.cards, args.memberships);
504
+ if (typeof source === 'string')
505
+ return fail(source);
506
+ const date = args.date ?? deps.today();
507
+ if (source.wallet === null) {
508
+ const sheet = catalogCheatSheet(catalog, date, {
509
+ ...settings({}),
510
+ memberships: args.memberships ?? [],
511
+ });
512
+ const withNote = { ...sheet, notes: [ALL_CARDS_NOTE] };
408
513
  return {
409
- content: [{ type: 'text', text: read.start }],
410
- structuredContent: { ...sheet, notes: [read.start] },
514
+ content: [{ type: 'text', text: formatCheatSheet(withNote) }],
515
+ structuredContent: withNote,
411
516
  };
517
+ }
518
+ const sheet = cheatSheet(catalog, source.wallet, date);
412
519
  return {
413
520
  content: [{ type: 'text', text: formatCheatSheet(sheet) }],
414
521
  structuredContent: sheet,
415
522
  };
416
523
  });
417
- server.registerTool('record_purchase', {
524
+ walletTools.push(server.registerTool('record_purchase', {
418
525
  title: 'Record a purchase',
419
526
  description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's wallet file. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written.",
420
527
  inputSchema: z.object({
@@ -438,7 +545,7 @@ export function createEarnrateServer(deps) {
438
545
  },
439
546
  // Queued per wallet file: parallel calls would otherwise each read the
440
547
  // old usage and the last write would drop the others' spend.
441
- (args) => withWalletLock(deps.walletFile, async () => {
548
+ (args) => (async () => {
442
549
  const { cardId, merchant, amount, channel, date, category } = args;
443
550
  const w = await wallet();
444
551
  if (typeof w === 'string')
@@ -454,23 +561,10 @@ export function createEarnrateServer(deps) {
454
561
  date: date ?? deps.today(),
455
562
  };
456
563
  const resolved = await resolve(merchant, purchase.channel, category ?? null);
457
- const booking = capBooking(catalog, w, purchase, resolved.merchant);
458
- const held = w.cards.find((c) => c.id === cardId);
459
- const status = held
460
- ? bonusStatus(catalog, w, held, purchase.date)
461
- : null;
462
- const bonus = status &&
463
- status.spend !== null &&
464
- (status.remaining ?? 0) > 0 &&
465
- countsTowardBonus(status, purchase.date)
466
- ? {
467
- cardId,
468
- before: status.spent,
469
- after: Math.round((status.spent + amount) * 100) / 100,
470
- spend: status.spend,
471
- deadline: status.deadline,
472
- }
473
- : null;
564
+ const result = await deps.store.record(purchase, resolved.merchant);
565
+ if (!result.ok)
566
+ return fail(`Couldn't record it: ${result.error}`);
567
+ const { cap: booking, bonus } = result;
474
568
  if (!booking && !bonus) {
475
569
  const output = {
476
570
  recorded: false,
@@ -483,15 +577,6 @@ export function createEarnrateServer(deps) {
483
577
  structuredContent: output,
484
578
  };
485
579
  }
486
- try {
487
- if (booking)
488
- await writeCapBooking(deps.walletFile, booking);
489
- if (bonus)
490
- await writeBonusSpent(deps.walletFile, cardId, bonus.after);
491
- }
492
- catch (e) {
493
- return fail(`Couldn't record it: ${e instanceof Error ? e.message : String(e)}`);
494
- }
495
580
  const messages = [];
496
581
  if (booking) {
497
582
  const left = Math.max(0, booking.capAmount - booking.after);
@@ -518,7 +603,7 @@ export function createEarnrateServer(deps) {
518
603
  ],
519
604
  structuredContent: output,
520
605
  };
521
- }));
606
+ })()));
522
607
  const CardId = z
523
608
  .string()
524
609
  .min(1)
@@ -542,6 +627,24 @@ export function createEarnrateServer(deps) {
542
627
  .array(z.string())
543
628
  .optional()
544
629
  .describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
630
+ bonus: z
631
+ .object({
632
+ opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
633
+ spent: z
634
+ .number()
635
+ .nonnegative()
636
+ .optional()
637
+ .describe('Dollars already spent toward the bonus. Default 0.'),
638
+ spend: z
639
+ .number()
640
+ .positive()
641
+ .optional()
642
+ .describe("Dollars of spending the bonus needs, only if the user's offer differs from the published one."),
643
+ by: IsoDate.optional().describe("The deadline, YYYY-MM-DD, only if the user's offer differs from the published one."),
644
+ })
645
+ .nullable()
646
+ .optional()
647
+ .describe("A welcome bonus the user is working toward, so purchases recorded on this card count toward it and answers can favor it. Ask when they opened the account; spending needed and the deadline default to the card's published offer. null stops tracking it."),
545
648
  };
546
649
  // A card change reports what that card still needs; other changes don't.
547
650
  function changed(result, message, cardChange = true) {
@@ -574,6 +677,22 @@ export function createEarnrateServer(deps) {
574
677
  ? {}
575
678
  : { anniversary: args.anniversary }),
576
679
  ...(args.activated === undefined ? {} : { activated: args.activated }),
680
+ ...(args.bonus === undefined
681
+ ? {}
682
+ : {
683
+ bonus: args.bonus === null
684
+ ? null
685
+ : {
686
+ opened: args.bonus.opened,
687
+ ...(args.bonus.spent === undefined
688
+ ? {}
689
+ : { spent: args.bonus.spent }),
690
+ ...(args.bonus.spend === undefined
691
+ ? {}
692
+ : { spend: args.bonus.spend }),
693
+ ...(args.bonus.by === undefined ? {} : { by: args.bonus.by }),
694
+ },
695
+ }),
577
696
  });
578
697
  const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
579
698
  server.registerTool('search_cards', {
@@ -588,7 +707,7 @@ export function createEarnrateServer(deps) {
588
707
  outputSchema: CardSearchSchema,
589
708
  annotations: { readOnlyHint: true, openWorldHint: false },
590
709
  }, async ({ query }) => {
591
- const held = await loadWallet(deps.walletFile).catch(() => null);
710
+ const held = await deps.store.load().catch(() => null);
592
711
  const result = searchCards(catalog, query ?? '', held, deps.today());
593
712
  const header = result.exact
594
713
  ? ''
@@ -605,7 +724,7 @@ export function createEarnrateServer(deps) {
605
724
  structuredContent: result,
606
725
  };
607
726
  });
608
- server.registerTool('add_card', {
727
+ walletTools.push(server.registerTool('add_card', {
609
728
  title: 'Add a card',
610
729
  description: "Adds a card the user holds to their wallet by its id from search_cards, creating the wallet if there isn't one. The reply says what the card still needs (category picks, memberships, activations); ask the user about those. Stores only the card's product id and settings, never card numbers.",
611
730
  inputSchema: z.object({ cardId: CardId, ...cardFields }),
@@ -616,11 +735,11 @@ export function createEarnrateServer(deps) {
616
735
  idempotentHint: false,
617
736
  openWorldHint: false,
618
737
  },
619
- }, (args) => withWalletLock(deps.walletFile, async () => {
620
- const result = await addCard(deps.walletFile, catalog, cardInput(args), deps.today());
621
- return changed(result, `Added ${cardName(args.cardId)} (${args.cardId})${result.ok && result.created ? `, starting a new wallet at ${deps.walletFile}` : ''}.`);
738
+ }, async (args) => {
739
+ const result = await deps.store.addCard(cardInput(args), deps.today());
740
+ return changed(result, `Added ${cardName(args.cardId)} (${args.cardId})${result.ok && result.created ? (deps.savedWallets === 'account' ? ', the first card in your saved wallet' : `, starting a new wallet at ${deps.store.where}`) : ''}.`);
622
741
  }));
623
- server.registerTool('update_card', {
742
+ walletTools.push(server.registerTool('update_card', {
624
743
  title: 'Update a card',
625
744
  description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary or activated categories. Picks for the current period are merged with the ones already saved. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+.',
626
745
  inputSchema: z.object({ cardId: CardId, ...cardFields }),
@@ -631,8 +750,8 @@ export function createEarnrateServer(deps) {
631
750
  idempotentHint: true,
632
751
  openWorldHint: false,
633
752
  },
634
- }, (args) => withWalletLock(deps.walletFile, async () => changed(await updateCard(deps.walletFile, catalog, cardInput(args), deps.today()), `Updated ${cardName(args.cardId)} (${args.cardId}).`)));
635
- server.registerTool('remove_card', {
753
+ }, async (args) => changed(await deps.store.updateCard(cardInput(args), deps.today()), `Updated ${cardName(args.cardId)} (${args.cardId}).`)));
754
+ walletTools.push(server.registerTool('remove_card', {
636
755
  title: 'Remove a card',
637
756
  description: "Removes a card from the wallet, with its tracked cap usage and bonus progress. Only when the user says they no longer have it or don't want it considered.",
638
757
  inputSchema: z.object({ cardId: CardId }),
@@ -643,8 +762,8 @@ export function createEarnrateServer(deps) {
643
762
  idempotentHint: true,
644
763
  openWorldHint: false,
645
764
  },
646
- }, ({ cardId }) => withWalletLock(deps.walletFile, async () => changed(await removeCard(deps.walletFile, catalog, cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
647
- server.registerTool('set_memberships', {
765
+ }, async ({ cardId }) => changed(await deps.store.removeCard(cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
766
+ walletTools.push(server.registerTool('set_memberships', {
648
767
  title: 'Set memberships',
649
768
  description: "Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier. Replaces the whole list, so include the ones to keep.",
650
769
  inputSchema: z.object({
@@ -659,8 +778,76 @@ export function createEarnrateServer(deps) {
659
778
  idempotentHint: true,
660
779
  openWorldHint: false,
661
780
  },
662
- }, (args) => withWalletLock(deps.walletFile, async () => changed(await setMemberships(deps.walletFile, catalog, args.memberships), args.memberships.length > 0
781
+ }, async (args) => changed(await deps.store.setMemberships(args.memberships), args.memberships.length > 0
663
782
  ? `Memberships set to ${args.memberships.join(', ')}.`
664
783
  : 'Memberships cleared.', false)));
784
+ if (deps.savedWallets === 'account') {
785
+ const store = deps.store;
786
+ server.registerTool('export_wallet', {
787
+ title: 'Export the wallet',
788
+ 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.',
789
+ inputSchema: z.object({}),
790
+ outputSchema: z.object({ yaml: z.string() }),
791
+ annotations: { readOnlyHint: true, openWorldHint: false },
792
+ }, async () => {
793
+ const w = await wallet();
794
+ if (typeof w === 'string')
795
+ return fail(w);
796
+ const yaml = walletYaml(w);
797
+ return {
798
+ content: [{ type: 'text', text: yaml }],
799
+ structuredContent: { yaml },
800
+ };
801
+ });
802
+ server.registerTool('import_wallet', {
803
+ title: 'Import a wallet',
804
+ 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. Confirm with the user first if they already have cards saved.',
805
+ inputSchema: z.object({
806
+ yaml: z
807
+ .string()
808
+ .min(1)
809
+ .max(100_000)
810
+ .describe('The wallet.yaml text, as the user gave it.'),
811
+ }),
812
+ outputSchema: WalletChangeSchema,
813
+ annotations: {
814
+ readOnlyHint: false,
815
+ destructiveHint: true,
816
+ idempotentHint: true,
817
+ openWorldHint: false,
818
+ },
819
+ }, async ({ yaml }) => {
820
+ const parsed = parseWalletYaml(yaml, catalog);
821
+ if (typeof parsed === 'string')
822
+ return fail(parsed);
823
+ return changed(await store.replace(parsed), `Imported a wallet with ${String(parsed.cards.length)} ${parsed.cards.length === 1 ? 'card' : 'cards'}.`, false);
824
+ });
825
+ server.registerTool('delete_wallet', {
826
+ title: 'Delete the wallet',
827
+ description: 'Deletes the saved wallet now and for good, and signs out every app using it. Only when the user asks to delete it; confirm first, and offer export_wallet to keep a copy.',
828
+ inputSchema: z.object({}),
829
+ outputSchema: z.object({ deleted: z.boolean() }),
830
+ annotations: {
831
+ readOnlyHint: false,
832
+ destructiveHint: true,
833
+ idempotentHint: true,
834
+ openWorldHint: false,
835
+ },
836
+ }, async () => {
837
+ await store.erase();
838
+ return {
839
+ content: [
840
+ {
841
+ type: 'text',
842
+ text: 'Deleted the saved wallet. Answers now cover every card, or the cards named in the conversation.',
843
+ },
844
+ ],
845
+ structuredContent: { deleted: true },
846
+ };
847
+ });
848
+ }
849
+ if (deps.savedWallets === 'none')
850
+ for (const tool of walletTools)
851
+ tool.remove();
665
852
  return server;
666
853
  }