earnrate 0.2.5 → 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.
Files changed (105) hide show
  1. package/README.md +14 -11
  2. package/data/CHANGELOG.md +40 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +14 -6
  5. package/data/cards/amex-blue-cash-everyday.yaml +12 -3
  6. package/data/cards/amex-blue-cash-preferred.yaml +17 -3
  7. package/data/cards/amex-gold.yaml +19 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
  13. package/data/cards/bilt-obsidian.yaml +4 -3
  14. package/data/cards/capital-one-kohls-card.yaml +11 -2
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
  16. package/data/cards/capital-one-savor.yaml +32 -10
  17. package/data/cards/capital-one-venture-x.yaml +19 -4
  18. package/data/cards/capital-one-venture.yaml +19 -5
  19. package/data/cards/chase-aeroplan.yaml +15 -3
  20. package/data/cards/chase-freedom-flex.yaml +50 -10
  21. package/data/cards/chase-freedom-unlimited.yaml +6 -2
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +8 -2
  26. package/data/cards/chase-ink-business-cash.yaml +7 -3
  27. package/data/cards/chase-ink-business-preferred.yaml +9 -2
  28. package/data/cards/chase-prime-visa.yaml +24 -5
  29. package/data/cards/chase-sapphire-preferred.yaml +59 -6
  30. package/data/cards/chase-sapphire-reserve.yaml +12 -2
  31. package/data/cards/chase-southwest-performance-business.yaml +22 -4
  32. package/data/cards/chase-southwest-plus.yaml +13 -3
  33. package/data/cards/chase-southwest-premier-business.yaml +22 -5
  34. package/data/cards/chase-southwest-premier.yaml +13 -3
  35. package/data/cards/chase-southwest-priority.yaml +18 -2
  36. package/data/cards/chase-united-business.yaml +5 -2
  37. package/data/cards/chase-united-club-business.yaml +2 -2
  38. package/data/cards/chase-united-club.yaml +16 -3
  39. package/data/cards/chase-united-explorer.yaml +5 -2
  40. package/data/cards/chase-united-gateway.yaml +2 -2
  41. package/data/cards/chase-united-quest.yaml +12 -3
  42. package/data/cards/citi-aadvantage-business.yaml +10 -4
  43. package/data/cards/citi-aadvantage-executive.yaml +8 -8
  44. package/data/cards/citi-aadvantage-mileup.yaml +7 -4
  45. package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
  46. package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
  47. package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
  48. package/data/cards/citi-custom-cash.yaml +43 -12
  49. package/data/cards/citi-double-cash.yaml +9 -3
  50. package/data/cards/citi-my-best-buy-visa.yaml +6 -3
  51. package/data/cards/citi-strata-elite.yaml +12 -2
  52. package/data/cards/citi-strata-premier.yaml +24 -2
  53. package/data/cards/sofi-unlimited-2.yaml +17 -3
  54. package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
  56. package/data/cards/td-bank-target-circle-card.yaml +13 -4
  57. package/data/cards/usbank-cash-plus.yaml +29 -7
  58. package/data/cards/wells-fargo-active-cash.yaml +9 -2
  59. package/data/cards/wells-fargo-autograph.yaml +31 -7
  60. package/data/issuers.yaml +32 -0
  61. package/data/merchants/seed.yaml +7 -0
  62. package/dist/cli/bin.js +10 -1
  63. package/dist/cli/cheatsheet.js +10 -5
  64. package/dist/cli/explore.js +6 -13
  65. package/dist/cli/import.js +2 -2
  66. package/dist/cli/offers.js +17 -7
  67. package/dist/cli/run.js +14 -17
  68. package/dist/cli/wallet.js +8 -5
  69. package/dist/data/file-store.js +6 -7
  70. package/dist/data/integrity.js +4 -0
  71. package/dist/data/load.js +4 -1
  72. package/dist/data/store.js +6 -22
  73. package/dist/data/wallet-edit.js +38 -13
  74. package/dist/engine/cheat-sheet.js +4 -5
  75. package/dist/engine/compare.js +2 -0
  76. package/dist/engine/evaluate.js +2 -4
  77. package/dist/engine/explore.js +4 -36
  78. package/dist/engine/memberships.js +1 -1
  79. package/dist/engine/offers.js +2 -1
  80. package/dist/engine/periods.js +18 -0
  81. package/dist/engine/recommend.js +14 -15
  82. package/dist/engine/setup.js +14 -24
  83. package/dist/engine/simulate.js +7 -6
  84. package/dist/engine/text.js +13 -1
  85. package/dist/engine/wallet-change.js +10 -12
  86. package/dist/engine/wallet-summary.js +64 -28
  87. package/dist/format/card-text.js +7 -20
  88. package/dist/format/explore.js +2 -2
  89. package/dist/format/offers.js +2 -2
  90. package/dist/format/recommendation.js +2 -2
  91. package/dist/format/wallet.js +1 -1
  92. package/dist/import/analyze.js +2 -1
  93. package/dist/mcp/context.js +30 -9
  94. package/dist/mcp/inputs.js +1 -1
  95. package/dist/mcp/schemas.js +1 -0
  96. package/dist/mcp/server.js +7 -1
  97. package/dist/mcp/tools/account.js +44 -16
  98. package/dist/mcp/tools/answers.js +58 -19
  99. package/dist/mcp/tools/wallet.js +58 -24
  100. package/dist/merchants/cache.js +0 -10
  101. package/dist/merchants/resolve.js +81 -4
  102. package/dist/schema/card.js +4 -1
  103. package/dist/schema/issuer.js +8 -0
  104. package/dist/schema/wallet.js +1 -1
  105. package/package.json +1 -1
