earnrate 0.2.0 → 0.2.2
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 +23 -20
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -3
- package/data/cards/amex-blue-cash-preferred.yaml +5 -7
- package/data/cards/amex-gold.yaml +1 -0
- package/data/cards/amex-platinum.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
- package/data/cards/chase-freedom-flex.yaml +5 -1
- package/data/cards/chase-southwest-plus.yaml +5 -5
- package/data/cards/chase-southwest-premier-business.yaml +8 -5
- package/data/cards/chase-southwest-premier.yaml +6 -6
- package/data/cards/chase-southwest-priority.yaml +6 -6
- package/data/cards/citi-aadvantage-business.yaml +6 -6
- package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +64 -51
- package/dist/cli/cheatsheet.js +9 -12
- package/dist/cli/explore.js +28 -27
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +7 -6
- package/dist/cli/run.js +17 -13
- package/dist/cli/wallet.js +7 -6
- package/dist/data/file-store.js +54 -15
- package/dist/data/load.js +1 -1
- package/dist/data/store.js +0 -16
- package/dist/data/wallet-edit.js +78 -3
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +2 -2
- package/dist/engine/evaluate.js +56 -13
- package/dist/engine/explore.js +9 -22
- package/dist/engine/offers.js +10 -22
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +14 -53
- package/dist/engine/record.js +2 -7
- package/dist/engine/setup.js +2 -2
- package/dist/engine/simulate.js +12 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +5 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +46 -19
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +1 -1
- package/dist/format/explore.js +3 -13
- package/dist/format/offers.js +15 -18
- package/dist/{cli/format.js → format/recommendation.js} +2 -6
- package/dist/format/wallet.js +57 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +155 -0
- package/dist/mcp/schemas.js +2 -1
- package/dist/mcp/server.js +15 -819
- package/dist/mcp/tools/account.js +78 -0
- package/dist/mcp/tools/answers.js +338 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/dist/schema/card.js +4 -0
- package/package.json +3 -2
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /package/dist/{cli → format}/cards.js +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { walletProblems } from "../data/integrity.js";
|
|
2
|
+
import { EMPTY_WALLET, walletFromCards } from "../engine/transient.js";
|
|
3
|
+
import { formatNotes } from "../format/cards.js";
|
|
4
|
+
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
5
|
+
import { defined } from "./inputs.js";
|
|
6
|
+
export const fail = (text) => ({
|
|
7
|
+
content: [{ type: 'text', text }],
|
|
8
|
+
isError: true,
|
|
9
|
+
});
|
|
10
|
+
export 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.";
|
|
11
|
+
// What every tool shares: the saved wallet, merchant resolution, and which
|
|
12
|
+
// cards an answer is about.
|
|
13
|
+
export function makeContext(deps) {
|
|
14
|
+
const { catalog } = deps;
|
|
15
|
+
const theWallet = deps.savedWallets === 'file'
|
|
16
|
+
? `the wallet at ${deps.store.where}`
|
|
17
|
+
: 'the saved wallet';
|
|
18
|
+
const noWallet = `There's no saved wallet yet${deps.savedWallets === 'file' ? ` (${deps.store.where})` : ''}. Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`;
|
|
19
|
+
// Read on every call, so edits to the wallet apply without a restart.
|
|
20
|
+
async function wallet() {
|
|
21
|
+
let loaded;
|
|
22
|
+
try {
|
|
23
|
+
const found = await deps.store.load();
|
|
24
|
+
if (found === null)
|
|
25
|
+
return noWallet;
|
|
26
|
+
loaded = found;
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return `Couldn't read ${theWallet}: ${e instanceof Error ? e.message : String(e)}`;
|
|
30
|
+
}
|
|
31
|
+
const problems = walletProblems(loaded, catalog);
|
|
32
|
+
return problems.length > 0
|
|
33
|
+
? `${theWallet[0]?.toUpperCase() ?? ''}${theWallet.slice(1)} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
|
|
34
|
+
: loaded;
|
|
35
|
+
}
|
|
36
|
+
// No saved wallet is the normal state, not a failure: it reads as an
|
|
37
|
+
// empty one, with a note on how to start one.
|
|
38
|
+
async function readableWallet() {
|
|
39
|
+
const w = await wallet();
|
|
40
|
+
if (typeof w !== 'string')
|
|
41
|
+
return { wallet: w, start: null };
|
|
42
|
+
return w === noWallet ? { wallet: EMPTY_WALLET, start: w } : w;
|
|
43
|
+
}
|
|
44
|
+
// The caller's category is free and comes before the paid Claude fallback;
|
|
45
|
+
// neither ever overrides a merchant earnrate already knows.
|
|
46
|
+
async function resolve(merchant, channel, category) {
|
|
47
|
+
const known = resolveMerchant(catalog, merchant, channel);
|
|
48
|
+
if (known.source !== 'none' || category !== null || !deps.llm) {
|
|
49
|
+
return { merchant: withCallerCategory(known, category), notices: [] };
|
|
50
|
+
}
|
|
51
|
+
return deps.llm.resolve(catalog, merchant, channel);
|
|
52
|
+
}
|
|
53
|
+
// Cards named in the conversation come first, then the saved wallet; with
|
|
54
|
+
// neither (null), answers cover every card earnrate knows.
|
|
55
|
+
async function cardsFor(cards, memberships) {
|
|
56
|
+
if (cards && cards.length > 0)
|
|
57
|
+
return walletFromCards(catalog, {
|
|
58
|
+
cards: cards.map(({ cardId, ...rest }) => ({
|
|
59
|
+
id: cardId,
|
|
60
|
+
...defined(rest),
|
|
61
|
+
})),
|
|
62
|
+
...(memberships ? { memberships } : {}),
|
|
63
|
+
}, deps.today());
|
|
64
|
+
const read = await readableWallet();
|
|
65
|
+
if (typeof read === 'string')
|
|
66
|
+
return read;
|
|
67
|
+
return read.start === null ? read.wallet : null;
|
|
68
|
+
}
|
|
69
|
+
// A card change reports what that card still needs; other changes don't.
|
|
70
|
+
function changed(result, message, { notes = true } = {}) {
|
|
71
|
+
if (!result.ok)
|
|
72
|
+
return fail(result.error);
|
|
73
|
+
const output = {
|
|
74
|
+
message,
|
|
75
|
+
created: result.created,
|
|
76
|
+
cardIds: result.wallet.cards.map((c) => c.id),
|
|
77
|
+
memberships: result.wallet.memberships,
|
|
78
|
+
notes: result.notes,
|
|
79
|
+
};
|
|
80
|
+
const text = [
|
|
81
|
+
message,
|
|
82
|
+
`Wallet: ${output.cardIds.join(', ')}.`,
|
|
83
|
+
...(notes ? [formatNotes(result.notes)] : []),
|
|
84
|
+
].join('\n');
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text', text }],
|
|
87
|
+
structuredContent: output,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
91
|
+
return { wallet, readableWallet, resolve, cardsFor, changed, cardName };
|
|
92
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
3
|
+
import { VALUATION_PROFILES } from "../engine/profiles.js";
|
|
4
|
+
import { knownMemberships } from "../engine/setup.js";
|
|
5
|
+
import { IsoDate } from "../schema/common.js";
|
|
6
|
+
export const Merchant = z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com".');
|
|
10
|
+
export const ChannelInput = z
|
|
11
|
+
.enum(['online', 'in_store'])
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
|
|
14
|
+
export const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
|
|
15
|
+
export const CardId = z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.describe('The card id from search_cards, e.g. chase-sapphire-preferred.');
|
|
19
|
+
// The filters every catalog-wide answer takes.
|
|
20
|
+
const OfferFilters = {
|
|
21
|
+
kind: z
|
|
22
|
+
.enum(['any', 'cash', 'points'])
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('cash: cash back cards only. points: points and miles cards only.'),
|
|
25
|
+
noAnnualFee: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe('Only cards with no annual fee.'),
|
|
29
|
+
business: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe('Business cards instead of personal ones.'),
|
|
33
|
+
};
|
|
34
|
+
const NamedCard = z.object({
|
|
35
|
+
cardId: z.string().min(1).describe('A card id from search_cards.'),
|
|
36
|
+
picks: z
|
|
37
|
+
.record(z.string(), z.array(z.string()).min(1))
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Bonus categories the user picked, for cards that have them: choice id → option ids, from search_cards. Ask; don't guess."),
|
|
40
|
+
activated: z
|
|
41
|
+
.array(z.string())
|
|
42
|
+
.optional()
|
|
43
|
+
.describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
|
|
44
|
+
});
|
|
45
|
+
const cardFields = {
|
|
46
|
+
checkoutLabel: z
|
|
47
|
+
.string()
|
|
48
|
+
.min(1)
|
|
49
|
+
.nullable()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional; null goes back to the card\'s own name.'),
|
|
52
|
+
picks: z
|
|
53
|
+
.record(z.string(), z.array(z.string()).min(1))
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('For cards where the cardholder picks bonus categories: choice id → option ids, from search_cards, e.g. {"five-percent": ["tv-internet-streaming", "fast-food"]}. Saved for the current period. Ask the user; don\'t guess.'),
|
|
56
|
+
anniversary: z
|
|
57
|
+
.string()
|
|
58
|
+
.regex(/^\d{2}-\d{2}$/)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
|
|
61
|
+
activated: z
|
|
62
|
+
.array(z.string())
|
|
63
|
+
.optional()
|
|
64
|
+
.describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
|
|
65
|
+
capSpent: z
|
|
66
|
+
.record(z.string(), z.number().nonnegative())
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('Dollars already spent this period on a capped bonus, for a card the user starts tracking partway through: cap key (as list_wallet shows it) → dollars, e.g. {"five-percent-quarterly": 400}. Ask; recorded purchases add to it from then on.'),
|
|
69
|
+
bonus: z
|
|
70
|
+
.object({
|
|
71
|
+
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
|
72
|
+
spent: z
|
|
73
|
+
.number()
|
|
74
|
+
.nonnegative()
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('Dollars already spent toward the bonus. Default 0.'),
|
|
77
|
+
spend: z
|
|
78
|
+
.number()
|
|
79
|
+
.positive()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Dollars of spending the bonus needs, only if the user's offer differs from the published one."),
|
|
82
|
+
by: IsoDate.optional().describe("The deadline, YYYY-MM-DD, only if the user's offer differs from the published one."),
|
|
83
|
+
})
|
|
84
|
+
.nullable()
|
|
85
|
+
.optional()
|
|
86
|
+
.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."),
|
|
87
|
+
};
|
|
88
|
+
export const CardChange = z.object({ cardId: CardId, ...cardFields });
|
|
89
|
+
// Drops keys whose value is undefined, which optional zod fields produce and
|
|
90
|
+
// exactOptionalPropertyTypes won't accept.
|
|
91
|
+
export function defined(value) {
|
|
92
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
|
|
93
|
+
}
|
|
94
|
+
export function cardInput({ cardId, bonus, ...rest }) {
|
|
95
|
+
return {
|
|
96
|
+
id: cardId,
|
|
97
|
+
...defined(rest),
|
|
98
|
+
...(bonus === undefined ? {} : { bonus: bonus && defined(bonus) }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Inputs that list the catalog's categories, issuers and memberships.
|
|
102
|
+
export function makeInputs(catalog) {
|
|
103
|
+
// A parent with subcategories and no merchant codes of its own ("Grocery")
|
|
104
|
+
// isn't something a purchase codes as, so it's left out.
|
|
105
|
+
const categories = catalog.categories.filter((c) => c.mccs.length > 0 ||
|
|
106
|
+
!catalog.categories.some((k) => k.id.startsWith(`${c.id}.`)));
|
|
107
|
+
const categoryIds = categories.map((c) => c.id);
|
|
108
|
+
const categoryList = categories.map((c) => `${c.id} (${c.name})`).join(', ');
|
|
109
|
+
const membershipIds = knownMemberships(catalog);
|
|
110
|
+
const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
111
|
+
const CategoryInput = z
|
|
112
|
+
.enum(categoryIds)
|
|
113
|
+
.optional()
|
|
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
|
+
const CatalogCategory = z
|
|
116
|
+
.enum(categoryIds)
|
|
117
|
+
.describe(`A spending category: ${categoryList}.`);
|
|
118
|
+
const Memberships = z.array(z.enum(membershipIds));
|
|
119
|
+
const NamedCards = z
|
|
120
|
+
.array(NamedCard)
|
|
121
|
+
.optional()
|
|
122
|
+
.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.");
|
|
124
|
+
const CatalogFilters = {
|
|
125
|
+
...OfferFilters,
|
|
126
|
+
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."),
|
|
128
|
+
valuation: z
|
|
129
|
+
.enum(['flat', 'points-heavy'])
|
|
130
|
+
.optional()
|
|
131
|
+
.describe('flat (default): points at their cash-out value, about 1¢. points-heavy: Amex MR and Chase UR at 1.7¢, for people who transfer points to airlines and hotels.'),
|
|
132
|
+
};
|
|
133
|
+
const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
|
|
134
|
+
return {
|
|
135
|
+
CategoryInput,
|
|
136
|
+
CatalogCategory,
|
|
137
|
+
NamedCards,
|
|
138
|
+
NamedMemberships,
|
|
139
|
+
OfferFilters,
|
|
140
|
+
CatalogFilters,
|
|
141
|
+
AllMemberships,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// The catalog filters as exploreCards and simulate take them.
|
|
145
|
+
export const settings = (args) => ({
|
|
146
|
+
filters: {
|
|
147
|
+
...ALL_PERSONAL_CARDS,
|
|
148
|
+
kind: args.kind ?? 'any',
|
|
149
|
+
noAnnualFee: args.noAnnualFee ?? false,
|
|
150
|
+
business: args.business ?? false,
|
|
151
|
+
issuer: args.issuer ?? null,
|
|
152
|
+
},
|
|
153
|
+
memberships: args.memberships ?? [],
|
|
154
|
+
valuations: args.valuation ? VALUATION_PROFILES[args.valuation] : {},
|
|
155
|
+
});
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -42,7 +42,7 @@ const RankedCardSchema = z.object({
|
|
|
42
42
|
reasoning: z.array(z.string()),
|
|
43
43
|
warnings: z.array(z.string()),
|
|
44
44
|
});
|
|
45
|
-
|
|
45
|
+
const RecommendationSchema = z.object({
|
|
46
46
|
decision: z
|
|
47
47
|
.object({
|
|
48
48
|
cardId: z.string(),
|
|
@@ -161,6 +161,7 @@ export const OfferComparisonSchema = z.object({
|
|
|
161
161
|
spend: z.number(),
|
|
162
162
|
months: z.number(),
|
|
163
163
|
validTo: z.string().nullable(),
|
|
164
|
+
varies: z.boolean(), // the amount is the most the issuer shows
|
|
164
165
|
note: z.string().nullable(),
|
|
165
166
|
annualFee: z.number().nullable(),
|
|
166
167
|
firstYearFee: z.number().nullable(),
|