earnrate 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +18 -15
  2. package/data/CHANGELOG.md +79 -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 +23 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
  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 +19 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +15 -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 +17 -4
  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 +6 -9
  47. package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
  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 +16 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +23 -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/categories.yaml +12 -0
  61. package/data/currencies.yaml +2 -0
  62. package/data/issuers.yaml +32 -0
  63. package/data/memberships.yaml +33 -0
  64. package/data/merchants/overrides.yaml +25 -0
  65. package/data/merchants/seed.yaml +7 -0
  66. package/dist/cli/bin.js +10 -1
  67. package/dist/cli/cheatsheet.js +11 -6
  68. package/dist/cli/explore.js +17 -23
  69. package/dist/cli/import.js +10 -10
  70. package/dist/cli/offers.js +19 -8
  71. package/dist/cli/run.js +31 -20
  72. package/dist/cli/wallet.js +50 -5
  73. package/dist/data/file-store.js +9 -7
  74. package/dist/data/integrity.js +22 -2
  75. package/dist/data/load.js +13 -2
  76. package/dist/data/store.js +13 -22
  77. package/dist/data/wallet-edit.js +108 -8
  78. package/dist/engine/cheat-sheet.js +15 -19
  79. package/dist/engine/compare.js +10 -0
  80. package/dist/engine/evaluate.js +2 -4
  81. package/dist/engine/explore.js +13 -56
  82. package/dist/engine/memberships.js +41 -15
  83. package/dist/engine/offers.js +18 -5
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/profiles.js +8 -0
  86. package/dist/engine/recommend.js +23 -37
  87. package/dist/engine/setup.js +28 -40
  88. package/dist/engine/simulate.js +11 -10
  89. package/dist/engine/text.js +49 -23
  90. package/dist/engine/valuation.js +1 -1
  91. package/dist/engine/wallet-change.js +26 -11
  92. package/dist/engine/wallet-summary.js +85 -31
  93. package/dist/format/card-text.js +19 -36
  94. package/dist/format/cards.js +3 -3
  95. package/dist/format/explore.js +15 -16
  96. package/dist/format/offers.js +10 -15
  97. package/dist/format/recommendation.js +6 -6
  98. package/dist/format/wallet.js +5 -2
  99. package/dist/import/analyze.js +2 -1
  100. package/dist/mcp/context.js +61 -13
  101. package/dist/mcp/inputs.js +16 -7
  102. package/dist/mcp/schemas.js +9 -0
  103. package/dist/mcp/server.js +8 -1
  104. package/dist/mcp/tools/account.js +45 -17
  105. package/dist/mcp/tools/answers.js +84 -29
  106. package/dist/mcp/tools/wallet.js +109 -22
  107. package/dist/merchants/cache.js +0 -10
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +8 -12
  110. package/dist/schema/category.js +4 -0
  111. package/dist/schema/currency.js +2 -0
  112. package/dist/schema/issuer.js +8 -0
  113. package/dist/schema/membership.js +15 -0
  114. package/dist/schema/merchant.js +4 -0
  115. package/dist/schema/wallet.js +1 -1
  116. package/package.json +1 -1
@@ -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";
@@ -24,6 +25,7 @@ const SOURCES = {
24
25
  category: 'a kind of purchase, not a store',
25
26
  none: 'not recognized',
26
27
  };
28
+ const EVERY_CARD_NOTE = "Picked from every card earnrate knows, as asked, not just the user's saved cards. The user's saved memberships and point values still apply.";
27
29
  const TOP = 10;
28
30
  // The tools that answer without a saved wallet: from every card, the cards
29
31
  // named in the conversation, or a saved wallet if there is one.
@@ -45,16 +47,21 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
45
47
  category: CategoryInput,
46
48
  cards: NamedCards,
47
49
  memberships: NamedMemberships,
50
+ valuation: CatalogFilters.valuation,
48
51
  }),
49
52
  outputSchema: PurchaseAnswerSchema,
