earnrate 0.2.14 → 0.2.16
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 +10 -10
- package/data/CHANGELOG.md +12 -0
- package/data/VERSION +1 -1
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +1 -1
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/dist/cli/args.js +30 -1
- package/dist/cli/import.js +14 -11
- package/dist/cli/run.js +10 -1
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +49 -16
- package/dist/engine/cheat-sheet.js +13 -10
- package/dist/engine/checklist.js +5 -10
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +7 -3
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +3 -7
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +19 -17
- package/dist/engine/record.js +1 -1
- package/dist/engine/rule-text.js +71 -23
- package/dist/engine/setup.js +4 -0
- package/dist/engine/text.js +23 -6
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +32 -23
- package/dist/format/card-text.js +6 -5
- package/dist/format/cards.js +2 -2
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +2 -2
- package/dist/format/wallet.js +5 -5
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +11 -3
- package/dist/mcp/schemas.js +2 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +50 -42
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +205 -148
- package/dist/mcp/wallet-api.js +78 -0
- package/dist/schema/card.js +6 -10
- package/package.json +2 -1
package/dist/format/card-text.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { earnText } from "../engine/text.js";
|
|
2
1
|
// Card names, fees and requirements as earnrate.dev and its static search
|
|
3
2
|
// pages show them. Node-free: the site bundles it.
|
|
4
3
|
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
5
|
-
import {
|
|
4
|
+
import { capSentence, earnText, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
|
|
6
5
|
// An issuer's name as people know it, from data/issuers.yaml.
|
|
7
6
|
export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
8
7
|
export function cardText(catalog) {
|
|
@@ -16,9 +15,11 @@ export function cardText(catalog) {
|
|
|
16
15
|
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
|
|
17
16
|
.replace(/ Card by /, ' by '));
|
|
18
17
|
}
|
|
19
|
-
|
|
18
|
+
// `membership: false` leaves out a membership the card needs, for lists
|
|
19
|
+
// whose own conditions say it only when it applies.
|
|
20
|
+
function feeLabel(id, { membership = true } = {}) {
|
|
20
21
|
const card = cards.get(id);
|
|
21
|
-
const m = card?.requires_membership;
|
|
22
|
+
const m = membership ? card?.requires_membership : undefined;
|
|
22
23
|
const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
|
|
23
24
|
const fee = card?.annual_fee;
|
|
24
25
|
if (fee === undefined)
|
|
@@ -42,7 +43,7 @@ export function cardText(catalog) {
|
|
|
42
43
|
if (until !== null)
|
|
43
44
|
lines.push(`This rate runs through ${until}`);
|
|
44
45
|
if (r.cap)
|
|
45
|
-
lines.push(
|
|
46
|
+
lines.push(capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency)));
|
|
46
47
|
return lines;
|
|
47
48
|
}
|
|
48
49
|
const memberName = (id) => membershipName(catalog, id);
|
package/dist/format/cards.js
CHANGED
|
@@ -5,7 +5,7 @@ export function formatCardInfo(catalog, card) {
|
|
|
5
5
|
? ''
|
|
6
6
|
: card.annualFee === 0
|
|
7
7
|
? ', no annual fee'
|
|
8
|
-
: `, ${money(card.annualFee)} a year`;
|
|
8
|
+
: `, ${money(card.annualFee)} a year${card.firstYearFee === null ? '' : `, ${card.firstYearFee === 0 ? 'waived' : money(card.firstYearFee)} the first year`}`;
|
|
9
9
|
const needs = [
|
|
10
10
|
...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
|
|
11
11
|
...(card.requiresMembership
|
|
@@ -24,7 +24,7 @@ export function formatCardInfo(catalog, card) {
|
|
|
24
24
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
25
25
|
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
26
26
|
...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
|
|
27
|
-
...card.credits.map((c) => `
|
|
27
|
+
...card.credits.map((c) => `statement credit ${c.id}: ${c.text}`),
|
|
28
28
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
29
29
|
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
30
30
|
];
|
package/dist/format/explore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
-
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
2
|
+
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
|
|
3
3
|
const membershipFees = (memberships) => memberships
|
|
4
4
|
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
5
|
.join('');
|
|
@@ -51,7 +51,7 @@ export function formatExploration(r, catalog, all) {
|
|
|
51
51
|
return `${lines.join('\n')}\n`;
|
|
52
52
|
}
|
|
53
53
|
function picksText(picks) {
|
|
54
|
-
const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words)
|
|
54
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${listed(options.map(words))} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
|
|
55
55
|
return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
|
|
56
56
|
}
|
|
57
57
|
function cardNamer(catalog) {
|
|
@@ -25,8 +25,8 @@ function compact(wallet) {
|
|
|
25
25
|
cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
-
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
|
|
29
|
-
// date, not a string.
|
|
28
|
+
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
|
|
29
|
+
// 2026-10-15 as a date, not a string.
|
|
30
30
|
function quotedYaml(value) {
|
|
31
31
|
const doc = new Document(value);
|
|
32
32
|
visit(doc, {
|
package/dist/format/wallet.js
CHANGED
|
@@ -48,10 +48,10 @@ export function formatWalletSummary(summary) {
|
|
|
48
48
|
else if (c.intro?.state === 'no-date')
|
|
49
49
|
lines.push(` Has ${introAdjective(c.intro.days)} rates for new accounts; give its opening date if it opened ${introSince(c.intro.days)}.`);
|
|
50
50
|
else if (c.opened !== null)
|
|
51
|
-
lines.push(` Opened ${c.opened}.`);
|
|
51
|
+
lines.push(` Opened ${shortDate(c.opened)}.`);
|
|
52
52
|
const b = c.bonus;
|
|
53
53
|
if (b?.state === 'open' && b.spend !== null)
|
|
54
|
-
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline
|
|
54
|
+
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline === null ? 'by its deadline' : shortDate(b.deadline)}.`);
|
|
55
55
|
else if (b)
|
|
56
56
|
lines.push(` Welcome bonus: ${b.state}.`);
|
|
57
57
|
return lines.join('\n');
|
|
@@ -65,19 +65,19 @@ export function formatWalletSummary(summary) {
|
|
|
65
65
|
return [...cards, memberships, values].join('\n');
|
|
66
66
|
}
|
|
67
67
|
export function formatBooking(amount, name, // the card's short name
|
|
68
|
-
cap, bonus, milestones = [], credit = null) {
|
|
68
|
+
cap, bonus, milestones = [], credit = null, capLabel = null) {
|
|
69
69
|
const messages = [];
|
|
70
70
|
if (credit)
|
|
71
71
|
messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${possessive(name)} ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
|
|
72
72
|
if (cap) {
|
|
73
73
|
const left = Math.max(0, cap.capAmount - cap.after);
|
|
74
|
-
messages.push(`Recorded ${money(amount)} on ${name}
|
|
74
|
+
messages.push(`Recorded ${money(amount)} on ${name} toward its ${capLabel ?? cap.capKey} cap: ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
|
|
75
75
|
}
|
|
76
76
|
if (bonus) {
|
|
77
77
|
const left = Math.max(0, bonus.spend - bonus.after);
|
|
78
78
|
messages.push(left === 0
|
|
79
79
|
? `Welcome bonus spend met on ${name}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
80
|
-
: `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline
|
|
80
|
+
: `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline === null ? 'the deadline' : shortDate(bonus.deadline)}.`);
|
|
81
81
|
}
|
|
82
82
|
// Every milestone counts the same purchases, so one line covers them.
|
|
83
83
|
const first = milestones[0];
|
package/dist/import/analyze.js
CHANGED
|
@@ -4,7 +4,7 @@ import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
|
4
4
|
import { milestoneProgress } from "../engine/milestones.js";
|
|
5
5
|
import { periodKey } from "../engine/periods.js";
|
|
6
6
|
import { recommend } from "../engine/recommend.js";
|
|
7
|
-
import { applyBooking, capBooking } from "../engine/record.js";
|
|
7
|
+
import { applyBooking, applyCreditBooking, capBooking, creditBooking, } from "../engine/record.js";
|
|
8
8
|
import { roundCents } from "../engine/text.js";
|
|
9
9
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
10
10
|
import { cleanDescriptor } from "./statement.js";
|
|
@@ -55,6 +55,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
55
55
|
const missed = new Map();
|
|
56
56
|
const coding = new Map();
|
|
57
57
|
const bookings = [];
|
|
58
|
+
const credits = [];
|
|
58
59
|
let outsideCurrentPeriod = 0;
|
|
59
60
|
let alreadyImported = 0;
|
|
60
61
|
let purchases = 0;
|
|
@@ -145,6 +146,16 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
145
146
|
outsideCurrentPeriod++;
|
|
146
147
|
}
|
|
147
148
|
}
|
|
149
|
+
if (line.amount > 0 && !alreadyRecorded(entry, 'credit')) {
|
|
150
|
+
const credit = creditBooking(catalog, current, recorded, merchant);
|
|
151
|
+
const held = current.cards.find((c) => c.id === cardId);
|
|
152
|
+
if (credit &&
|
|
153
|
+
periodKey(credit.periodKind, today, held?.anniversary ?? null) ===
|
|
154
|
+
credit.period) {
|
|
155
|
+
credits.push({ ...entry, booking: credit });
|
|
156
|
+
current = applyCreditBooking(current, credit);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
148
159
|
if (line.amount < 0) {
|
|
149
160
|
refunds += -line.amount;
|
|
150
161
|
continue;
|
|
@@ -244,6 +255,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
244
255
|
.sort((a, b) => b.best - b.earned - (a.best - a.earned)),
|
|
245
256
|
coding: [...coding.values()].sort((a, b) => b.count - a.count),
|
|
246
257
|
bookings,
|
|
258
|
+
credits,
|
|
247
259
|
outsideCurrentPeriod,
|
|
248
260
|
alreadyImported,
|
|
249
261
|
bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
|
package/dist/mcp/context.js
CHANGED
|
@@ -72,9 +72,8 @@ export function makeContext(deps) {
|
|
|
72
72
|
}
|
|
73
73
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
74
74
|
// neither (null), answers cover every card earnrate knows.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// none.
|
|
75
|
+
// Saved point values and memberships apply to every answer, not just ones
|
|
76
|
+
// from saved cards. A wallet that can't be read has none.
|
|
78
77
|
async function saved() {
|
|
79
78
|
try {
|
|
80
79
|
const wallet = await deps.store.load();
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -49,6 +49,14 @@ const NamedCard = z.object({
|
|
|
49
49
|
.array(z.string())
|
|
50
50
|
.optional()
|
|
51
51
|
.describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
|
|
52
|
+
enrolled: z
|
|
53
|
+
.array(z.string())
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Statement credits the user has enrolled in with the issuer, as credit ids from search_cards. A credit that needs enrolling counts only when it's here. Ask; never assume."),
|
|
56
|
+
opened: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('The day the account opened (YYYY-MM-DD), for a card with a first-year rate. Optional; without it the card is ranked at its standard rates.'),
|
|
52
60
|
});
|
|
53
61
|
const cardFields = {
|
|
54
62
|
checkoutLabel: z
|
|
@@ -80,15 +88,15 @@ const cardFields = {
|
|
|
80
88
|
milestoneSpent: z
|
|
81
89
|
.record(z.string(), z.number().nonnegative())
|
|
82
90
|
.optional()
|
|
83
|
-
.describe('Dollars already spent on the card this year, for its spend
|
|
91
|
+
.describe('Dollars already spent on the card this year, for its spend bonuses (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
|
|
84
92
|
enrolled: z
|
|
85
93
|
.array(z.string())
|
|
86
94
|
.optional()
|
|
87
|
-
.describe('
|
|
95
|
+
.describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
|
|
88
96
|
creditUsed: z
|
|
89
97
|
.record(z.string(), z.number().nonnegative())
|
|
90
98
|
.optional()
|
|
91
|
-
.describe('Dollars of a
|
|
99
|
+
.describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
|
|
92
100
|
bonus: z
|
|
93
101
|
.object({
|
|
94
102
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -298,6 +298,7 @@ export const CardSearchSchema = z.object({
|
|
|
298
298
|
network: CardNetwork,
|
|
299
299
|
currency: z.string(),
|
|
300
300
|
annualFee: z.number().nullable(),
|
|
301
|
+
firstYearFee: z.number().nullable(),
|
|
301
302
|
foreignTransactionFee: z.number().nullable(),
|
|
302
303
|
business: z.boolean(),
|
|
303
304
|
onlyAt: z.array(z.string()).nullable(),
|
|
@@ -412,6 +413,7 @@ export const ExplorationSchema = z.object({
|
|
|
412
413
|
conditions: z.array(z.string()),
|
|
413
414
|
evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
|
|
414
415
|
withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
|
|
416
|
+
needsMembership: z.string().nullable(),
|
|
415
417
|
})),
|
|
416
418
|
excluded: z.array(ExcludedCardSchema),
|
|
417
419
|
assumptions: z.array(z.string()),
|
package/dist/mcp/server.js
CHANGED
|
@@ -6,7 +6,6 @@ import { registerPrompts } from "./prompts.js";
|
|
|
6
6
|
import { registerAccountTools } from "./tools/account.js";
|
|
7
7
|
import { registerAnswerTools } from "./tools/answers.js";
|
|
8
8
|
import { registerWalletTools } from "./tools/wallet.js";
|
|
9
|
-
// The tools that answer without a saved wallet.
|
|
10
9
|
export const ANSWER_TOOLS = [
|
|
11
10
|
'best_card_for_purchase',
|
|
12
11
|
'best_cards_for',
|
|
@@ -16,7 +15,6 @@ export const ANSWER_TOOLS = [
|
|
|
16
15
|
'resolve_merchant',
|
|
17
16
|
'search_cards',
|
|
18
17
|
];
|
|
19
|
-
// The tools that read or change a saved wallet.
|
|
20
18
|
export const SAVED_WALLET_TOOLS = [
|
|
21
19
|
'list_wallet',
|
|
22
20
|
'record_purchase',
|
|
@@ -5,22 +5,59 @@ import { listed } from "../../engine/text.js";
|
|
|
5
5
|
import { savedSettingsText } from "../../engine/wallet-summary.js";
|
|
6
6
|
import { fail } from "../context.js";
|
|
7
7
|
import { WalletChangeSchema } from "../schemas.js";
|
|
8
|
+
const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
|
|
9
|
+
// Everything a saved wallet holds, in words; empty when there's nothing.
|
|
10
|
+
const contentsOf = (catalog, w) => [
|
|
11
|
+
...(w.cards.length > 0
|
|
12
|
+
? [`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`]
|
|
13
|
+
: []),
|
|
14
|
+
...savedSettingsText(catalog, w),
|
|
15
|
+
];
|
|
16
|
+
const ImportInput = z.object({
|
|
17
|
+
yaml: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(100_000)
|
|
21
|
+
.describe('The wallet.yaml text, as the user gave it.'),
|
|
22
|
+
replace: z
|
|
23
|
+
.boolean()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('true to replace what the wallet already holds, after the user confirms.'),
|
|
26
|
+
});
|
|
27
|
+
export function importHandler(catalog, store, ctx) {
|
|
28
|
+
const contents = (w) => contentsOf(catalog, w);
|
|
29
|
+
return {
|
|
30
|
+
input: ImportInput,
|
|
31
|
+
run: async ({ yaml, replace }) => {
|
|
32
|
+
const parsed = parseWalletYaml(yaml, catalog);
|
|
33
|
+
if (typeof parsed === 'string')
|
|
34
|
+
return fail(parsed);
|
|
35
|
+
let current;
|
|
36
|
+
try {
|
|
37
|
+
current = await store.load();
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (replace !== true)
|
|
41
|
+
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.`);
|
|
42
|
+
current = null;
|
|
43
|
+
}
|
|
44
|
+
const before = current === null ? [] : contents(current);
|
|
45
|
+
if (before.length > 0 && replace !== true)
|
|
46
|
+
return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
|
|
47
|
+
return ctx.changed(await store.replace(parsed), before.length > 0
|
|
48
|
+
? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
|
|
49
|
+
: `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
8
53
|
// A saved wallet on the hosted server has no file to copy or delete, so
|
|
9
54
|
// these do it.
|
|
10
55
|
export function registerAccountTools(server, catalog, store, ctx) {
|
|
11
|
-
const
|
|
12
|
-
|
|
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
|
-
];
|
|
56
|
+
const contents = (w) => contentsOf(catalog, w);
|
|
57
|
+
const importWallet = importHandler(catalog, store, ctx);
|
|
21
58
|
server.registerTool('export_wallet', {
|
|
22
59
|
title: 'Export the wallet',
|
|
23
|
-
description: 'The saved wallet as a wallet.yaml file
|
|
60
|
+
description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in and used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
|
|
24
61
|
inputSchema: z.object({}),
|
|
25
62
|
outputSchema: z.object({ yaml: z.string() }),
|
|
26
63
|
annotations: {
|
|
@@ -42,17 +79,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
42
79
|
server.registerTool('import_wallet', {
|
|
43
80
|
title: 'Import a wallet',
|
|
44
81
|
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.',
|
|
45
|
-
inputSchema:
|
|
46
|
-
yaml: z
|
|
47
|
-
.string()
|
|
48
|
-
.min(1)
|
|
49
|
-
.max(100_000)
|
|
50
|
-
.describe('The wallet.yaml text, as the user gave it.'),
|
|
51
|
-
replace: z
|
|
52
|
-
.boolean()
|
|
53
|
-
.optional()
|
|
54
|
-
.describe('true to replace what the wallet already holds, after the user confirms.'),
|
|
55
|
-
}),
|
|
82
|
+
inputSchema: importWallet.input,
|
|
56
83
|
outputSchema: WalletChangeSchema,
|
|
57
84
|
annotations: {
|
|
58
85
|
title: 'Import a wallet',
|
|
@@ -61,26 +88,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
61
88
|
idempotentHint: true,
|
|
62
89
|
openWorldHint: false,
|
|
63
90
|
},
|
|
64
|
-
},
|
|
65
|
-
const parsed = parseWalletYaml(yaml, catalog);
|
|
66
|
-
if (typeof parsed === 'string')
|
|
67
|
-
return fail(parsed);
|
|
68
|
-
let current;
|
|
69
|
-
try {
|
|
70
|
-
current = await store.load();
|
|
71
|
-
}
|
|
72
|
-
catch (e) {
|
|
73
|
-
if (replace !== true)
|
|
74
|
-
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.`);
|
|
75
|
-
current = null;
|
|
76
|
-
}
|
|
77
|
-
const before = current === null ? [] : contents(current);
|
|
78
|
-
if (before.length > 0 && replace !== true)
|
|
79
|
-
return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
|
|
80
|
-
return ctx.changed(await store.replace(parsed), before.length > 0
|
|
81
|
-
? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
|
|
82
|
-
: `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
|
|
83
|
-
});
|
|
91
|
+
}, (args) => importWallet.run(args));
|
|
84
92
|
server.registerTool('delete_wallet', {
|
|
85
93
|
title: 'Delete the wallet',
|
|
86
94
|
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every 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.",
|
|
@@ -34,7 +34,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
34
34
|
const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
|
|
35
35
|
server.registerTool('best_card_for_purchase', {
|
|
36
36
|
title: 'Best card for a purchase',
|
|
37
|
-
description: "Which credit 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. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A
|
|
37
|
+
description: "Which credit 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. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
|
|
38
38
|
inputSchema: z.object({
|
|
39
39
|
merchant: Merchant,
|
|
40
40
|
amount: z
|
|
@@ -166,6 +166,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
166
166
|
.optional()
|
|
167
167
|
.describe('Include cards already in the wallet.'),
|
|
168
168
|
cards: NamedCards,
|
|
169
|
+
memberships: NamedMemberships,
|
|
169
170
|
}),
|
|
170
171
|
outputSchema: OfferComparisonSchema,
|
|
171
172
|
annotations: {
|
|
@@ -175,10 +176,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
175
176
|
openWorldHint: false,
|
|
176
177
|
},
|
|
177
178
|
}, async (args) => {
|
|
178
|
-
const wallet = await ctx.cardsFor(args.cards,
|
|
179
|
+
const wallet = await ctx.cardsFor(args.cards, args.memberships);
|
|
179
180
|
if (typeof wallet === 'string')
|
|
180
181
|
return fail(wallet);
|
|
181
|
-
const
|
|
182
|
+
const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
|
|
183
|
+
const base = args.memberships
|
|
184
|
+
? { ...found, memberships: args.memberships }
|
|
185
|
+
: found;
|
|
182
186
|
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
183
187
|
kind: args.kind ?? 'any',
|
|
184
188
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
@@ -391,7 +395,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
391
395
|
});
|
|
392
396
|
server.registerTool('search_cards', {
|
|
393
397
|
title: 'Search cards',
|
|
394
|
-
description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet,
|
|
398
|
+
description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee (and any first-year waiver), foreign transaction fee, whether it\'s already in the wallet, what it needs set up (category picks and their options, memberships, activations), its yearly spend bonuses, first-year rates and statement credits, each with the id update_card takes. Leave the query out to list every card. US cards only.',
|
|
395
399
|
inputSchema: z.object({
|
|
396
400
|
query: z
|
|
397
401
|
.string()
|