earnrate 0.2.3 → 0.2.5
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 +15 -14
- package/data/CHANGELOG.md +74 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards.yaml +7 -0
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/chase-united-club.yaml +1 -1
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +33 -0
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +3 -3
- package/dist/cli/explore.js +12 -11
- package/dist/cli/import.js +13 -12
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +18 -4
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +26 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +83 -2
- package/dist/engine/cheat-sheet.js +53 -14
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +18 -20
- package/dist/engine/memberships.js +71 -0
- package/dist/engine/offers.js +37 -5
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +78 -44
- package/dist/engine/setup.js +22 -5
- package/dist/engine/simulate.js +36 -14
- package/dist/engine/text.js +67 -2
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +42 -4
- package/dist/engine/wallet-summary.js +83 -37
- package/dist/format/card-text.js +19 -29
- package/dist/format/cards.js +8 -9
- package/dist/format/explore.js +22 -14
- package/dist/format/offers.js +12 -21
- package/dist/format/recommendation.js +16 -10
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +16 -20
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +17 -8
- package/dist/mcp/schemas.js +21 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +28 -10
- package/dist/mcp/tools/answers.js +51 -18
- package/dist/mcp/tools/wallet.js +106 -11
- package/dist/schema/card.js +7 -0
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
package/dist/mcp/context.js
CHANGED
|
@@ -54,15 +54,34 @@ export function makeContext(deps) {
|
|
|
54
54
|
}
|
|
55
55
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
56
56
|
// neither (null), answers cover every card earnrate knows.
|
|
57
|
+
// The point values and memberships the user saved apply to every answer,
|
|
58
|
+
// not just ones from their saved cards. A wallet that can't be read has
|
|
59
|
+
// none.
|
|
60
|
+
async function saved() {
|
|
61
|
+
try {
|
|
62
|
+
const wallet = await deps.store.load();
|
|
63
|
+
return {
|
|
64
|
+
valuations: wallet?.valuations ?? {},
|
|
65
|
+
memberships: wallet?.memberships ?? [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { valuations: {}, memberships: [] };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
57
72
|
async function cardsFor(cards, memberships) {
|
|
58
|
-
if (cards && cards.length > 0)
|
|
59
|
-
|
|
73
|
+
if (cards && cards.length > 0) {
|
|
74
|
+
const named = walletFromCards(catalog, {
|
|
60
75
|
cards: cards.map(({ cardId, ...rest }) => ({
|
|
61
76
|
id: cardId,
|
|
62
77
|
...defined(rest),
|
|
63
78
|
})),
|
|
64
|
-
|
|
79
|
+
memberships: memberships ?? (await saved()).memberships,
|
|
65
80
|
}, deps.today());
|
|
81
|
+
return typeof named === 'string'
|
|
82
|
+
? named
|
|
83
|
+
: { ...named, valuations: (await saved()).valuations };
|
|
84
|
+
}
|
|
66
85
|
const read = await readableWallet();
|
|
67
86
|
if (typeof read === 'string')
|
|
68
87
|
return read;
|
|
@@ -92,5 +111,13 @@ export function makeContext(deps) {
|
|
|
92
111
|
};
|
|
93
112
|
}
|
|
94
113
|
const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
95
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
wallet,
|
|
116
|
+
readableWallet,
|
|
117
|
+
resolve,
|
|
118
|
+
cardsFor,
|
|
119
|
+
saved,
|
|
120
|
+
changed,
|
|
121
|
+
cardName,
|
|
122
|
+
};
|
|
96
123
|
}
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
3
|
-
import { VALUATION_PROFILES } from "../engine/profiles.js";
|
|
3
|
+
import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
|
|
4
4
|
import { knownMemberships } from "../engine/setup.js";
|
|
5
5
|
import { IsoDate } from "../schema/common.js";
|
|
6
6
|
export const Merchant = z
|
|
@@ -112,6 +112,8 @@ export function makeInputs(catalog) {
|
|
|
112
112
|
.enum(categoryIds)
|
|
113
113
|
.optional()
|
|
114
114
|
.describe(`Only for a merchant earnrate may not know, and only if you're confident what kind of business it is: its spending category, e.g. grocery.supermarket for a supermarket, dining for a restaurant, gas for a gas station. Ignored when earnrate knows the merchant. Leave it out if unsure. Categories: ${categoryList}.`);
|
|
115
|
+
// record_purchase can't skip an unknown merchant's caps, so it asks.
|
|
116
|
+
const RecordCategoryInput = CategoryInput.describe(`For a merchant earnrate doesn't know, its spending category, e.g. dining for a restaurant: without one the purchase isn't recorded, since its caps can't be told. If nobody knows, other records it toward a welcome bonus without touching any cap. Ignored when earnrate knows the merchant. Categories: ${categoryList}.`);
|
|
115
117
|
const CatalogCategory = z
|
|
116
118
|
.enum(categoryIds)
|
|
117
119
|
.describe(`A spending category: ${categoryList}.`);
|
|
@@ -120,19 +122,20 @@ export function makeInputs(catalog) {
|
|
|
120
122
|
.array(NamedCard)
|
|
121
123
|
.optional()
|
|
122
124
|
.describe("Cards the user named in this conversation, to answer for just those cards without saving anything. Leave out to use the user's saved wallet if they have one; with neither, the answer covers every card earnrate knows.");
|
|
123
|
-
const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime, when they're named in the conversation rather than saved.");
|
|
125
|
+
const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco, when they're named in the conversation rather than saved.");
|
|
124
126
|
const CatalogFilters = {
|
|
125
127
|
...OfferFilters,
|
|
126
128
|
issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
|
|
127
|
-
memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
|
|
129
|
+
memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco. Rates that need one they don't have are listed separately; a card that needs one (Costco Anywhere) says so."),
|
|
128
130
|
valuation: z
|
|
129
131
|
.enum(['flat', 'points-heavy'])
|
|
130
132
|
.optional()
|
|
131
|
-
.describe('
|
|
133
|
+
.describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
|
|
132
134
|
};
|
|
133
135
|
const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
|
|
134
136
|
return {
|
|
135
137
|
CategoryInput,
|
|
138
|
+
RecordCategoryInput,
|
|
136
139
|
CatalogCategory,
|
|
137
140
|
NamedCards,
|
|
138
141
|
NamedMemberships,
|
|
@@ -141,8 +144,12 @@ export function makeInputs(catalog) {
|
|
|
141
144
|
AllMemberships,
|
|
142
145
|
};
|
|
143
146
|
}
|
|
144
|
-
// The catalog filters as exploreCards and simulate take them.
|
|
145
|
-
|
|
147
|
+
// The catalog filters as exploreCards and simulate take them. Memberships or
|
|
148
|
+
// a valuation profile asked for win over the user's saved ones.
|
|
149
|
+
export const settings = (args, saved = {
|
|
150
|
+
valuations: {},
|
|
151
|
+
memberships: [],
|
|
152
|
+
}) => ({
|
|
146
153
|
filters: {
|
|
147
154
|
...ALL_PERSONAL_CARDS,
|
|
148
155
|
kind: args.kind ?? 'any',
|
|
@@ -150,6 +157,8 @@ export const settings = (args) => ({
|
|
|
150
157
|
business: args.business ?? false,
|
|
151
158
|
issuer: args.issuer ?? null,
|
|
152
159
|
},
|
|
153
|
-
memberships: args.memberships ??
|
|
154
|
-
valuations: args.valuation
|
|
160
|
+
memberships: args.memberships ?? saved.memberships,
|
|
161
|
+
valuations: args.valuation
|
|
162
|
+
? VALUATION_PROFILES[args.valuation]
|
|
163
|
+
: saved.valuations,
|
|
155
164
|
});
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -78,6 +78,13 @@ const RecommendationSchema = z.object({
|
|
|
78
78
|
});
|
|
79
79
|
export const WalletSummarySchema = z.object({
|
|
80
80
|
memberships: z.array(z.string()),
|
|
81
|
+
pointValues: z.array(z.object({
|
|
82
|
+
currency: z.string(),
|
|
83
|
+
name: z.string(),
|
|
84
|
+
centsPerUnit: z.number(),
|
|
85
|
+
redemption: z.string().nullable(),
|
|
86
|
+
})),
|
|
87
|
+
started: z.string().nullable(),
|
|
81
88
|
cards: z.array(z.object({
|
|
82
89
|
cardId: z.string(),
|
|
83
90
|
cardName: z.string(),
|
|
@@ -99,6 +106,7 @@ export const WalletSummarySchema = z.object({
|
|
|
99
106
|
})
|
|
100
107
|
.nullable(),
|
|
101
108
|
currency: z.string(),
|
|
109
|
+
currencyName: z.string(),
|
|
102
110
|
centsPerUnit: z.number(),
|
|
103
111
|
lastVerified: z.string(),
|
|
104
112
|
verifiedBy: z.string(),
|
|
@@ -170,6 +178,8 @@ export const OfferComparisonSchema = z.object({
|
|
|
170
178
|
note: z.string().nullable(),
|
|
171
179
|
annualFee: z.number().nullable(),
|
|
172
180
|
firstYearFee: z.number().nullable(),
|
|
181
|
+
membershipFee: z.number(),
|
|
182
|
+
ineligible: z.string().nullable(),
|
|
173
183
|
bonusValue: z.number(),
|
|
174
184
|
firstYearValue: z.number(),
|
|
175
185
|
held: z.boolean(),
|
|
@@ -204,6 +214,9 @@ export const CardSearchSchema = z.object({
|
|
|
204
214
|
default: z.array(z.string()).nullable(),
|
|
205
215
|
})),
|
|
206
216
|
memberships: z.array(z.string()),
|
|
217
|
+
requiresMembership: z
|
|
218
|
+
.object({ id: z.string(), name: z.string(), annualFee: z.number() })
|
|
219
|
+
.nullable(),
|
|
207
220
|
activation: z.array(z.string()),
|
|
208
221
|
needsAnniversary: z.boolean(),
|
|
209
222
|
})),
|
|
@@ -272,6 +285,13 @@ const SimulationSchema = z.object({
|
|
|
272
285
|
spend: z.number(),
|
|
273
286
|
rewards: z.number(),
|
|
274
287
|
annualFees: z.number(),
|
|
288
|
+
memberships: z.array(z.object({
|
|
289
|
+
id: z.string(),
|
|
290
|
+
name: z.string(),
|
|
291
|
+
annualFee: z.number(),
|
|
292
|
+
cardIds: z.array(z.string()),
|
|
293
|
+
})),
|
|
294
|
+
membershipFees: z.number(),
|
|
275
295
|
net: z.number(),
|
|
276
296
|
lines: z.array(z.object({
|
|
277
297
|
label: z.string(),
|
|
@@ -292,6 +312,7 @@ export const SpendingSimulationSchema = z.object({
|
|
|
292
312
|
picks: Picks,
|
|
293
313
|
extraRewards: z.number(),
|
|
294
314
|
annualFee: z.number(),
|
|
315
|
+
membershipFee: z.number(),
|
|
295
316
|
gain: z.number(),
|
|
296
317
|
})),
|
|
297
318
|
});
|
package/dist/mcp/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { WalletChangeSchema } from "../schemas.js";
|
|
|
7
7
|
export function registerAccountTools(server, catalog, store, ctx) {
|
|
8
8
|
server.registerTool('export_wallet', {
|
|
9
9
|
title: 'Export the wallet',
|
|
10
|
-
description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, cap usage and bonus progress. Never card numbers.',
|
|
10
|
+
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
11
|
inputSchema: z.object({}),
|
|
12
12
|
outputSchema: z.object({ yaml: z.string() }),
|
|
13
13
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -46,19 +46,35 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
46
46
|
const parsed = parseWalletYaml(yaml, catalog);
|
|
47
47
|
if (typeof parsed === 'string')
|
|
48
48
|
return fail(parsed);
|
|
49
|
-
|
|
49
|
+
let current;
|
|
50
|
+
try {
|
|
51
|
+
current = await store.load();
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (replace !== true)
|
|
55
|
+
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
|
+
current = null;
|
|
57
|
+
}
|
|
50
58
|
if (current !== null && current.cards.length > 0 && replace !== true)
|
|
51
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.`);
|
|
52
|
-
const
|
|
53
|
-
|
|
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 });
|
|
54
65
|
});
|
|
55
66
|
server.registerTool('delete_wallet', {
|
|
56
67
|
title: 'Delete the wallet',
|
|
57
|
-
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: offer export_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.",
|
|
58
69
|
inputSchema: z.object({
|
|
59
70
|
confirm: z
|
|
60
71
|
.literal(true)
|
|
61
72
|
.describe('true, only after the user confirms the deletion.'),
|
|
73
|
+
cards: z
|
|
74
|
+
.number()
|
|
75
|
+
.int()
|
|
76
|
+
.nonnegative()
|
|
77
|
+
.describe('How many cards the wallet held when the user confirmed, from list_wallet: 0 for an empty wallet.'),
|
|
62
78
|
}),
|
|
63
79
|
outputSchema: z.object({ deleted: z.boolean() }),
|
|
64
80
|
annotations: {
|
|
@@ -67,10 +83,12 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
67
83
|
idempotentHint: true,
|
|
68
84
|
openWorldHint: false,
|
|
69
85
|
},
|
|
70
|
-
}, async () => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
}, async ({ cards }) => {
|
|
87
|
+
// 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.`);
|
|
74
92
|
await store.erase();
|
|
75
93
|
return {
|
|
76
94
|
content: [
|
|
@@ -84,7 +102,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
84
102
|
});
|
|
85
103
|
server.registerTool('switch_wallet', {
|
|
86
104
|
title: 'Switch wallets',
|
|
87
|
-
description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
|
|
105
|
+
description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another Claude account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
|
|
88
106
|
inputSchema: z.object({}),
|
|
89
107
|
outputSchema: z.object({ disconnected: z.boolean() }),
|
|
90
108
|
annotations: {
|
|
@@ -6,7 +6,7 @@ import { withValuationProfile } from "../../engine/profiles.js";
|
|
|
6
6
|
import { recommend } from "../../engine/recommend.js";
|
|
7
7
|
import { searchCards } from "../../engine/setup.js";
|
|
8
8
|
import { resolveSpendLines, simulateAdditions, simulateCatalog, TYPICAL_SPEND_NOTE, typicalSpendLines, } from "../../engine/simulate.js";
|
|
9
|
-
import { currencyName } from "../../engine/text.js";
|
|
9
|
+
import { categoryPhrase, currencyName, NETWORK_NAMES, } from "../../engine/text.js";
|
|
10
10
|
import { EMPTY_WALLET } from "../../engine/transient.js";
|
|
11
11
|
import { formatCardInfo } from "../../format/cards.js";
|
|
12
12
|
import { formatCheatSheet } from "../../format/cheat-sheet.js";
|
|
@@ -16,6 +16,15 @@ import { formatRecommendation } from "../../format/recommendation.js";
|
|
|
16
16
|
import { ALL_CARDS_NOTE, fail } from "../context.js";
|
|
17
17
|
import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
|
|
18
18
|
import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
|
|
19
|
+
const SOURCES = {
|
|
20
|
+
override: "from earnrate's merchant list",
|
|
21
|
+
seed: "from earnrate's merchant list",
|
|
22
|
+
llm: 'classified by Claude',
|
|
23
|
+
caller: 'from the category given with the request',
|
|
24
|
+
category: 'a kind of purchase, not a store',
|
|
25
|
+
none: 'not recognized',
|
|
26
|
+
};
|
|
27
|
+
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.";
|
|
19
28
|
const TOP = 10;
|
|
20
29
|
// The tools that answer without a saved wallet: from every card, the cards
|
|
21
30
|
// named in the conversation, or a saved wallet if there is one.
|
|
@@ -24,7 +33,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
24
33
|
const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
|
|
25
34
|
server.registerTool('best_card_for_purchase', {
|
|
26
35
|
title: 'Best card for a purchase',
|
|
27
|
-
description: "Which card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. Never needs or returns card numbers.",
|
|
36
|
+
description: "Which card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. Never needs or returns card numbers.",
|
|
28
37
|
inputSchema: z.object({
|
|
29
38
|
merchant: Merchant,
|
|
30
39
|
amount: z
|
|
@@ -55,6 +64,9 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
55
64
|
country: 'US',
|
|
56
65
|
};
|
|
57
66
|
const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
|
|
67
|
+
const retry = resolved.merchant.source === 'none'
|
|
68
|
+
? `earnrate doesn't recognize "${merchant}", so this answer uses only base rates and may miss the best card. If you can tell what kind of business it is (a restaurant, a grocery store, a gas station), call best_card_for_purchase again with category; otherwise ask the user.\n\n`
|
|
69
|
+
: '';
|
|
58
70
|
if (wallet === null) {
|
|
59
71
|
const explored = exploreCards(catalog, {
|
|
60
72
|
merchant,
|
|
@@ -62,14 +74,14 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
62
74
|
amount: purchase.amount,
|
|
63
75
|
channel: purchase.channel,
|
|
64
76
|
date: purchase.date,
|
|
65
|
-
}, settings({ memberships }), resolved.merchant);
|
|
77
|
+
}, settings({ memberships }, await ctx.saved()), resolved.merchant);
|
|
66
78
|
const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
|
|
67
79
|
const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
|
|
68
80
|
return {
|
|
69
81
|
content: [
|
|
70
82
|
{
|
|
71
83
|
type: 'text',
|
|
72
|
-
text: `${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
|
|
84
|
+
text: `${retry}${formatExploration(allCards, catalog, true)}\n${ALL_CARDS_NOTE}`,
|
|
73
85
|
},
|
|
74
86
|
],
|
|
75
87
|
structuredContent: {
|
|
@@ -89,7 +101,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
89
101
|
content: [
|
|
90
102
|
{
|
|
91
103
|
type: 'text',
|
|
92
|
-
text: formatRecommendation(output, purchase, { all: true })
|
|
104
|
+
text: `${retry}${formatRecommendation(output, purchase, { all: true })}`,
|
|
93
105
|
},
|
|
94
106
|
],
|
|
95
107
|
structuredContent: output,
|
|
@@ -109,7 +121,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
109
121
|
const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
|
|
110
122
|
const m = resolved.merchant;
|
|
111
123
|
const text = [
|
|
112
|
-
`${m.resolvedName}: ${m.category}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence,
|
|
124
|
+
`${m.resolvedName}: ${categoryPhrase(catalog, m.category)}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence, ${SOURCES[m.source]}.`,
|
|
125
|
+
m.accepts === null
|
|
126
|
+
? m.source === 'none'
|
|
127
|
+
? 'Which card networks it takes is unknown.'
|
|
128
|
+
: 'Takes all major card networks.'
|
|
129
|
+
: `Takes ${m.accepts.map((n) => NETWORK_NAMES[n] ?? n).join(', ')} only.`,
|
|
130
|
+
...(m.ambiguity ? [m.ambiguity.note] : []),
|
|
113
131
|
...(m.inference
|
|
114
132
|
? [`Claude's reasoning: ${m.inference.rationale}`]
|
|
115
133
|
: []),
|
|
@@ -122,7 +140,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
122
140
|
});
|
|
123
141
|
server.registerTool('compare_welcome_offers', {
|
|
124
142
|
title: 'Compare welcome offers',
|
|
125
|
-
description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
|
|
143
|
+
description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee and any membership the card needs that the user doesn't have. Offers the issuer rules out because of a card in the user's wallet are marked likely not eligible and listed last. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
|
|
126
144
|
inputSchema: z.object({
|
|
127
145
|
...OfferFilters,
|
|
128
146
|
valuation: CatalogFilters.valuation,
|
|
@@ -138,7 +156,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
138
156
|
const wallet = await ctx.cardsFor(args.cards, undefined);
|
|
139
157
|
if (typeof wallet === 'string')
|
|
140
158
|
return fail(wallet);
|
|
141
|
-
const base = wallet ?? EMPTY_WALLET;
|
|
159
|
+
const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
|
|
142
160
|
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
143
161
|
kind: args.kind ?? 'any',
|
|
144
162
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
@@ -147,7 +165,9 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
147
165
|
}, deps.today());
|
|
148
166
|
const trimmed = {
|
|
149
167
|
...comparison,
|
|
150
|
-
|
|
168
|
+
// Offers ruled out by a card the user holds sort last; keep them in
|
|
169
|
+
// view so the reason shows.
|
|
170
|
+
offers: comparison.offers.filter((o, i) => i < TOP || o.ineligible !== null),
|
|
151
171
|
};
|
|
152
172
|
const more = comparison.offers.length - trimmed.offers.length;
|
|
153
173
|
return {
|
|
@@ -156,7 +176,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
156
176
|
type: 'text',
|
|
157
177
|
text: formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
|
|
158
178
|
(more > 0
|
|
159
|
-
? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers. Narrow them with kind, noAnnualFee or business.\n`
|
|
179
|
+
? `\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`
|
|
160
180
|
: ''),
|
|
161
181
|
},
|
|
162
182
|
],
|
|
@@ -179,7 +199,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
179
199
|
}),
|
|
180
200
|
outputSchema: ExplorationSchema,
|
|
181
201
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
182
|
-
}, (args) => {
|
|
202
|
+
}, async (args) => {
|
|
183
203
|
if (args.merchant === undefined && args.category === undefined)
|
|
184
204
|
return fail('Give a merchant or a category.');
|
|
185
205
|
const result = exploreCards(catalog, {
|
|
@@ -188,16 +208,18 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
188
208
|
amount: args.amount ?? null,
|
|
189
209
|
channel: args.channel ?? null,
|
|
190
210
|
date: deps.today(),
|
|
191
|
-
}, settings(args));
|
|
211
|
+
}, settings(args, await ctx.saved()));
|
|
192
212
|
const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
|
|
193
213
|
return {
|
|
194
|
-
content: [
|
|
214
|
+
content: [
|
|
215
|
+
{ type: 'text', text: formatExploration(trimmed, catalog, true) },
|
|
216
|
+
],
|
|
195
217
|
structuredContent: trimmed,
|
|
196
218
|
};
|
|
197
219
|
});
|
|
198
220
|
server.registerTool('simulate_spending', {
|
|
199
221
|
title: 'Simulate a year of spending',
|
|
200
|
-
description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
|
|
222
|
+
description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees, and minus a membership a card needs (a Costco membership) unless the user has it or spends at Costco. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
|
|
201
223
|
inputSchema: z.object({
|
|
202
224
|
spending: z
|
|
203
225
|
.array(z.object({
|
|
@@ -231,7 +253,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
231
253
|
if (unknown.length > 0)
|
|
232
254
|
return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
|
|
233
255
|
const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
|
|
234
|
-
const options = {
|
|
256
|
+
const options = {
|
|
257
|
+
...settings(args, await ctx.saved()),
|
|
258
|
+
start: deps.today(),
|
|
259
|
+
};
|
|
235
260
|
const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
|
|
236
261
|
args.compareToWallet === true;
|
|
237
262
|
const wallet = wantsTheirs
|
|
@@ -279,6 +304,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
279
304
|
inputSchema: z.object({
|
|
280
305
|
date: DateInput,
|
|
281
306
|
cards: NamedCards,
|
|
307
|
+
everyCard: z
|
|
308
|
+
.boolean()
|
|
309
|
+
.optional()
|
|
310
|
+
.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.'),
|
|
282
311
|
memberships: NamedMemberships,
|
|
283
312
|
kind: CatalogFilters.kind,
|
|
284
313
|
noAnnualFee: CatalogFilters.noAnnualFee,
|
|
@@ -289,14 +318,18 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
289
318
|
outputSchema: CheatSheetSchema,
|
|
290
319
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
291
320
|
}, async (args) => {
|
|
292
|
-
const wallet =
|
|
321
|
+
const wallet = args.everyCard === true
|
|
322
|
+
? null
|
|
323
|
+
: await ctx.cardsFor(args.cards, args.memberships);
|
|
293
324
|
if (typeof wallet === 'string')
|
|
294
325
|
return fail(wallet);
|
|
295
326
|
const date = args.date ?? deps.today();
|
|
296
327
|
const sheet = wallet === null
|
|
297
328
|
? {
|
|
298
|
-
...catalogCheatSheet(catalog, date, settings(args)),
|
|
299
|
-
notes: [
|
|
329
|
+
...catalogCheatSheet(catalog, date, settings(args, await ctx.saved())),
|
|
330
|
+
notes: [
|
|
331
|
+
args.everyCard === true ? EVERY_CARD_NOTE : ALL_CARDS_NOTE,
|
|
332
|
+
],
|
|
300
333
|
}
|
|
301
334
|
: cheatSheet(catalog, args.valuation
|
|
302
335
|
? withValuationProfile(wallet, args.valuation)
|