50
53
  annotations: {
54
+ title: 'Best card for a purchase',
51
55
  readOnlyHint: true,
52
56
  openWorldHint: deps.llm !== null,
53
57
  },
54
- }, async ({ merchant, amount, channel, date, category, cards, memberships, }) => {
55
- const wallet = await ctx.cardsFor(cards, memberships);
56
- if (typeof wallet === 'string')
57
- 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;
58
65
  const purchase = {
59
66
  merchant,
60
67
  amount: amount ?? null,
@@ -73,14 +80,14 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
73
80
  amount: purchase.amount,
74
81
  channel: purchase.channel,
75
82
  date: purchase.date,
76
- }, settings({ memberships }), resolved.merchant);
83
+ }, settings({ memberships, valuation }, await ctx.saved()), resolved.merchant);
77
84
  const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
78
85
  const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
79
86
  return {
80
87
  content: [
81
88
  {
82
89
  type: 'text',
83
- text: `${retry}${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
90
+ text: `${retry}${formatExploration(allCards, catalog, true)}\n${ALL_CARDS_NOTE}`,
84
91
  },
85
92
  ],
86
93
  structuredContent: {
@@ -100,7 +107,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
100
107
  content: [
101
108
  {
102
109
  type: 'text',
103
- text: `${retry}${formatRecommendation(output, purchase, { all: true })}`,
110
+ text: `${retry}${ctx.fromCards(cards)}${formatRecommendation(output, purchase, { all: true })}`,
104
111
  },
105
112
  ],
106
113
  structuredContent: output,
@@ -115,7 +122,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
115
122
  category: CategoryInput,
116
123
  }),
117
124
  outputSchema: MerchantResolutionSchema,
118
- annotations: { readOnlyHint: true, openWorldHint: deps.llm !== null },
125
+ annotations: {
126
+ title: 'Resolve a merchant',
127
+ readOnlyHint: true,
128
+ openWorldHint: deps.llm !== null,
129
+ },
119
130
  }, async ({ merchant, channel, category }) => {
120
131
  const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
121
132
  const m = resolved.merchant;
@@ -150,12 +161,16 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
150
161
  cards: NamedCards,
151
162
  }),
152
163
  outputSchema: OfferComparisonSchema,
153
- annotations: { readOnlyHint: true, openWorldHint: false },
164
+ annotations: {
165
+ title: 'Compare welcome offers',
166
+ readOnlyHint: true,
167
+ openWorldHint: false,
168
+ },
154
169
  }, async (args) => {
155
170
  const wallet = await ctx.cardsFor(args.cards, undefined);
156
171
  if (typeof wallet === 'string')
157
172
  return fail(wallet);
158
- const base = wallet ?? EMPTY_WALLET;
173
+ const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
159
174
  const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
160
175
  kind: args.kind ?? 'any',
161
176
  noAnnualFee: args.noAnnualFee ?? false,
@@ -164,16 +179,19 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
164
179
  }, deps.today());
165
180
  const trimmed = {
166
181
  ...comparison,
167
- offers: comparison.offers.slice(0, TOP),
182
+ // Offers ruled out by a card the user holds sort last; keep them in
183
+ // view so the reason shows.
184
+ offers: comparison.offers.filter((o, i) => i < TOP || o.ineligible !== null),
168
185
  };
169
186
  const more = comparison.offers.length - trimmed.offers.length;
170
187
  return {
171
188
  content: [
172
189
  {
173
190
  type: 'text',
174
- text: formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
191
+ text: (wallet === null ? '' : ctx.fromCards(args.cards)) +
192
+ formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
175
193
  (more > 0
176
- ? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers. Narrow them with kind, noAnnualFee or business.\n`
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`
177
195
  : ''),
178
196
  },
179
197
  ],
@@ -182,7 +200,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
182
200
  });
183
201
  server.registerTool('best_cards_for', {
184
202
  title: 'Best cards for a purchase',
185
- 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.',
186
204
  inputSchema: z.object({
187
205
  merchant: Merchant.optional(),
188
206
  category: CatalogCategory.optional(),
@@ -195,20 +213,29 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
195
213
  ...CatalogFilters,
196
214
  }),
197
215
  outputSchema: ExplorationSchema,
198
- annotations: { readOnlyHint: true, openWorldHint: false },
199
- }, (args) => {
216
+ annotations: {
217
+ title: 'Best cards for a purchase',
218
+ readOnlyHint: true,
219
+ openWorldHint: false,
220
+ },
221
+ }, async (args) => {
200
222
  if (args.merchant === undefined && args.category === undefined)
201
223
  return fail('Give a merchant or a category.');
224
+ const channel = args.channel ?? null;
202
225
  const result = exploreCards(catalog, {
203
226
  merchant: args.merchant ?? null,
204
- category: args.merchant === undefined ? (args.category ?? null) : null,
227
+ category: args.category ?? null,
205
228
  amount: args.amount ?? null,
206
- channel: args.channel ?? null,
229
+ channel,
207
230
  date: deps.today(),
208
- }, settings(args));
231
+ }, settings(args, await ctx.saved()), args.merchant === undefined
232
+ ? undefined
233
+ : withCallerCategory(resolvePlace(catalog, args.merchant, channel), args.category ?? null));
209
234
  const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
210
235
  return {
211
- content: [{ type: 'text', text: formatExploration(trimmed, true) }],
236
+ content: [
237
+ { type: 'text', text: formatExploration(trimmed, catalog, true) },
238
+ ],
212
239
  structuredContent: trimmed,
213
240
  };
214
241
  });
@@ -232,7 +259,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
232
259
  ...CatalogFilters,
233
260
  }),
234
261
  outputSchema: SpendingSimulationSchema,
235
- annotations: { readOnlyHint: true, openWorldHint: false },
262
+ annotations: {
263
+ title: 'Simulate a year of spending',
264
+ readOnlyHint: true,
265
+ openWorldHint: false,
266
+ },
236
267
  }, async (args) => {
237
268
  const given = args.spending ?? [];
238
269
  if (given.some((l) => l.merchant === undefined && l.category === undefined))
@@ -248,7 +279,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
248
279
  if (unknown.length > 0)
249
280
  return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
250
281
  const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
251
- const options = { ...settings(args), start: deps.today() };
282
+ const options = {
283
+ ...settings(args, await ctx.saved()),
284
+ start: deps.today(),
285
+ };
252
286
  const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
253
287
  args.compareToWallet === true;
254
288
  const wallet = wantsTheirs
@@ -261,7 +295,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
261
295
  ? withValuationProfile(wallet, args.valuation)
262
296
  : wallet;
263
297
  const result = simulateAdditions(catalog, valued, lines, options);
264
- const text = formatAdditions(result, catalog, false);
298
+ const text = `${ctx.fromCards(args.cards)}${formatAdditions(result, catalog, false)}`;
265
299
  return {
266
300
  content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
267
301
  structuredContent: {
@@ -296,6 +330,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
296
330
  inputSchema: z.object({
297
331
  date: DateInput,
298
332
  cards: NamedCards,
333
+ everyCard: z
334
+ .boolean()
335
+ .optional()
336
+ .describe('true to pick from every card earnrate knows even when the user has a saved wallet ("which cards should I get?"); the filters then apply.'),
299
337
  memberships: NamedMemberships,
300
338
  kind: CatalogFilters.kind,
301
339
  noAnnualFee: CatalogFilters.noAnnualFee,
@@ -304,22 +342,35 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
304
342
  valuation: CatalogFilters.valuation,
305
343
  }),
306
344
  outputSchema: CheatSheetSchema,
307
- annotations: { readOnlyHint: true, openWorldHint: false },
345
+ annotations: {
346
+ title: 'Cheat sheet',
347
+ readOnlyHint: true,
348
+ openWorldHint: false,
349
+ },
308
350
  }, async (args) => {
309
- const wallet = await ctx.cardsFor(args.cards, args.memberships);
351
+ const wallet = args.everyCard === true
352
+ ? null
353
+ : await ctx.cardsFor(args.cards, args.memberships);
310
354
  if (typeof wallet === 'string')
311
355
  return fail(wallet);
312
356
  const date = args.date ?? deps.today();
313
357
  const sheet = wallet === null
314
358
  ? {
315
- ...catalogCheatSheet(catalog, date, settings(args)),
316
- notes: [ALL_CARDS_NOTE],
359
+ ...catalogCheatSheet(catalog, date, settings(args, await ctx.saved())),
360
+ notes: [
361
+ args.everyCard === true ? EVERY_CARD_NOTE : ALL_CARDS_NOTE,
362
+ ],
317
363
  }
318
364
  : cheatSheet(catalog, args.valuation
319
365
  ? withValuationProfile(wallet, args.valuation)
320
366
  : wallet, date);
321
367
  return {
322
- content: [{ type: 'text', text: formatCheatSheet(sheet) }],
368
+ content: [
369
+ {
370
+ type: 'text',
371
+ text: `${wallet === null ? '' : ctx.fromCards(args.cards)}${formatCheatSheet(sheet)}`,
372
+ },
373
+ ],
323
374
  structuredContent: sheet,
324
375
  };
325
376
  });
@@ -333,7 +384,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
333
384
  .describe('Card name or issuer, as the user said it.'),
334
385
  }),
335
386
  outputSchema: CardSearchSchema,
336
- annotations: { readOnlyHint: true, openWorldHint: false },
387
+ annotations: {
388
+ title: 'Search cards',
389
+ readOnlyHint: true,
390
+ openWorldHint: false,
391
+ },
337
392
  }, async ({ query }) => {
338
393
  const held = await deps.store.load().catch(() => null);
339
394
  const result = searchCards(catalog, query ?? '', held, deps.today());
@@ -1,17 +1,34 @@
1
1
  import { z } from 'zod';
2
2
  import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
3
- import { choiceName, money, words } from "../../engine/text.js";
4
- import { walletSummary } from "../../engine/wallet-summary.js";
3
+ import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../../engine/text.js";
4
+ import { membershipName } from "../../engine/memberships.js";
5
+ import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
6
+ import { optionName, walletSummary } from "../../engine/wallet-summary.js";
5
7
  import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
6
8
  import { fail } from "../context.js";
7
9
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
8
10
  import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
9
11
  const WRITE = { readOnlyHint: false, openWorldHint: false };
12
+ // "Saved wallet started 4 minutes ago": relative, since the server doesn't
13
+ // know the user's time zone, and precise enough to tell apart two wallets
14
+ // started the same day.
15
+ function startedText(started, now) {
16
+ const minutes = Math.floor((now - Date.parse(started)) / 60_000);
17
+ const ago = minutes < 1
18
+ ? 'just now'
19
+ : minutes < 60
20
+ ? `${String(minutes)} minute${minutes === 1 ? '' : 's'} ago`
21
+ : minutes < 48 * 60
22
+ ? `${String(Math.floor(minutes / 60))} hour${minutes < 120 ? '' : 's'} ago`
23
+ : `on ${shortDate(started.slice(0, 10))}`;
24
+ return `It was started ${ago}.`;
25
+ }
10
26
  // What update_card changed, so the reply confirms the values it saved.
11
- function changes(args) {
27
+ function changes(catalog, args) {
28
+ const card = catalog.cards.find((c) => c.id === args.cardId);
12
29
  const out = [];
13
30
  for (const [choice, options] of Object.entries(args.picks ?? {}))
14
- 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('; ')}`);
15
32
  if (args.checkoutLabel === null)
16
33
  out.push('checkout name cleared');
17
34
  else if (args.checkoutLabel !== undefined)
@@ -35,24 +52,38 @@ export function registerWalletTools(server, deps, ctx, inputs) {
35
52
  const { catalog, store } = deps;
36
53
  server.registerTool('list_wallet', {
37
54
  title: 'List wallet',
38
- 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.",
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}`,
39
56
  outputSchema: WalletSummarySchema,
40
- annotations: { readOnlyHint: true, openWorldHint: false },
57
+ annotations: {
58
+ title: 'List wallet',
59
+ readOnlyHint: true,
60
+ openWorldHint: false,
61
+ },
41
62
  }, async () => {
42
63
  const read = await ctx.readableWallet();
43
64
  if (typeof read === 'string')
44
65
  return fail(read);
45
- const summary = walletSummary(catalog, read.wallet, deps.today());
66
+ const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
67
+ const summary = {
68
+ ...walletSummary(catalog, read.wallet, deps.today()),
69
+ started,
70
+ };
71
+ const when = started === null
72
+ ? `This is ${ctx.walletName}.\n`
73
+ : `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
46
74
  return {
47
75
  content: [
48
- { type: 'text', text: read.start ?? formatWalletSummary(summary) },
76
+ {
77
+ type: 'text',
78
+ text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatWalletSummary(summary)}`,
79
+ },
49
80
  ],
50
81
  structuredContent: summary,
51
82
  };
52
83
  });
53
84
  server.registerTool('record_purchase', {
54
85
  title: 'Record a purchase',
55
- 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}`,
56
87
  inputSchema: z.object({
57
88
  cardId: z
58
89
  .string()
@@ -62,10 +93,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
62
93
  amount: z.number().positive().describe('Amount spent in US dollars.'),
63
94
  channel: ChannelInput,
64
95
  date: DateInput,
65
- category: inputs.CategoryInput,
96
+ category: inputs.RecordCategoryInput,
66
97
  }),
67
98
  outputSchema: RecordResultSchema,
68
99
  annotations: {
100
+ title: 'Record a purchase',
69
101
  ...WRITE,
70
102
  destructiveHint: false,
71
103
  idempotentHint: false,
@@ -96,7 +128,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
96
128
  recorded: true,
97
129
  booking,
98
130
  bonus,
99
- message: formatBooking(amount, cardId, booking, bonus),
131
+ message: `${formatBooking(amount, cardId, booking, bonus)} Saved in ${ctx.walletName}.`,
100
132
  }
101
133
  : {
102
134
  recorded: false,
@@ -116,10 +148,15 @@ export function registerWalletTools(server, deps, ctx, inputs) {
116
148
  });
117
149
  server.registerTool('add_card', {
118
150
  title: 'Add a card',
119
- 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}`,
120
152
  inputSchema: CardChange,
121
153
  outputSchema: WalletChangeSchema,
122
- annotations: { ...WRITE, destructiveHint: false, idempotentHint: false },
154
+ annotations: {
155
+ title: 'Add a card',
156
+ ...WRITE,
157
+ destructiveHint: false,
158
+ idempotentHint: false,
159
+ },
123
160
  }, async (args) => {
124
161
  const result = await store.addCard(cardInput(args), deps.today());
125
162
  const started = !result.ok || !result.created
@@ -131,12 +168,17 @@ export function registerWalletTools(server, deps, ctx, inputs) {
131
168
  });
132
169
  server.registerTool('update_card', {
133
170
  title: 'Update a card',
134
- 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}`,
135
172
  inputSchema: CardChange,
136
173
  outputSchema: WalletChangeSchema,
137
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
174
+ annotations: {
175
+ title: 'Update a card',
176
+ ...WRITE,
177
+ destructiveHint: true,
178
+ idempotentHint: true,
179
+ },
138
180
  }, async (args) => {
139
- const what = changes(args);
181
+ const what = changes(catalog, args);
140
182
  const card = catalog.cards.find((c) => c.id === args.cardId);
141
183
  const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
142
184
  ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
@@ -145,17 +187,27 @@ export function registerWalletTools(server, deps, ctx, inputs) {
145
187
  });
146
188
  server.registerTool('remove_card', {
147
189
  title: 'Remove a card',
148
- 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}`,
149
191
  inputSchema: z.object({ cardId: CardId }),
150
192
  outputSchema: WalletChangeSchema,
151
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
193
+ annotations: {
194
+ title: 'Remove a card',
195
+ ...WRITE,
196
+ destructiveHint: true,
197
+ idempotentHint: true,
198
+ },
152
199
  }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
153
200
  server.registerTool('set_memberships', {
154
201
  title: 'Set memberships',
155
- 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}`,
156
203
  inputSchema: z.object({ memberships: inputs.AllMemberships }),
157
204
  outputSchema: WalletChangeSchema,
158
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
205
+ annotations: {
206
+ title: 'Set memberships',
207
+ ...WRITE,
208
+ destructiveHint: true,
209
+ idempotentHint: true,
210
+ },
159
211
  }, async ({ memberships }) => {
160
212
  const result = await store.setMemberships(memberships);
161
213
  const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
@@ -164,7 +216,42 @@ export function registerWalletTools(server, deps, ctx, inputs) {
164
216
  }));
165
217
  const unused = memberships.filter((m) => !used.has(m));
166
218
  return ctx.changed(result, `${memberships.length > 0
167
- ? `Memberships set to ${memberships.join(', ')}.`
168
- : '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 });
221
+ });
222
+ server.registerTool('set_point_values', {
223
+ title: 'Set point values',
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}`,
225
+ inputSchema: z.object({
226
+ values: z
227
+ .record(z.string(), z.number().nullable())
228
+ .optional()
229
+ .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
230
+ profile: z
231
+ .enum(['flat', 'points-heavy'])
232
+ .optional()
233
+ .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
234
+ }),
235
+ outputSchema: WalletChangeSchema,
236
+ annotations: {
237
+ title: 'Set point values',
238
+ ...WRITE,
239
+ destructiveHint: true,
240
+ idempotentHint: true,
241
+ },
242
+ }, async ({ values, profile }) => {
243
+ const changes = {
244
+ ...(profile ? VALUATION_PROFILES[profile] : {}),
245
+ ...(values ?? {}),
246
+ };
247
+ if (Object.keys(changes).length === 0)
248
+ return fail('Give values (cents per point by currency) or a profile.');
249
+ const result = await store.setValuations(changes);
250
+ const set = Object.entries(changes).map(([id, cents]) => cents === null
251
+ ? `${currencyLabel(catalog, id)} back to the default`
252
+ : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
253
+ return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
254
+ notes: false,
255
+ });
169
256
  });
170
257
  }
@@ -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
@@ -146,17 +149,10 @@ export const CardSchema = z
146
149
  // When the first year differs, usually a waived fee.
147
150
  first_year_fee: z.number().nonnegative().optional(),
148
151
  welcome_offer: WelcomeOfferSchema.optional(),
149
- // A paid membership the card can't be had without (Costco Anywhere). Its
150
- // cost counts like an annual fee for anyone not already paying for it.
151
- requires_membership: z
152
- .strictObject({
153
- id: KebabId,
154
- name: z.string().min(1), // "a Costco membership"
155
- annual_fee: z.number().nonnegative(), // the basic tier, dollars
156
- // Only members can buy here, so spending here means a member.
157
- members_shop_at: z.array(KebabId).default([]),
158
- })
159
- .optional(),
152
+ // A paid membership (data/memberships.yaml) the card can't be had
153
+ // without, Costco's for Costco Anywhere. Its fee counts like an annual
154
+ // fee for anyone not already paying it.
155
+ requires_membership: KebabId.optional(),
160
156
  pools_into: z
161
157
  .strictObject({
162
158
  currency: CurrencyId,
@@ -9,5 +9,9 @@ export const CategorySchema = z.strictObject({
9
9
  // Everyday words people search by ("groceries"), besides the name.
10
10
  aliases: z.array(z.string().min(1)).default([]),
11
11
  note: z.string().optional(),
12
+ // Bought as a service, a category reads "on" (3x on flights); places read
13
+ // "at". Subcategories inherit it.
14
+ preposition: z.literal('on').optional(),
15
+ slug: z.string().optional(), // its search page, when not from its name
12
16
  });
13
17
  export const CategoriesFileSchema = z.array(CategorySchema).min(1);