earnrate 0.2.4 → 0.2.6
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 +18 -15
- package/data/CHANGELOG.md +79 -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 +23 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
- 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 +50 -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 +19 -2
- package/data/cards/chase-ihg-one-rewards.yaml +15 -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 +17 -4
- 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 +6 -9
- package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
- 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 +16 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +23 -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/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/issuers.yaml +32 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +25 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +11 -6
- package/dist/cli/explore.js +17 -23
- package/dist/cli/import.js +10 -10
- package/dist/cli/offers.js +19 -8
- package/dist/cli/run.js +31 -20
- package/dist/cli/wallet.js +50 -5
- package/dist/data/file-store.js +9 -7
- package/dist/data/integrity.js +22 -2
- package/dist/data/load.js +13 -2
- package/dist/data/store.js +13 -22
- package/dist/data/wallet-edit.js +108 -8
- package/dist/engine/cheat-sheet.js +15 -19
- package/dist/engine/compare.js +10 -0
- package/dist/engine/evaluate.js +2 -4
- package/dist/engine/explore.js +13 -56
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +18 -5
- package/dist/engine/periods.js +18 -0
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +23 -37
- package/dist/engine/setup.js +28 -40
- package/dist/engine/simulate.js +11 -10
- package/dist/engine/text.js +49 -23
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +26 -11
- package/dist/engine/wallet-summary.js +85 -31
- package/dist/format/card-text.js +19 -36
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +15 -16
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +6 -6
- package/dist/format/wallet.js +5 -2
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +61 -13
- package/dist/mcp/inputs.js +16 -7
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/server.js +8 -1
- package/dist/mcp/tools/account.js +45 -17
- package/dist/mcp/tools/answers.js +84 -29
- package/dist/mcp/tools/wallet.js +109 -22
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +8 -12
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ import { formatCheatSheet } from "../../format/cheat-sheet.js";
|
|
|
13
13
|
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../../format/explore.js";
|
|
14
14
|
import { formatOffers } from "../../format/offers.js";
|
|
15
15
|
import { formatRecommendation } from "../../format/recommendation.js";
|
|
16
|
+
import { resolvePlace, withCallerCategory } from "../../merchants/resolve.js";
|
|
16
17
|
import { ALL_CARDS_NOTE, fail } from "../context.js";
|
|
17
18
|
import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
|
|
18
19
|
import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
|
|
@@ -24,6 +25,7 @@ const SOURCES = {
|
|
|
24
25
|
category: 'a kind of purchase, not a store',
|
|
25
26
|
none: 'not recognized',
|
|
26
27
|
};
|
|
28
|
+
const EVERY_CARD_NOTE = "Picked from every card earnrate knows, as asked, not just the user's saved cards. The user's saved memberships and point values still apply.";
|
|
27
29
|
const TOP = 10;
|
|
28
30
|
// The tools that answer without a saved wallet: from every card, the cards
|
|
29
31
|
// named in the conversation, or a saved wallet if there is one.
|
|
@@ -45,16 +47,21 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
45
47
|
category: CategoryInput,
|
|
46
48
|
cards: NamedCards,
|
|
47
49
|
memberships: NamedMemberships,
|
|
50
|
+
valuation: CatalogFilters.valuation,
|
|
48
51
|
}),
|
|
49
52
|
outputSchema: PurchaseAnswerSchema,
|
|
50
53
|
annotations: {
|
|
54
|
+
title: 'Best card for a purchase',
|
|
51
55
|
readOnlyHint: true,
|
|
52
56
|
openWorldHint: deps.llm !== null,
|
|
53
57
|
},
|
|
54
|
-
}, async ({ merchant, amount, channel, date, category, cards, memberships, }) => {
|
|
55
|
-
const
|
|
56
|
-
if (typeof
|
|
57
|
-
return fail(
|
|
58
|
+
}, async ({ merchant, amount, channel, date, category, cards, memberships, valuation, }) => {
|
|
59
|
+
const held = await ctx.cardsFor(cards, memberships);
|
|
60
|
+
if (typeof held === 'string')
|
|
61
|
+
return fail(held);
|
|
62
|
+
const wallet = held !== null && valuation
|
|
63
|
+
? withValuationProfile(held, valuation)
|
|
64
|
+
: held;
|
|
58
65
|
const purchase = {
|
|
59
66
|
merchant,
|
|
60
67
|
amount: amount ?? null,
|
|
@@ -73,14 +80,14 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
73
80
|
amount: purchase.amount,
|
|
74
81
|
channel: purchase.channel,
|
|
75
82
|
date: purchase.date,
|
|
76
|
-
}, settings({ memberships }), resolved.merchant);
|
|
83
|
+
}, settings({ memberships, valuation }, await ctx.saved()), resolved.merchant);
|
|
77
84
|
const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
|
|
78
85
|
const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
|
|
79
86
|
return {
|
|
80
87
|
content: [
|
|
81
88
|
{
|
|
82
89
|
type: 'text',
|
|
83
|
-
text: `${retry}${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
|
|
90
|
+
text: `${retry}${formatExploration(allCards, catalog, true)}\n${ALL_CARDS_NOTE}`,
|
|
84
91
|
},
|
|
85
92
|
],
|
|
86
93
|
structuredContent: {
|
|
@@ -100,7 +107,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
100
107
|
content: [
|
|
101
108
|
{
|
|
102
109
|
type: 'text',
|
|
103
|
-
text: `${retry}${formatRecommendation(output, purchase, { all: true })}`,
|
|
110
|
+
text: `${retry}${ctx.fromCards(cards)}${formatRecommendation(output, purchase, { all: true })}`,
|
|
104
111
|
},
|
|
105
112
|
],
|
|
106
113
|
structuredContent: output,
|
|
@@ -115,7 +122,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
115
122
|
category: CategoryInput,
|
|
116
123
|
}),
|
|
117
124
|
outputSchema: MerchantResolutionSchema,
|
|
118
|
-
annotations: {
|
|
125
|
+
annotations: {
|
|
126
|
+
title: 'Resolve a merchant',
|
|
127
|
+
readOnlyHint: true,
|
|
128
|
+
openWorldHint: deps.llm !== null,
|
|
129
|
+
},
|
|
119
130
|
}, async ({ merchant, channel, category }) => {
|
|
120
131
|
const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
|
|
121
132
|
const m = resolved.merchant;
|
|
@@ -150,12 +161,16 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
150
161
|
cards: NamedCards,
|
|
151
162
|
}),
|
|
152
163
|
outputSchema: OfferComparisonSchema,
|
|
153
|
-
annotations: {
|
|
164
|
+
annotations: {
|
|
165
|
+
title: 'Compare welcome offers',
|
|
166
|
+
readOnlyHint: true,
|
|
167
|
+
openWorldHint: false,
|
|
168
|
+
},
|
|
154
169
|
}, async (args) => {
|
|
155
170
|
const wallet = await ctx.cardsFor(args.cards, undefined);
|
|
156
171
|
if (typeof wallet === 'string')
|
|
157
172
|
return fail(wallet);
|
|
158
|
-
const base = wallet ?? EMPTY_WALLET;
|
|
173
|
+
const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
|
|
159
174
|
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
160
175
|
kind: args.kind ?? 'any',
|
|
161
176
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
@@ -164,16 +179,19 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
164
179
|
}, deps.today());
|
|
165
180
|
const trimmed = {
|
|
166
181
|
...comparison,
|
|
167
|
-
|
|
182
|
+
// Offers ruled out by a card the user holds sort last; keep them in
|
|
183
|
+
// view so the reason shows.
|
|
184
|
+
offers: comparison.offers.filter((o, i) => i < TOP || o.ineligible !== null),
|
|
168
185
|
};
|
|
169
186
|
const more = comparison.offers.length - trimmed.offers.length;
|
|
170
187
|
return {
|
|
171
188
|
content: [
|
|
172
189
|
{
|
|
173
190
|
type: 'text',
|
|
174
|
-
text:
|
|
191
|
+
text: (wallet === null ? '' : ctx.fromCards(args.cards)) +
|
|
192
|
+
formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
|
|
175
193
|
(more > 0
|
|
176
|
-
? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers. Narrow them with kind, noAnnualFee or business.\n`
|
|
194
|
+
? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers${trimmed.offers.length > TOP ? `, plus ${String(trimmed.offers.length - TOP)} likely not eligible` : ''}. Narrow them with kind, noAnnualFee or business.\n`
|
|
177
195
|
: ''),
|
|
178
196
|
},
|
|
179
197
|
],
|
|
@@ -182,7 +200,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
182
200
|
});
|
|
183
201
|
server.registerTool('best_cards_for', {
|
|
184
202
|
title: 'Best cards for a purchase',
|
|
185
|
-
description: 'Ranks every card earnrate knows, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
|
|
203
|
+
description: 'Ranks every card earnrate knows that takes new applicants, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
|
|
186
204
|
inputSchema: z.object({
|
|
187
205
|
merchant: Merchant.optional(),
|
|
188
206
|
category: CatalogCategory.optional(),
|
|
@@ -195,20 +213,29 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
195
213
|
...CatalogFilters,
|
|
196
214
|
}),
|
|
197
215
|
outputSchema: ExplorationSchema,
|
|
198
|
-
annotations: {
|
|
199
|
-
|
|
216
|
+
annotations: {
|
|
217
|
+
title: 'Best cards for a purchase',
|
|
218
|
+
readOnlyHint: true,
|
|
219
|
+
openWorldHint: false,
|
|
220
|
+
},
|
|
221
|
+
}, async (args) => {
|
|
200
222
|
if (args.merchant === undefined && args.category === undefined)
|
|
201
223
|
return fail('Give a merchant or a category.');
|
|
224
|
+
const channel = args.channel ?? null;
|
|
202
225
|
const result = exploreCards(catalog, {
|
|
203
226
|
merchant: args.merchant ?? null,
|
|
204
|
-
category: args.
|
|
227
|
+
category: args.category ?? null,
|
|
205
228
|
amount: args.amount ?? null,
|
|
206
|
-
channel
|
|
229
|
+
channel,
|
|
207
230
|
date: deps.today(),
|
|
208
|
-
}, settings(args))
|
|
231
|
+
}, settings(args, await ctx.saved()), args.merchant === undefined
|
|
232
|
+
? undefined
|
|
233
|
+
: withCallerCategory(resolvePlace(catalog, args.merchant, channel), args.category ?? null));
|
|
209
234
|
const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
|
|
210
235
|
return {
|
|
211
|
-
content: [
|
|
236
|
+
content: [
|
|
237
|
+
{ type: 'text', text: formatExploration(trimmed, catalog, true) },
|
|
238
|
+
],
|
|
212
239
|
structuredContent: trimmed,
|
|
213
240
|
};
|
|
214
241
|
});
|
|
@@ -232,7 +259,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
232
259
|
...CatalogFilters,
|
|
233
260
|
}),
|
|
234
261
|
outputSchema: SpendingSimulationSchema,
|
|
235
|
-
annotations: {
|
|
262
|
+
annotations: {
|
|
263
|
+
title: 'Simulate a year of spending',
|
|
264
|
+
readOnlyHint: true,
|
|
265
|
+
openWorldHint: false,
|
|
266
|
+
},
|
|
236
267
|
}, async (args) => {
|
|
237
268
|
const given = args.spending ?? [];
|
|
238
269
|
if (given.some((l) => l.merchant === undefined && l.category === undefined))
|
|
@@ -248,7 +279,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
248
279
|
if (unknown.length > 0)
|
|
249
280
|
return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
|
|
250
281
|
const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
|
|
251
|
-
const options = {
|
|
282
|
+
const options = {
|
|
283
|
+
...settings(args, await ctx.saved()),
|
|
284
|
+
start: deps.today(),
|
|
285
|
+
};
|
|
252
286
|
const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
|
|
253
287
|
args.compareToWallet === true;
|
|
254
288
|
const wallet = wantsTheirs
|
|
@@ -261,7 +295,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
261
295
|
? withValuationProfile(wallet, args.valuation)
|
|
262
296
|
: wallet;
|
|
263
297
|
const result = simulateAdditions(catalog, valued, lines, options);
|
|
264
|
-
const text = formatAdditions(result, catalog, false)
|
|
298
|
+
const text = `${ctx.fromCards(args.cards)}${formatAdditions(result, catalog, false)}`;
|
|
265
299
|
return {
|
|
266
300
|
content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
|
|
267
301
|
structuredContent: {
|
|
@@ -296,6 +330,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
296
330
|
inputSchema: z.object({
|
|
297
331
|
date: DateInput,
|
|
298
332
|
cards: NamedCards,
|
|
333
|
+
everyCard: z
|
|
334
|
+
.boolean()
|
|
335
|
+
.optional()
|
|
336
|
+
.describe('true to pick from every card earnrate knows even when the user has a saved wallet ("which cards should I get?"); the filters then apply.'),
|
|
299
337
|
memberships: NamedMemberships,
|
|
300
338
|
kind: CatalogFilters.kind,
|
|
301
339
|
noAnnualFee: CatalogFilters.noAnnualFee,
|
|
@@ -304,22 +342,35 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
304
342
|
valuation: CatalogFilters.valuation,
|
|
305
343
|
}),
|
|
306
344
|
outputSchema: CheatSheetSchema,
|
|
307
|
-
annotations: {
|
|
345
|
+
annotations: {
|
|
346
|
+
title: 'Cheat sheet',
|
|
347
|
+
readOnlyHint: true,
|
|
348
|
+
openWorldHint: false,
|
|
349
|
+
},
|
|
308
350
|
}, async (args) => {
|
|
309
|
-
const wallet =
|
|
351
|
+
const wallet = args.everyCard === true
|
|
352
|
+
? null
|
|
353
|
+
: await ctx.cardsFor(args.cards, args.memberships);
|
|
310
354
|
if (typeof wallet === 'string')
|
|
311
355
|
return fail(wallet);
|
|
312
356
|
const date = args.date ?? deps.today();
|
|
313
357
|
const sheet = wallet === null
|
|
314
358
|
? {
|
|
315
|
-
...catalogCheatSheet(catalog, date, settings(args)),
|
|
316
|
-
notes: [
|
|
359
|
+
...catalogCheatSheet(catalog, date, settings(args, await ctx.saved())),
|
|
360
|
+
notes: [
|
|
361
|
+
args.everyCard === true ? EVERY_CARD_NOTE : ALL_CARDS_NOTE,
|
|
362
|
+
],
|
|
317
363
|
}
|
|
318
364
|
: cheatSheet(catalog, args.valuation
|
|
319
365
|
? withValuationProfile(wallet, args.valuation)
|
|
320
366
|
: wallet, date);
|
|
321
367
|
return {
|
|
322
|
-
content: [
|
|
368
|
+
content: [
|
|
369
|
+
{
|
|
370
|
+
type: 'text',
|
|
371
|
+
text: `${wallet === null ? '' : ctx.fromCards(args.cards)}${formatCheatSheet(sheet)}`,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
323
374
|
structuredContent: sheet,
|
|
324
375
|
};
|
|
325
376
|
});
|
|
@@ -333,7 +384,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
333
384
|
.describe('Card name or issuer, as the user said it.'),
|
|
334
385
|
}),
|
|
335
386
|
outputSchema: CardSearchSchema,
|
|
336
|
-
annotations: {
|
|
387
|
+
annotations: {
|
|
388
|
+
title: 'Search cards',
|
|
389
|
+
readOnlyHint: true,
|
|
390
|
+
openWorldHint: false,
|
|
391
|
+
},
|
|
337
392
|
}, async ({ query }) => {
|
|
338
393
|
const held = await deps.store.load().catch(() => null);
|
|
339
394
|
const result = searchCards(catalog, query ?? '', held, deps.today());
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
|
|
3
|
-
import { choiceName, money, words } from "../../engine/text.js";
|
|
4
|
-
import {
|
|
3
|
+
import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../../engine/text.js";
|
|
4
|
+
import { membershipName } from "../../engine/memberships.js";
|
|
5
|
+
import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
|
|
6
|
+
import { optionName, walletSummary } from "../../engine/wallet-summary.js";
|
|
5
7
|
import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
|
|
6
8
|
import { fail } from "../context.js";
|
|
7
9
|
import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
|
|
8
10
|
import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
|
|
9
11
|
const WRITE = { readOnlyHint: false, openWorldHint: false };
|
|
12
|
+
// "Saved wallet started 4 minutes ago": relative, since the server doesn't
|
|
13
|
+
// know the user's time zone, and precise enough to tell apart two wallets
|
|
14
|
+
// started the same day.
|
|
15
|
+
function startedText(started, now) {
|
|
16
|
+
const minutes = Math.floor((now - Date.parse(started)) / 60_000);
|
|
17
|
+
const ago = minutes < 1
|
|
18
|
+
? 'just now'
|
|
19
|
+
: minutes < 60
|
|
20
|
+
? `${String(minutes)} minute${minutes === 1 ? '' : 's'} ago`
|
|
21
|
+
: minutes < 48 * 60
|
|
22
|
+
? `${String(Math.floor(minutes / 60))} hour${minutes < 120 ? '' : 's'} ago`
|
|
23
|
+
: `on ${shortDate(started.slice(0, 10))}`;
|
|
24
|
+
return `It was started ${ago}.`;
|
|
25
|
+
}
|
|
10
26
|
// What update_card changed, so the reply confirms the values it saved.
|
|
11
|
-
function changes(args) {
|
|
27
|
+
function changes(catalog, args) {
|
|
28
|
+
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
12
29
|
const out = [];
|
|
13
30
|
for (const [choice, options] of Object.entries(args.picks ?? {}))
|
|
14
|
-
out.push(`${choiceName(choice)} picks: ${options.map(words).join('
|
|
31
|
+
out.push(`${choiceName(choice)} picks: ${options.map((o) => (card ? optionName(catalog, card, choice, o) : words(o))).join('; ')}`);
|
|
15
32
|
if (args.checkoutLabel === null)
|
|
16
33
|
out.push('checkout name cleared');
|
|
17
34
|
else if (args.checkoutLabel !== undefined)
|
|
@@ -35,24 +52,38 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
35
52
|
const { catalog, store } = deps;
|
|
36
53
|
server.registerTool('list_wallet', {
|
|
37
54
|
title: 'List wallet',
|
|
38
|
-
description:
|
|
55
|
+
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, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
|
|
39
56
|
outputSchema: WalletSummarySchema,
|
|
40
|
-
annotations: {
|
|
57
|
+
annotations: {
|
|
58
|
+
title: 'List wallet',
|
|
59
|
+
readOnlyHint: true,
|
|
60
|
+
openWorldHint: false,
|
|
61
|
+
},
|
|
41
62
|
}, async () => {
|
|
42
63
|
const read = await ctx.readableWallet();
|
|
43
64
|
if (typeof read === 'string')
|
|
44
65
|
return fail(read);
|
|
45
|
-
const
|
|
66
|
+
const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
|
|
67
|
+
const summary = {
|
|
68
|
+
...walletSummary(catalog, read.wallet, deps.today()),
|
|
69
|
+
started,
|
|
70
|
+
};
|
|
71
|
+
const when = started === null
|
|
72
|
+
? `This is ${ctx.walletName}.\n`
|
|
73
|
+
: `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
|
|
46
74
|
return {
|
|
47
75
|
content: [
|
|
48
|
-
{
|
|
76
|
+
{
|
|
77
|
+
type: 'text',
|
|
78
|
+
text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatWalletSummary(summary)}`,
|
|
79
|
+
},
|
|
49
80
|
],
|
|
50
81
|
structuredContent: summary,
|
|
51
82
|
};
|
|
52
83
|
});
|
|
53
84
|
server.registerTool('record_purchase', {
|
|
54
85
|
title: 'Record a purchase',
|
|
55
|
-
description:
|
|
86
|
+
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 saved wallet. 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. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
|
|
56
87
|
inputSchema: z.object({
|
|
57
88
|
cardId: z
|
|
58
89
|
.string()
|
|
@@ -62,10 +93,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
62
93
|
amount: z.number().positive().describe('Amount spent in US dollars.'),
|
|
63
94
|
channel: ChannelInput,
|
|
64
95
|
date: DateInput,
|
|
65
|
-
category: inputs.
|
|
96
|
+
category: inputs.RecordCategoryInput,
|
|
66
97
|
}),
|
|
67
98
|
outputSchema: RecordResultSchema,
|
|
68
99
|
annotations: {
|
|
100
|
+
title: 'Record a purchase',
|
|
69
101
|
...WRITE,
|
|
70
102
|
destructiveHint: false,
|
|
71
103
|
idempotentHint: false,
|
|
@@ -96,7 +128,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
96
128
|
recorded: true,
|
|
97
129
|
booking,
|
|
98
130
|
bonus,
|
|
99
|
-
message: formatBooking(amount, cardId, booking, bonus)
|
|
131
|
+
message: `${formatBooking(amount, cardId, booking, bonus)} Saved in ${ctx.walletName}.`,
|
|
100
132
|
}
|
|
101
133
|
: {
|
|
102
134
|
recorded: false,
|
|
@@ -116,10 +148,15 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
116
148
|
});
|
|
117
149
|
server.registerTool('add_card', {
|
|
118
150
|
title: 'Add a card',
|
|
119
|
-
description:
|
|
151
|
+
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. ${ctx.walletUse}`,
|
|
120
152
|
inputSchema: CardChange,
|
|
121
153
|
outputSchema: WalletChangeSchema,
|
|
122
|
-
annotations: {
|
|
154
|
+
annotations: {
|
|
155
|
+
title: 'Add a card',
|
|
156
|
+
...WRITE,
|
|
157
|
+
destructiveHint: false,
|
|
158
|
+
idempotentHint: false,
|
|
159
|
+
},
|
|
123
160
|
}, async (args) => {
|
|
124
161
|
const result = await store.addCard(cardInput(args), deps.today());
|
|
125
162
|
const started = !result.ok || !result.created
|
|
@@ -131,12 +168,17 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
131
168
|
});
|
|
132
169
|
server.registerTool('update_card', {
|
|
133
170
|
title: 'Update a card',
|
|
134
|
-
description:
|
|
171
|
+
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
|
|
135
172
|
inputSchema: CardChange,
|
|
136
173
|
outputSchema: WalletChangeSchema,
|
|
137
|
-
annotations: {
|
|
174
|
+
annotations: {
|
|
175
|
+
title: 'Update a card',
|
|
176
|
+
...WRITE,
|
|
177
|
+
destructiveHint: true,
|
|
178
|
+
idempotentHint: true,
|
|
179
|
+
},
|
|
138
180
|
}, async (args) => {
|
|
139
|
-
const what = changes(args);
|
|
181
|
+
const what = changes(catalog, args);
|
|
140
182
|
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
141
183
|
const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
|
|
142
184
|
? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
|
|
@@ -145,17 +187,27 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
145
187
|
});
|
|
146
188
|
server.registerTool('remove_card', {
|
|
147
189
|
title: 'Remove a card',
|
|
148
|
-
description:
|
|
190
|
+
description: `Removes a card from the wallet, with its tracked cap usage and bonus progress. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
|
|
149
191
|
inputSchema: z.object({ cardId: CardId }),
|
|
150
192
|
outputSchema: WalletChangeSchema,
|
|
151
|
-
annotations: {
|
|
193
|
+
annotations: {
|
|
194
|
+
title: 'Remove a card',
|
|
195
|
+
...WRITE,
|
|
196
|
+
destructiveHint: true,
|
|
197
|
+
idempotentHint: true,
|
|
198
|
+
},
|
|
152
199
|
}, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
|
|
153
200
|
server.registerTool('set_memberships', {
|
|
154
201
|
title: 'Set memberships',
|
|
155
|
-
description:
|
|
202
|
+
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, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. ${ctx.walletUse}`,
|
|
156
203
|
inputSchema: z.object({ memberships: inputs.AllMemberships }),
|
|
157
204
|
outputSchema: WalletChangeSchema,
|
|
158
|
-
annotations: {
|
|
205
|
+
annotations: {
|
|
206
|
+
title: 'Set memberships',
|
|
207
|
+
...WRITE,
|
|
208
|
+
destructiveHint: true,
|
|
209
|
+
idempotentHint: true,
|
|
210
|
+
},
|
|
159
211
|
}, async ({ memberships }) => {
|
|
160
212
|
const result = await store.setMemberships(memberships);
|
|
161
213
|
const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
|
|
@@ -164,7 +216,42 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
164
216
|
}));
|
|
165
217
|
const unused = memberships.filter((m) => !used.has(m));
|
|
166
218
|
return ctx.changed(result, `${memberships.length > 0
|
|
167
|
-
? `Memberships set to ${memberships.join(', ')}.`
|
|
168
|
-
: 'Memberships cleared.'}${unused.length > 0 ? ` ${unused.
|
|
219
|
+
? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
|
|
220
|
+
: 'Memberships cleared.'}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
|
|
221
|
+
});
|
|
222
|
+
server.registerTool('set_point_values', {
|
|
223
|
+
title: 'Set point values',
|
|
224
|
+
description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. No saved cards are needed. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies: ${catalog.currencies.map((c) => c.id).join(', ')}. ${ctx.walletUse}`,
|
|
225
|
+
inputSchema: z.object({
|
|
226
|
+
values: z
|
|
227
|
+
.record(z.string(), z.number().nullable())
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
|
|
230
|
+
profile: z
|
|
231
|
+
.enum(['flat', 'points-heavy'])
|
|
232
|
+
.optional()
|
|
233
|
+
.describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
|
|
234
|
+
}),
|
|
235
|
+
outputSchema: WalletChangeSchema,
|
|
236
|
+
annotations: {
|
|
237
|
+
title: 'Set point values',
|
|
238
|
+
...WRITE,
|
|
239
|
+
destructiveHint: true,
|
|
240
|
+
idempotentHint: true,
|
|
241
|
+
},
|
|
242
|
+
}, async ({ values, profile }) => {
|
|
243
|
+
const changes = {
|
|
244
|
+
...(profile ? VALUATION_PROFILES[profile] : {}),
|
|
245
|
+
...(values ?? {}),
|
|
246
|
+
};
|
|
247
|
+
if (Object.keys(changes).length === 0)
|
|
248
|
+
return fail('Give values (cents per point by currency) or a profile.');
|
|
249
|
+
const result = await store.setValuations(changes);
|
|
250
|
+
const set = Object.entries(changes).map(([id, cents]) => cents === null
|
|
251
|
+
? `${currencyLabel(catalog, id)} back to the default`
|
|
252
|
+
: `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
|
|
253
|
+
return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
|
|
254
|
+
notes: false,
|
|
255
|
+
});
|
|
169
256
|
});
|
|
170
257
|
}
|
package/dist/merchants/cache.js
CHANGED
|
@@ -38,16 +38,6 @@ export function merchantCachePath(env = process.env) {
|
|
|
38
38
|
return (env[MERCHANT_CACHE_ENV_VAR] ??
|
|
39
39
|
path.join(homedir(), CONFIG_DIR, 'merchant-cache.json'));
|
|
40
40
|
}
|
|
41
|
-
export function memoryCache() {
|
|
42
|
-
const entries = new Map();
|
|
43
|
-
return {
|
|
44
|
-
get: (key) => entries.get(key),
|
|
45
|
-
set: (key, value) => {
|
|
46
|
-
entries.set(key, value);
|
|
47
|
-
return Promise.resolve();
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
41
|
// A cache file that can't be read is left alone rather than overwritten, so a
|
|
52
42
|
// hand edit gone wrong doesn't lose every saved classification.
|
|
53
43
|
export async function openMerchantCache(file) {
|
|
@@ -116,17 +116,94 @@ function fromMerchant(found, source, input, channel, typedDomain) {
|
|
|
116
116
|
inference: null,
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
+
// A merchant's record by id; an override replaces the seed entry.
|
|
120
|
+
export function findMerchant(catalog, id) {
|
|
121
|
+
return (catalog.merchants.overrides.find((m) => m.id === id) ??
|
|
122
|
+
catalog.merchants.seed.find((m) => m.id === id));
|
|
123
|
+
}
|
|
119
124
|
export function resolveMerchantById(catalog, id, channel) {
|
|
120
125
|
const override = catalog.merchants.overrides.find((m) => m.id === id);
|
|
121
|
-
const found =
|
|
126
|
+
const found = findMerchant(catalog, id);
|
|
122
127
|
if (!found)
|
|
123
128
|
return null;
|
|
124
129
|
return fromMerchant(found, override ? 'override' : 'seed', found.name, channel, false);
|
|
125
130
|
}
|
|
126
|
-
//
|
|
127
|
-
// category
|
|
131
|
+
// Curated merchant data always wins over a category supplied with the
|
|
132
|
+
// request; the caller's category wins over an unknown name or a different
|
|
133
|
+
// category word.
|
|
128
134
|
export function withCallerCategory(merchant, category) {
|
|
129
|
-
if (
|
|
135
|
+
if (category === null)
|
|
136
|
+
return merchant;
|
|
137
|
+
const otherWord = merchant.source === 'category' && merchant.category !== category;
|
|
138
|
+
if (merchant.source !== 'none' && !otherWord)
|
|
130
139
|
return merchant;
|
|
131
140
|
return { ...merchant, category, confidence: 'medium', source: 'caller' };
|
|
132
141
|
}
|
|
142
|
+
const plainWords = (s) => s
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
145
|
+
.trim();
|
|
146
|
+
// A category from everyday words: an alias ("groceries") first, then the
|
|
147
|
+
// name, then the id, so "grocery" means supermarkets rather than the parent.
|
|
148
|
+
export function findCategory(catalog, text) {
|
|
149
|
+
const wanted = plainWords(text);
|
|
150
|
+
if (wanted === '')
|
|
151
|
+
return null;
|
|
152
|
+
// "grocery store" finds "grocery stores", and "drugstore" "drugstores".
|
|
153
|
+
const forms = [
|
|
154
|
+
wanted,
|
|
155
|
+
wanted.endsWith('s') ? wanted.slice(0, -1) : `${wanted}s`,
|
|
156
|
+
];
|
|
157
|
+
for (const form of forms) {
|
|
158
|
+
const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(form))?.id;
|
|
159
|
+
const found = by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]);
|
|
160
|
+
if (found !== undefined)
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
export function categoryResolution(catalog, category, channel) {
|
|
166
|
+
const found = catalog.categories.find((c) => c.id === category);
|
|
167
|
+
if (!found)
|
|
168
|
+
throw new Error(`unknown category '${category}'`);
|
|
169
|
+
return {
|
|
170
|
+
input: found.name,
|
|
171
|
+
merchantId: null,
|
|
172
|
+
resolvedName: found.name,
|
|
173
|
+
category,
|
|
174
|
+
likelyMcc: null,
|
|
175
|
+
accepts: null,
|
|
176
|
+
ambiguity: null,
|
|
177
|
+
channel,
|
|
178
|
+
confidence: 'high',
|
|
179
|
+
source: 'category',
|
|
180
|
+
bookedVia: [],
|
|
181
|
+
inference: null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// Words around a place that don't name it: "a restaurant", "the grocery
|
|
185
|
+
// store", "a gas station near me".
|
|
186
|
+
const LEADING = /^(?:(?:a|an|the|my|any|some|our|your|this|that|local|nearby|nearest)\s+)+/i;
|
|
187
|
+
const TRAILING = /\s+(?:near me|nearby|around here|close by)$/i;
|
|
188
|
+
// What someone typed: a store earnrate knows, else a category word
|
|
189
|
+
// ("restaurants", "gas"), else unrecognized. Names are tried as given first,
|
|
190
|
+
// so a store like The Home Depot keeps its "The".
|
|
191
|
+
export function resolvePlace(catalog, text, channel) {
|
|
192
|
+
const attempt = (t) => {
|
|
193
|
+
const known = resolveMerchant(catalog, t, channel);
|
|
194
|
+
if (known.source !== 'none')
|
|
195
|
+
return known;
|
|
196
|
+
const category = findCategory(catalog, t);
|
|
197
|
+
return category === null
|
|
198
|
+
? known
|
|
199
|
+
: categoryResolution(catalog, category, channel);
|
|
200
|
+
};
|
|
201
|
+
const asGiven = attempt(text);
|
|
202
|
+
if (asGiven.source !== 'none')
|
|
203
|
+
return asGiven;
|
|
204
|
+
const bare = text.trim().replace(TRAILING, '').replace(LEADING, '');
|
|
205
|
+
if (bare === '' || bare === text.trim())
|
|
206
|
+
return asGiven;
|
|
207
|
+
const found = attempt(bare);
|
|
208
|
+
return found.source === 'none' ? asGiven : found;
|
|
209
|
+
}
|
package/dist/schema/card.js
CHANGED
|
@@ -58,12 +58,15 @@ export const RuleSchema = z
|
|
|
58
58
|
.optional(),
|
|
59
59
|
valid_from: IsoDate.optional(),
|
|
60
60
|
valid_to: IsoDate.optional(),
|
|
61
|
+
// The issuer's last day to activate a bonus that needs it.
|
|
62
|
+
activate_by: IsoDate.optional(),
|
|
61
63
|
// Set when a rule isn't confirmed against the issuer's terms. The engine
|
|
62
64
|
// warns when an unverified rule decides the winner.
|
|
63
65
|
unverified: z.string().optional(),
|
|
64
66
|
note: z.string().optional(),
|
|
65
67
|
})
|
|
66
|
-
.refine((r) => !r.valid_from || !r.valid_to || r.valid_from <= r.valid_to, 'valid_from must not be after valid_to')
|
|
68
|
+
.refine((r) => !r.valid_from || !r.valid_to || r.valid_from <= r.valid_to, 'valid_from must not be after valid_to')
|
|
69
|
+
.refine((r) => r.activate_by === undefined || r.requires?.activation === true, 'activate_by needs requires.activation');
|
|
67
70
|
// The public sign-up bonus on the issuer's page. Offers vary by person and
|
|
68
71
|
// channel, so a wallet can override it.
|
|
69
72
|
export const WelcomeOfferSchema = z
|
|
@@ -146,17 +149,10 @@ export const CardSchema = z
|
|
|
146
149
|
// When the first year differs, usually a waived fee.
|
|
147
150
|
first_year_fee: z.number().nonnegative().optional(),
|
|
148
151
|
welcome_offer: WelcomeOfferSchema.optional(),
|
|
149
|
-
// A paid membership the card can't be had
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
id: KebabId,
|
|
154
|
-
name: z.string().min(1), // "a Costco membership"
|
|
155
|
-
annual_fee: z.number().nonnegative(), // the basic tier, dollars
|
|
156
|
-
// Only members can buy here, so spending here means a member.
|
|
157
|
-
members_shop_at: z.array(KebabId).default([]),
|
|
158
|
-
})
|
|
159
|
-
.optional(),
|
|
152
|
+
// A paid membership (data/memberships.yaml) the card can't be had
|
|
153
|
+
// without, Costco's for Costco Anywhere. Its fee counts like an annual
|
|
154
|
+
// fee for anyone not already paying it.
|
|
155
|
+
requires_membership: KebabId.optional(),
|
|
160
156
|
pools_into: z
|
|
161
157
|
.strictObject({
|
|
162
158
|
currency: CurrencyId,
|
package/dist/schema/category.js
CHANGED
|
@@ -9,5 +9,9 @@ export const CategorySchema = z.strictObject({
|
|
|
9
9
|
// Everyday words people search by ("groceries"), besides the name.
|
|
10
10
|
aliases: z.array(z.string().min(1)).default([]),
|
|
11
11
|
note: z.string().optional(),
|
|
12
|
+
// Bought as a service, a category reads "on" (3x on flights); places read
|
|
13
|
+
// "at". Subcategories inherit it.
|
|
14
|
+
preposition: z.literal('on').optional(),
|
|
15
|
+
slug: z.string().optional(), // its search page, when not from its name
|
|
12
16
|
});
|
|
13
17
|
export const CategoriesFileSchema = z.array(CategorySchema).min(1);
|