earnrate 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -18
- package/data/CHANGELOG.md +56 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +19 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -5
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +49 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
- package/data/cards/chase-ihg-one-rewards.yaml +8 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +16 -3
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
- package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/issuers.yaml +32 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/brand.js +1 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +10 -5
- package/dist/cli/explore.js +6 -13
- package/dist/cli/import.js +2 -2
- package/dist/cli/missed.js +65 -0
- package/dist/cli/offers.js +17 -7
- package/dist/cli/run.js +29 -17
- package/dist/cli/todo.js +62 -0
- package/dist/cli/wallet.js +9 -6
- package/dist/data/file-store.js +6 -7
- package/dist/data/integrity.js +4 -0
- package/dist/data/load.js +4 -1
- package/dist/data/store.js +6 -22
- package/dist/data/wallet-edit.js +38 -13
- package/dist/engine/cheat-sheet.js +14 -6
- package/dist/engine/checklist.js +152 -0
- package/dist/engine/compare.js +2 -0
- package/dist/engine/evaluate.js +16 -11
- package/dist/engine/explore.js +6 -38
- package/dist/engine/memberships.js +1 -1
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +18 -0
- package/dist/engine/recommend.js +21 -20
- package/dist/engine/rule-text.js +47 -0
- package/dist/engine/setup.js +14 -24
- package/dist/engine/simulate.js +7 -6
- package/dist/engine/text.js +13 -1
- package/dist/engine/wallet-change.js +10 -12
- package/dist/engine/wallet-summary.js +80 -65
- package/dist/format/card-text.js +7 -20
- package/dist/format/explore.js +2 -2
- package/dist/format/offers.js +2 -2
- package/dist/format/recommendation.js +2 -2
- package/dist/format/wallet.js +7 -1
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +38 -13
- package/dist/mcp/inputs.js +1 -1
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/serve.js +4 -0
- package/dist/mcp/server.js +8 -2
- package/dist/mcp/tools/account.js +44 -16
- package/dist/mcp/tools/answers.js +58 -19
- package/dist/mcp/tools/wallet.js +64 -25
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/missed.js +78 -0
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +4 -1
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
package/dist/engine/simulate.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { categoryResolution, findCategory, resolvePlace, } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
|
-
import {
|
|
3
|
+
import { idealHolding, matchesFilters, } from "./explore.js";
|
|
4
4
|
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
5
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { rotationGap } from "./rotation.js";
|
|
8
8
|
import { money, roundCents } from "./text.js";
|
|
9
9
|
import { cardValuation } from "./valuation.js";
|
|
10
|
-
import { EPSILON } from "./compare.js";
|
|
10
|
+
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
11
11
|
const MONTHS = 12;
|
|
12
12
|
const SHORTLIST = 8; // best single cards considered for pairs
|
|
13
13
|
const PER_LINE = 2; // plus the best cards for each spending line
|
|
@@ -16,7 +16,7 @@ export function resolveSpendLines(catalog, lines) {
|
|
|
16
16
|
.filter((l) => l.monthly > 0)
|
|
17
17
|
.map((l) => {
|
|
18
18
|
const merchant = l.merchant !== null
|
|
19
|
-
?
|
|
19
|
+
? resolvePlace(catalog, l.merchant, null)
|
|
20
20
|
: categoryResolution(catalog, l.category ?? 'other', null);
|
|
21
21
|
return { label: merchant.resolvedName, monthly: l.monthly, merchant };
|
|
22
22
|
});
|
|
@@ -224,7 +224,7 @@ function foldTies(pairs) {
|
|
|
224
224
|
const folded = [];
|
|
225
225
|
for (const pair of pairs) {
|
|
226
226
|
const [a, b] = pair.cards;
|
|
227
|
-
const twin = folded.find((f) => Math.abs(f.net - pair.net) <
|
|
227
|
+
const twin = folded.find((f) => Math.abs(f.net - pair.net) < HALF_CENT &&
|
|
228
228
|
costs(f) === costs(pair) &&
|
|
229
229
|
f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
|
|
230
230
|
if (!twin) {
|
|
@@ -339,7 +339,8 @@ export function spendLineFor(catalog, what, monthly) {
|
|
|
339
339
|
const category = findCategory(catalog, what);
|
|
340
340
|
return { merchant: category === null ? what : null, category, monthly };
|
|
341
341
|
}
|
|
342
|
+
export const typicalSpend = (catalog) => TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly));
|
|
342
343
|
export function typicalSpendLines(catalog) {
|
|
343
|
-
return resolveSpendLines(catalog,
|
|
344
|
+
return resolveSpendLines(catalog, typicalSpend(catalog));
|
|
344
345
|
}
|
|
345
346
|
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} ${money(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
|
package/dist/engine/text.js
CHANGED
|
@@ -2,6 +2,18 @@ import { singleMcc } from "./evaluate.js";
|
|
|
2
2
|
export const num = (n) => String(Number(n.toFixed(2)));
|
|
3
3
|
export const pct = (n) => `${num(n)}%`;
|
|
4
4
|
export const roundCents = (n) => Math.round(n * 100) / 100;
|
|
5
|
+
// Ids for a message: "amex-gold, citi-double-cash".
|
|
6
|
+
export const idList = (ids) => ids.join(', ');
|
|
7
|
+
export const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
8
|
+
// "usbank.com" from "https://www.usbank.com/credit-cards/...".
|
|
9
|
+
export function hostname(url) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
5
17
|
// An id in words: "gas-ev-charging" reads "gas ev charging".
|
|
6
18
|
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
7
19
|
const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
@@ -47,7 +59,7 @@ export function categoryPhrase(catalog, id) {
|
|
|
47
59
|
}
|
|
48
60
|
export function earnText(catalog, rate, currency) {
|
|
49
61
|
return currency === 'cash'
|
|
50
|
-
? `${
|
|
62
|
+
? `${pct(rate)} cash back`
|
|
51
63
|
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
52
64
|
}
|
|
53
65
|
export function codesAs(catalog, merchant) {
|
|
@@ -3,15 +3,15 @@ import { addMonths } from "./bonus.js";
|
|
|
3
3
|
import { capByKey } from "./evaluate.js";
|
|
4
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
5
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
6
|
+
import { idList } from "./text.js";
|
|
6
7
|
const failed = (error) => ({ ok: false, error });
|
|
7
|
-
const list = (ids) => ids.join(', ');
|
|
8
8
|
function findCatalogCard(catalog, id, today) {
|
|
9
9
|
const card = catalog.cards.find((c) => c.id === id);
|
|
10
10
|
if (card)
|
|
11
11
|
return card;
|
|
12
12
|
const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
|
|
13
13
|
return near.length > 0
|
|
14
|
-
? `No card '${id}' in earnrate. Closest: ${
|
|
14
|
+
? `No card '${id}' in earnrate. Closest: ${idList(near)}.`
|
|
15
15
|
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
16
16
|
}
|
|
17
17
|
function isAnniversary(text) {
|
|
@@ -51,7 +51,7 @@ export function cardFields(card, held, input, today) {
|
|
|
51
51
|
for (const id of input.activated) {
|
|
52
52
|
if (!needing.includes(id)) {
|
|
53
53
|
problems.push(needing.length > 0
|
|
54
|
-
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${
|
|
54
|
+
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${idList(needing)}.`
|
|
55
55
|
: `${card.id} has no rules that need activating.`);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -62,14 +62,14 @@ export function cardFields(card, held, input, today) {
|
|
|
62
62
|
const choice = card.choices.find((c) => c.id === choiceId);
|
|
63
63
|
if (!choice) {
|
|
64
64
|
problems.push(card.choices.length > 0
|
|
65
|
-
? `${card.id} has no '${choiceId}' pick. Its picks are: ${
|
|
65
|
+
? `${card.id} has no '${choiceId}' pick. Its picks are: ${idList(card.choices.map((c) => c.id))}.`
|
|
66
66
|
: `${card.id} has no categories to pick.`);
|
|
67
67
|
continue;
|
|
68
68
|
}
|
|
69
69
|
const options = Object.keys(choice.options);
|
|
70
70
|
for (const p of picks) {
|
|
71
71
|
if (!options.includes(p)) {
|
|
72
|
-
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${
|
|
72
|
+
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${idList(options)}.`);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
if (picks.length === 0 || picks.length > choice.pick) {
|
|
@@ -101,7 +101,7 @@ export function cardFields(card, held, input, today) {
|
|
|
101
101
|
.map((r) => r.id),
|
|
102
102
|
];
|
|
103
103
|
problems.push(keys.length > 0
|
|
104
|
-
? `${card.id} has no cap '${key}'. Its caps are: ${
|
|
104
|
+
? `${card.id} has no cap '${key}'. Its caps are: ${idList(keys)}.`
|
|
105
105
|
: `${card.id} has no spending caps.`);
|
|
106
106
|
continue;
|
|
107
107
|
}
|
|
@@ -206,7 +206,7 @@ export function updateInWallet(catalog, wallet, input, today) {
|
|
|
206
206
|
const held = wallet.cards.find((c) => c.id === input.id);
|
|
207
207
|
const card = catalog.cards.find((c) => c.id === input.id);
|
|
208
208
|
if (!held || !card) {
|
|
209
|
-
return failed(`${input.id} isn't in the wallet. Cards: ${
|
|
209
|
+
return failed(`${input.id} isn't in the wallet. Cards: ${idList(wallet.cards.map((c) => c.id))}.`);
|
|
210
210
|
}
|
|
211
211
|
const fields = cardFields(card, held, input, today);
|
|
212
212
|
if (typeof fields === 'string')
|
|
@@ -227,10 +227,8 @@ export function updateInWallet(catalog, wallet, input, today) {
|
|
|
227
227
|
}
|
|
228
228
|
export function removeFromWallet(wallet, id) {
|
|
229
229
|
if (!wallet.cards.some((c) => c.id === id)) {
|
|
230
|
-
return failed(`${id} isn't in the wallet. Cards: ${
|
|
230
|
+
return failed(`${id} isn't in the wallet. Cards: ${idList(wallet.cards.map((c) => c.id))}.`);
|
|
231
231
|
}
|
|
232
|
-
// Removing the last card leaves an empty wallet, which each store keeps
|
|
233
|
-
// in its own way (or refuses, for a wallet file).
|
|
234
232
|
return {
|
|
235
233
|
ok: true,
|
|
236
234
|
wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
|
|
@@ -242,7 +240,7 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
242
240
|
const known = knownMemberships(catalog);
|
|
243
241
|
const unknown = memberships.filter((m) => !known.includes(m));
|
|
244
242
|
if (unknown.length > 0) {
|
|
245
|
-
return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${
|
|
243
|
+
return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${idList(known)}.`);
|
|
246
244
|
}
|
|
247
245
|
return {
|
|
248
246
|
ok: true,
|
|
@@ -256,7 +254,7 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
256
254
|
export function withValuations(catalog, wallet, changes) {
|
|
257
255
|
const unknown = Object.keys(changes).filter((id) => !catalog.currencies.some((c) => c.id === id));
|
|
258
256
|
if (unknown.length > 0)
|
|
259
|
-
return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${
|
|
257
|
+
return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${idList(catalog.currencies.map((c) => c.id))}.`);
|
|
260
258
|
const odd = Object.entries(changes).filter(([, cents]) => cents !== null && !(cents > 0 && cents <= MAX_CENTS));
|
|
261
259
|
if (odd.length > 0)
|
|
262
260
|
return failed(`A point is worth more than 0¢ and at most ${String(MAX_CENTS)}¢; check ${odd.map(([id]) => id).join(', ')}.`);
|
|
@@ -1,68 +1,32 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
2
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
|
-
import {
|
|
3
|
+
import { membershipName } from "./memberships.js";
|
|
4
|
+
import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
|
|
5
|
+
import { daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
4
6
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
7
|
import { rotationGap } from "./rotation.js";
|
|
6
8
|
import { activationRules } from "./setup.js";
|
|
7
|
-
import {
|
|
9
|
+
import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
|
|
8
10
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
9
|
-
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
10
|
-
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
11
|
-
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
12
|
-
function merchantName(catalog, id) {
|
|
13
|
-
const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
|
|
14
|
-
const merchant = all.find((m) => m.id === id);
|
|
15
|
-
const name = merchant?.name ?? words(id);
|
|
16
|
-
return merchant?.kind === 'booking'
|
|
17
|
-
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
18
|
-
: name;
|
|
19
|
-
}
|
|
20
|
-
export function optionName(catalog, card, choiceId, option) {
|
|
21
|
-
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
22
|
-
if (!o)
|
|
23
|
-
return words(option);
|
|
24
|
-
const parts = [
|
|
25
|
-
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
26
|
-
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
27
|
-
];
|
|
28
|
-
if (o.online)
|
|
29
|
-
parts.push('online shopping');
|
|
30
|
-
return parts.length > 0 ? listed(parts) : words(option);
|
|
31
|
-
}
|
|
32
|
-
// What a rule covers, as a list of phrases.
|
|
33
|
-
function ruleParts(catalog, rule) {
|
|
34
|
-
if (rule.match.choice !== undefined)
|
|
35
|
-
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
36
|
-
const parts = [
|
|
37
|
-
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
38
|
-
...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
39
|
-
];
|
|
40
|
-
return parts.length > 0 ? parts : [words(rule.id)];
|
|
41
|
-
}
|
|
42
|
-
function ruleWhat(catalog, rule) {
|
|
43
|
-
return listed(ruleParts(catalog, rule));
|
|
44
|
-
}
|
|
45
|
-
function periodEnd(period) {
|
|
46
|
-
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
47
|
-
if (quarter) {
|
|
48
|
-
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
49
|
-
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
50
|
-
}
|
|
51
|
-
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
52
|
-
if (month) {
|
|
53
|
-
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
54
|
-
return last.toISOString().slice(0, 10);
|
|
55
|
-
}
|
|
56
|
-
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
57
|
-
}
|
|
58
|
-
const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
|
|
59
|
-
(rule.valid_to === undefined || rule.valid_to >= today);
|
|
60
11
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
61
12
|
? []
|
|
62
13
|
: [
|
|
63
14
|
...(rule.match.categories ?? []).map((c) => `category:${c}`),
|
|
64
15
|
...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
|
|
65
16
|
];
|
|
17
|
+
// "first $1,500 a quarter, combined": how much of a rate's spending earns it.
|
|
18
|
+
function capLimit(card, shown, rule) {
|
|
19
|
+
if (rule.cap === undefined)
|
|
20
|
+
return '';
|
|
21
|
+
const cap = typeof rule.cap === 'string'
|
|
22
|
+
? card.caps.find((c) => c.id === rule.cap)
|
|
23
|
+
: rule.cap;
|
|
24
|
+
if (!cap)
|
|
25
|
+
return '';
|
|
26
|
+
const shared = typeof rule.cap === 'string' &&
|
|
27
|
+
shown.filter((r) => r.cap === rule.cap).length > 1;
|
|
28
|
+
return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
|
|
29
|
+
}
|
|
66
30
|
// Every bonus rate the cardholder gets now, best first, so none reads as the
|
|
67
31
|
// base rate. Rates behind a membership the wallet lacks are left out, and so
|
|
68
32
|
// is a lower tier that an uncapped, always-on higher rate fully covers (Prime
|
|
@@ -70,7 +34,7 @@ const matchKeys = (rule) => rule.match.choice !== undefined
|
|
|
70
34
|
// a line.
|
|
71
35
|
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
72
36
|
const shown = card.rules
|
|
73
|
-
.filter((r) =>
|
|
37
|
+
.filter((r) => inEffect(r, today) &&
|
|
74
38
|
r.rate > card.base_rate &&
|
|
75
39
|
(r.requires?.membership === undefined ||
|
|
76
40
|
wallet.memberships.includes(r.requires.membership)))
|
|
@@ -85,9 +49,22 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
85
49
|
.flatMap(matchKeys);
|
|
86
50
|
if (keys.length > 0 && keys.every((k) => higher.includes(k)))
|
|
87
51
|
continue;
|
|
52
|
+
// A membership tier's higher rate on the same purchases replaces the
|
|
53
|
+
// card's own (BofA Rewards tiers), cap or not.
|
|
54
|
+
const replaced = shown.some((h) => h !== r &&
|
|
55
|
+
h.rate > r.rate &&
|
|
56
|
+
h.requires?.membership !== undefined &&
|
|
57
|
+
r.requires?.membership === undefined &&
|
|
58
|
+
JSON.stringify(h.match) === JSON.stringify(r.match) &&
|
|
59
|
+
JSON.stringify(h.exclude) === JSON.stringify(r.exclude));
|
|
60
|
+
if (replaced)
|
|
61
|
+
continue;
|
|
88
62
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
89
|
-
const
|
|
90
|
-
|
|
63
|
+
const key = [
|
|
64
|
+
`${earnText(catalog, r.rate, currency)} on`,
|
|
65
|
+
capLimit(card, shown, r),
|
|
66
|
+
pending ? ', once activated' : '',
|
|
67
|
+
].join('|');
|
|
91
68
|
const whats = lines.get(key) ?? [];
|
|
92
69
|
for (const part of ruleParts(catalog, r))
|
|
93
70
|
if (!whats.includes(part))
|
|
@@ -95,11 +72,19 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
95
72
|
lines.set(key, whats);
|
|
96
73
|
}
|
|
97
74
|
return [
|
|
98
|
-
...[...lines].map(([key, whats]) =>
|
|
99
|
-
|
|
75
|
+
...[...lines].map(([key, whats]) => {
|
|
76
|
+
const [head = '', limit = '', activate = ''] = key.split('|');
|
|
77
|
+
const after = `${limit}${activate}`;
|
|
78
|
+
return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
79
|
+
}),
|
|
80
|
+
...(shown.some((r) => r.match.any && r.rate > card.base_rate)
|
|
81
|
+
? []
|
|
82
|
+
: [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
|
|
100
83
|
];
|
|
101
84
|
}
|
|
102
|
-
|
|
85
|
+
// Bonuses current or starting soon that the card hasn't activated, one per
|
|
86
|
+
// activation group (one activation covers a quarter's categories).
|
|
87
|
+
export function activationsDue(catalog, card, held, today) {
|
|
103
88
|
const seen = new Set();
|
|
104
89
|
const out = [];
|
|
105
90
|
for (const id of activationRules(card, today)) {
|
|
@@ -110,13 +95,25 @@ function toActivate(catalog, card, held, today) {
|
|
|
110
95
|
seen.add(g);
|
|
111
96
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
112
97
|
const first = rules[0];
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
:
|
|
116
|
-
|
|
98
|
+
out.push({
|
|
99
|
+
what: rules.map((r) => ruleWhat(catalog, r)).join(', '),
|
|
100
|
+
from: first?.valid_from ?? null,
|
|
101
|
+
to: first?.valid_to ?? null,
|
|
102
|
+
activateBy: first?.activate_by ?? null,
|
|
103
|
+
});
|
|
117
104
|
}
|
|
118
105
|
return out;
|
|
119
106
|
}
|
|
107
|
+
function toActivate(catalog, card, held, today) {
|
|
108
|
+
return activationsDue(catalog, card, held, today).map((a) => {
|
|
109
|
+
const deadline = a.activateBy === null ? '' : `activate by ${shortDate(a.activateBy)}`;
|
|
110
|
+
const dates = a.from !== null && a.to !== null
|
|
111
|
+
? `${shortDate(a.from)} to ${shortDate(a.to)}`
|
|
112
|
+
: '';
|
|
113
|
+
const when = [dates, deadline].filter((x) => x !== '').join(', ');
|
|
114
|
+
return `${a.what}${when === '' ? '' : ` (${when})`}`;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
120
117
|
function capSummaries(catalog, card, held, today) {
|
|
121
118
|
const caps = new Map();
|
|
122
119
|
for (const shared of card.caps)
|
|
@@ -204,7 +201,16 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
204
201
|
anniversary: held.anniversary ?? null,
|
|
205
202
|
});
|
|
206
203
|
}
|
|
207
|
-
|
|
204
|
+
return {
|
|
205
|
+
memberships: wallet.memberships,
|
|
206
|
+
membershipNames: wallet.memberships.map((m) => membershipName(catalog, m)),
|
|
207
|
+
pointValues: pointValues(catalog, wallet),
|
|
208
|
+
started: null,
|
|
209
|
+
cards,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function pointValues(catalog, wallet) {
|
|
213
|
+
return Object.keys(wallet.valuations)
|
|
208
214
|
.filter((id) => catalog.currencies.some((c) => c.id === id))
|
|
209
215
|
.map((id) => ({
|
|
210
216
|
currency: id,
|
|
@@ -212,5 +218,14 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
212
218
|
centsPerUnit: currencyValuation(catalog, wallet, id).centsPerUnit,
|
|
213
219
|
redemption: redemptionOf(catalog, id, wallet.valuations[id]),
|
|
214
220
|
}));
|
|
215
|
-
|
|
221
|
+
}
|
|
222
|
+
// The memberships and point values a wallet holds besides its cards, in
|
|
223
|
+
// words: what replacing or deleting it would lose.
|
|
224
|
+
export function savedSettingsText(catalog, wallet) {
|
|
225
|
+
return [
|
|
226
|
+
...wallet.memberships.map((m) => membershipName(catalog, m)),
|
|
227
|
+
...pointValues(catalog, wallet).map((v) => v.redemption === null
|
|
228
|
+
? `${v.name} at ${String(v.centsPerUnit)}¢`
|
|
229
|
+
: `${v.name} redeemed ${v.redemption}`),
|
|
230
|
+
];
|
|
216
231
|
}
|
package/dist/format/card-text.js
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
import { earnText } from "../engine/text.js";
|
|
2
2
|
// Card names, fees and requirements as earnrate.dev and its static search
|
|
3
3
|
// pages show them. Node-free: the site bundles it.
|
|
4
|
-
const ISSUERS = {
|
|
5
|
-
amex: 'American Express',
|
|
6
|
-
'bank-of-america': 'Bank of America',
|
|
7
|
-
'capital-one': 'Capital One',
|
|
8
|
-
chase: 'Chase',
|
|
9
|
-
citi: 'Citi',
|
|
10
|
-
column: 'Bilt',
|
|
11
|
-
sofi: 'SoFi',
|
|
12
|
-
'td-bank': 'TD Bank',
|
|
13
|
-
usbank: 'U.S. Bank',
|
|
14
|
-
'wells-fargo': 'Wells Fargo',
|
|
15
|
-
};
|
|
16
|
-
export { CAP_PERIODS, PICK_PERIODS, words } from "../engine/text.js";
|
|
17
4
|
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
18
5
|
import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
|
|
19
|
-
//
|
|
20
|
-
export const
|
|
21
|
-
export const issuerName = (id) => ISSUERS[id] ?? id;
|
|
6
|
+
// An issuer's name as people know it, from data/issuers.yaml.
|
|
7
|
+
export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
22
8
|
export function cardText(catalog) {
|
|
23
9
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
24
10
|
function shortName(id) {
|
|
@@ -40,8 +26,8 @@ export function cardText(catalog) {
|
|
|
40
26
|
return `No annual fee${needs}`;
|
|
41
27
|
const first = card?.first_year_fee;
|
|
42
28
|
if (first !== undefined && first !== fee)
|
|
43
|
-
return `${
|
|
44
|
-
return `${
|
|
29
|
+
return `${wholeDollars(fee)} a year (${first === 0 ? 'waived' : wholeDollars(first)} the first year)${needs}`;
|
|
30
|
+
return `${wholeDollars(fee)} a year${needs}`;
|
|
45
31
|
}
|
|
46
32
|
// `until` is the last day a time-limited rate applies, already formatted.
|
|
47
33
|
function requirementLines(cardId, r, until = null) {
|
|
@@ -49,13 +35,13 @@ export function cardText(catalog) {
|
|
|
49
35
|
if (r.pick)
|
|
50
36
|
lines.push(`Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
|
|
51
37
|
if (r.activation !== null)
|
|
52
|
-
lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
38
|
+
lines.push(`Activate the bonus with ${issuerName(catalog, cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
53
39
|
if (r.membership !== null)
|
|
54
40
|
lines.push(`With ${membershipName(catalog, r.membership)}`);
|
|
55
41
|
if (until !== null)
|
|
56
42
|
lines.push(`This rate runs through ${until}`);
|
|
57
43
|
if (r.cap)
|
|
58
|
-
lines.push(`Up to ${
|
|
44
|
+
lines.push(`Up to ${wholeDollars(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
|
|
59
45
|
return lines;
|
|
60
46
|
}
|
|
61
47
|
const memberName = (id) => membershipName(catalog, id);
|
|
@@ -65,5 +51,6 @@ export function cardText(catalog) {
|
|
|
65
51
|
feeLabel,
|
|
66
52
|
requirementLines,
|
|
67
53
|
membershipName: memberName,
|
|
54
|
+
issuerName: (id) => issuerName(catalog, id),
|
|
68
55
|
};
|
|
69
56
|
}
|
package/dist/format/explore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
-
import { choiceName, feeText,
|
|
2
|
+
import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
3
3
|
const membershipFees = (memberships) => memberships
|
|
4
4
|
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
5
|
.join('');
|
|
@@ -17,7 +17,7 @@ export function formatExploration(r, catalog, all) {
|
|
|
17
17
|
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
18
18
|
const lines = [`Best cards for ${title}:`, ''];
|
|
19
19
|
shown.forEach((e, i) => {
|
|
20
|
-
const earned = e.earned === null ? '' : ` ${
|
|
20
|
+
const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
|
|
21
21
|
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
22
22
|
for (const c of e.conditions)
|
|
23
23
|
lines.push(` ${c}`);
|
package/dist/format/offers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { offerTerms } from "../engine/offers.js";
|
|
2
|
-
import { feeText, money, shortDate, wholeDollars } from "../engine/text.js";
|
|
2
|
+
import { capitalize, feeText, money, shortDate, wholeDollars, } from "../engine/text.js";
|
|
3
3
|
const TOP = 10;
|
|
4
4
|
export function formatOffers(c, currencyName, all) {
|
|
5
5
|
if (c.offers.length === 0) {
|
|
@@ -11,7 +11,7 @@ export function formatOffers(c, currencyName, all) {
|
|
|
11
11
|
shown.forEach((o, i) => {
|
|
12
12
|
const weeks = (o.months * 52) / 12;
|
|
13
13
|
const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
|
|
14
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${
|
|
14
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
15
15
|
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
16
16
|
if (o.ineligible)
|
|
17
17
|
lines.push(` ${o.ineligible}`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dollarsAndCents, money, pct } from "../engine/text.js";
|
|
1
|
+
import { capitalize, dollarsAndCents, money, pct } from "../engine/text.js";
|
|
2
2
|
import { noCardText } from "../engine/recommend.js";
|
|
3
3
|
export const plain = (_format, text) => text;
|
|
4
4
|
const TOP = 3;
|
|
@@ -55,7 +55,7 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
55
55
|
const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
|
|
56
56
|
const confidence = rec.decision.confidence;
|
|
57
57
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
58
|
-
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${
|
|
58
|
+
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
|
|
59
59
|
if (rec.bonus && rec.notes[0])
|
|
60
60
|
lines.push(rec.notes[0]);
|
|
61
61
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
2
|
+
// "To do now", above the card details; nothing when all's done.
|
|
3
|
+
export function formatChecklist(items) {
|
|
4
|
+
return items.length === 0
|
|
5
|
+
? ''
|
|
6
|
+
: `To do now:\n${items.map((i) => `- ${i.text}`).join('\n')}\n\n`;
|
|
7
|
+
}
|
|
2
8
|
export function formatWalletSummary(summary) {
|
|
3
9
|
const cards = summary.cards.map((c) => {
|
|
4
10
|
const label = c.checkoutLabel
|
|
@@ -40,7 +46,7 @@ export function formatWalletSummary(summary) {
|
|
|
40
46
|
return lines.join('\n');
|
|
41
47
|
});
|
|
42
48
|
const memberships = summary.memberships.length > 0
|
|
43
|
-
? `Memberships: ${summary.memberships.join(', ')}.`
|
|
49
|
+
? `Memberships: ${summary.memberships.map((id, i) => `${summary.membershipNames[i] ?? id} (${id})`).join(', ')}.`
|
|
44
50
|
: 'Memberships: none saved.';
|
|
45
51
|
const values = summary.pointValues.length > 0
|
|
46
52
|
? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
|
package/dist/import/analyze.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
2
|
+
import { HALF_CENT } from "../engine/compare.js";
|
|
2
3
|
import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
3
4
|
import { periodKey } from "../engine/periods.js";
|
|
4
5
|
import { recommend } from "../engine/recommend.js";
|
|
@@ -155,7 +156,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
155
156
|
if (topBooking)
|
|
156
157
|
simulated = applyBooking(simulated, topBooking);
|
|
157
158
|
const gain = (top.earned ?? 0) - (used.earned ?? 0);
|
|
158
|
-
if (top.cardId !== cardId && gain >
|
|
159
|
+
if (top.cardId !== cardId && gain > HALF_CENT) {
|
|
159
160
|
const key = `${name}|${cardId}|${top.cardId}`;
|
|
160
161
|
const miss = missed.get(key) ?? {
|
|
161
162
|
merchant: name,
|
package/dist/mcp/context.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { walletProblems } from "../data/integrity.js";
|
|
2
2
|
import { EMPTY_WALLET, walletFromCards } from "../engine/transient.js";
|
|
3
|
+
import { capitalize } from "../engine/text.js";
|
|
3
4
|
import { formatNotes } from "../format/cards.js";
|
|
4
|
-
import {
|
|
5
|
+
import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
|
|
5
6
|
import { defined } from "./inputs.js";
|
|
6
7
|
export const fail = (text) => ({
|
|
7
8
|
content: [{ type: 'text', text }],
|
|
@@ -15,9 +16,22 @@ export function makeContext(deps) {
|
|
|
15
16
|
const theWallet = deps.savedWallets === 'file'
|
|
16
17
|
? `the wallet at ${deps.store.where}`
|
|
17
18
|
: 'the saved wallet';
|
|
19
|
+
// Which wallet this server keeps, for replies and tool descriptions. The
|
|
20
|
+
// local extension and the hosted connector can both be connected at once,
|
|
21
|
+
// each with its own wallet, and their tools are otherwise identical.
|
|
22
|
+
const walletName = deps.savedWallets === 'file'
|
|
23
|
+
? `the wallet file on this computer (${deps.store.where})`
|
|
24
|
+
: 'your hosted earnrate wallet';
|
|
25
|
+
const walletUse = deps.savedWallets === 'file'
|
|
26
|
+
? "It uses the wallet file on this computer, not the hosted earnrate connector's wallet."
|
|
27
|
+
: "It uses the user's hosted earnrate wallet, not a wallet file on their computer.";
|
|
28
|
+
const startHint = 'Answers 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.';
|
|
29
|
+
const noCards = deps.savedWallets === 'file'
|
|
30
|
+
? `The wallet at ${deps.store.where} has no cards yet. ${startHint}`
|
|
31
|
+
: `The user's saved wallet has no cards yet. ${startHint}`;
|
|
18
32
|
const noWallet = deps.savedWallets === 'file'
|
|
19
33
|
? `There's no saved wallet yet (${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.`
|
|
20
|
-
:
|
|
34
|
+
: noCards;
|
|
21
35
|
// Read on every call, so edits to the wallet apply without a restart.
|
|
22
36
|
async function wallet() {
|
|
23
37
|
let loaded;
|
|
@@ -32,25 +46,29 @@ export function makeContext(deps) {
|
|
|
32
46
|
}
|
|
33
47
|
const problems = walletProblems(loaded, catalog);
|
|
34
48
|
return problems.length > 0
|
|
35
|
-
? `${
|
|
49
|
+
? `${capitalize(theWallet)} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
|
|
36
50
|
: loaded;
|
|
37
51
|
}
|
|
38
|
-
// No saved wallet is the normal state, not a
|
|
39
|
-
// empty one, with a note on how to
|
|
52
|
+
// No saved wallet, or one with no cards, is the normal state, not a
|
|
53
|
+
// failure: it reads as an empty one, with a note on how to add cards.
|
|
40
54
|
async function readableWallet() {
|
|
41
55
|
const w = await wallet();
|
|
42
56
|
if (typeof w !== 'string')
|
|
43
|
-
return { wallet: w, start: null };
|
|
57
|
+
return { wallet: w, start: w.cards.length === 0 ? noCards : null };
|
|
44
58
|
return w === noWallet ? { wallet: EMPTY_WALLET, start: w } : w;
|
|
45
59
|
}
|
|
46
60
|
// The caller's category is free and comes before the paid Claude fallback;
|
|
47
61
|
// neither ever overrides a merchant earnrate already knows.
|
|
48
62
|
async function resolve(merchant, channel, category) {
|
|
49
|
-
const known =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
const known = resolvePlace(catalog, merchant, channel);
|
|
64
|
+
const resolved = known.source !== 'none' || category !== null || !deps.llm
|
|
65
|
+
? { merchant: withCallerCategory(known, category), notices: [] }
|
|
66
|
+
: await deps.llm.resolve(catalog, merchant, channel);
|
|
67
|
+
const source = resolved.merchant.source;
|
|
68
|
+
if (deps.missed &&
|
|
69
|
+
(source === 'none' || source === 'caller' || source === 'llm'))
|
|
70
|
+
await deps.missed.note(merchant, source === 'none' ? null : resolved.merchant.category, deps.today());
|
|
71
|
+
return resolved;
|
|
54
72
|
}
|
|
55
73
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
56
74
|
// neither (null), answers cover every card earnrate knows.
|
|
@@ -101,8 +119,8 @@ export function makeContext(deps) {
|
|
|
101
119
|
const text = [
|
|
102
120
|
message,
|
|
103
121
|
output.cardIds.length > 0
|
|
104
|
-
? `
|
|
105
|
-
:
|
|
122
|
+
? `Saved in ${walletName}, which has ${String(output.cardIds.length)} ${output.cardIds.length === 1 ? 'card' : 'cards'}.`
|
|
123
|
+
: `Saved in ${walletName}, which has no cards now; answers cover every card.`,
|
|
106
124
|
...(notes ? [formatNotes(result.notes)] : []),
|
|
107
125
|
].join('\n');
|
|
108
126
|
return {
|
|
@@ -111,7 +129,14 @@ export function makeContext(deps) {
|
|
|
111
129
|
};
|
|
112
130
|
}
|
|
113
131
|
const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
132
|
+
// The first line of an answer from the user's own cards: which ones.
|
|
133
|
+
const fromCards = (named) => named !== undefined && named.length > 0
|
|
134
|
+
? 'For the cards named in this conversation.\n'
|
|
135
|
+
: `From ${walletName}.\n`;
|
|
114
136
|
return {
|
|
137
|
+
walletName,
|
|
138
|
+
fromCards,
|
|
139
|
+
walletUse,
|
|
115
140
|
wallet,
|
|
116
141
|
readableWallet,
|
|
117
142
|
resolve,
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -6,7 +6,7 @@ import { IsoDate } from "../schema/common.js";
|
|
|
6
6
|
export const Merchant = z
|
|
7
7
|
.string()
|
|
8
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".');
|
|
9
|
+
.describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com", or a kind of place, e.g. "restaurants" or "gas station".');
|
|
10
10
|
export const ChannelInput = z
|
|
11
11
|
.enum(['online', 'in_store'])
|
|
12
12
|
.optional()
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -78,6 +78,7 @@ const RecommendationSchema = z.object({
|
|
|
78
78
|
});
|
|
79
79
|
export const WalletSummarySchema = z.object({
|
|
80
80
|
memberships: z.array(z.string()),
|
|
81
|
+
membershipNames: z.array(z.string()),
|
|
81
82
|
pointValues: z.array(z.object({
|
|
82
83
|
currency: z.string(),
|
|
83
84
|
name: z.string(),
|
|
@@ -85,6 +86,14 @@ export const WalletSummarySchema = z.object({
|
|
|
85
86
|
redemption: z.string().nullable(),
|
|
86
87
|
})),
|
|
87
88
|
started: z.string().nullable(),
|
|
89
|
+
checklist: z
|
|
90
|
+
.array(z.object({
|
|
91
|
+
kind: z.enum(['activate', 'pick', 'cap', 'bonus', 'rotation']),
|
|
92
|
+
cardId: z.string(),
|
|
93
|
+
text: z.string(),
|
|
94
|
+
due: z.string().nullable(),
|
|
95
|
+
}))
|
|
96
|
+
.optional(),
|
|
88
97
|
cards: z.array(z.object({
|
|
89
98
|
cardId: z.string(),
|
|
90
99
|
cardName: z.string(),
|