earnrate 0.1.3 → 0.2.0
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 +45 -7
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/cli/args.js +9 -0
- package/dist/cli/cheatsheet.js +85 -0
- package/dist/cli/explore.js +23 -140
- package/dist/cli/format.js +7 -5
- package/dist/cli/offers.js +10 -53
- package/dist/cli/run.js +84 -14
- package/dist/cli/wallet.js +40 -0
- package/dist/data/file-store.js +56 -0
- package/dist/data/load.js +9 -1
- package/dist/data/store.js +108 -0
- package/dist/data/wallet-edit.js +25 -2
- package/dist/engine/cheat-sheet.js +103 -0
- package/dist/engine/explore.js +7 -6
- package/dist/engine/recommend.js +3 -0
- package/dist/engine/record.js +35 -0
- package/dist/engine/simulate.js +22 -1
- package/dist/engine/transient.js +21 -0
- package/dist/engine/wallet-change.js +28 -1
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +124 -0
- package/dist/format/offers.js +42 -0
- package/dist/format/wallet-yaml.js +45 -0
- package/dist/mcp/schemas.js +20 -1
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +339 -100
- package/dist/schema/catalog.js +1 -0
- package/package.json +11 -3
package/dist/mcp/server.js
CHANGED
|
@@ -3,24 +3,46 @@ import { z } from 'zod';
|
|
|
3
3
|
import { MCP_SERVER_NAME } from "../brand.js";
|
|
4
4
|
import { formatCardInfo, formatNotes } from "../cli/cards.js";
|
|
5
5
|
import { formatRecommendation } from "../cli/format.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { formatCheatSheet } from "../format/cheat-sheet.js";
|
|
7
|
+
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
|
|
8
|
+
import { formatOffers } from "../format/offers.js";
|
|
9
|
+
import { parseWalletYaml, walletYaml } from "../format/wallet-yaml.js";
|
|
8
10
|
import { walletProblems } from "../data/integrity.js";
|
|
9
|
-
import {
|
|
10
|
-
import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
|
|
11
|
-
import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
|
|
12
|
-
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
11
|
+
import { catalogCheatSheet, cheatSheet } from "../engine/cheat-sheet.js";
|
|
13
12
|
import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
|
|
13
|
+
import { walletFromCards } from "../engine/transient.js";
|
|
14
14
|
import { compareOffers } from "../engine/offers.js";
|
|
15
15
|
import { VALUATION_PROFILES } from "../engine/profiles.js";
|
|
16
|
-
import { capBooking } from "../engine/record.js";
|
|
17
16
|
import { recommend } from "../engine/recommend.js";
|
|
18
|
-
import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
|
|
17
|
+
import { resolveSpendLines, simulateAdditions, simulateCatalog, TYPICAL_SPEND_NOTE, typicalSpendLines, } from "../engine/simulate.js";
|
|
19
18
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
20
19
|
import { walletSummary } from "../engine/wallet-summary.js";
|
|
21
20
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
22
21
|
import { IsoDate } from "../schema/common.js";
|
|
23
|
-
import { CardSearchSchema, ExplorationSchema, MerchantResolutionSchema,
|
|
22
|
+
import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, PurchaseAnswerSchema, SpendingSimulationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
|
|
23
|
+
// The tools that answer without a saved wallet.
|
|
24
|
+
export const ANSWER_TOOLS = [
|
|
25
|
+
'best_card_for_purchase',
|
|
26
|
+
'best_cards_for',
|
|
27
|
+
'simulate_spending',
|
|
28
|
+
'compare_welcome_offers',
|
|
29
|
+
'cheat_sheet',
|
|
30
|
+
'resolve_merchant',
|
|
31
|
+
'search_cards',
|
|
32
|
+
];
|
|
33
|
+
// The tools that read or change a saved wallet. The remote server asks for
|
|
34
|
+
// sign-in before running one.
|
|
35
|
+
export const SAVED_WALLET_TOOLS = [
|
|
36
|
+
'list_wallet',
|
|
37
|
+
'record_purchase',
|
|
38
|
+
'add_card',
|
|
39
|
+
'update_card',
|
|
40
|
+
'remove_card',
|
|
41
|
+
'set_memberships',
|
|
42
|
+
'export_wallet',
|
|
43
|
+
'import_wallet',
|
|
44
|
+
'delete_wallet',
|
|
45
|
+
];
|
|
24
46
|
const Merchant = z
|
|
25
47
|
.string()
|
|
26
48
|
.min(1)
|
|
@@ -30,34 +52,54 @@ const ChannelInput = z
|
|
|
30
52
|
.optional()
|
|
31
53
|
.describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
|
|
32
54
|
const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
|
|
33
|
-
const
|
|
55
|
+
const SAVING = {
|
|
56
|
+
none: "This server doesn't save wallets: pass the cards each time. For a saved wallet with cap and welcome-bonus tracking, the user can install earnrate locally (earnrate.dev).",
|
|
57
|
+
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.',
|
|
58
|
+
account: "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. Saved wallets are anonymous: the first wallet tool asks the user to open an earnrate page, where they start a wallet (it shows a recovery code to keep; no email or name) or reconnect one with their code. Never ask for the recovery code in the conversation; it's typed only on that page. To change the code, the user opens https://mcp.earnrate.dev/change-code.",
|
|
59
|
+
};
|
|
60
|
+
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.
|
|
34
61
|
|
|
35
|
-
|
|
62
|
+
Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
|
|
63
|
+
|
|
64
|
+
The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.`;
|
|
36
65
|
const fail = (text) => ({
|
|
37
66
|
content: [{ type: 'text', text }],
|
|
38
67
|
isError: true,
|
|
39
68
|
});
|
|
40
69
|
const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
41
70
|
export function createEarnrateServer(deps) {
|
|
42
|
-
const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
|
|
71
|
+
const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS(deps.savedWallets) });
|
|
43
72
|
const { catalog } = deps;
|
|
44
|
-
//
|
|
73
|
+
// Tools that need a saved wallet; a server without saved wallets drops them.
|
|
74
|
+
const walletTools = [];
|
|
75
|
+
const noWallet = `There's no wallet yet (${deps.store.where}), which is normal before the user adds a card. Ask the user which credit cards they have, find each one with search_cards, and add it with add_card. best_cards_for and simulate_spending work without a wallet.`;
|
|
76
|
+
// Read on every call, so edits to the wallet apply without a restart.
|
|
45
77
|
async function wallet() {
|
|
46
78
|
let loaded;
|
|
47
79
|
try {
|
|
48
|
-
|
|
80
|
+
const found = await deps.store.load();
|
|
81
|
+
if (found === null)
|
|
82
|
+
return noWallet;
|
|
83
|
+
loaded = found;
|
|
49
84
|
}
|
|
50
85
|
catch (e) {
|
|
51
|
-
|
|
52
|
-
return `There's no wallet yet (${deps.walletFile}). Ask the user which credit cards they have, find each one with search_cards, and add it with add_card.`;
|
|
53
|
-
}
|
|
54
|
-
return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
|
|
86
|
+
return `Couldn't read the wallet at ${deps.store.where}: ${e instanceof Error ? e.message : String(e)}`;
|
|
55
87
|
}
|
|
56
88
|
const problems = walletProblems(loaded, catalog);
|
|
57
89
|
return problems.length > 0
|
|
58
|
-
? `The wallet at ${deps.
|
|
90
|
+
? `The wallet at ${deps.store.where} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
|
|
59
91
|
: loaded;
|
|
60
92
|
}
|
|
93
|
+
// No wallet yet is the first-run state, not a failure: read-only tools
|
|
94
|
+
// answer from an empty wallet and say how to start one.
|
|
95
|
+
async function readableWallet() {
|
|
96
|
+
const w = await wallet();
|
|
97
|
+
if (typeof w !== 'string')
|
|
98
|
+
return { wallet: w, start: null };
|
|
99
|
+
return w === noWallet
|
|
100
|
+
? { wallet: { valuations: {}, memberships: [], cards: [] }, start: w }
|
|
101
|
+
: w;
|
|
102
|
+
}
|
|
61
103
|
// The caller's category is free and comes before the paid Claude fallback;
|
|
62
104
|
// neither ever overrides a merchant earnrate already knows.
|
|
63
105
|
async function resolve(merchant, channel, category) {
|
|
@@ -72,9 +114,50 @@ export function createEarnrateServer(deps) {
|
|
|
72
114
|
.enum(ids)
|
|
73
115
|
.optional()
|
|
74
116
|
.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: ${catalog.categories.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
|
|
117
|
+
const namedMembershipIds = knownMemberships(catalog);
|
|
118
|
+
const NamedCardsInput = z
|
|
119
|
+
.array(z.object({
|
|
120
|
+
cardId: z.string().min(1).describe('A card id from search_cards.'),
|
|
121
|
+
picks: z
|
|
122
|
+
.record(z.string(), z.array(z.string()).min(1))
|
|
123
|
+
.optional()
|
|
124
|
+
.describe("Bonus categories the user picked, for cards that have them: choice id → option ids, from search_cards. Ask; don't guess."),
|
|
125
|
+
activated: z
|
|
126
|
+
.array(z.string())
|
|
127
|
+
.optional()
|
|
128
|
+
.describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
|
|
129
|
+
}))
|
|
130
|
+
.optional()
|
|
131
|
+
.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.");
|
|
132
|
+
const NamedMembershipsInput = z
|
|
133
|
+
.array(z.enum(namedMembershipIds))
|
|
134
|
+
.optional()
|
|
135
|
+
.describe("Memberships the user has, such as amazon-prime, when they're named in the conversation rather than saved.");
|
|
136
|
+
const ALL_CARDS_NOTE = "The user hasn't named their cards, so this covers every card earnrate knows. That's a complete answer. If they want the best of their own cards, they can name them (pass them as `cards`); saving a wallet is optional.";
|
|
137
|
+
// Cards named in the conversation come first, then the saved wallet; with
|
|
138
|
+
// neither, read tools answer from every card earnrate knows.
|
|
139
|
+
async function sourceFor(cards, memberships) {
|
|
140
|
+
if (cards && cards.length > 0) {
|
|
141
|
+
const w = walletFromCards(catalog, {
|
|
142
|
+
cards: cards.map((c) => ({
|
|
143
|
+
id: c.cardId,
|
|
144
|
+
...(c.picks ? { picks: c.picks } : {}),
|
|
145
|
+
...(c.activated ? { activated: c.activated } : {}),
|
|
146
|
+
})),
|
|
147
|
+
...(memberships ? { memberships } : {}),
|
|
148
|
+
}, deps.today());
|
|
149
|
+
return typeof w === 'string' ? w : { wallet: w, named: true };
|
|
150
|
+
}
|
|
151
|
+
const read = await readableWallet();
|
|
152
|
+
if (typeof read === 'string')
|
|
153
|
+
return read;
|
|
154
|
+
return read.start !== null
|
|
155
|
+
? { wallet: null }
|
|
156
|
+
: { wallet: read.wallet, named: false };
|
|
157
|
+
}
|
|
75
158
|
server.registerTool('best_card_for_purchase', {
|
|
76
159
|
title: 'Best card for a purchase',
|
|
77
|
-
description: "
|
|
160
|
+
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.",
|
|
78
161
|
inputSchema: z.object({
|
|
79
162
|
merchant: Merchant,
|
|
80
163
|
amount: z
|
|
@@ -85,16 +168,18 @@ export function createEarnrateServer(deps) {
|
|
|
85
168
|
channel: ChannelInput,
|
|
86
169
|
date: DateInput,
|
|
87
170
|
category: CategoryInput,
|
|
171
|
+
cards: NamedCardsInput,
|
|
172
|
+
memberships: NamedMembershipsInput,
|
|
88
173
|
}),
|
|
89
|
-
outputSchema:
|
|
174
|
+
outputSchema: PurchaseAnswerSchema,
|
|
90
175
|
annotations: {
|
|
91
176
|
readOnlyHint: true,
|
|
92
177
|
openWorldHint: deps.llm !== null,
|
|
93
178
|
},
|
|
94
|
-
}, async ({ merchant, amount, channel, date, category }) => {
|
|
95
|
-
const
|
|
96
|
-
if (typeof
|
|
97
|
-
return fail(
|
|
179
|
+
}, async ({ merchant, amount, channel, date, category, cards, memberships, }) => {
|
|
180
|
+
const source = await sourceFor(cards, memberships);
|
|
181
|
+
if (typeof source === 'string')
|
|
182
|
+
return fail(source);
|
|
98
183
|
const purchase = {
|
|
99
184
|
merchant,
|
|
100
185
|
amount: amount ?? null,
|
|
@@ -103,10 +188,35 @@ export function createEarnrateServer(deps) {
|
|
|
103
188
|
country: 'US',
|
|
104
189
|
};
|
|
105
190
|
const resolved = await resolve(merchant, purchase.channel, category ?? null);
|
|
106
|
-
|
|
191
|
+
if (source.wallet === null) {
|
|
192
|
+
const explored = exploreCards(catalog, {
|
|
193
|
+
merchant,
|
|
194
|
+
category: null,
|
|
195
|
+
amount: purchase.amount,
|
|
196
|
+
channel: purchase.channel,
|
|
197
|
+
date: purchase.date,
|
|
198
|
+
}, { ...settings({}), memberships: memberships ?? [] }, resolved.merchant);
|
|
199
|
+
const allCards = { ...explored, ranked: explored.ranked.slice(0, 10) };
|
|
200
|
+
const empty = recommend(catalog, { valuations: {}, memberships: [], cards: [] }, purchase, resolved.merchant);
|
|
201
|
+
return {
|
|
202
|
+
content: [
|
|
203
|
+
{
|
|
204
|
+
type: 'text',
|
|
205
|
+
text: `${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
structuredContent: {
|
|
209
|
+
...empty,
|
|
210
|
+
warnings: [ALL_CARDS_NOTE, ...resolved.notices],
|
|
211
|
+
allCards,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const rec = recommend(catalog, source.wallet, purchase, resolved.merchant);
|
|
107
216
|
const output = {
|
|
108
217
|
...rec,
|
|
109
218
|
warnings: [...rec.warnings, ...resolved.notices],
|
|
219
|
+
allCards: null,
|
|
110
220
|
};
|
|
111
221
|
return {
|
|
112
222
|
content: [
|
|
@@ -140,16 +250,21 @@ export function createEarnrateServer(deps) {
|
|
|
140
250
|
structuredContent: m,
|
|
141
251
|
};
|
|
142
252
|
});
|
|
143
|
-
server.registerTool('list_wallet', {
|
|
253
|
+
walletTools.push(server.registerTool('list_wallet', {
|
|
144
254
|
title: 'List wallet',
|
|
145
255
|
description: "Lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, and when each card's data was last verified.",
|
|
146
256
|
outputSchema: WalletSummarySchema,
|
|
147
257
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
148
258
|
}, async () => {
|
|
149
|
-
const
|
|
150
|
-
if (typeof
|
|
151
|
-
return fail(
|
|
152
|
-
const summary = walletSummary(catalog,
|
|
259
|
+
const read = await readableWallet();
|
|
260
|
+
if (typeof read === 'string')
|
|
261
|
+
return fail(read);
|
|
262
|
+
const summary = walletSummary(catalog, read.wallet, deps.today());
|
|
263
|
+
if (read.start !== null)
|
|
264
|
+
return {
|
|
265
|
+
content: [{ type: 'text', text: read.start }],
|
|
266
|
+
structuredContent: summary,
|
|
267
|
+
};
|
|
153
268
|
const lines = summary.cards.map((c) => {
|
|
154
269
|
const caps = c.caps
|
|
155
270
|
.filter((cap) => cap.remaining !== null)
|
|
@@ -157,7 +272,9 @@ export function createEarnrateServer(deps) {
|
|
|
157
272
|
const chosen = c.chosen
|
|
158
273
|
? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
|
|
159
274
|
: '';
|
|
160
|
-
const onlyAt = c.onlyAt
|
|
275
|
+
const onlyAt = c.onlyAt
|
|
276
|
+
? `; works only at ${c.onlyAt.join(', ')}`
|
|
277
|
+
: '';
|
|
161
278
|
const rotation = c.rotationGap === null
|
|
162
279
|
? ''
|
|
163
280
|
: `; rotating categories for ${c.rotationGap} not in earnrate yet`;
|
|
@@ -173,7 +290,7 @@ export function createEarnrateServer(deps) {
|
|
|
173
290
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
174
291
|
structuredContent: summary,
|
|
175
292
|
};
|
|
176
|
-
});
|
|
293
|
+
}));
|
|
177
294
|
server.registerTool('compare_welcome_offers', {
|
|
178
295
|
title: 'Compare welcome offers',
|
|
179
296
|
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.",
|
|
@@ -194,14 +311,15 @@ export function createEarnrateServer(deps) {
|
|
|
194
311
|
.boolean()
|
|
195
312
|
.optional()
|
|
196
313
|
.describe('Include cards already in the wallet.'),
|
|
314
|
+
cards: NamedCardsInput,
|
|
197
315
|
}),
|
|
198
316
|
outputSchema: OfferComparisonSchema,
|
|
199
317
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
200
318
|
}, async (args) => {
|
|
201
|
-
const
|
|
202
|
-
if (typeof
|
|
203
|
-
return fail(
|
|
204
|
-
const comparison = compareOffers(catalog,
|
|
319
|
+
const source = await sourceFor(args.cards, undefined);
|
|
320
|
+
if (typeof source === 'string')
|
|
321
|
+
return fail(source);
|
|
322
|
+
const comparison = compareOffers(catalog, source.wallet ?? { valuations: {}, memberships: [], cards: [] }, {
|
|
205
323
|
kind: args.kind ?? 'any',
|
|
206
324
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
207
325
|
business: args.business ?? false,
|
|
@@ -288,7 +406,7 @@ export function createEarnrateServer(deps) {
|
|
|
288
406
|
});
|
|
289
407
|
server.registerTool('simulate_spending', {
|
|
290
408
|
title: 'Simulate a year of spending',
|
|
291
|
-
description: 'Spends
|
|
409
|
+
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.',
|
|
292
410
|
inputSchema: z.object({
|
|
293
411
|
spending: z
|
|
294
412
|
.array(z.object({
|
|
@@ -296,32 +414,42 @@ export function createEarnrateServer(deps) {
|
|
|
296
414
|
category: CatalogCategory.optional(),
|
|
297
415
|
monthly: z.number().positive().describe('US dollars a month.'),
|
|
298
416
|
}))
|
|
299
|
-
.
|
|
300
|
-
.describe(
|
|
417
|
+
.optional()
|
|
418
|
+
.describe("Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco). Optional: leave out to use typical amounts, and don't press the user for numbers they haven't offered."),
|
|
419
|
+
cards: NamedCardsInput,
|
|
301
420
|
compareToWallet: z
|
|
302
421
|
.boolean()
|
|
303
422
|
.optional()
|
|
304
|
-
.describe("Rank the cards that would add the most to the user's wallet."),
|
|
423
|
+
.describe("Rank the cards that would add the most to the user's saved wallet."),
|
|
305
424
|
...CatalogFilters,
|
|
306
425
|
}),
|
|
307
426
|
outputSchema: SpendingSimulationSchema,
|
|
308
427
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
309
428
|
}, async (args) => {
|
|
310
|
-
|
|
429
|
+
const given = args.spending ?? [];
|
|
430
|
+
if (given.some((l) => l.merchant === undefined && l.category === undefined))
|
|
311
431
|
return fail('Each spending line needs a merchant or a category.');
|
|
312
|
-
const lines =
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
432
|
+
const lines = given.length === 0
|
|
433
|
+
? typicalSpendLines(catalog)
|
|
434
|
+
: resolveSpendLines(catalog, given.map((l) => ({
|
|
435
|
+
merchant: l.merchant ?? null,
|
|
436
|
+
category: l.merchant === undefined ? (l.category ?? null) : null,
|
|
437
|
+
monthly: l.monthly,
|
|
438
|
+
})));
|
|
317
439
|
const unknown = lines.filter((l) => l.merchant.source === 'none');
|
|
318
440
|
if (unknown.length > 0)
|
|
319
441
|
return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
|
|
442
|
+
const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
|
|
320
443
|
const options = { ...settings(args), start: deps.today() };
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
444
|
+
const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
|
|
445
|
+
args.compareToWallet === true;
|
|
446
|
+
const source = wantsTheirs
|
|
447
|
+
? await sourceFor(args.cards, args.memberships)
|
|
448
|
+
: null;
|
|
449
|
+
if (typeof source === 'string')
|
|
450
|
+
return fail(source);
|
|
451
|
+
if (source && source.wallet !== null) {
|
|
452
|
+
const w = source.wallet;
|
|
325
453
|
const valued = args.valuation
|
|
326
454
|
? {
|
|
327
455
|
...w,
|
|
@@ -332,11 +460,11 @@ export function createEarnrateServer(deps) {
|
|
|
332
460
|
}
|
|
333
461
|
: w;
|
|
334
462
|
const result = simulateAdditions(catalog, valued, lines, options);
|
|
463
|
+
const text = formatAdditions(result, catalog, false);
|
|
335
464
|
return {
|
|
336
|
-
content: [
|
|
337
|
-
{ type: 'text', text: formatAdditions(result, catalog, false) },
|
|
338
|
-
],
|
|
465
|
+
content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
|
|
339
466
|
structuredContent: {
|
|
467
|
+
notes,
|
|
340
468
|
singles: [],
|
|
341
469
|
pairs: [],
|
|
342
470
|
current: result.current,
|
|
@@ -346,15 +474,14 @@ export function createEarnrateServer(deps) {
|
|
|
346
474
|
},
|
|
347
475
|
};
|
|
348
476
|
}
|
|
477
|
+
if (source)
|
|
478
|
+
notes.push(ALL_CARDS_NOTE);
|
|
349
479
|
const result = simulateCatalog(catalog, lines, options);
|
|
480
|
+
const text = formatCatalogSimulation(result, catalog, false);
|
|
350
481
|
return {
|
|
351
|
-
content: [
|
|
352
|
-
{
|
|
353
|
-
type: 'text',
|
|
354
|
-
text: formatCatalogSimulation(result, catalog, false),
|
|
355
|
-
},
|
|
356
|
-
],
|
|
482
|
+
content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
|
|
357
483
|
structuredContent: {
|
|
484
|
+
notes,
|
|
358
485
|
singles: result.singles.slice(0, 10),
|
|
359
486
|
pairs: result.pairs.slice(0, 5),
|
|
360
487
|
current: null,
|
|
@@ -362,7 +489,39 @@ export function createEarnrateServer(deps) {
|
|
|
362
489
|
},
|
|
363
490
|
};
|
|
364
491
|
});
|
|
365
|
-
server.registerTool('
|
|
492
|
+
server.registerTool('cheat_sheet', {
|
|
493
|
+
title: 'Cheat sheet',
|
|
494
|
+
description: "The card to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else. With no cards given and no saved wallet, it picks from every card earnrate knows (`scope: all cards`, each row noting the card's fee and conditions). With cards named in the conversation or a saved wallet, it picks from the user's own cards, with caps, picks and activations as they are. For one specific store, call best_card_for_purchase instead. Pass on the notes.",
|
|
495
|
+
inputSchema: z.object({
|
|
496
|
+
date: DateInput,
|
|
497
|
+
cards: NamedCardsInput,
|
|
498
|
+
memberships: NamedMembershipsInput,
|
|
499
|
+
}),
|
|
500
|
+
outputSchema: CheatSheetSchema,
|
|
501
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
502
|
+
}, async (args) => {
|
|
503
|
+
const source = await sourceFor(args.cards, args.memberships);
|
|
504
|
+
if (typeof source === 'string')
|
|
505
|
+
return fail(source);
|
|
506
|
+
const date = args.date ?? deps.today();
|
|
507
|
+
if (source.wallet === null) {
|
|
508
|
+
const sheet = catalogCheatSheet(catalog, date, {
|
|
509
|
+
...settings({}),
|
|
510
|
+
memberships: args.memberships ?? [],
|
|
511
|
+
});
|
|
512
|
+
const withNote = { ...sheet, notes: [ALL_CARDS_NOTE] };
|
|
513
|
+
return {
|
|
514
|
+
content: [{ type: 'text', text: formatCheatSheet(withNote) }],
|
|
515
|
+
structuredContent: withNote,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const sheet = cheatSheet(catalog, source.wallet, date);
|
|
519
|
+
return {
|
|
520
|
+
content: [{ type: 'text', text: formatCheatSheet(sheet) }],
|
|
521
|
+
structuredContent: sheet,
|
|
522
|
+
};
|
|
523
|
+
});
|
|
524
|
+
walletTools.push(server.registerTool('record_purchase', {
|
|
366
525
|
title: 'Record a purchase',
|
|
367
526
|
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 wallet file. 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.",
|
|
368
527
|
inputSchema: z.object({
|
|
@@ -386,7 +545,7 @@ export function createEarnrateServer(deps) {
|
|
|
386
545
|
},
|
|
387
546
|
// Queued per wallet file: parallel calls would otherwise each read the
|
|
388
547
|
// old usage and the last write would drop the others' spend.
|
|
389
|
-
(args) =>
|
|
548
|
+
(args) => (async () => {
|
|
390
549
|
const { cardId, merchant, amount, channel, date, category } = args;
|
|
391
550
|
const w = await wallet();
|
|
392
551
|
if (typeof w === 'string')
|
|
@@ -402,23 +561,10 @@ export function createEarnrateServer(deps) {
|
|
|
402
561
|
date: date ?? deps.today(),
|
|
403
562
|
};
|
|
404
563
|
const resolved = await resolve(merchant, purchase.channel, category ?? null);
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
: null;
|
|
410
|
-
const bonus = status &&
|
|
411
|
-
status.spend !== null &&
|
|
412
|
-
(status.remaining ?? 0) > 0 &&
|
|
413
|
-
countsTowardBonus(status, purchase.date)
|
|
414
|
-
? {
|
|
415
|
-
cardId,
|
|
416
|
-
before: status.spent,
|
|
417
|
-
after: Math.round((status.spent + amount) * 100) / 100,
|
|
418
|
-
spend: status.spend,
|
|
419
|
-
deadline: status.deadline,
|
|
420
|
-
}
|
|
421
|
-
: null;
|
|
564
|
+
const result = await deps.store.record(purchase, resolved.merchant);
|
|
565
|
+
if (!result.ok)
|
|
566
|
+
return fail(`Couldn't record it: ${result.error}`);
|
|
567
|
+
const { cap: booking, bonus } = result;
|
|
422
568
|
if (!booking && !bonus) {
|
|
423
569
|
const output = {
|
|
424
570
|
recorded: false,
|
|
@@ -431,15 +577,6 @@ export function createEarnrateServer(deps) {
|
|
|
431
577
|
structuredContent: output,
|
|
432
578
|
};
|
|
433
579
|
}
|
|
434
|
-
try {
|
|
435
|
-
if (booking)
|
|
436
|
-
await writeCapBooking(deps.walletFile, booking);
|
|
437
|
-
if (bonus)
|
|
438
|
-
await writeBonusSpent(deps.walletFile, cardId, bonus.after);
|
|
439
|
-
}
|
|
440
|
-
catch (e) {
|
|
441
|
-
return fail(`Couldn't record it: ${e instanceof Error ? e.message : String(e)}`);
|
|
442
|
-
}
|
|
443
580
|
const messages = [];
|
|
444
581
|
if (booking) {
|
|
445
582
|
const left = Math.max(0, booking.capAmount - booking.after);
|
|
@@ -466,7 +603,7 @@ export function createEarnrateServer(deps) {
|
|
|
466
603
|
],
|
|
467
604
|
structuredContent: output,
|
|
468
605
|
};
|
|
469
|
-
}));
|
|
606
|
+
})()));
|
|
470
607
|
const CardId = z
|
|
471
608
|
.string()
|
|
472
609
|
.min(1)
|
|
@@ -490,6 +627,24 @@ export function createEarnrateServer(deps) {
|
|
|
490
627
|
.array(z.string())
|
|
491
628
|
.optional()
|
|
492
629
|
.describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
|
|
630
|
+
bonus: z
|
|
631
|
+
.object({
|
|
632
|
+
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
|
633
|
+
spent: z
|
|
634
|
+
.number()
|
|
635
|
+
.nonnegative()
|
|
636
|
+
.optional()
|
|
637
|
+
.describe('Dollars already spent toward the bonus. Default 0.'),
|
|
638
|
+
spend: z
|
|
639
|
+
.number()
|
|
640
|
+
.positive()
|
|
641
|
+
.optional()
|
|
642
|
+
.describe("Dollars of spending the bonus needs, only if the user's offer differs from the published one."),
|
|
643
|
+
by: IsoDate.optional().describe("The deadline, YYYY-MM-DD, only if the user's offer differs from the published one."),
|
|
644
|
+
})
|
|
645
|
+
.nullable()
|
|
646
|
+
.optional()
|
|
647
|
+
.describe("A welcome bonus the user is working toward, so purchases recorded on this card count toward it and answers can favor it. Ask when they opened the account; spending needed and the deadline default to the card's published offer. null stops tracking it."),
|
|
493
648
|
};
|
|
494
649
|
// A card change reports what that card still needs; other changes don't.
|
|
495
650
|
function changed(result, message, cardChange = true) {
|
|
@@ -522,6 +677,22 @@ export function createEarnrateServer(deps) {
|
|
|
522
677
|
? {}
|
|
523
678
|
: { anniversary: args.anniversary }),
|
|
524
679
|
...(args.activated === undefined ? {} : { activated: args.activated }),
|
|
680
|
+
...(args.bonus === undefined
|
|
681
|
+
? {}
|
|
682
|
+
: {
|
|
683
|
+
bonus: args.bonus === null
|
|
684
|
+
? null
|
|
685
|
+
: {
|
|
686
|
+
opened: args.bonus.opened,
|
|
687
|
+
...(args.bonus.spent === undefined
|
|
688
|
+
? {}
|
|
689
|
+
: { spent: args.bonus.spent }),
|
|
690
|
+
...(args.bonus.spend === undefined
|
|
691
|
+
? {}
|
|
692
|
+
: { spend: args.bonus.spend }),
|
|
693
|
+
...(args.bonus.by === undefined ? {} : { by: args.bonus.by }),
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
525
696
|
});
|
|
526
697
|
const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
527
698
|
server.registerTool('search_cards', {
|
|
@@ -536,7 +707,7 @@ export function createEarnrateServer(deps) {
|
|
|
536
707
|
outputSchema: CardSearchSchema,
|
|
537
708
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
538
709
|
}, async ({ query }) => {
|
|
539
|
-
const held = await
|
|
710
|
+
const held = await deps.store.load().catch(() => null);
|
|
540
711
|
const result = searchCards(catalog, query ?? '', held, deps.today());
|
|
541
712
|
const header = result.exact
|
|
542
713
|
? ''
|
|
@@ -553,7 +724,7 @@ export function createEarnrateServer(deps) {
|
|
|
553
724
|
structuredContent: result,
|
|
554
725
|
};
|
|
555
726
|
});
|
|
556
|
-
server.registerTool('add_card', {
|
|
727
|
+
walletTools.push(server.registerTool('add_card', {
|
|
557
728
|
title: 'Add a card',
|
|
558
729
|
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.",
|
|
559
730
|
inputSchema: z.object({ cardId: CardId, ...cardFields }),
|
|
@@ -564,11 +735,11 @@ export function createEarnrateServer(deps) {
|
|
|
564
735
|
idempotentHint: false,
|
|
565
736
|
openWorldHint: false,
|
|
566
737
|
},
|
|
567
|
-
},
|
|
568
|
-
const result = await addCard(
|
|
569
|
-
return changed(result, `Added ${cardName(args.cardId)} (${args.cardId})${result.ok && result.created ? `, starting a new wallet at ${deps.
|
|
738
|
+
}, async (args) => {
|
|
739
|
+
const result = await deps.store.addCard(cardInput(args), deps.today());
|
|
740
|
+
return changed(result, `Added ${cardName(args.cardId)} (${args.cardId})${result.ok && result.created ? (deps.savedWallets === 'account' ? ', the first card in your saved wallet' : `, starting a new wallet at ${deps.store.where}`) : ''}.`);
|
|
570
741
|
}));
|
|
571
|
-
server.registerTool('update_card', {
|
|
742
|
+
walletTools.push(server.registerTool('update_card', {
|
|
572
743
|
title: 'Update a card',
|
|
573
744
|
description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary or activated categories. Picks for the current period are merged with the ones already saved. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+.',
|
|
574
745
|
inputSchema: z.object({ cardId: CardId, ...cardFields }),
|
|
@@ -579,8 +750,8 @@ export function createEarnrateServer(deps) {
|
|
|
579
750
|
idempotentHint: true,
|
|
580
751
|
openWorldHint: false,
|
|
581
752
|
},
|
|
582
|
-
},
|
|
583
|
-
server.registerTool('remove_card', {
|
|
753
|
+
}, async (args) => changed(await deps.store.updateCard(cardInput(args), deps.today()), `Updated ${cardName(args.cardId)} (${args.cardId}).`)));
|
|
754
|
+
walletTools.push(server.registerTool('remove_card', {
|
|
584
755
|
title: 'Remove a card',
|
|
585
756
|
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.",
|
|
586
757
|
inputSchema: z.object({ cardId: CardId }),
|
|
@@ -591,8 +762,8 @@ export function createEarnrateServer(deps) {
|
|
|
591
762
|
idempotentHint: true,
|
|
592
763
|
openWorldHint: false,
|
|
593
764
|
},
|
|
594
|
-
}, ({ cardId }) =>
|
|
595
|
-
server.registerTool('set_memberships', {
|
|
765
|
+
}, async ({ cardId }) => changed(await deps.store.removeCard(cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
|
|
766
|
+
walletTools.push(server.registerTool('set_memberships', {
|
|
596
767
|
title: 'Set memberships',
|
|
597
768
|
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. Replaces the whole list, so include the ones to keep.",
|
|
598
769
|
inputSchema: z.object({
|
|
@@ -607,8 +778,76 @@ export function createEarnrateServer(deps) {
|
|
|
607
778
|
idempotentHint: true,
|
|
608
779
|
openWorldHint: false,
|
|
609
780
|
},
|
|
610
|
-
},
|
|
781
|
+
}, async (args) => changed(await deps.store.setMemberships(args.memberships), args.memberships.length > 0
|
|
611
782
|
? `Memberships set to ${args.memberships.join(', ')}.`
|
|
612
783
|
: 'Memberships cleared.', false)));
|
|
784
|
+
if (deps.savedWallets === 'account') {
|
|
785
|
+
const store = deps.store;
|
|
786
|
+
server.registerTool('export_wallet', {
|
|
787
|
+
title: 'Export the wallet',
|
|
788
|
+
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.',
|
|
789
|
+
inputSchema: z.object({}),
|
|
790
|
+
outputSchema: z.object({ yaml: z.string() }),
|
|
791
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
792
|
+
}, async () => {
|
|
793
|
+
const w = await wallet();
|
|
794
|
+
if (typeof w === 'string')
|
|
795
|
+
return fail(w);
|
|
796
|
+
const yaml = walletYaml(w);
|
|
797
|
+
return {
|
|
798
|
+
content: [{ type: 'text', text: yaml }],
|
|
799
|
+
structuredContent: { yaml },
|
|
800
|
+
};
|
|
801
|
+
});
|
|
802
|
+
server.registerTool('import_wallet', {
|
|
803
|
+
title: 'Import a wallet',
|
|
804
|
+
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. Confirm with the user first if they already have cards saved.',
|
|
805
|
+
inputSchema: z.object({
|
|
806
|
+
yaml: z
|
|
807
|
+
.string()
|
|
808
|
+
.min(1)
|
|
809
|
+
.max(100_000)
|
|
810
|
+
.describe('The wallet.yaml text, as the user gave it.'),
|
|
811
|
+
}),
|
|
812
|
+
outputSchema: WalletChangeSchema,
|
|
813
|
+
annotations: {
|
|
814
|
+
readOnlyHint: false,
|
|
815
|
+
destructiveHint: true,
|
|
816
|
+
idempotentHint: true,
|
|
817
|
+
openWorldHint: false,
|
|
818
|
+
},
|
|
819
|
+
}, async ({ yaml }) => {
|
|
820
|
+
const parsed = parseWalletYaml(yaml, catalog);
|
|
821
|
+
if (typeof parsed === 'string')
|
|
822
|
+
return fail(parsed);
|
|
823
|
+
return changed(await store.replace(parsed), `Imported a wallet with ${String(parsed.cards.length)} ${parsed.cards.length === 1 ? 'card' : 'cards'}.`, false);
|
|
824
|
+
});
|
|
825
|
+
server.registerTool('delete_wallet', {
|
|
826
|
+
title: 'Delete the wallet',
|
|
827
|
+
description: 'Deletes the saved wallet now and for good, and signs out every app using it. Only when the user asks to delete it; confirm first, and offer export_wallet to keep a copy.',
|
|
828
|
+
inputSchema: z.object({}),
|
|
829
|
+
outputSchema: z.object({ deleted: z.boolean() }),
|
|
830
|
+
annotations: {
|
|
831
|
+
readOnlyHint: false,
|
|
832
|
+
destructiveHint: true,
|
|
833
|
+
idempotentHint: true,
|
|
834
|
+
openWorldHint: false,
|
|
835
|
+
},
|
|
836
|
+
}, async () => {
|
|
837
|
+
await store.erase();
|
|
838
|
+
return {
|
|
839
|
+
content: [
|
|
840
|
+
{
|
|
841
|
+
type: 'text',
|
|
842
|
+
text: 'Deleted the saved wallet. Answers now cover every card, or the cards named in the conversation.',
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
structuredContent: { deleted: true },
|
|
846
|
+
};
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
if (deps.savedWallets === 'none')
|
|
850
|
+
for (const tool of walletTools)
|
|
851
|
+
tool.remove();
|
|
613
852
|
return server;
|
|
614
853
|
}
|