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.
- package/README.md +25 -18
- package/data/CHANGELOG.md +56 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +19 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -5
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +49 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
- package/data/cards/chase-ihg-one-rewards.yaml +8 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +16 -3
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
- package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/issuers.yaml +32 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/brand.js +1 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +10 -5
- package/dist/cli/explore.js +6 -13
- package/dist/cli/import.js +2 -2
- package/dist/cli/missed.js +65 -0
- package/dist/cli/offers.js +17 -7
- package/dist/cli/run.js +29 -17
- package/dist/cli/todo.js +62 -0
- package/dist/cli/wallet.js +9 -6
- package/dist/data/file-store.js +6 -7
- package/dist/data/integrity.js +4 -0
- package/dist/data/load.js +4 -1
- package/dist/data/store.js +6 -22
- package/dist/data/wallet-edit.js +38 -13
- package/dist/engine/cheat-sheet.js +14 -6
- package/dist/engine/checklist.js +152 -0
- package/dist/engine/compare.js +2 -0
- package/dist/engine/evaluate.js +16 -11
- package/dist/engine/explore.js +6 -38
- package/dist/engine/memberships.js +1 -1
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +18 -0
- package/dist/engine/recommend.js +21 -20
- package/dist/engine/rule-text.js +47 -0
- package/dist/engine/setup.js +14 -24
- package/dist/engine/simulate.js +7 -6
- package/dist/engine/text.js +13 -1
- package/dist/engine/wallet-change.js +10 -12
- package/dist/engine/wallet-summary.js +80 -65
- package/dist/format/card-text.js +7 -20
- package/dist/format/explore.js +2 -2
- package/dist/format/offers.js +2 -2
- package/dist/format/recommendation.js +2 -2
- package/dist/format/wallet.js +7 -1
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +38 -13
- package/dist/mcp/inputs.js +1 -1
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/serve.js +4 -0
- package/dist/mcp/server.js +8 -2
- package/dist/mcp/tools/account.js +44 -16
- package/dist/mcp/tools/answers.js +58 -19
- package/dist/mcp/tools/wallet.js +64 -25
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/missed.js +78 -0
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +4 -1
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/wallet.js +1 -1
- 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
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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: {
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
57
|
-
if (typeof
|
|
58
|
-
return fail(
|
|
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: {
|
|
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: {
|
|
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:
|
|
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: {
|
|
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.
|
|
227
|
+
category: args.category ?? null,
|
|
208
228
|
amount: args.amount ?? null,
|
|
209
|
-
channel
|
|
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: {
|
|
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: {
|
|
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: [
|
|
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: {
|
|
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());
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -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 {
|
|
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 `
|
|
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:
|
|
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: {
|
|
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
|
-
...
|
|
72
|
+
...base,
|
|
63
73
|
started,
|
|
74
|
+
checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
|
|
64
75
|
};
|
|
65
|
-
const when = started === null
|
|
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
|
|
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:
|
|
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:
|
|
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: {
|
|
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:
|
|
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: {
|
|
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:
|
|
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: {
|
|
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:
|
|
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: {
|
|
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.
|
|
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.
|
|
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: {
|
|
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] : {}),
|
package/dist/merchants/cache.js
CHANGED
|
@@ -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) {
|