@@ -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
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: { readOnlyHint: true, openWorldHint: false },
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 cards are already saved, it refuses unless replace is true: confirm with the user first.',
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 a wallet that already has cards, after the user confirms.'),
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
- if (current !== null && current.cards.length > 0 && replace !== true)
59
- return fail(`This would replace the ${String(current.cards.length)} ${current.cards.length === 1 ? 'card' : 'cards'} already saved (${current.cards.map((c) => c.id).join(', ')}). Ask the user, then call again with replace: true.`);
60
- const cards = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
61
- const before = current?.cards.length ?? 0;
62
- return ctx.changed(await store.replace(parsed), before > 0
63
- ? `Replaced the ${cards(before)} that were saved with the ${cards(parsed.cards.length)} imported.`
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 cards only after the user confirms. It refuses if the wallet no longer holds that many cards, so a retried call can't delete a different wallet.",
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 current = await store.load().catch(() => undefined);
89
- const held = current === undefined ? cards : (current?.cards.length ?? 0);
90
- if (held !== cards)
91
- return fail(`This saved wallet holds ${String(held)} ${held === 1 ? 'card' : 'cards'}, not ${String(cards)}, so it isn't the wallet the user confirmed (it may have been deleted and a new one connected). Show it with list_wallet and confirm again.`);
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,
@@ -13,6 +13,7 @@ import { formatCheatSheet } from "../../format/cheat-sheet.js";
13
13
  import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../../format/explore.js";
14
14
  import { formatOffers } from "../../format/offers.js";
15
15
  import { formatRecommendation } from "../../format/recommendation.js";
16
+ import { resolvePlace, withCallerCategory } from "../../merchants/resolve.js";
16
17
  import { ALL_CARDS_NOTE, fail } from "../context.js";
17
18
  import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
18
19
  import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
@@ -46,16 +47,21 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
46
47
  category: CategoryInput,
47
48
  cards: NamedCards,
48
49
  memberships: NamedMemberships,
50
+ valuation: CatalogFilters.valuation,
49
51
  }),
50
52
  outputSchema: PurchaseAnswerSchema,
51
53
  annotations: {
54
+ title: 'Best card for a purchase',
52
55
  readOnlyHint: true,
53
56
  openWorldHint: deps.llm !== null,
54
57
  },
55
- }, async ({ merchant, amount, channel, date, category, cards, memberships, }) => {
56
- const wallet = await ctx.cardsFor(cards, memberships);
57
- if (typeof wallet === 'string')
58
- return fail(wallet);
58
+ }, async ({ merchant, amount, channel, date, category, cards, memberships, valuation, }) => {
59
+ const held = await ctx.cardsFor(cards, memberships);
60
+ if (typeof held === 'string')
61
+ return fail(held);
62
+ const wallet = held !== null && valuation
63
+ ? withValuationProfile(held, valuation)
64
+ : held;
59
65
  const purchase = {
60
66
  merchant,
61
67
  amount: amount ?? null,
@@ -74,7 +80,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
74
80
  amount: purchase.amount,
75
81
  channel: purchase.channel,
76
82
  date: purchase.date,
77
- }, settings({ memberships }, await ctx.saved()), resolved.merchant);
83
+ }, settings({ memberships, valuation }, await ctx.saved()), resolved.merchant);
78
84
  const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
