earnrate 0.2.5 → 0.2.7

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 (112) hide show
  1. package/README.md +25 -18
  2. package/data/CHANGELOG.md +56 -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 -5
  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 +49 -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/brand.js +1 -0
  63. package/dist/cli/bin.js +10 -1
  64. package/dist/cli/cheatsheet.js +10 -5
  65. package/dist/cli/explore.js +6 -13
  66. package/dist/cli/import.js +2 -2
  67. package/dist/cli/missed.js +65 -0
  68. package/dist/cli/offers.js +17 -7
  69. package/dist/cli/run.js +29 -17
  70. package/dist/cli/todo.js +62 -0
  71. package/dist/cli/wallet.js +9 -6
  72. package/dist/data/file-store.js +6 -7
  73. package/dist/data/integrity.js +4 -0
  74. package/dist/data/load.js +4 -1
  75. package/dist/data/store.js +6 -22
  76. package/dist/data/wallet-edit.js +38 -13
  77. package/dist/engine/cheat-sheet.js +14 -6
  78. package/dist/engine/checklist.js +152 -0
  79. package/dist/engine/compare.js +2 -0
  80. package/dist/engine/evaluate.js +16 -11
  81. package/dist/engine/explore.js +6 -38
  82. package/dist/engine/memberships.js +1 -1
  83. package/dist/engine/offers.js +2 -1
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/recommend.js +21 -20
  86. package/dist/engine/rule-text.js +47 -0
  87. package/dist/engine/setup.js +14 -24
  88. package/dist/engine/simulate.js +7 -6
  89. package/dist/engine/text.js +13 -1
  90. package/dist/engine/wallet-change.js +10 -12
  91. package/dist/engine/wallet-summary.js +80 -65
  92. package/dist/format/card-text.js +7 -20
  93. package/dist/format/explore.js +2 -2
  94. package/dist/format/offers.js +2 -2
  95. package/dist/format/recommendation.js +2 -2
  96. package/dist/format/wallet.js +7 -1
  97. package/dist/import/analyze.js +2 -1
  98. package/dist/mcp/context.js +38 -13
  99. package/dist/mcp/inputs.js +1 -1
  100. package/dist/mcp/schemas.js +9 -0
  101. package/dist/mcp/serve.js +4 -0
  102. package/dist/mcp/server.js +8 -2
  103. package/dist/mcp/tools/account.js +44 -16
  104. package/dist/mcp/tools/answers.js +58 -19
  105. package/dist/mcp/tools/wallet.js +64 -25
  106. package/dist/merchants/cache.js +0 -10
  107. package/dist/merchants/missed.js +78 -0
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +4 -1
  110. package/dist/schema/issuer.js +8 -0
  111. package/dist/schema/wallet.js +1 -1
  112. package/package.json +1 -1
package/dist/mcp/serve.js CHANGED
@@ -3,12 +3,15 @@ import { FileWalletStore } from "../data/file-store.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { localToday as today } from "../engine/periods.js";
5
5
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
6
+ import { missedPath, missLogAt } from "../merchants/missed.js";
6
7
  import { packageVersion } from "../version.js";
7
8
  import { createEarnrateServer } from "./server.js";
8
9
  // stdout carries the protocol, so diagnostics go to stderr only.
9
10
  export async function serveMcp(env) {
10
11
  const version = await packageVersion();
11
12
  const catalog = await loadCatalog();
13
+ const missedFile = missedPath(env);
14
+ const missed = missedFile === null ? null : missLogAt(missedFile);
12
15
  let llm = null;
13
16
  if (llmEnabled(env)) {
14
17
  const opened = await openLlmSession({ env, today });
@@ -26,6 +29,7 @@ export async function serveMcp(env) {
26
29
  savedWallets: 'file',
27
30
  today,
28
31
  llm,
32
+ missed,
29
33
  version,
30
34
  }));
31
35
  }
@@ -33,11 +33,17 @@ const SAVING = {
33
33
  file: 'Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools.',
34
34
  account: "The user signed in to earnrate when they connected it, to an anonymous saved wallet (no email or name). It may have no cards yet; answers then cover every card. Save cards with add_card only when the user agrees. The wallet can change outside this conversation when the user signs in again on earnrate's page, so read it with list_wallet rather than from memory. To use a different wallet or start a new one, call switch_wallet; the user then connects on earnrate's page with that wallet's recovery code. Never ask for the recovery code in the conversation; it's typed only on earnrate's page. To change the code, the user opens https://mcp.earnrate.dev/change-code. earnrate.dev and the earnrate command line keep their own separate wallets; export_wallet and import_wallet move one between them.",
35
35
  };
