@decibelsystems/tools 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +115 -233
  2. package/dist/client/facade-client.d.ts +9 -3
  3. package/dist/client/facade-client.d.ts.map +1 -1
  4. package/dist/client/facade-client.js +58 -29
  5. package/dist/client/facade-client.js.map +1 -1
  6. package/dist/client/index.d.ts +1 -1
  7. package/dist/client/index.d.ts.map +1 -1
  8. package/dist/client/transports.d.ts +3 -0
  9. package/dist/client/transports.d.ts.map +1 -1
  10. package/dist/client/transports.js +12 -0
  11. package/dist/client/transports.js.map +1 -1
  12. package/dist/client/types.d.ts +18 -0
  13. package/dist/client/types.d.ts.map +1 -1
  14. package/dist/daemon.d.ts +29 -0
  15. package/dist/daemon.d.ts.map +1 -1
  16. package/dist/daemon.js +64 -0
  17. package/dist/daemon.js.map +1 -1
  18. package/dist/daemonConfig.d.ts +7 -0
  19. package/dist/daemonConfig.d.ts.map +1 -1
  20. package/dist/daemonConfig.js +9 -0
  21. package/dist/daemonConfig.js.map +1 -1
  22. package/dist/facades/definitions.d.ts.map +1 -1
  23. package/dist/facades/definitions.js +116 -35
  24. package/dist/facades/definitions.js.map +1 -1
  25. package/dist/facades/types.d.ts +2 -0
  26. package/dist/facades/types.d.ts.map +1 -1
  27. package/dist/httpServer.d.ts +4 -0
  28. package/dist/httpServer.d.ts.map +1 -1
  29. package/dist/httpServer.js +183 -13
  30. package/dist/httpServer.js.map +1 -1
  31. package/dist/kernel.d.ts +9 -0
  32. package/dist/kernel.d.ts.map +1 -1
  33. package/dist/kernel.js +12 -7
  34. package/dist/kernel.js.map +1 -1
  35. package/dist/server.js +30 -1
  36. package/dist/server.js.map +1 -1
  37. package/dist/tools/coordinator/coordinator.d.ts +14 -0
  38. package/dist/tools/coordinator/coordinator.d.ts.map +1 -1
  39. package/dist/tools/coordinator/coordinator.js +101 -0
  40. package/dist/tools/coordinator/coordinator.js.map +1 -1
  41. package/dist/tools/coordinator/index.d.ts +2 -0
  42. package/dist/tools/coordinator/index.d.ts.map +1 -1
  43. package/dist/tools/coordinator/index.js +2 -0
  44. package/dist/tools/coordinator/index.js.map +1 -1
  45. package/dist/tools/deck.d.ts +4 -4
  46. package/dist/tools/deck.d.ts.map +1 -1
  47. package/dist/tools/deck.js +1546 -78
  48. package/dist/tools/deck.js.map +1 -1
  49. package/dist/tools/designer/index.js +1 -1
  50. package/dist/tools/designer/index.js.map +1 -1
  51. package/dist/tools/guardian/index.d.ts +1 -0
  52. package/dist/tools/guardian/index.d.ts.map +1 -1
  53. package/dist/tools/guardian/index.js +40 -1
  54. package/dist/tools/guardian/index.js.map +1 -1
  55. package/dist/tools/guardian.d.ts +30 -0
  56. package/dist/tools/guardian.d.ts.map +1 -1
  57. package/dist/tools/guardian.js +96 -0
  58. package/dist/tools/guardian.js.map +1 -1
  59. package/dist/tools/index.d.ts.map +1 -1
  60. package/dist/tools/index.js +5 -1
  61. package/dist/tools/index.js.map +1 -1
  62. package/dist/tools/mother.d.ts +11 -0
  63. package/dist/tools/mother.d.ts.map +1 -0
  64. package/dist/tools/mother.js +503 -0
  65. package/dist/tools/mother.js.map +1 -0
  66. package/dist/tools/swarm.d.ts +11 -0
  67. package/dist/tools/swarm.d.ts.map +1 -0
  68. package/dist/tools/swarm.js +701 -0
  69. package/dist/tools/swarm.js.map +1 -0
  70. package/dist/tools/terminal.d.ts +17 -0
  71. package/dist/tools/terminal.d.ts.map +1 -0
  72. package/dist/tools/terminal.js +968 -0
  73. package/dist/tools/terminal.js.map +1 -0
  74. package/dist/transports/http.d.ts.map +1 -1
  75. package/dist/transports/http.js +2 -0
  76. package/dist/transports/http.js.map +1 -1
  77. package/dist/transports/mcp.d.ts.map +1 -1
  78. package/dist/transports/mcp.js +3 -0
  79. package/dist/transports/mcp.js.map +1 -1
  80. package/dist/transports/types.d.ts +4 -0
  81. package/dist/transports/types.d.ts.map +1 -1
  82. package/package.json +1 -1
@@ -1,32 +1,28 @@
1
1
  /**
2
- * Deck Tools - MTG Buylist Price Database
2
+ * Deck Tools - MTG Card Database (Supabase)
3
3
  *
4
- * Tools for searching and browsing Magic: The Gathering card buylist prices
5
- * from multiple stores. Data stored in Render Postgres (deck-db).
4
+ * Card catalog, price history, and market movers for Magic: The Gathering.
5
+ * Data lives in the Core Supabase project (dqftcixhsvcjtyxsxoao).
6
6
  *
7
- * Env var: DECK_DATABASE_URL
7
+ * Env var: DECK_SUPABASE_KEY (service role or anon key — tables have public SELECT policies)
8
8
  */