79
85
  const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
80
86
  return {
@@ -101,7 +107,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
101
107
  content: [
102
108
  {
103
109
  type: 'text',
104
- text: `${retry}${formatRecommendation(output, purchase, { all: true })}`,
110
+ text: `${retry}${ctx.fromCards(cards)}${formatRecommendation(output, purchase, { all: true })}`,
105
111
  },
106
112
  ],
107
113
  structuredContent: output,
@@ -116,7 +122,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
116
122
  category: CategoryInput,
117
123
  }),
118
124
  outputSchema: MerchantResolutionSchema,
119
- annotations: { readOnlyHint: true, openWorldHint: deps.llm !== null },
125
+ annotations: {
126
+ title: 'Resolve a merchant',
127
+ readOnlyHint: true,
128
+ openWorldHint: deps.llm !== null,
129
+ },
120
130
  }, async ({ merchant, channel, category }) => {
121
131
  const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
122
132
  const m = resolved.merchant;
@@ -151,7 +161,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
151
161
  cards: NamedCards,
152
162
  }),
153
163
  outputSchema: OfferComparisonSchema,
154
- annotations: { readOnlyHint: true, openWorldHint: false },
164
+ annotations: {
165
+ title: 'Compare welcome offers',
166
+ readOnlyHint: true,
167
+ openWorldHint: false,
168
+ },
155
169
  }, async (args) => {
156
170
  const wallet = await ctx.cardsFor(args.cards, undefined);
157
171
  if (typeof wallet === 'string')
@@ -174,7 +188,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
174
188
  content: [
175
189
  {
176
190
  type: 'text',
177
- text: formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
191
+ text: (wallet === null ? '' : ctx.fromCards(args.cards)) +
192
+ formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
178
193
  (more > 0
179
194
  ? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers${trimmed.offers.length > TOP ? `, plus ${String(trimmed.offers.length - TOP)} likely not eligible` : ''}. Narrow them with kind, noAnnualFee or business.\n`
180
195
  : ''),
@@ -185,7 +200,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
185
200
  });
186
201
  server.registerTool('best_cards_for', {
187
202
  title: 'Best cards for a purchase',
188
- description: 'Ranks every card earnrate knows, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
203
+ description: 'Ranks every card earnrate knows that takes new applicants, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
189
204
  inputSchema: z.object({
190
205
  merchant: Merchant.optional(),
191
206
  category: CatalogCategory.optional(),
@@ -198,17 +213,24 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
198
213
  ...CatalogFilters,
199
214
  }),
200
215
  outputSchema: ExplorationSchema,
201
- annotations: { readOnlyHint: true, openWorldHint: false },
216
+ annotations: {
217
+ title: 'Best cards for a purchase',
218
+ readOnlyHint: true,
219
+ openWorldHint: false,
220
+ },
202
221
  }, async (args) => {
203
222
  if (args.merchant === undefined && args.category === undefined)
204
223
  return fail('Give a merchant or a category.');
224
+ const channel = args.channel ?? null;
205
225
  const result = exploreCards(catalog, {
206
226
  merchant: args.merchant ?? null,
207
- category: args.merchant === undefined ? (args.category ?? null) : null,
227
+ category: args.category ?? null,
208
228
  amount: args.amount ?? null,
209
- channel: args.channel ?? null,
229
+ channel,
210
230
  date: deps.today(),
211
- }, settings(args, await ctx.saved()));
231
+ }, settings(args, await ctx.saved()), args.merchant === undefined
232
+ ? undefined
233
+ : withCallerCategory(resolvePlace(catalog, args.merchant, channel), args.category ?? null));
212
234
  const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
213
235
  return {
214
236
  content: [
@@ -237,7 +259,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
237
259
  ...CatalogFilters,
238
260
  }),
239
261
  outputSchema: SpendingSimulationSchema,
240
- annotations: { readOnlyHint: true, openWorldHint: false },
262
+ annotations: {
263
+ title: 'Simulate a year of spending',
264
+ readOnlyHint: true,
265
+ openWorldHint: false,
266
+ },
241
267
  }, async (args) => {
242
268
  const given = args.spending ?? [];
243
269
  if (given.some((l) => l.merchant === undefined && l.category === undefined))
@@ -269,7 +295,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
269
295
  ? withValuationProfile(wallet, args.valuation)
270
296
  : wallet;
271
297
  const result = simulateAdditions(catalog, valued, lines, options);
272
- const text = formatAdditions(result, catalog, false);
298
+ const text = `${ctx.fromCards(args.cards)}${formatAdditions(result, catalog, false)}`;
273
299
  return {
274
300
  content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
275
301
  structuredContent: {
@@ -316,7 +342,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
316
342
  valuation: CatalogFilters.valuation,
317
343
  }),
318
344
  outputSchema: CheatSheetSchema,
319
- annotations: { readOnlyHint: true, openWorldHint: false },
345
+ annotations: {
346
+ title: 'Cheat sheet',
347
+ readOnlyHint: true,
348
+ openWorldHint: false,
349
+ },
320
350
  }, async (args) => {
321
351
  const wallet = args.everyCard === true
322
352
  ? null
@@ -335,7 +365,12 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
335
365
  ? withValuationProfile(wallet, args.valuation)
336
366
  : wallet, date);
337
367
  return {
338
- content: [{ type: 'text', text: formatCheatSheet(sheet) }],
368
+ content: [
369
+ {
370
+ type: 'text',
371
+ text: `${wallet === null ? '' : ctx.fromCards(args.cards)}${formatCheatSheet(sheet)}`,
372
+ },
373
+ ],
339
374
  structuredContent: sheet,
340
375
  };
341
376
  });
@@ -349,7 +384,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
349
384
  .describe('Card name or issuer, as the user said it.'),
350
385
  }),
351
386
  outputSchema: CardSearchSchema,
352
- annotations: { readOnlyHint: true, openWorldHint: false },
387
+ annotations: {
388
+ title: 'Search cards',
389
+ readOnlyHint: true,
390
+ openWorldHint: false,
391
+ },
353
392
  }, async ({ query }) => {
354
393
  const held = await deps.store.load().catch(() => null);
355
394
  const result = searchCards(catalog, query ?? '', held, deps.today());
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
3
- import { choiceName, currencyLabel, money, shortDate, words, } from "../../engine/text.js";
3
+ import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../../engine/text.js";
4
+ import { membershipName } from "../../engine/memberships.js";
4
5
  import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
5
- import { walletSummary } from "../../engine/wallet-summary.js";
6
+ import { optionName, walletSummary } from "../../engine/wallet-summary.js";
6
7
  import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
7
8
  import { fail } from "../context.js";
8
9
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
@@ -20,13 +21,14 @@ function startedText(started, now) {
20
21
  : minutes < 48 * 60
21
22
  ? `${String(Math.floor(minutes / 60))} hour${minutes < 120 ? '' : 's'} ago`
22
23
  : `on ${shortDate(started.slice(0, 10))}`;
23
- return `Saved wallet started ${ago}.`;
24
+ return `It was started ${ago}.`;
24
25
  }
25
26
  // What update_card changed, so the reply confirms the values it saved.
26
- function changes(args) {
27
+ function changes(catalog, args) {
28
+ const card = catalog.cards.find((c) => c.id === args.cardId);
27
29
  const out = [];
28
30
  for (const [choice, options] of Object.entries(args.picks ?? {}))
29
- out.push(`${choiceName(choice)} picks: ${options.map(words).join(', ')}`);
31
+ out.push(`${choiceName(choice)} picks: ${options.map((o) => (card ? optionName(catalog, card, choice, o) : words(o))).join('; ')}`);
30
32
  if (args.checkoutLabel === null)
31
33
  out.push('checkout name cleared');
32
34
  else if (args.checkoutLabel !== undefined)
@@ -50,9 +52,13 @@ export function registerWalletTools(server, deps, ctx, inputs) {
50
52
  const { catalog, store } = deps;
51
53
  server.registerTool('list_wallet', {
52
54
  title: 'List wallet',
53
- 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, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started.",
55
+ 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, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
54
56
  outputSchema: WalletSummarySchema,
55
- annotations: { readOnlyHint: true, openWorldHint: false },
57
+ annotations: {
58
+ title: 'List wallet',
59
+ readOnlyHint: true,
60
+ openWorldHint: false,
61
+ },
56
62
  }, async () => {
57
63
  const read = await ctx.readableWallet();
58
64
  if (typeof read === 'string')
@@ -62,12 +68,14 @@ export function registerWalletTools(server, deps, ctx, inputs) {
62
68
  ...walletSummary(catalog, read.wallet, deps.today()),
63
69
  started,
64
70
  };
65
- const when = started === null ? '' : `${startedText(started, Date.now())}\n`;
71
+ const when = started === null
72
+ ? `This is ${ctx.walletName}.\n`
73
+ : `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
66
74
  return {
67
75
  content: [
68
76
  {
69
77
  type: 'text',
70
- text: `${when}${read.start ?? formatWalletSummary(summary)}`,
78
+ text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatWalletSummary(summary)}`,
71
79
  },
72
80
  ],
73
81
  structuredContent: summary,
@@ -75,7 +83,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
75
83
  });
76
84
  server.registerTool('record_purchase', {
77
85
  title: 'Record a purchase',
78
- 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 saved wallet. 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. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed.",
86
+ 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 saved wallet. 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. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
79
87
  inputSchema: z.object({
80
88
  cardId: z
81
89
  .string()
@@ -89,6 +97,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
89
97
  }),
90
98
  outputSchema: RecordResultSchema,
91
99
  annotations: {
100
+ title: 'Record a purchase',
92
101
  ...WRITE,
93
102
  destructiveHint: false,
94
103
  idempotentHint: false,
@@ -119,7 +128,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
119
128
  recorded: true,
120
129
  booking,
121
130
  bonus,
122
- message: formatBooking(amount, cardId, booking, bonus),
131
+ message: `${formatBooking(amount, cardId, booking, bonus)} Saved in ${ctx.walletName}.`,
123
132
  }
124
133
  : {
125
134
  recorded: false,
@@ -139,10 +148,15 @@ export function registerWalletTools(server, deps, ctx, inputs) {
139
148
  });
140
149
  server.registerTool('add_card', {
141
150
  title: 'Add a card',
142
- 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.",
151
+ 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. ${ctx.walletUse}`,
143
152
  inputSchema: CardChange,
144
153
  outputSchema: WalletChangeSchema,
145
- annotations: { ...WRITE, destructiveHint: false, idempotentHint: false },
154
+ annotations: {
155
+ title: 'Add a card',
156
+ ...WRITE,
157
+ destructiveHint: false,
158
+ idempotentHint: false,
159
+ },
146
160
  }, async (args) => {
147
161
  const result = await store.addCard(cardInput(args), deps.today());
148
162
  const started = !result.ok || !result.created
@@ -154,12 +168,17 @@ export function registerWalletTools(server, deps, ctx, inputs) {
154
168
  });
155
169
  server.registerTool('update_card', {
156
170
  title: 'Update a card',
157
- description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+.',
171
+ description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
158
172
  inputSchema: CardChange,
159
173
  outputSchema: WalletChangeSchema,
160
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
174
+ annotations: {
175
+ title: 'Update a card',
176
+ ...WRITE,
177
+ destructiveHint: true,
178
+ idempotentHint: true,
179
+ },
161
180
  }, async (args) => {
162
- const what = changes(args);
181
+ const what = changes(catalog, args);
163
182
  const card = catalog.cards.find((c) => c.id === args.cardId);
164
183
  const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
165
184
  ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
@@ -168,17 +187,27 @@ export function registerWalletTools(server, deps, ctx, inputs) {
168
187
  });
169
188
  server.registerTool('remove_card', {
170
189
  title: 'Remove a card',
171
- 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.",
190
+ description: `Removes a card from the wallet, with its tracked cap usage and bonus progress. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
172
191
  inputSchema: z.object({ cardId: CardId }),
173
192
  outputSchema: WalletChangeSchema,
174
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
193
+ annotations: {
194
+ title: 'Remove a card',
195
+ ...WRITE,
196
+ destructiveHint: true,
197
+ idempotentHint: true,
198
+ },
175
199
  }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
176
200
  server.registerTool('set_memberships', {
177
201
  title: 'Set memberships',
178
- 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, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep.",
202
+ 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, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. ${ctx.walletUse}`,
179
203
  inputSchema: z.object({ memberships: inputs.AllMemberships }),
180
204
  outputSchema: WalletChangeSchema,
181
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
205
+ annotations: {
206
+ title: 'Set memberships',
207
+ ...WRITE,
208
+ destructiveHint: true,
209
+ idempotentHint: true,
210
+ },
182
211
  }, async ({ memberships }) => {
183
212
  const result = await store.setMemberships(memberships);
184
213
  const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
@@ -187,12 +216,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
187
216
  }));
188
217
  const unused = memberships.filter((m) => !used.has(m));
189
218
  return ctx.changed(result, `${memberships.length > 0
190
- ? `Memberships set to ${memberships.join(', ')}.`
191
- : 'Memberships cleared.'}${unused.length > 0 ? ` ${unused.join(', ')} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
219
+ ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
220
+ : 'Memberships cleared.'}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
192
221
  });
193
222
  server.registerTool('set_point_values', {
194
223
  title: 'Set point values',
195
- description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. It needs a saved wallet with a card in it. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies: ${catalog.currencies.map((c) => c.id).join(', ')}.`,
224
+ description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. No saved cards are needed. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies: ${catalog.currencies.map((c) => c.id).join(', ')}. ${ctx.walletUse}`,
196
225
  inputSchema: z.object({
197
226
  values: z
198
227
  .record(z.string(), z.number().nullable())
@@ -204,7 +233,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
204
233
  .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
205
234
  }),
206
235
  outputSchema: WalletChangeSchema,
207
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
236
+ annotations: {
237
+ title: 'Set point values',
238
+ ...WRITE,
239
+ destructiveHint: true,
240
+ idempotentHint: true,
241
+ },
208
242
  }, async ({ values, profile }) => {
209
243
  const changes = {
210
244
  ...(profile ? VALUATION_PROFILES[profile] : {}),
@@ -38,16 +38,6 @@ export function merchantCachePath(env = process.env) {
38
38
  return (env[MERCHANT_CACHE_ENV_VAR] ??
39
39
  path.join(homedir(), CONFIG_DIR, 'merchant-cache.json'));
40
40
  }
41
- export function memoryCache() {
42
- const entries = new Map();
43
- return {
44
- get: (key) => entries.get(key),
45
- set: (key, value) => {
46
- entries.set(key, value);
47
- return Promise.resolve();
48
- },
49
- };
50
- }
51
41
  // A cache file that can't be read is left alone rather than overwritten, so a
52
42
  // hand edit gone wrong doesn't lose every saved classification.
53
43
  export async function openMerchantCache(file) {
@@ -116,17 +116,94 @@ function fromMerchant(found, source, input, channel, typedDomain) {
116
116
  inference: null,
117
117
  };
118
118
  }
119
+ // A merchant's record by id; an override replaces the seed entry.
120
+ export function findMerchant(catalog, id) {
121
+ return (catalog.merchants.overrides.find((m) => m.id === id) ??
122
+ catalog.merchants.seed.find((m) => m.id === id));
123
+ }
119
124
  export function resolveMerchantById(catalog, id, channel) {
120
125
  const override = catalog.merchants.overrides.find((m) => m.id === id);
121
- const found = override ?? catalog.merchants.seed.find((m) => m.id === id);
126
+ const found = findMerchant(catalog, id);
122
127
  if (!found)
123
128
  return null;
124
129
  return fromMerchant(found, override ? 'override' : 'seed', found.name, channel, false);
125
130
  }
126
- // Used only when the merchant is unknown: curated data always wins over a
127
- // category supplied with the request.
131
+ // Curated merchant data always wins over a category supplied with the
132
+ // request; the caller's category wins over an unknown name or a different
133
+ // category word.
128
134
  export function withCallerCategory(merchant, category) {
129
- if (merchant.source !== 'none' || category === null)
135
+ if (category === null)
136
+ return merchant;
137
+ const otherWord = merchant.source === 'category' && merchant.category !== category;
138
+ if (merchant.source !== 'none' && !otherWord)
130
139
  return merchant;
131
140
  return { ...merchant, category, confidence: 'medium', source: 'caller' };
132
141
  }
142
+ const plainWords = (s) => s
143
+ .toLowerCase()
144
+ .replace(/[^a-z0-9]+/g, ' ')
145
+ .trim();
146
+ // A category from everyday words: an alias ("groceries") first, then the
147
+ // name, then the id, so "grocery" means supermarkets rather than the parent.
148
+ export function findCategory(catalog, text) {
149
+ const wanted = plainWords(text);
150
+ if (wanted === '')
151
+ return null;
152
+ // "grocery store" finds "grocery stores", and "drugstore" "drugstores".
153
+ const forms = [
154
+ wanted,
155
+ wanted.endsWith('s') ? wanted.slice(0, -1) : `${wanted}s`,
156
+ ];
157
+ for (const form of forms) {
158
+ const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(form))?.id;
159
+ const found = by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]);
160
+ if (found !== undefined)
161
+ return found;
162
+ }
163
+ return null;
164
+ }
165
+ export function categoryResolution(catalog, category, channel) {
166
+ const found = catalog.categories.find((c) => c.id === category);
167
+ if (!found)
168
+ throw new Error(`unknown category '${category}'`);
169
+ return {
170
+ input: found.name,
171
+ merchantId: null,
172
+ resolvedName: found.name,
173
+ category,
174
+ likelyMcc: null,
175
+ accepts: null,
176
+ ambiguity: null,
177
+ channel,
178
+ confidence: 'high',
179
+ source: 'category',
180
+ bookedVia: [],
181
+ inference: null,
182
+ };
183
+ }
184
+ // Words around a place that don't name it: "a restaurant", "the grocery
185
+ // store", "a gas station near me".
186
+ const LEADING = /^(?:(?:a|an|the|my|any|some|our|your|this|that|local|nearby|nearest)\s+)+/i;
187
+ const TRAILING = /\s+(?:near me|nearby|around here|close by)$/i;
188
+ // What someone typed: a store earnrate knows, else a category word
189
+ // ("restaurants", "gas"), else unrecognized. Names are tried as given first,
190
+ // so a store like The Home Depot keeps its "The".
191
+ export function resolvePlace(catalog, text, channel) {
192
+ const attempt = (t) => {
193
+ const known = resolveMerchant(catalog, t, channel);
194
+ if (known.source !== 'none')
195
+ return known;
196
+ const category = findCategory(catalog, t);
197
+ return category === null
198
+ ? known
199
+ : categoryResolution(catalog, category, channel);
200
+ };
201
+ const asGiven = attempt(text);
202
+ if (asGiven.source !== 'none')
203
+ return asGiven;
204
+ const bare = text.trim().replace(TRAILING, '').replace(LEADING, '');
205
+ if (bare === '' || bare === text.trim())
206
+ return asGiven;
207
+ const found = attempt(bare);
208
+ return found.source === 'none' ? asGiven : found;
209
+ }
@@ -58,12 +58,15 @@ export const RuleSchema = z
58
58
  .optional(),
59
59
  valid_from: IsoDate.optional(),
60
60
  valid_to: IsoDate.optional(),
61
+ // The issuer's last day to activate a bonus that needs it.
62
+ activate_by: IsoDate.optional(),
61
63
  // Set when a rule isn't confirmed against the issuer's terms. The engine
62
64
  // warns when an unverified rule decides the winner.
63
65
  unverified: z.string().optional(),
64
66
  note: z.string().optional(),
65
67
  })
66
- .refine((r) => !r.valid_from || !r.valid_to || r.valid_from <= r.valid_to, 'valid_from must not be after valid_to');
68
+ .refine((r) => !r.valid_from || !r.valid_to || r.valid_from <= r.valid_to, 'valid_from must not be after valid_to')
69
+ .refine((r) => r.activate_by === undefined || r.requires?.activation === true, 'activate_by needs requires.activation');
67
70
  // The public sign-up bonus on the issuer's page. Offers vary by person and
68
71
  // channel, so a wallet can override it.
69
72
  export const WelcomeOfferSchema = z
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ import { KebabId } from "./common.js";
3
+ export const IssuerSchema = z.strictObject({
4
+ id: KebabId,
5
+ name: z.string().min(1), // as shown: "American Express"
6
+ search: z.array(z.string().min(1)).default([]), // other words people use
7
+ });
8
+ export const IssuersFileSchema = z.array(IssuerSchema).min(1);