36
+ const WHICH_WALLET = {
37
+ file: "This server's saved wallet is a file on the user's computer.",
38
+ account: "This server's saved wallet is hosted by earnrate, not a file on the user's computer.",
39
+ };
36
40
  const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
37
41
 
38
- Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
42
+ Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing. When the user asks what to do with their cards (this quarter, or what they're forgetting), call list_wallet: it starts with a to-do list.
43
+
44
+ The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
39
45
 
40
- The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.`;
46
+ ${WHICH_WALLET[savedWallets]} The earnrate extension on the user's computer and the hosted earnrate connector keep separate wallets with the same tools. If both are connected, say which wallet an answer comes from, and ask the user which one to use before saving or recording anything.`;
41
47
  export function createEarnrateServer(deps) {
42
48
  const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: instructions(deps.savedWallets) });
43
49
  const ctx = makeContext(deps);
@@ -1,16 +1,33 @@
1
1
  import { z } from 'zod';
2
+ import { emptyWallet } from "../../data/store.js";
2
3
  import { parseWalletYaml, walletYaml } from "../../format/wallet-yaml.js";
4
+ import { listed } from "../../engine/text.js";
5
+ import { savedSettingsText } from "../../engine/wallet-summary.js";
3
6
  import { fail } from "../context.js";
4
7
  import { WalletChangeSchema } from "../schemas.js";
5
8
  // A saved wallet on the hosted server has no file to copy or delete, so
6
9
  // these do it.