9
- import pg from 'pg';
10
- const { Pool } = pg;
11
- // Lazy-init pool
12
- let pool = null;
13
- function getPool() {
14
- if (!pool) {
15
- const connectionString = process.env.DECK_DATABASE_URL;
16
- if (!connectionString) {
17
- throw new Error('DECK_DATABASE_URL environment variable not set');
18
- }
19
- pool = new Pool({
20
- connectionString,
21
- ssl: { rejectUnauthorized: false }, // Render requires SSL
22
- max: 5,
23
- idleTimeoutMillis: 30000,
9
+ import { createClient } from '@supabase/supabase-js';
10
+ const CORE_SUPABASE_URL = 'https://dqftcixhsvcjtyxsxoao.supabase.co';
11
+ // Anon key for public read access (all deck tables have public SELECT RLS policies)
12
+ const CORE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRxZnRjaXhoc3ZjanR5eHN4b2FvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcyMTgzMTYsImV4cCI6MjA4Mjc5NDMxNn0.erZq0zJMF-tATX7b0mofGsajFUvcqlZE5PQ4qcggmGc';
13
+ // Lazy-init client
14
+ let client = null;
15
+ function getClient() {
16
+ if (!client) {
17
+ const key = process.env.DECK_SUPABASE_KEY || CORE_ANON_KEY;
18
+ client = createClient(CORE_SUPABASE_URL, key, {
19
+ auth: { persistSession: false, autoRefreshToken: false },
24
20
  });
25
21
  }
26
- return pool;
22
+ return client;
27
23
  }
28
24
  // =============================================================================
29
- // Helper
25
+ // Helpers
30
26
  // =============================================================================
31
27
  function jsonResult(data, isError = false) {
32
28
  return {
@@ -34,15 +30,81 @@ function jsonResult(data, isError = false) {
34
30
  isError,
35
31
  };
36
32
  }
33
+ /**
34
+ * Fallback to Scryfall API when a card isn't found in the local database.
35
+ * Returns results shaped like the Supabase cards table for consistency.
36
+ */
37
+ async function scryfallSearch(name, setCode) {
38
+ let query = `name:${name}`;
39
+ if (setCode)
40
+ query += ` set:${setCode}`;
41
+ const url = `https://api.scryfall.com/cards/search?q=${encodeURIComponent(query)}&order=usd&dir=desc`;
42
+ const res = await fetch(url);
43
+ if (!res.ok)
44
+ return [];
45
+ const json = (await res.json());
46
+ if (!json.data?.length)
47
+ return [];
48
+ return json.data.slice(0, 50).map((card) => ({
49
+ name: card.name,
50
+ set_code: card.set,
51
+ set_name: card.set_name,
52
+ rarity: card.rarity,
53
+ price_usd: card.prices?.usd ?? null,
54
+ price_usd_foil: card.prices?.usd_foil ?? null,
55
+ legalities: card.legalities,
56
+ scryfall_id: card.id,
57
+ image_uri: card.image_uris?.normal ??
58
+ card.image_uris?.large ??
59
+ null,
60
+ type_line: card.type_line,
61
+ mana_cost: card.mana_cost,
62
+ source: 'scryfall',
63
+ }));
64
+ }
65
+ // =============================================================================
66
+ // Validation Helpers
67
+ // =============================================================================
68
+ const VALID_FORMATS = [
69
+ 'standard', 'modern', 'pioneer', 'commander', 'legacy', 'vintage',
70
+ 'pauper', 'historic', 'explorer', 'brawl', 'alchemy', 'penny', 'oathbreaker',
71
+ ];
72
+ const VALID_RARITIES = ['common', 'uncommon', 'rare', 'mythic'];
73
+ function validateFormat(format) {
74
+ const f = format.toLowerCase();
75
+ if (!VALID_FORMATS.includes(f)) {
76
+ throw new Error(`Invalid format "${format}". Valid: ${VALID_FORMATS.join(', ')}`);
77
+ }
78
+ return f;
79
+ }
80
+ function validateRarity(rarity) {
81
+ const r = rarity.toLowerCase();
82
+ if (!VALID_RARITIES.includes(r)) {
83
+ throw new Error(`Invalid rarity "${rarity}". Valid: ${VALID_RARITIES.join(', ')}`);
84
+ }
85
+ return r;
86
+ }
87
+ /** Grade price stability from coefficient of variation */
88
+ function gradeStability(cv) {
89
+ if (cv < 5)
90
+ return { grade: 'A', label: 'Very Stable' };
91
+ if (cv < 10)
92
+ return { grade: 'B', label: 'Stable' };
93
+ if (cv < 20)
94
+ return { grade: 'C', label: 'Moderate' };
95
+ if (cv < 35)
96
+ return { grade: 'D', label: 'Volatile' };
97
+ return { grade: 'F', label: 'Highly Volatile' };
98
+ }
37
99
  // =============================================================================
38
100
  // Tool Specs
39
101
  // =============================================================================
40
- const deckBuylistSearch = {
102
+ const deckSearch = {
41
103
  definition: {
42
- name: 'deck_buylist_search',
43
- description: 'Search MTG card buylist prices by card name. Returns prices from multiple stores.',
104
+ name: 'deck_search',
105
+ description: 'Search MTG cards by name. Returns card details with current prices.',
44
106
  annotations: {
45
- title: 'Search Buylist',
107
+ title: 'Search Cards',
46
108
  readOnlyHint: true,
47
109
  destructiveHint: false,
48
110
  openWorldHint: true,
@@ -54,9 +116,9 @@ const deckBuylistSearch = {
54
116
  type: 'string',
55
117
  description: 'Card name to search for (partial match, case-insensitive)',
56
118
  },
57
- store: {
119
+ set_code: {
58
120
  type: 'string',
59
- description: 'Optional: filter by specific store name',
121
+ description: 'Optional: filter by set code (e.g. "MH3", "LCI")',
60
122
  },
61
123
  },
62
124
  required: ['name'],
@@ -64,21 +126,34 @@ const deckBuylistSearch = {
64
126
  },
65
127
  handler: async (args) => {
66
128
  try {
67
- const db = getPool();
68
- const query = `
69
- SELECT card_name, set_code, set_name, is_foil, nm_price, lp_price, mp_price, hp_price,
70
- store, scryfall_id
71
- FROM buylist_prices
72
- WHERE card_name ILIKE '%' || $1 || '%'
73
- AND ($2::text IS NULL OR store = $2)
74
- ORDER BY nm_price DESC NULLS LAST
75
- LIMIT 50
76
- `;
77
- const result = await db.query(query, [args.name, args.store || null]);
129
+ const db = getClient();
130
+ let query = db
131
+ .from('cards')
132
+ .select('name, set_code, set_name, rarity, price_usd, price_usd_foil, legalities, scryfall_id, image_uri, type_line, mana_cost')
133
+ .ilike('name', `%${args.name}%`)
134
+ .order('price_usd', { ascending: false, nullsFirst: false })
135
+ .limit(50);
136
+ if (args.set_code) {
137
+ query = query.eq('set_code', args.set_code.toLowerCase());
138
+ }
139
+ const { data, error } = await query;
140
+ if (error)
141
+ throw new Error(error.message);
142
+ // Fallback to Scryfall API when local DB returns no results
143
+ if (!data || data.length === 0) {
144
+ const fallback = await scryfallSearch(args.name, args.set_code);
145
+ return jsonResult({
146
+ success: true,
147
+ cards: fallback,
148
+ count: fallback.length,
149
+ source: fallback.length > 0 ? 'scryfall_fallback' : 'none',
150
+ });
151
+ }
78
152
  return jsonResult({
79
153
  success: true,
80
- cards: result.rows,
81
- count: result.rows.length,
154
+ cards: data,
155
+ count: data.length,
156
+ source: 'database',
82
157
  });
83
158
  }
84
159
  catch (error) {
@@ -87,12 +162,12 @@ const deckBuylistSearch = {
87
162
  }
88
163
  },
89
164
  };
90
- const deckBuylistList = {
165
+ const deckList = {
91
166
  definition: {
92
- name: 'deck_buylist_list',
93
- description: 'List top buylist prices for a specific store, ordered by NM price descending.',
167
+ name: 'deck_list',
168
+ description: 'List top price movers for a given period. Returns cards with the biggest price changes (gainers and losers).',
94
169
  annotations: {
95
- title: 'List Store Buylist',
170
+ title: 'Top Movers',
96
171
  readOnlyHint: true,
97
172
  destructiveHint: false,
98
173
  openWorldHint: true,
@@ -100,34 +175,66 @@ const deckBuylistList = {
100
175
  inputSchema: {
101
176
  type: 'object',
102
177
  properties: {
103
- store: {
178
+ period: {
104
179
  type: 'string',
105
- description: 'Store name to list cards for',
180
+ description: 'Time period for price changes (default: "7d"). E.g. "1d", "7d", "30d"',
106
181
  },
107
182
  limit: {
108
183
  type: 'number',
109
- description: 'Max cards to return (default: 50)',
184
+ description: 'Max results (default: 50)',
110
185
  },
111
186
  },
112
- required: ['store'],
187
+ required: [],
113
188
  },
114
189
  },
115
190
  handler: async (args) => {
116
191
  try {
117
- const db = getPool();
192
+ const db = getClient();
193
+ const period = args.period || '7d';
118
194
  const limit = args.limit || 50;
119
- const query = `
120
- SELECT card_name, set_code, set_name, is_foil, nm_price, lp_price, store
121
- FROM buylist_prices
122
- WHERE store = $1
123
- ORDER BY nm_price DESC NULLS LAST
124
- LIMIT $2
125
- `;
126
- const result = await db.query(query, [args.store, limit]);
195
+ // Fetch movers — grab extra to cover abs() sorting
196
+ const { data: movers, error: moversErr } = await db
197
+ .from('price_movers')
198
+ .select('scryfall_id, period, price_from, price_to, change_pct, computed_at')
199
+ .eq('period', period)
200
+ .order('change_pct', { ascending: false })
201
+ .limit(limit * 2);
202
+ if (moversErr)
203
+ throw new Error(moversErr.message);
204
+ if (!movers || movers.length === 0) {
205
+ return jsonResult({ success: true, movers: [], count: 0, period });
206
+ }
207
+ // Sort by abs(change_pct), take top N
208
+ movers.sort((a, b) => Math.abs(b.change_pct) - Math.abs(a.change_pct));
209
+ const topMovers = movers.slice(0, limit);
210
+ // Batch-lookup card names
211
+ const ids = topMovers.map((m) => m.scryfall_id);
212
+ const { data: cards, error: cardsErr } = await db
213
+ .from('cards')
214
+ .select('scryfall_id, name, set_code')
215
+ .in('scryfall_id', ids);
216
+ if (cardsErr)
217
+ throw new Error(cardsErr.message);
218
+ const cardMap = new Map((cards || []).map((c) => [
219
+ c.scryfall_id,
220
+ c,
221
+ ]));
222
+ const results = topMovers.map((m) => {
223
+ const card = cardMap.get(m.scryfall_id);
224
+ return {
225
+ name: card?.name || 'Unknown',
226
+ set_code: card?.set_code || '',
227
+ price_from: m.price_from,
228
+ price_to: m.price_to,
229
+ change_pct: m.change_pct,
230
+ computed_at: m.computed_at,
231
+ };
232
+ });
127
233
  return jsonResult({
128
234
  success: true,
129
- cards: result.rows,
130
- count: result.rows.length,
235
+ movers: results,
236
+ count: results.length,
237
+ period,
131
238
  });
132
239
  }
133
240
  catch (error) {
@@ -136,12 +243,12 @@ const deckBuylistList = {
136
243
  }
137
244
  },
138
245
  };
139
- const deckBuylistStores = {
246
+ const deckSummary = {
140
247
  definition: {
141
- name: 'deck_buylist_stores',
142
- description: 'List all stores in the buylist database with card counts and last update time.',
248
+ name: 'deck_summary',
249
+ description: 'Get a summary of the card database: card count, price sources, last update time, available mover periods.',
143
250
  annotations: {
144
- title: 'List Stores',
251
+ title: 'Data Summary',
145
252
  readOnlyHint: true,
146
253
  destructiveHint: false,
147
254
  openWorldHint: true,
@@ -154,20 +261,692 @@ const deckBuylistStores = {
154
261
  },
155
262
  handler: async () => {
156
263
  try {
157
- const db = getPool();
158
- const query = `
159
- SELECT store, COUNT(*) as card_count, MAX(updated_at) as last_updated
160
- FROM buylist_prices
161
- GROUP BY store
162
- ORDER BY card_count DESC
163
- `;
164
- const result = await db.query(query);
264
+ const db = getClient();
265
+ const [cardCount, sources, latestUpdate, periods] = await Promise.all([
266
+ db.from('cards').select('*', { count: 'exact', head: true }),
267
+ db.from('price_history').select('source').limit(1000),
268
+ db
269
+ .from('cards')
270
+ .select('price_updated_at')
271
+ .order('price_updated_at', { ascending: false })
272
+ .limit(1),
273
+ db.from('price_movers').select('period').limit(1000),
274
+ ]);
275
+ const distinctSources = [
276
+ ...new Set((sources.data || []).map((r) => r.source)),
277
+ ];
278
+ const distinctPeriods = [
279
+ ...new Set((periods.data || []).map((r) => r.period)),
280
+ ];
281
+ return jsonResult({
282
+ success: true,
283
+ summary: {
284
+ card_count: cardCount.count || 0,
285
+ price_sources: distinctSources,
286
+ latest_price_update: latestUpdate.data?.[0]?.price_updated_at || null,
287
+ available_periods: distinctPeriods,
288
+ },
289
+ });
290
+ }
291
+ catch (error) {
292
+ const msg = error instanceof Error ? error.message : String(error);
293
+ return jsonResult({ success: false, error: msg }, true);
294
+ }
295
+ },
296
+ };
297
+ const deckHistory = {
298
+ definition: {
299
+ name: 'deck_history',
300
+ description: 'Get price history for a specific card. Accepts scryfall_id or card name. Returns up to 90 days of price data.',
301
+ annotations: {
302
+ title: 'Price History',
303
+ readOnlyHint: true,
304
+ destructiveHint: false,
305
+ openWorldHint: true,
306
+ },
307
+ inputSchema: {
308
+ type: 'object',
309
+ properties: {
310
+ scryfall_id: {
311
+ type: 'string',
312
+ description: 'Scryfall UUID of the card',
313
+ },
314
+ name: {
315
+ type: 'string',
316
+ description: 'Card name (exact match preferred; used if scryfall_id not provided)',
317
+ },
318
+ limit: {
319
+ type: 'number',
320
+ description: 'Max rows (default: 90 — ~3 months)',
321
+ },
322
+ },
323
+ required: [],
324
+ },
325
+ },
326
+ handler: async (args) => {
327
+ try {
328
+ if (!args.scryfall_id && !args.name) {
329
+ return jsonResult({ success: false, error: 'Provide either scryfall_id or name' }, true);
330
+ }
331
+ const db = getClient();
332
+ const limit = args.limit || 90;
333
+ let scryfallId = args.scryfall_id;
334
+ // Resolve name → scryfall_id if needed
335
+ if (!scryfallId && args.name) {
336
+ const { data: cards, error: cardErr } = await db
337
+ .from('cards')
338
+ .select('scryfall_id, name')
339
+ .ilike('name', args.name)
340
+ .limit(1);
341
+ if (cardErr)
342
+ throw new Error(cardErr.message);
343
+ if (!cards || cards.length === 0) {
344
+ return jsonResult({ success: false, error: `Card not found: ${args.name}` }, true);
345
+ }
346
+ scryfallId = cards[0]
347
+ .scryfall_id;
348
+ }
349
+ const { data, error } = await db
350
+ .from('price_history')
351
+ .select('date, price_usd, price_usd_foil, source')
352
+ .eq('scryfall_id', scryfallId)
353
+ .order('date', { ascending: false })
354
+ .limit(limit);
355
+ if (error)
356
+ throw new Error(error.message);
357
+ return jsonResult({
358
+ success: true,
359
+ scryfall_id: scryfallId,
360
+ history: data,
361
+ count: data?.length || 0,
362
+ });
363
+ }
364
+ catch (error) {
365
+ const msg = error instanceof Error ? error.message : String(error);
366
+ return jsonResult({ success: false, error: msg }, true);
367
+ }
368
+ },
369
+ };
370
+ // =============================================================================
371
+ // Tool 5: Format Deals
372
+ // =============================================================================
373
+ const deckFormatDeals = {
374
+ definition: {
375
+ name: 'deck_format_deals',
376
+ description: 'Find underpriced cards legal in a specific format. Great for budget deck building.',
377
+ annotations: {
378
+ title: 'Format Deals',
379
+ readOnlyHint: true,
380
+ destructiveHint: false,
381
+ openWorldHint: true,
382
+ },
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ format: {
387
+ type: 'string',
388
+ description: `Format to check legality (${VALID_FORMATS.join(', ')})`,
389
+ },
390
+ max_price: {
391
+ type: 'number',
392
+ description: 'Maximum price in USD (default: no limit)',
393
+ },
394
+ min_price: {
395
+ type: 'number',
396
+ description: 'Minimum price in USD (default: 0.01 to skip bulk)',
397
+ },
398
+ rarity: {
399
+ type: 'string',
400
+ description: 'Filter by rarity (common, uncommon, rare, mythic)',
401
+ },
402
+ sort_by: {
403
+ type: 'string',
404
+ description: 'Sort order: price_asc (default) or price_desc',
405
+ },
406
+ limit: {
407
+ type: 'number',
408
+ description: 'Max results (default: 50, max: 200)',
409
+ },
410
+ },
411
+ required: ['format'],
412
+ },
413
+ },
414
+ handler: async (args) => {
415
+ try {
416
+ const format = validateFormat(args.format);
417
+ const minPrice = args.min_price ?? 0.01;
418
+ const limit = Math.min(args.limit || 50, 200);
419
+ const ascending = (args.sort_by || 'price_asc') === 'price_asc';
420
+ const db = getClient();
421
+ let query = db
422
+ .from('cards')
423
+ .select('name, set_code, set_name, rarity, price_usd, type_line, mana_cost, scryfall_id')
424
+ .filter(`legalities->>${format}`, 'eq', 'legal')
425
+ .gte('price_usd', minPrice)
426
+ .order('price_usd', { ascending, nullsFirst: false })
427
+ .limit(limit);
428
+ if (args.max_price != null) {
429
+ query = query.lte('price_usd', args.max_price);
430
+ }
431
+ if (args.rarity) {
432
+ query = query.eq('rarity', validateRarity(args.rarity));
433
+ }
434
+ const { data, error } = await query;
435
+ if (error)
436
+ throw new Error(error.message);
437
+ return jsonResult({
438
+ success: true,
439
+ format,
440
+ cards: data,
441
+ count: data?.length || 0,
442
+ });
443
+ }
444
+ catch (error) {
445
+ const msg = error instanceof Error ? error.message : String(error);
446
+ return jsonResult({ success: false, error: msg }, true);
447
+ }
448
+ },
449
+ };
450
+ // =============================================================================
451
+ // Tool 6: Type Search
452
+ // =============================================================================
453
+ const deckTypeSearch = {
454
+ definition: {
455
+ name: 'deck_type_search',
456
+ description: 'Search cards by type line for tribal/archetype building (e.g. Elf, Planeswalker, Equipment).',
457
+ annotations: {
458
+ title: 'Type Search',
459
+ readOnlyHint: true,
460
+ destructiveHint: false,
461
+ openWorldHint: true,
462
+ },
463
+ inputSchema: {
464
+ type: 'object',
465
+ properties: {
466
+ type: {
467
+ type: 'string',
468
+ description: 'Type to search for (e.g. "Elf", "Planeswalker", "Equipment")',
469
+ },
470
+ format: {
471
+ type: 'string',
472
+ description: 'Filter by format legality',
473
+ },
474
+ max_price: {
475
+ type: 'number',
476
+ description: 'Maximum price in USD',
477
+ },
478
+ min_price: {
479
+ type: 'number',
480
+ description: 'Minimum price in USD',
481
+ },
482
+ set_code: {
483
+ type: 'string',
484
+ description: 'Filter by set code (e.g. "MH3")',
485
+ },
486
+ limit: {
487
+ type: 'number',
488
+ description: 'Max results (default: 50, max: 200)',
489
+ },
490
+ },
491
+ required: ['type'],
492
+ },
493
+ },
494
+ handler: async (args) => {
495
+ try {
496
+ const limit = Math.min(args.limit || 50, 200);
497
+ const db = getClient();
498
+ let query = db
499
+ .from('cards')
500
+ .select('name, set_code, set_name, rarity, price_usd, type_line, mana_cost, scryfall_id')
501
+ .ilike('type_line', `%${args.type}%`)
502
+ .order('price_usd', { ascending: false, nullsFirst: false })
503
+ .limit(limit);
504
+ if (args.format) {
505
+ const format = validateFormat(args.format);
506
+ query = query.filter(`legalities->>${format}`, 'eq', 'legal');
507
+ }
508
+ if (args.max_price != null) {
509
+ query = query.lte('price_usd', args.max_price);
510
+ }
511
+ if (args.min_price != null) {
512
+ query = query.gte('price_usd', args.min_price);
513
+ }
514
+ if (args.set_code) {
515
+ query = query.eq('set_code', args.set_code.toLowerCase());
516
+ }
517
+ const { data, error } = await query;
518
+ if (error)
519
+ throw new Error(error.message);
520
+ return jsonResult({
521
+ success: true,
522
+ type: args.type,
523
+ cards: data,
524
+ count: data?.length || 0,
525
+ });
526
+ }
527
+ catch (error) {
528
+ const msg = error instanceof Error ? error.message : String(error);
529
+ return jsonResult({ success: false, error: msg }, true);
530
+ }
531
+ },
532
+ };
533
+ // =============================================================================
534
+ // Tool 7: Price Bracket
535
+ // =============================================================================
536
+ const deckPriceBracket = {
537
+ definition: {
538
+ name: 'deck_price_bracket',
539
+ description: 'Multi-filter power search: combine price range, rarity, format, set, and type filters.',
540
+ annotations: {
541
+ title: 'Price Bracket',
542
+ readOnlyHint: true,
543
+ destructiveHint: false,
544
+ openWorldHint: true,
545
+ },
546
+ inputSchema: {
547
+ type: 'object',
548
+ properties: {
549
+ min_price: { type: 'number', description: 'Minimum price in USD' },
550
+ max_price: { type: 'number', description: 'Maximum price in USD' },
551
+ rarity: { type: 'string', description: 'Card rarity filter' },
552
+ format: { type: 'string', description: 'Format legality filter' },
553
+ set_code: { type: 'string', description: 'Set code filter' },
554
+ type_line: { type: 'string', description: 'Type line filter (partial match)' },
555
+ sort_by: {
556
+ type: 'string',
557
+ description: 'Sort: price_desc (default), price_asc, name',
558
+ },
559
+ limit: { type: 'number', description: 'Max results (default: 50, max: 200)' },
560
+ },
561
+ required: [],
562
+ },
563
+ },
564
+ handler: async (args) => {
565
+ try {
566
+ const hasFilter = args.min_price != null ||
567
+ args.max_price != null ||
568
+ args.rarity ||
569
+ args.format ||
570
+ args.set_code ||
571
+ args.type_line;
572
+ if (!hasFilter) {
573
+ return jsonResult({ success: false, error: 'Provide at least one filter (min_price, max_price, rarity, format, set_code, type_line)' }, true);
574
+ }
575
+ const limit = Math.min(args.limit || 50, 200);
576
+ const db = getClient();
577
+ const filtersApplied = [];
578
+ let query = db
579
+ .from('cards')
580
+ .select('name, set_code, set_name, rarity, price_usd, type_line, mana_cost, scryfall_id')
581
+ .limit(limit);
582
+ if (args.min_price != null) {
583
+ query = query.gte('price_usd', args.min_price);
584
+ filtersApplied.push(`min_price=${args.min_price}`);
585
+ }
586
+ if (args.max_price != null) {
587
+ query = query.lte('price_usd', args.max_price);
588
+ filtersApplied.push(`max_price=${args.max_price}`);
589
+ }
590
+ if (args.rarity) {
591
+ query = query.eq('rarity', validateRarity(args.rarity));
592
+ filtersApplied.push(`rarity=${args.rarity}`);
593
+ }
594
+ if (args.format) {
595
+ const format = validateFormat(args.format);
596
+ query = query.filter(`legalities->>${format}`, 'eq', 'legal');
597
+ filtersApplied.push(`format=${format}`);
598
+ }
599
+ if (args.set_code) {
600
+ query = query.eq('set_code', args.set_code.toLowerCase());
601
+ filtersApplied.push(`set_code=${args.set_code}`);
602
+ }
603
+ if (args.type_line) {
604
+ query = query.ilike('type_line', `%${args.type_line}%`);
605
+ filtersApplied.push(`type_line~${args.type_line}`);
606
+ }
607
+ // Sort
608
+ const sortBy = args.sort_by || 'price_desc';
609
+ if (sortBy === 'price_asc') {
610
+ query = query.order('price_usd', { ascending: true, nullsFirst: false });
611
+ }
612
+ else if (sortBy === 'name') {
613
+ query = query.order('name', { ascending: true });
614
+ }
615
+ else {
616
+ query = query.order('price_usd', { ascending: false, nullsFirst: false });
617
+ }
618
+ const { data, error } = await query;
619
+ if (error)
620
+ throw new Error(error.message);
621
+ return jsonResult({
622
+ success: true,
623
+ filters_applied: filtersApplied,
624
+ cards: data,
625
+ count: data?.length || 0,
626
+ });
627
+ }
628
+ catch (error) {
629
+ const msg = error instanceof Error ? error.message : String(error);
630
+ return jsonResult({ success: false, error: msg }, true);
631
+ }
632
+ },
633
+ };
634
+ // =============================================================================
635
+ // Tool 8: Set Analysis
636
+ // =============================================================================
637
+ const deckSetAnalysis = {
638
+ definition: {
639
+ name: 'deck_set_analysis',
640
+ description: 'Aggregate price stats for a set: EV breakdown by rarity, top cards, cheapest mythics.',
641
+ annotations: {
642
+ title: 'Set Analysis',
643
+ readOnlyHint: true,
644
+ destructiveHint: false,
645
+ openWorldHint: true,
646
+ },
647
+ inputSchema: {
648
+ type: 'object',
649
+ properties: {
650
+ set_code: {
651
+ type: 'string',
652
+ description: 'Set code (e.g. "MH3", "LCI", "ONE")',
653
+ },
654
+ },
655
+ required: ['set_code'],
656
+ },
657
+ },
658
+ handler: async (args) => {
659
+ try {
660
+ const db = getClient();
661
+ const setCode = args.set_code.toLowerCase();
662
+ // Use the deck_set_stats SQL function for efficient server-side aggregation
663
+ const { data: stats, error: statsErr } = await db.rpc('deck_set_stats', {
664
+ p_set_code: setCode,
665
+ });
666
+ if (statsErr)
667
+ throw new Error(statsErr.message);
668
+ if (!stats || stats.length === 0) {
669
+ return jsonResult({ success: false, error: `No cards found for set "${args.set_code}"` }, true);
670
+ }
671
+ const row = stats[0];
672
+ // Also fetch top 10 most expensive for detail
673
+ const { data: topCards, error: topErr } = await db
674
+ .from('cards')
675
+ .select('name, rarity, price_usd')
676
+ .eq('set_code', setCode)
677
+ .not('price_usd', 'is', null)
678
+ .gt('price_usd', 0)
679
+ .order('price_usd', { ascending: false, nullsFirst: false })
680
+ .limit(10);
681
+ if (topErr)
682
+ throw new Error(topErr.message);
683
+ return jsonResult({
684
+ success: true,
685
+ set_code: row.set_code,
686
+ set_name: row.set_name,
687
+ total_cards: row.total_cards,
688
+ cards_with_price: row.cards_with_price,
689
+ total_value: row.total_value,
690
+ avg_price: row.avg_price,
691
+ median_price: row.median_price,
692
+ max_price: row.max_price,
693
+ max_price_card: row.max_price_card,
694
+ rarity_averages: {
695
+ mythic: row.mythic_avg,
696
+ rare: row.rare_avg,
697
+ uncommon: row.uncommon_avg,
698
+ common: row.common_avg,
699
+ },
700
+ cheapest_mythic: row.cheapest_mythic_name
701
+ ? { name: row.cheapest_mythic_name, price: row.cheapest_mythic_price }
702
+ : null,
703
+ top_10_expensive: topCards || [],
704
+ });
705
+ }
706
+ catch (error) {
707
+ const msg = error instanceof Error ? error.message : String(error);
708
+ return jsonResult({ success: false, error: msg }, true);
709
+ }
710
+ },
711
+ };
712
+ // =============================================================================
713
+ // Tool 9: Volatility
714
+ // =============================================================================
715
+ const deckVolatility = {
716
+ definition: {
717
+ name: 'deck_volatility',
718
+ description: 'Price volatility scoring. Broad scan (no name) finds most volatile cards. Single card mode (with name/scryfall_id) gives detailed stability analysis with A-F grade.',
719
+ annotations: {
720
+ title: 'Volatility',
721
+ readOnlyHint: true,
722
+ destructiveHint: false,
723
+ openWorldHint: true,
724
+ },
725
+ inputSchema: {
726
+ type: 'object',
727
+ properties: {
728
+ name: {
729
+ type: 'string',
730
+ description: 'Card name for single-card analysis (omit for broad scan)',
731
+ },
732
+ scryfall_id: {
733
+ type: 'string',
734
+ description: 'Scryfall UUID for single-card analysis',
735
+ },
736
+ min_price: {
737
+ type: 'number',
738
+ description: 'Minimum price filter for broad scan (default: 1.00)',
739
+ },
740
+ days: {
741
+ type: 'number',
742
+ description: 'History window for single-card mode (default: 90)',
743
+ },
744
+ limit: {
745
+ type: 'number',
746
+ description: 'Max results for broad scan (default: 25, max: 100)',
747
+ },
748
+ },
749
+ required: [],
750
+ },
751
+ },
752
+ handler: async (args) => {
753
+ try {
754
+ const db = getClient();
755
+ // ── Single card mode ──
756
+ if (args.name || args.scryfall_id) {
757
+ let scryfallId = args.scryfall_id;
758
+ // Resolve name → scryfall_id
759
+ if (!scryfallId && args.name) {
760
+ const { data: cards, error: cardErr } = await db
761
+ .from('cards')
762
+ .select('scryfall_id, name, price_usd')
763
+ .ilike('name', args.name)
764
+ .limit(1);
765
+ if (cardErr)
766
+ throw new Error(cardErr.message);
767
+ if (!cards || cards.length === 0) {
768
+ return jsonResult({ success: false, error: `Card not found: ${args.name}` }, true);
769
+ }
770
+ scryfallId = cards[0].scryfall_id;
771
+ }
772
+ const days = args.days || 90;
773
+ // Fetch price history and movers in parallel
774
+ const [historyRes, moversRes] = await Promise.all([
775
+ db
776
+ .from('price_history')
777
+ .select('date, price_usd')
778
+ .eq('scryfall_id', scryfallId)
779
+ .order('date', { ascending: false })
780
+ .limit(days),
781
+ db
782
+ .from('price_movers')
783
+ .select('period, price_from, price_to, change_pct')
784
+ .eq('scryfall_id', scryfallId),
785
+ ]);
786
+ if (historyRes.error)
787
+ throw new Error(historyRes.error.message);
788
+ const prices = (historyRes.data || [])
789
+ .map((r) => r.price_usd)
790
+ .filter((p) => p != null && p > 0);
791
+ if (prices.length < 2) {
792
+ return jsonResult({
793
+ success: true,
794
+ mode: 'single',
795
+ scryfall_id: scryfallId,
796
+ message: 'Not enough price history for volatility analysis',
797
+ data_points: prices.length,
798
+ });
799
+ }
800
+ const mean = prices.reduce((s, p) => s + p, 0) / prices.length;
801
+ const variance = prices.reduce((s, p) => s + (p - mean) ** 2, 0) / prices.length;
802
+ const stddev = Math.sqrt(variance);
803
+ const cv = (stddev / mean) * 100; // coefficient of variation as %
804
+ const stability = gradeStability(cv);
805
+ const trend = prices.length >= 2
806
+ ? prices[0] > prices[prices.length - 1]
807
+ ? 'rising'
808
+ : prices[0] < prices[prices.length - 1]
809
+ ? 'falling'
810
+ : 'flat'
811
+ : 'unknown';
812
+ return jsonResult({
813
+ success: true,
814
+ mode: 'single',
815
+ scryfall_id: scryfallId,
816
+ data_points: prices.length,
817
+ stats: {
818
+ mean: Math.round(mean * 100) / 100,
819
+ stddev: Math.round(stddev * 100) / 100,
820
+ coefficient_of_variation: Math.round(cv * 100) / 100,
821
+ min: Math.min(...prices),
822
+ max: Math.max(...prices),
823
+ range: Math.round((Math.max(...prices) - Math.min(...prices)) * 100) / 100,
824
+ trend,
825
+ },
826
+ stability_grade: stability.grade,
827
+ stability_label: stability.label,
828
+ recent_movers: moversRes.data || [],
829
+ });
830
+ }
831
+ // ── Broad scan mode — use SQL function for real CV-based volatility ──
832
+ const minPrice = args.min_price ?? 1.0;
833
+ const limit = Math.min(args.limit || 25, 100);
834
+ const days = args.days || 30;
835
+ const { data: volData, error: volErr } = await db.rpc('deck_volatility_scores', { p_days: days, p_min_price: minPrice, p_limit: limit });
836
+ if (volErr)
837
+ throw new Error(volErr.message);
838
+ const results = (volData || []).map((r) => ({
839
+ name: r.name,
840
+ set_code: r.set_code,
841
+ rarity: r.rarity,
842
+ scryfall_id: r.scryfall_id,
843
+ current_price: r.current_price,
844
+ avg_price: r.avg_price,
845
+ stddev: r.stddev_price,
846
+ price_range: { min: r.min_price, max: r.max_price },
847
+ cv_pct: r.cv_pct,
848
+ stability: gradeStability(Number(r.cv_pct) || 0),
849
+ data_points: r.data_points,
850
+ }));
851
+ return jsonResult({
852
+ success: true,
853
+ mode: 'broad',
854
+ days,
855
+ min_price_filter: minPrice,
856
+ cards: results,
857
+ count: results.length,
858
+ });
859
+ }
860
+ catch (error) {
861
+ const msg = error instanceof Error ? error.message : String(error);
862
+ return jsonResult({ success: false, error: msg }, true);
863
+ }
864
+ },
865
+ };
866
+ // =============================================================================
867
+ // Tool 10: Reprints
868
+ // =============================================================================
869
+ const deckReprints = {
870
+ definition: {
871
+ name: 'deck_reprints',
872
+ description: 'Cross-printing price comparison and arbitrage finder. Shows all printings of a card sorted by price.',
873
+ annotations: {
874
+ title: 'Reprints',
875
+ readOnlyHint: true,
876
+ destructiveHint: false,
877
+ openWorldHint: true,
878
+ },
879
+ inputSchema: {
880
+ type: 'object',
881
+ properties: {
882
+ name: {
883
+ type: 'string',
884
+ description: 'Card name (exact match, case-insensitive)',
885
+ },
886
+ },
887
+ required: ['name'],
888
+ },
889
+ },
890
+ handler: async (args) => {
891
+ try {
892
+ const db = getClient();
893
+ // Exact match (case-insensitive, no wildcards)
894
+ const { data, error } = await db
895
+ .from('cards')
896
+ .select('name, set_code, set_name, rarity, price_usd, price_usd_foil, scryfall_id, image_uri')
897
+ .ilike('name', args.name)
898
+ .order('price_usd', { ascending: true, nullsFirst: false })
899
+ .limit(100);
900
+ if (error)
901
+ throw new Error(error.message);
902
+ // No exact match — try partial for suggestions
903
+ if (!data || data.length === 0) {
904
+ const { data: suggestions, error: sugErr } = await db
905
+ .from('cards')
906
+ .select('name')
907
+ .ilike('name', `%${args.name}%`)
908
+ .limit(20);
909
+ if (sugErr)
910
+ throw new Error(sugErr.message);
911
+ const uniqueNames = [...new Set((suggestions || []).map((s) => s.name))].slice(0, 5);
912
+ return jsonResult({
913
+ success: false,
914
+ error: `No exact match for "${args.name}"`,
915
+ suggestions: uniqueNames.length > 0 ? uniqueNames : undefined,
916
+ }, true);
917
+ }
918
+ const printings = data;
919
+ // Compute summary stats
920
+ const priced = printings.filter((c) => c.price_usd != null && c.price_usd > 0);
921
+ const prices = priced.map((c) => c.price_usd);
922
+ const summary = prices.length > 0
923
+ ? {
924
+ total_printings: printings.length,
925
+ priced_printings: prices.length,
926
+ cheapest: Math.min(...prices),
927
+ most_expensive: Math.max(...prices),
928
+ price_spread: Math.round((Math.max(...prices) - Math.min(...prices)) * 100) / 100,
929
+ average_price: Math.round((prices.reduce((s, p) => s + p, 0) / prices.length) * 100) / 100,
930
+ }
931
+ : {
932
+ total_printings: printings.length,
933
+ priced_printings: 0,
934
+ cheapest: null,
935
+ most_expensive: null,
936
+ price_spread: null,
937
+ average_price: null,
938
+ };
165
939
  return jsonResult({
166
940
  success: true,
167
- stores: result.rows.map(row => ({
168
- store: row.store,
169
- card_count: parseInt(row.card_count, 10),
170
- last_updated: row.last_updated?.toISOString() || null,
941
+ name: printings[0].name,
942
+ summary,
943
+ printings: printings.map((c) => ({
944
+ set_code: c.set_code,
945
+ set_name: c.set_name,
946
+ rarity: c.rarity,
947
+ price_usd: c.price_usd,
948
+ price_usd_foil: c.price_usd_foil,
949
+ scryfall_id: c.scryfall_id,
171
950
  })),
172
951
  });
173
952
  }
@@ -178,11 +957,700 @@ const deckBuylistStores = {
178
957
  },
179
958
  };
180
959
  // =============================================================================
960
+ // Tool 11: Match History
961
+ // =============================================================================
962
+ const deckMatchHistory = {
963
+ definition: {
964
+ name: 'deck_match_history',
965
+ description: 'Recent MTG Arena match history. Returns opponent, format, result, deck, duration.',
966
+ annotations: {
967
+ title: 'Match History',
968
+ readOnlyHint: true,
969
+ destructiveHint: false,
970
+ openWorldHint: true,
971
+ },
972
+ inputSchema: {
973
+ type: 'object',
974
+ properties: {
975
+ limit: {
976
+ type: 'number',
977
+ description: 'Max results (default: 20, max: 100)',
978
+ },
979
+ format: {
980
+ type: 'string',
981
+ description: 'Optional: filter by format (e.g. "Standard", "Historic")',
982
+ },
983
+ },
984
+ required: [],
985
+ },
986
+ },
987
+ handler: async (args) => {
988
+ try {
989
+ const db = getClient();
990
+ const limit = Math.min(args.limit || 20, 100);
991
+ let query = db
992
+ .from('match_summary')
993
+ .select('match_id, opponent, format, result, deck_name, duration_seconds, started_at')
994
+ .order('started_at_ms', { ascending: false })
995
+ .limit(limit);
996
+ if (args.format) {
997
+ query = query.ilike('format', args.format);
998
+ }
999
+ const { data, error } = await query;
1000
+ if (error)
1001
+ throw new Error(error.message);
1002
+ return jsonResult({
1003
+ success: true,
1004
+ matches: data || [],
1005
+ count: (data || []).length,
1006
+ });
1007
+ }
1008
+ catch (error) {
1009
+ const msg = error instanceof Error ? error.message : String(error);
1010
+ return jsonResult({ success: false, error: msg }, true);
1011
+ }
1012
+ },
1013
+ };
1014
+ // =============================================================================
1015
+ // Tool 12: Win Rate
1016
+ // =============================================================================
1017
+ const deckWinRate = {
1018
+ definition: {
1019
+ name: 'deck_win_rate',
1020
+ description: 'Win rate statistics grouped by format. Includes overall stats and current streak.',
1021
+ annotations: {
1022
+ title: 'Win Rate',
1023
+ readOnlyHint: true,
1024
+ destructiveHint: false,
1025
+ openWorldHint: true,
1026
+ },
1027
+ inputSchema: {
1028
+ type: 'object',
1029
+ properties: {
1030
+ format: {
1031
+ type: 'string',
1032
+ description: 'Optional: filter to a specific format',
1033
+ },
1034
+ },
1035
+ required: [],
1036
+ },
1037
+ },
1038
+ handler: async (args) => {
1039
+ try {
1040
+ const db = getClient();
1041
+ let query = db
1042
+ .from('match_summary')
1043
+ .select('format, result, started_at_ms')
1044
+ .order('started_at_ms', { ascending: false });
1045
+ if (args.format) {
1046
+ query = query.ilike('format', args.format);
1047
+ }
1048
+ const { data, error } = await query;
1049
+ if (error)
1050
+ throw new Error(error.message);
1051
+ const matches = (data || []);
1052
+ // Group by format
1053
+ const byFormat = new Map();
1054
+ for (const m of matches) {
1055
+ const fmt = m.format || 'Unknown';
1056
+ if (!byFormat.has(fmt))
1057
+ byFormat.set(fmt, { wins: 0, losses: 0, draws: 0, results: [] });
1058
+ const bucket = byFormat.get(fmt);
1059
+ const result = m.result || '';
1060
+ if (result === 'win')
1061
+ bucket.wins++;
1062
+ else if (result === 'loss' || result === 'concede')
1063
+ bucket.losses++;
1064
+ else
1065
+ bucket.draws++;
1066
+ bucket.results.push(result);
1067
+ }
1068
+ // Compute current streak from all matches (chronological, most recent first)
1069
+ const allResults = matches.map((m) => m.result);
1070
+ let streak = 0;
1071
+ const streakType = allResults[0] === 'win' ? 'W' : allResults[0] === 'loss' || allResults[0] === 'concede' ? 'L' : 'D';
1072
+ for (const r of allResults) {
1073
+ const rType = r === 'win' ? 'W' : r === 'loss' || r === 'concede' ? 'L' : 'D';
1074
+ if (rType === streakType)
1075
+ streak++;
1076
+ else
1077
+ break;
1078
+ }
1079
+ const formatStats = Array.from(byFormat.entries()).map(([fmt, b]) => {
1080
+ const total = b.wins + b.losses + b.draws;
1081
+ return {
1082
+ format: fmt,
1083
+ total,
1084
+ wins: b.wins,
1085
+ losses: b.losses,
1086
+ draws: b.draws,
1087
+ win_rate: total > 0 ? Math.round((b.wins / total) * 10000) / 100 : 0,
1088
+ };
1089
+ });
1090
+ // Overall
1091
+ const totalWins = formatStats.reduce((s, f) => s + f.wins, 0);
1092
+ const totalLosses = formatStats.reduce((s, f) => s + f.losses, 0);
1093
+ const totalDraws = formatStats.reduce((s, f) => s + f.draws, 0);
1094
+ const totalAll = totalWins + totalLosses + totalDraws;
1095
+ return jsonResult({
1096
+ success: true,
1097
+ overall: {
1098
+ total: totalAll,
1099
+ wins: totalWins,
1100
+ losses: totalLosses,
1101
+ draws: totalDraws,
1102
+ win_rate: totalAll > 0 ? Math.round((totalWins / totalAll) * 10000) / 100 : 0,
1103
+ current_streak: `${streak}${streakType}`,
1104
+ },
1105
+ by_format: formatStats,
1106
+ });
1107
+ }
1108
+ catch (error) {
1109
+ const msg = error instanceof Error ? error.message : String(error);
1110
+ return jsonResult({ success: false, error: msg }, true);
1111
+ }
1112
+ },
1113
+ };
1114
+ // =============================================================================
1115
+ // Tool 13: Card Performance
1116
+ // =============================================================================
1117
+ const deckCardPerformance = {
1118
+ definition: {
1119
+ name: 'deck_card_performance',
1120
+ description: 'Best and worst cards by win rate. Requires minimum match threshold to filter noise.',
1121
+ annotations: {
1122
+ title: 'Card Performance',
1123
+ readOnlyHint: true,
1124
+ destructiveHint: false,
1125
+ openWorldHint: true,
1126
+ },
1127
+ inputSchema: {
1128
+ type: 'object',
1129
+ properties: {
1130
+ format: {
1131
+ type: 'string',
1132
+ description: 'Optional: filter by format',
1133
+ },
1134
+ min_matches: {
1135
+ type: 'number',
1136
+ description: 'Minimum matches to include (default: 3)',
1137
+ },
1138
+ sort: {
1139
+ type: 'string',
1140
+ description: 'Sort order: "win_rate desc" (default), "win_rate asc", "matches desc"',
1141
+ },
1142
+ limit: {
1143
+ type: 'number',
1144
+ description: 'Max results (default: 20, max: 100)',
1145
+ },
1146
+ },
1147
+ required: [],
1148
+ },
1149
+ },
1150
+ handler: async (args) => {
1151
+ try {
1152
+ const db = getClient();
1153
+ const minMatches = args.min_matches || 3;
1154
+ const limit = Math.min(args.limit || 20, 100);
1155
+ let query = db
1156
+ .from('card_win_rates')
1157
+ .select('name, format, matches, wins, losses, win_rate, set_code')
1158
+ .gte('matches', minMatches);
1159
+ if (args.format) {
1160
+ query = query.ilike('format', args.format);
1161
+ }
1162
+ // Sort
1163
+ const sort = (args.sort || 'win_rate desc').toLowerCase();
1164
+ if (sort === 'win_rate asc') {
1165
+ query = query.order('win_rate', { ascending: true }).order('matches', { ascending: false });
1166
+ }
1167
+ else if (sort === 'matches desc') {
1168
+ query = query.order('matches', { ascending: false });
1169
+ }
1170
+ else {
1171
+ query = query.order('win_rate', { ascending: false }).order('matches', { ascending: false });
1172
+ }
1173
+ query = query.limit(limit);
1174
+ const { data, error } = await query;
1175
+ if (error)
1176
+ throw new Error(error.message);
1177
+ return jsonResult({
1178
+ success: true,
1179
+ cards: data || [],
1180
+ count: (data || []).length,
1181
+ min_matches_filter: minMatches,
1182
+ });
1183
+ }
1184
+ catch (error) {
1185
+ const msg = error instanceof Error ? error.message : String(error);
1186
+ return jsonResult({ success: false, error: msg }, true);
1187
+ }
1188
+ },
1189
+ };
1190
+ // =============================================================================
1191
+ // Tool 14: Deck Record
1192
+ // =============================================================================
1193
+ const deckRecord = {
1194
+ definition: {
1195
+ name: 'deck_record',
1196
+ description: 'Deck-level win/loss records from deck_stats. Search by name or list all.',
1197
+ annotations: {
1198
+ title: 'Deck Record',
1199
+ readOnlyHint: true,
1200
+ destructiveHint: false,
1201
+ openWorldHint: true,
1202
+ },
1203
+ inputSchema: {
1204
+ type: 'object',
1205
+ properties: {
1206
+ deck_name: {
1207
+ type: 'string',
1208
+ description: 'Optional: search by deck name (partial match)',
1209
+ },
1210
+ limit: {
1211
+ type: 'number',
1212
+ description: 'Max results (default: 20)',
1213
+ },
1214
+ },
1215
+ required: [],
1216
+ },
1217
+ },
1218
+ handler: async (args) => {
1219
+ try {
1220
+ const db = getClient();
1221
+ const limit = Math.min(args.limit || 20, 100);
1222
+ let query = db
1223
+ .from('deck_stats')
1224
+ .select('deck_name, total_matches, wins, losses, last_played_ms')
1225
+ .order('total_matches', { ascending: false })
1226
+ .limit(limit);
1227
+ if (args.deck_name) {
1228
+ query = query.ilike('deck_name', `%${args.deck_name}%`);
1229
+ }
1230
+ const { data, error } = await query;
1231
+ if (error)
1232
+ throw new Error(error.message);
1233
+ const decks = (data || []).map((d) => {
1234
+ const total = d.total_matches || 0;
1235
+ const wins = d.wins || 0;
1236
+ const losses = d.losses || 0;
1237
+ return {
1238
+ deck_name: d.deck_name,
1239
+ total_matches: total,
1240
+ wins,
1241
+ losses,
1242
+ win_rate: total > 0 ? Math.round((wins / total) * 10000) / 100 : 0,
1243
+ last_played: d.last_played_ms
1244
+ ? new Date(Number(d.last_played_ms)).toISOString()
1245
+ : null,
1246
+ };
1247
+ });
1248
+ return jsonResult({
1249
+ success: true,
1250
+ decks,
1251
+ count: decks.length,
1252
+ });
1253
+ }
1254
+ catch (error) {
1255
+ const msg = error instanceof Error ? error.message : String(error);
1256
+ return jsonResult({ success: false, error: msg }, true);
1257
+ }
1258
+ },
1259
+ };
1260
+ // =============================================================================
1261
+ // Tool 15: Player Summary
1262
+ // =============================================================================
1263
+ const deckPlayerSummary = {
1264
+ definition: {
1265
+ name: 'deck_player_summary',
1266
+ description: 'Combined gameplay dashboard: total matches, overall win rate, best format, best deck, longest streak, recent form (last 10).',
1267
+ annotations: {
1268
+ title: 'Player Summary',
1269
+ readOnlyHint: true,
1270
+ destructiveHint: false,
1271
+ openWorldHint: true,
1272
+ },
1273
+ inputSchema: {
1274
+ type: 'object',
1275
+ properties: {},
1276
+ required: [],
1277
+ },
1278
+ },
1279
+ handler: async () => {
1280
+ try {
1281
+ const db = getClient();
1282
+ // Fetch matches and deck stats in parallel
1283
+ const [matchRes, deckRes] = await Promise.all([
1284
+ db
1285
+ .from('match_summary')
1286
+ .select('format, result, deck_name, started_at_ms')
1287
+ .order('started_at_ms', { ascending: false }),
1288
+ db
1289
+ .from('deck_stats')
1290
+ .select('deck_name, total_matches, wins, losses')
1291
+ .order('total_matches', { ascending: false }),
1292
+ ]);
1293
+ if (matchRes.error)
1294
+ throw new Error(matchRes.error.message);
1295
+ if (deckRes.error)
1296
+ throw new Error(deckRes.error.message);
1297
+ const matches = (matchRes.data || []);
1298
+ const decks = (deckRes.data || []);
1299
+ // Overall stats
1300
+ const totalMatches = matches.length;
1301
+ const wins = matches.filter((m) => m.result === 'win').length;
1302
+ const losses = matches.filter((m) => m.result === 'loss' || m.result === 'concede').length;
1303
+ const overallWinRate = totalMatches > 0 ? Math.round((wins / totalMatches) * 10000) / 100 : 0;
1304
+ // Best format (by win rate, min 5 matches)
1305
+ const fmtMap = new Map();
1306
+ for (const m of matches) {
1307
+ const fmt = m.format || 'Unknown';
1308
+ if (!fmtMap.has(fmt))
1309
+ fmtMap.set(fmt, { w: 0, t: 0 });
1310
+ const b = fmtMap.get(fmt);
1311
+ b.t++;
1312
+ if (m.result === 'win')
1313
+ b.w++;
1314
+ }
1315
+ let bestFormat = null;
1316
+ for (const [fmt, b] of fmtMap) {
1317
+ if (b.t >= 5) {
1318
+ const wr = b.w / b.t;
1319
+ if (!bestFormat || wr > bestFormat.win_rate) {
1320
+ bestFormat = { format: fmt, win_rate: Math.round(wr * 10000) / 100, matches: b.t };
1321
+ }
1322
+ }
1323
+ }
1324
+ // Best deck (from deck_stats, min 3 matches)
1325
+ let bestDeck = null;
1326
+ for (const d of decks) {
1327
+ const total = d.total_matches || 0;
1328
+ const w = d.wins || 0;
1329
+ if (total >= 3) {
1330
+ const wr = w / total;
1331
+ if (!bestDeck || wr > bestDeck.win_rate) {
1332
+ bestDeck = {
1333
+ deck_name: d.deck_name,
1334
+ win_rate: Math.round(wr * 10000) / 100,
1335
+ matches: total,
1336
+ };
1337
+ }
1338
+ }
1339
+ }
1340
+ // Longest streak (all-time)
1341
+ let longestStreak = 0;
1342
+ let longestStreakType = '';
1343
+ let currentRun = 0;
1344
+ let currentType = '';
1345
+ for (const m of matches) {
1346
+ const r = m.result === 'win' ? 'W' : 'L';
1347
+ if (r === currentType) {
1348
+ currentRun++;
1349
+ }
1350
+ else {
1351
+ currentType = r;
1352
+ currentRun = 1;
1353
+ }
1354
+ if (currentRun > longestStreak) {
1355
+ longestStreak = currentRun;
1356
+ longestStreakType = currentType;
1357
+ }
1358
+ }
1359
+ // Recent form (last 10)
1360
+ const recentForm = matches.slice(0, 10).map((m) => {
1361
+ const r = m.result;
1362
+ return r === 'win' ? 'W' : r === 'loss' || r === 'concede' ? 'L' : 'D';
1363
+ });
1364
+ return jsonResult({
1365
+ success: true,
1366
+ total_matches: totalMatches,
1367
+ overall_win_rate: overallWinRate,
1368
+ wins,
1369
+ losses,
1370
+ best_format: bestFormat,
1371
+ best_deck: bestDeck,
1372
+ longest_streak: `${longestStreak}${longestStreakType}`,
1373
+ recent_form: recentForm.join(''),
1374
+ total_decks: decks.length,
1375
+ });
1376
+ }
1377
+ catch (error) {
1378
+ const msg = error instanceof Error ? error.message : String(error);
1379
+ return jsonResult({ success: false, error: msg }, true);
1380
+ }
1381
+ },
1382
+ };
1383
+ // =============================================================================
1384
+ // Tool 16: Draft Stats
1385
+ // =============================================================================
1386
+ const deckDraftStats = {
1387
+ definition: {
1388
+ name: 'deck_draft_stats',
1389
+ description: 'Draft history and performance from MTG Arena. Returns event, set, record, trophy status per draft.',
1390
+ annotations: {
1391
+ title: 'Draft Stats',
1392
+ readOnlyHint: true,
1393
+ destructiveHint: false,
1394
+ openWorldHint: true,
1395
+ },
1396
+ inputSchema: {
1397
+ type: 'object',
1398
+ properties: {
1399
+ set_code: {
1400
+ type: 'string',
1401
+ description: 'Optional: filter by set code (e.g. "OTJ", "MKM")',
1402
+ },
1403
+ limit: {
1404
+ type: 'number',
1405
+ description: 'Max results (default: 20, max: 100)',
1406
+ },
1407
+ },
1408
+ required: [],
1409
+ },
1410
+ },
1411
+ handler: async (args) => {
1412
+ try {
1413
+ const db = getClient();
1414
+ const limit = Math.min(args.limit || 20, 100);
1415
+ let query = db
1416
+ .from('draft_summary')
1417
+ .select('event_name, set_code, completed_at, total_picks, wins, losses')
1418
+ .order('completed_at', { ascending: false })
1419
+ .limit(limit);
1420
+ if (args.set_code) {
1421
+ query = query.ilike('set_code', args.set_code);
1422
+ }
1423
+ const { data, error } = await query;
1424
+ if (error)
1425
+ throw new Error(error.message);
1426
+ const drafts = (data || []).map((d) => {
1427
+ const wins = d.wins || 0;
1428
+ const losses = d.losses || 0;
1429
+ return {
1430
+ event_name: d.event_name,
1431
+ set_code: d.set_code,
1432
+ completed_at: d.completed_at,
1433
+ total_picks: d.total_picks,
1434
+ wins,
1435
+ losses,
1436
+ record: `${wins}-${losses}`,
1437
+ trophy: wins >= 7,
1438
+ };
1439
+ });
1440
+ return jsonResult({
1441
+ success: true,
1442
+ drafts,
1443
+ count: drafts.length,
1444
+ });
1445
+ }
1446
+ catch (error) {
1447
+ const msg = error instanceof Error ? error.message : String(error);
1448
+ return jsonResult({ success: false, error: msg }, true);
1449
+ }
1450
+ },
1451
+ };
1452
+ // =============================================================================
1453
+ // Intel Reports
1454
+ // =============================================================================
1455
+ const INTEL_TYPES = [
1456
+ 'price_spike', 'price_crash', 'format_shakeup', 'sleeper_pick',
1457
+ 'deck_tech', 'meta_shift', 'budget_alert', 'spec_target',
1458
+ 'correlation', 'event_impact', 'reprint_arb', 'supply_signal',
1459
+ ];
1460
+ const INTEL_URGENCIES = ['breaking', 'daily', 'weekly', 'background'];
1461
+ const deckIntelWrite = {
1462
+ definition: {
1463
+ name: 'deck_intel_write',
1464
+ description: 'Write an intel report to the intel_reports table. Used by the deckscry-intel agent to publish analysis results.',
1465
+ annotations: {
1466
+ title: 'Write Intel Report',
1467
+ readOnlyHint: false,
1468
+ destructiveHint: false,
1469
+ openWorldHint: true,
1470
+ },
1471
+ inputSchema: {
1472
+ type: 'object',
1473
+ properties: {
1474
+ type: {
1475
+ type: 'string',
1476
+ enum: [...INTEL_TYPES],
1477
+ description: 'Intel report type',
1478
+ },
1479
+ format: {
1480
+ type: 'string',
1481
+ description: 'MTG format (standard, pioneer, modern, etc.) or null for cross-format',
1482
+ },
1483
+ urgency: {
1484
+ type: 'string',
1485
+ enum: [...INTEL_URGENCIES],
1486
+ description: 'Report urgency (default: daily)',
1487
+ },
1488
+ title: {
1489
+ type: 'string',
1490
+ description: 'Report title — e.g. "Sheoldred spikes 40% after Pioneer RCQ results"',
1491
+ },
1492
+ summary: {
1493
+ type: 'string',
1494
+ description: '2-3 sentence analysis with evidence and reasoning',
1495
+ },
1496
+ cards: {
1497
+ type: 'array',
1498
+ items: { type: 'string' },
1499
+ description: 'Scryfall UUIDs of cards involved',
1500
+ },
1501
+ data: {
1502
+ type: 'object',
1503
+ description: 'Structured payload — analyzer name, confidence, evidence, involved_cards details',
1504
+ },
1505
+ social_hook: {
1506
+ type: 'string',
1507
+ description: 'Pre-written social media hook with specific numbers, timeframe, and why. Under 200 chars.',
1508
+ },
1509
+ },
1510
+ required: ['type', 'title', 'summary'],
1511
+ },
1512
+ },
1513
+ handler: async (args) => {
1514
+ try {
1515
+ const db = getClient();
1516
+ const { data, error } = await db
1517
+ .from('intel_reports')
1518
+ .insert({
1519
+ type: args.type,
1520
+ format: args.format || null,
1521
+ urgency: args.urgency || 'daily',
1522
+ title: args.title,
1523
+ summary: args.summary,
1524
+ cards: args.cards || [],
1525
+ data: args.data || {},
1526
+ social_hook: args.social_hook || null,
1527
+ })
1528
+ .select('id, type, format, urgency, title, created_at')
1529
+ .single();
1530
+ if (error)
1531
+ throw new Error(error.message);
1532
+ return jsonResult({ success: true, report: data });
1533
+ }
1534
+ catch (error) {
1535
+ const msg = error instanceof Error ? error.message : String(error);
1536
+ return jsonResult({ success: false, error: msg }, true);
1537
+ }
1538
+ },
1539
+ };
1540
+ const deckIntelReports = {
1541
+ definition: {
1542
+ name: 'deck_intel_reports',
1543
+ description: 'Read intel reports. Filter by type, format, urgency, or unpublished status. Used by marketer and other consumers.',
1544
+ annotations: {
1545
+ title: 'Read Intel Reports',
1546
+ readOnlyHint: true,
1547
+ destructiveHint: false,
1548
+ openWorldHint: true,
1549
+ },
1550
+ inputSchema: {
1551
+ type: 'object',
1552
+ properties: {
1553
+ type: { type: 'string', description: 'Filter by intel type' },
1554
+ format: { type: 'string', description: 'Filter by MTG format' },
1555
+ urgency: { type: 'string', description: 'Filter by urgency level' },
1556
+ unpublished: { type: 'boolean', description: 'If true, only return reports not yet consumed by marketer' },
1557
+ limit: { type: 'number', description: 'Max reports to return (default: 20)' },
1558
+ },
1559
+ required: [],
1560
+ },
1561
+ },
1562
+ handler: async (args) => {
1563
+ try {
1564
+ const db = getClient();
1565
+ let query = db
1566
+ .from('intel_reports')
1567
+ .select('*')
1568
+ .order('created_at', { ascending: false })
1569
+ .limit(args.limit || 20);
1570
+ if (args.type)
1571
+ query = query.eq('type', args.type);
1572
+ if (args.format)
1573
+ query = query.eq('format', args.format);
1574
+ if (args.urgency)
1575
+ query = query.eq('urgency', args.urgency);
1576
+ if (args.unpublished)
1577
+ query = query.eq('published', false);
1578
+ const { data, error } = await query;
1579
+ if (error)
1580
+ throw new Error(error.message);
1581
+ return jsonResult({
1582
+ success: true,
1583
+ reports: data,
1584
+ count: data?.length || 0,
1585
+ });
1586
+ }
1587
+ catch (error) {
1588
+ const msg = error instanceof Error ? error.message : String(error);
1589
+ return jsonResult({ success: false, error: msg }, true);
1590
+ }
1591
+ },
1592
+ };
1593
+ const deckIntelPublish = {
1594
+ definition: {
1595
+ name: 'deck_intel_publish',
1596
+ description: 'Mark intel reports as published (consumed by marketer). Accepts one or more report IDs.',
1597
+ annotations: {
1598
+ title: 'Mark Intel Published',
1599
+ readOnlyHint: false,
1600
+ destructiveHint: false,
1601
+ openWorldHint: true,
1602
+ },
1603
+ inputSchema: {
1604
+ type: 'object',
1605
+ properties: {
1606
+ report_ids: {
1607
+ type: 'array',
1608
+ items: { type: 'string' },
1609
+ description: 'UUIDs of reports to mark as published',
1610
+ },
1611
+ },
1612
+ required: ['report_ids'],
1613
+ },
1614
+ },
1615
+ handler: async (args) => {
1616
+ try {
1617
+ const db = getClient();
1618
+ const { error } = await db
1619
+ .from('intel_reports')
1620
+ .update({ published: true })
1621
+ .in('id', args.report_ids);
1622
+ if (error)
1623
+ throw new Error(error.message);
1624
+ return jsonResult({ success: true, published: args.report_ids.length });
1625
+ }
1626
+ catch (error) {
1627
+ const msg = error instanceof Error ? error.message : String(error);
1628
+ return jsonResult({ success: false, error: msg }, true);
1629
+ }
1630
+ },
1631
+ };
1632
+ // =============================================================================
181
1633
  // Export
182
1634
  // =============================================================================
183
1635
  export const deckTools = [
184
- deckBuylistSearch,
185
- deckBuylistList,
186
- deckBuylistStores,
1636
+ deckSearch,
1637
+ deckList,
1638
+ deckSummary,
1639
+ deckHistory,
1640
+ deckFormatDeals,
1641
+ deckTypeSearch,
1642
+ deckPriceBracket,
1643
+ deckSetAnalysis,
1644
+ deckVolatility,
1645
+ deckReprints,
1646
+ deckMatchHistory,
1647
+ deckWinRate,
1648
+ deckCardPerformance,
1649
+ deckRecord,
1650
+ deckPlayerSummary,
1651
+ deckDraftStats,
1652
+ deckIntelWrite,
1653
+ deckIntelReports,
1654
+ deckIntelPublish,
187
1655
  ];
188
1656
  //# sourceMappingURL=deck.js.map