7
10
  export function registerAccountTools(server, catalog, store, ctx) {
11
+ const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
12
+ // Everything a saved wallet holds, in words; empty when there's nothing.
13
+ const contents = (w) => [
14
+ ...(w.cards.length > 0
15
+ ? [
16
+ `${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`,
17
+ ]
18
+ : []),
19
+ ...savedSettingsText(catalog, w),
20
+ ];
8
21
  server.registerTool('export_wallet', {
9
22
  title: 'Export the wallet',
10
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,9 +1,13 @@
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";
6
+ import { optionName } from "../../engine/rule-text.js";
5
7
  import { walletSummary } from "../../engine/wallet-summary.js";
6
- import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
8
+ import { checklist } from "../../engine/checklist.js";
9
+ import { cardText } from "../../format/card-text.js";
10
+ import { formatBooking, formatChecklist, formatWalletSummary, } from "../../format/wallet.js";
7
11
  import { fail } from "../context.js";
8
12
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
9
13
  import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
@@ -20,13 +24,14 @@ function startedText(started, now) {
20
24
  : minutes < 48 * 60
21
25
  ? `${String(Math.floor(minutes / 60))} hour${minutes < 120 ? '' : 's'} ago`
22
26
  : `on ${shortDate(started.slice(0, 10))}`;
23
- return `Saved wallet started ${ago}.`;
27
+ return `It was started ${ago}.`;
24
28
  }
25
29
  // What update_card changed, so the reply confirms the values it saved.
26
- function changes(args) {
30
+ function changes(catalog, args) {
31
+ const card = catalog.cards.find((c) => c.id === args.cardId);
27
32
  const out = [];
28
33
  for (const [choice, options] of Object.entries(args.picks ?? {}))
29
- out.push(`${choiceName(choice)} picks: ${options.map(words).join(', ')}`);
34
+ out.push(`${choiceName(choice)} picks: ${options.map((o) => (card ? optionName(catalog, card, choice, o) : words(o))).join('; ')}`);
30
35
  if (args.checkoutLabel === null)
31
36
  out.push('checkout name cleared');
32
37
  else if (args.checkoutLabel !== undefined)
@@ -50,24 +55,32 @@ export function registerWalletTools(server, deps, ctx, inputs) {
50
55
  const { catalog, store } = deps;
51
56
  server.registerTool('list_wallet', {
52
57
  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.",
58
+ description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then 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
59
  outputSchema: WalletSummarySchema,
55
- annotations: { readOnlyHint: true, openWorldHint: false },
60
+ annotations: {
61
+ title: 'List wallet',
62
+ readOnlyHint: true,
63
+ openWorldHint: false,
64
+ },
56
65
  }, async () => {
57
66
  const read = await ctx.readableWallet();
58
67
  if (typeof read === 'string')
59
68
  return fail(read);
60
69
  const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
70
+ const base = walletSummary(catalog, read.wallet, deps.today());
61
71
  const summary = {
62
- ...walletSummary(catalog, read.wallet, deps.today()),
72
+ ...base,
63
73
  started,
74
+ checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
64
75
  };
65
- const when = started === null ? '' : `${startedText(started, Date.now())}\n`;
76
+ const when = started === null
77
+ ? `This is ${ctx.walletName}.\n`
78
+ : `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
66
79
  return {
67
80
  content: [
68
81
  {
69
82
  type: 'text',
70
- text: `${when}${read.start ?? formatWalletSummary(summary)}`,
83
+ text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
71
84
  },
72
85
  ],
73
86
  structuredContent: summary,
@@ -75,7 +88,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
75
88
  });
76
89
  server.registerTool('record_purchase', {
77
90
  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.",
91
+ 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
92
  inputSchema: z.object({
80
93
  cardId: z
81
94
  .string()
@@ -89,6 +102,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
89
102
  }),
90
103
  outputSchema: RecordResultSchema,
91
104
  annotations: {
105
+ title: 'Record a purchase',
92
106
  ...WRITE,
93
107
  destructiveHint: false,
94
108
  idempotentHint: false,
@@ -119,7 +133,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
119
133
  recorded: true,
120
134
  booking,
121
135
  bonus,
122
- message: formatBooking(amount, cardId, booking, bonus),
136
+ message: `${formatBooking(amount, cardId, booking, bonus)} Saved in ${ctx.walletName}.`,
123
137
  }
124
138
  : {
125
139
  recorded: false,
@@ -139,10 +153,15 @@ export function registerWalletTools(server, deps, ctx, inputs) {
139
153
  });
140
154
  server.registerTool('add_card', {
141
155
  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.",
156
+ 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
157
  inputSchema: CardChange,
144
158
  outputSchema: WalletChangeSchema,
145
- annotations: { ...WRITE, destructiveHint: false, idempotentHint: false },
159
+ annotations: {
160
+ title: 'Add a card',
161
+ ...WRITE,
162
+ destructiveHint: false,
163
+ idempotentHint: false,
164
+ },
146
165
  }, async (args) => {
147
166
  const result = await store.addCard(cardInput(args), deps.today());
148
167
  const started = !result.ok || !result.created
@@ -154,12 +173,17 @@ export function registerWalletTools(server, deps, ctx, inputs) {
154
173
  });
155
174
  server.registerTool('update_card', {
156
175
  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+.',
176
+ 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
177
  inputSchema: CardChange,
159
178
  outputSchema: WalletChangeSchema,
160
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
179
+ annotations: {
180
+ title: 'Update a card',
181
+ ...WRITE,
182
+ destructiveHint: true,
183
+ idempotentHint: true,
184
+ },
161
185
  }, async (args) => {
162
- const what = changes(args);
186
+ const what = changes(catalog, args);
163
187
  const card = catalog.cards.find((c) => c.id === args.cardId);
164
188
  const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
165
189
  ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
@@ -168,17 +192,27 @@ export function registerWalletTools(server, deps, ctx, inputs) {
168
192
  });
169
193
  server.registerTool('remove_card', {
170
194
  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.",
195
+ 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
196
  inputSchema: z.object({ cardId: CardId }),
173
197
  outputSchema: WalletChangeSchema,
174
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
198
+ annotations: {
199
+ title: 'Remove a card',
200
+ ...WRITE,
201
+ destructiveHint: true,
202
+ idempotentHint: true,
203
+ },
175
204
  }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
176
205
  server.registerTool('set_memberships', {
177
206
  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.",
207
+ 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
208
  inputSchema: z.object({ memberships: inputs.AllMemberships }),
180
209
  outputSchema: WalletChangeSchema,
181
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
210
+ annotations: {
211
+ title: 'Set memberships',
212
+ ...WRITE,
213
+ destructiveHint: true,
214
+ idempotentHint: true,
215
+ },
182
216
  }, async ({ memberships }) => {
183
217
  const result = await store.setMemberships(memberships);
184
218
  const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
@@ -187,12 +221,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
187
221
  }));
188
222
  const unused = memberships.filter((m) => !used.has(m));
189
223
  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 });
224
+ ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
225
+ : '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
226
  });
193
227
  server.registerTool('set_point_values', {
194
228
  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(', ')}.`,
229
+ 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
230
  inputSchema: z.object({
197
231
  values: z
198
232
  .record(z.string(), z.number().nullable())
@@ -204,7 +238,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
204
238
  .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
205
239
  }),
206
240
  outputSchema: WalletChangeSchema,
207
- annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
241
+ annotations: {
242
+ title: 'Set point values',
243
+ ...WRITE,
244
+ destructiveHint: true,
245
+ idempotentHint: true,
246
+ },
208
247
  }, async ({ values, profile }) => {
209
248
  const changes = {
210
249
  ...(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) {