@semanticintent/semantic-chirp-intelligence-mcp 3.1.0 → 4.0.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 🏒 League Data Service — league state from the store, in the shape analyses expect
3
+ *
4
+ * v3 read rosters from Yahoo and parsed them out of deeply nested, irregular
5
+ * fantasy JSON. v4 reads the same information from what the user pasted, and
6
+ * presents it in exactly the shape the existing analyses already consume — so
7
+ * the intelligence layer is untouched by the change of source.
8
+ *
9
+ * 🏛️ Rule 1 (Semantic Over Structural): analyses ask for "my roster", not for a
10
+ * provider's payload. Where that roster comes from is not their concern.
11
+ * 🏛️ Rule 3 (Observable Anchoring): when no roster has been provided, this
12
+ * reports that plainly. It never returns an empty roster that would read as
13
+ * "you have no players" rather than "you have not told me your players".
14
+ */
15
+ import { ROSTER_STORE } from './RosterStore.js';
16
+ import { NHL_STATS } from './NhlStatsService.js';
17
+ /** Why a roster could not be produced, phrased for the person reading it. */
18
+ export const NO_ROSTER_MESSAGE = 'No roster has been provided yet. Paste yours with `set_roster` — copy it from ' +
19
+ 'any fantasy site, or just list the player names, one per line.';
20
+ export const NO_OPPONENT_MESSAGE = 'No opponent roster has been provided. Paste one with `set_opponent_roster` to ' +
21
+ 'enable head-to-head analysis.';
22
+ export class LeagueDataService {
23
+ /**
24
+ * The user's roster, or null when none has been stored.
25
+ *
26
+ * Enriches each player with their current NHL club and eligible positions,
27
+ * so a roster pasted weeks ago still reflects trades and call-ups.
28
+ */
29
+ getRoster() {
30
+ return this.toLeagueRoster(ROSTER_STORE.getRoster('roster'), 'my-team');
31
+ }
32
+ /** The stored opponent roster, or null. */
33
+ getOpponentRoster() {
34
+ return this.toLeagueRoster(ROSTER_STORE.getRoster('opponent'), 'opponent-team');
35
+ }
36
+ hasRoster() {
37
+ return (ROSTER_STORE.getRoster('roster')?.players.length ?? 0) > 0;
38
+ }
39
+ hasOpponent() {
40
+ return (ROSTER_STORE.getRoster('opponent')?.players.length ?? 0) > 0;
41
+ }
42
+ getStandings() {
43
+ return ROSTER_STORE.getStandings();
44
+ }
45
+ /**
46
+ * Every NHL player not on the stored roster, as an analysis-shaped pool.
47
+ *
48
+ * This replaces the Yahoo free-agent list. It cannot know who is *available*
49
+ * in a given league — ownership is league-private — so it deliberately
50
+ * describes itself as "not on your roster" rather than "available". Callers
51
+ * must not present these as confirmed waiver adds.
52
+ */
53
+ getPlayerPool(options = {}) {
54
+ const owned = new Set((ROSTER_STORE.getRoster('roster')?.players ?? []).map(p => p.player_id));
55
+ const opponentOwned = new Set((ROSTER_STORE.getRoster('opponent')?.players ?? []).map(p => p.player_id));
56
+ // Fantasy platforms say LW/RW; the NHL says L/R.
57
+ const wanted = String(options.position ?? '').toUpperCase()
58
+ .replace(/^LW$/, 'L')
59
+ .replace(/^RW$/, 'R');
60
+ const pool = NHL_STATS.getAll()
61
+ .filter(p => !owned.has(p.player_id) && !opponentOwned.has(p.player_id))
62
+ .filter(p => !wanted || p.position === wanted)
63
+ .map(p => ({
64
+ player_id: p.player_id,
65
+ name: p.name,
66
+ position: this.eligiblePositions(p.position),
67
+ team: p.team,
68
+ selected_position: '',
69
+ status: '',
70
+ stats: p.stats,
71
+ // Kept for analyses written against Yahoo's ownership figure. There is
72
+ // no public equivalent, so it is explicitly null rather than a guess.
73
+ percent_owned: null
74
+ }));
75
+ pool.sort((a, b) => {
76
+ const av = a.position === 'G' ? (a.stats?.wins ?? 0) : (a.stats?.points ?? 0);
77
+ const bv = b.position === 'G' ? (b.stats?.wins ?? 0) : (b.stats?.points ?? 0);
78
+ return bv - av;
79
+ });
80
+ return options.limit ? pool.slice(0, options.limit) : pool;
81
+ }
82
+ /** Why a pool is not a waiver wire, in words a tool can print. */
83
+ static POOL_CAVEAT = 'These are NHL players not on the rosters you have provided, ranked by last ' +
84
+ 'season production. Whether any of them is actually available in your league ' +
85
+ 'is league-private and cannot be determined from public data — check before adding.';
86
+ toLeagueRoster(stored, keyPrefix) {
87
+ if (!stored || stored.players.length === 0)
88
+ return null;
89
+ return {
90
+ team_key: keyPrefix,
91
+ team_name: stored.label,
92
+ players: stored.players.map(p => this.toLeaguePlayer(p))
93
+ };
94
+ }
95
+ toLeaguePlayer(stored) {
96
+ // Re-resolve against the live index so a player traded since the paste
97
+ // reports their current club rather than a stale one.
98
+ const live = NHL_STATS.getById(stored.player_id);
99
+ const team = live?.team ?? stored.team;
100
+ const position = live?.position ?? stored.position;
101
+ return {
102
+ player_id: stored.player_id,
103
+ name: stored.name,
104
+ position: this.eligiblePositions(position),
105
+ team,
106
+ // A paste without slot information means the player is simply rostered;
107
+ // treat that as an active lineup spot rather than inventing a bench.
108
+ selected_position: stored.slot ?? position,
109
+ status: '',
110
+ stats: live?.stats
111
+ };
112
+ }
113
+ /**
114
+ * Map an NHL position code to fantasy-eligible positions.
115
+ *
116
+ * The NHL publishes L / R / C / D / G. Fantasy platforms speak LW / RW, and
117
+ * treat wingers as winger-eligible, so translate rather than pass through.
118
+ */
119
+ eligiblePositions(positionCode) {
120
+ switch (String(positionCode).toUpperCase()) {
121
+ case 'L': return 'LW';
122
+ case 'R': return 'RW';
123
+ case 'C': return 'C';
124
+ case 'D': return 'D';
125
+ case 'G': return 'G';
126
+ default: return positionCode || '';
127
+ }
128
+ }
129
+ }
130
+ export const LEAGUE_DATA = new LeagueDataService();
@@ -0,0 +1,357 @@
1
+ /**
2
+ * 🗓️ NHL Schedule Service
3
+ *
4
+ * The single source of real game-schedule truth for every analysis.
5
+ *
6
+ * Before this service existed, the schedule-aware tools estimated games with a
7
+ * constant (`players * 3.5 * weeks`) or invented them with `Math.random()`.
8
+ * Everything schedule-shaped now resolves through here, against the NHL's
9
+ * public club-schedule endpoint:
10
+ *
11
+ * https://api-web.nhle.com/v1/club-schedule-season/{TRICODE}/{SEASON}
12
+ *
13
+ * No API key, no auth, ~8s for all 32 clubs cold, then served from a
14
+ * season-scoped disk cache.
15
+ *
16
+ * 🏛️ Rule 1 (Semantic Over Structural): callers ask domain questions
17
+ * ("how many games in this window?"), not HTTP questions.
18
+ * 🏛️ Rule 3 (Observable Anchoring): when the schedule cannot be loaded the
19
+ * service reports `available: false`. It never substitutes an estimate for a
20
+ * fact — an analysis that cannot see the schedule must say so.
21
+ */
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import { NHL_TRICODES, toNhlTricode } from '../domain/nhl-teams.js';
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+ const NHL_API_BASE = 'https://api-web.nhle.com/v1';
29
+ /** Regular season. Preseason is gameType 1, playoffs 3 — neither counts for fantasy. */
30
+ const GAME_TYPE_REGULAR_SEASON = 2;
31
+ /** Cache lifetime. The schedule shifts rarely (postponements), so a day is plenty. */
32
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
33
+ export class NhlScheduleService {
34
+ schedules = new Map();
35
+ season = null;
36
+ loaded = false;
37
+ loadError = null;
38
+ inFlight = null;
39
+ strengths = new Map();
40
+ strengthsInFlight = null;
41
+ cacheDir;
42
+ constructor(cacheDir) {
43
+ this.cacheDir = cacheDir ?? path.join(__dirname, '..', '..', '.nhl-schedule-cache');
44
+ }
45
+ // ==========================================
46
+ // 🎯 Season resolution
47
+ // ==========================================
48
+ /**
49
+ * NHL season id for a date, e.g. 2026-09-01 -> "20262027".
50
+ * The season rolls over in August: anything from August onward belongs to the
51
+ * season that starts that calendar year.
52
+ */
53
+ static seasonForDate(date = new Date()) {
54
+ const year = date.getUTCFullYear();
55
+ const startYear = date.getUTCMonth() >= 7 ? year : year - 1; // month 7 = August
56
+ return `${startYear}${startYear + 1}`;
57
+ }
58
+ // ==========================================
59
+ // 🎯 Loading
60
+ // ==========================================
61
+ /**
62
+ * Load the full season schedule for all 32 clubs.
63
+ * Safe to call repeatedly — concurrent callers share one in-flight load.
64
+ */
65
+ async load(season) {
66
+ const target = season ?? NhlScheduleService.seasonForDate();
67
+ if (this.loaded && this.season === target)
68
+ return;
69
+ if (this.inFlight)
70
+ return this.inFlight;
71
+ this.inFlight = this.performLoad(target).finally(() => {
72
+ this.inFlight = null;
73
+ });
74
+ return this.inFlight;
75
+ }
76
+ async performLoad(season) {
77
+ this.season = season;
78
+ this.loadError = null;
79
+ const cached = this.readCache(season);
80
+ if (cached) {
81
+ this.schedules = new Map(Object.entries(cached.teams));
82
+ this.loaded = true;
83
+ return;
84
+ }
85
+ const results = await Promise.all(NHL_TRICODES.map(async (team) => ({
86
+ team,
87
+ games: await this.fetchTeamSeason(team, season)
88
+ })));
89
+ const failed = results.filter(r => r.games === null).map(r => r.team);
90
+ // A partial schedule produces silently wrong comparisons between two teams,
91
+ // so treat any failure as a failed load rather than serving half the league.
92
+ if (failed.length > 0) {
93
+ this.loaded = false;
94
+ this.loadError =
95
+ `NHL schedule unavailable for ${failed.length} club(s): ${failed.join(', ')}`;
96
+ return;
97
+ }
98
+ this.schedules = new Map(results.map(r => [r.team, r.games]));
99
+ this.loaded = true;
100
+ this.writeCache(season);
101
+ }
102
+ async fetchTeamSeason(team, season) {
103
+ try {
104
+ const response = await fetch(`${NHL_API_BASE}/club-schedule-season/${team}/${season}`);
105
+ if (!response.ok)
106
+ return null;
107
+ const payload = await response.json();
108
+ const games = payload?.games ?? [];
109
+ return games
110
+ .filter(g => g?.gameType === GAME_TYPE_REGULAR_SEASON && g?.gameDate)
111
+ .map(g => {
112
+ const homeAbbr = toNhlTricode(g?.homeTeam?.abbrev);
113
+ const awayAbbr = toNhlTricode(g?.awayTeam?.abbrev);
114
+ const isHome = homeAbbr === team;
115
+ const opponent = isHome ? awayAbbr : homeAbbr;
116
+ return opponent
117
+ ? { date: g.gameDate, opponent, home: isHome }
118
+ : null;
119
+ })
120
+ .filter((g) => g !== null)
121
+ .sort((a, b) => a.date.localeCompare(b.date));
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ // ==========================================
128
+ // 🎯 Availability — never fake a schedule
129
+ // ==========================================
130
+ /** Whether real schedule data is loaded and safe to reason from. */
131
+ isAvailable() {
132
+ return this.loaded && this.schedules.size === NHL_TRICODES.length;
133
+ }
134
+ /** Human-readable reason the schedule is unavailable, if it is. */
135
+ getUnavailableReason() {
136
+ if (this.isAvailable())
137
+ return null;
138
+ return this.loadError ?? 'NHL schedule has not been loaded yet';
139
+ }
140
+ getSeason() {
141
+ return this.season;
142
+ }
143
+ // ==========================================
144
+ // 🎯 Queries
145
+ // ==========================================
146
+ /** All regular-season games for a club, in date order. */
147
+ getTeamGames(abbr) {
148
+ const tricode = toNhlTricode(abbr);
149
+ if (!tricode)
150
+ return [];
151
+ return this.schedules.get(tricode) ?? [];
152
+ }
153
+ /** Games for a club within an inclusive YYYY-MM-DD date range. */
154
+ getGamesInRange(abbr, start, end) {
155
+ return this.getTeamGames(abbr).filter(g => g.date >= start && g.date <= end);
156
+ }
157
+ /** Game count for a club within an inclusive date range. */
158
+ countGamesInRange(abbr, start, end) {
159
+ return this.getGamesInRange(abbr, start, end).length;
160
+ }
161
+ /** Whether a club plays on a given YYYY-MM-DD date. */
162
+ hasGameOn(abbr, date) {
163
+ return this.getTeamGames(abbr).some(g => g.date === date);
164
+ }
165
+ /** Count of back-to-back game pairs for a club in a range. */
166
+ countBackToBacks(abbr, start, end) {
167
+ const games = this.getGamesInRange(abbr, start, end);
168
+ let count = 0;
169
+ for (let i = 1; i < games.length; i++) {
170
+ const previous = Date.parse(`${games[i - 1].date}T00:00:00Z`);
171
+ const current = Date.parse(`${games[i].date}T00:00:00Z`);
172
+ if (current - previous === 24 * 60 * 60 * 1000)
173
+ count++;
174
+ }
175
+ return count;
176
+ }
177
+ /**
178
+ * Games per fantasy week for a club, keyed by the Monday that starts the week.
179
+ * Yahoo fantasy hockey weeks run Monday through Sunday.
180
+ */
181
+ getGamesByWeek(abbr) {
182
+ const byWeek = {};
183
+ for (const game of this.getTeamGames(abbr)) {
184
+ const monday = NhlScheduleService.weekStart(game.date);
185
+ byWeek[monday] = (byWeek[monday] ?? 0) + 1;
186
+ }
187
+ return byWeek;
188
+ }
189
+ /** Full season profile for one club. */
190
+ getTeamProfile(abbr) {
191
+ const tricode = toNhlTricode(abbr);
192
+ if (!tricode)
193
+ return null;
194
+ const games = this.getTeamGames(tricode);
195
+ const byWeek = this.getGamesByWeek(tricode);
196
+ const counts = Object.values(byWeek);
197
+ const firstDate = games[0]?.date ?? '';
198
+ const lastDate = games[games.length - 1]?.date ?? '';
199
+ return {
200
+ team: tricode,
201
+ total_games: games.length,
202
+ weeks_with_4_plus: counts.filter(c => c >= 4).length,
203
+ weeks_with_2_or_fewer: counts.filter(c => c <= 2).length,
204
+ back_to_backs: this.countBackToBacks(tricode, firstDate, lastDate),
205
+ games_by_week: byWeek
206
+ };
207
+ }
208
+ /**
209
+ * Earliest regular-season game date across the league (YYYY-MM-DD).
210
+ *
211
+ * Used as the fantasy week-1 anchor when Yahoo's league `start_date` is
212
+ * unavailable — fantasy hockey week 1 begins with the NHL season.
213
+ */
214
+ getSeasonStartDate() {
215
+ if (!this.isAvailable())
216
+ return null;
217
+ let earliest = null;
218
+ for (const games of this.schedules.values()) {
219
+ const first = games[0]?.date;
220
+ if (first && (earliest === null || first < earliest))
221
+ earliest = first;
222
+ }
223
+ return earliest;
224
+ }
225
+ /** Season profiles for all 32 clubs. */
226
+ getAllProfiles() {
227
+ return NHL_TRICODES
228
+ .map(t => this.getTeamProfile(t))
229
+ .filter((p) => p !== null);
230
+ }
231
+ /** The Monday (YYYY-MM-DD) that starts the fantasy week containing `date`. */
232
+ static weekStart(date) {
233
+ const d = new Date(`${date}T00:00:00Z`);
234
+ const dayOfWeek = (d.getUTCDay() + 6) % 7; // Monday = 0
235
+ d.setUTCDate(d.getUTCDate() - dayOfWeek);
236
+ return d.toISOString().split('T')[0];
237
+ }
238
+ /** Add `days` to a YYYY-MM-DD date, returning YYYY-MM-DD. */
239
+ static addDays(date, days) {
240
+ const d = new Date(`${date}T00:00:00Z`);
241
+ d.setUTCDate(d.getUTCDate() + days);
242
+ return d.toISOString().split('T')[0];
243
+ }
244
+ /** Today as YYYY-MM-DD (UTC). */
245
+ static today() {
246
+ return new Date().toISOString().split('T')[0];
247
+ }
248
+ // ==========================================
249
+ // 🎯 Opponent strength
250
+ // ==========================================
251
+ /**
252
+ * Load league-wide defensive strength from the NHL standings.
253
+ *
254
+ * Matchup difficulty used to be `Math.random() * 100`. This anchors it to
255
+ * goals allowed per game, ranked across the league. During the offseason the
256
+ * endpoint returns the last completed season, which is the right baseline for
257
+ * draft-time analysis anyway.
258
+ */
259
+ async loadStandings() {
260
+ if (this.strengths.size > 0)
261
+ return;
262
+ if (this.strengthsInFlight)
263
+ return this.strengthsInFlight;
264
+ this.strengthsInFlight = this.performStandingsLoad().finally(() => {
265
+ this.strengthsInFlight = null;
266
+ });
267
+ return this.strengthsInFlight;
268
+ }
269
+ async performStandingsLoad() {
270
+ try {
271
+ const response = await fetch(`${NHL_API_BASE}/standings/now`);
272
+ if (!response.ok)
273
+ return;
274
+ const payload = await response.json();
275
+ const rows = payload?.standings ?? [];
276
+ const parsed = rows
277
+ .map(row => {
278
+ const tricode = toNhlTricode(row?.teamAbbrev?.default ?? row?.teamAbbrev);
279
+ const gamesPlayed = Number(row?.gamesPlayed ?? 0);
280
+ if (!tricode || gamesPlayed <= 0)
281
+ return null;
282
+ return {
283
+ team: tricode,
284
+ gaPerGame: Number(row?.goalAgainst ?? 0) / gamesPlayed,
285
+ pointPctg: Number(row?.pointPctg ?? 0)
286
+ };
287
+ })
288
+ .filter((r) => r !== null);
289
+ if (parsed.length === 0)
290
+ return;
291
+ // Rank by goals allowed: the stingiest defence is the hardest matchup.
292
+ const ranked = [...parsed].sort((a, b) => a.gaPerGame - b.gaPerGame);
293
+ const lastIndex = Math.max(1, ranked.length - 1);
294
+ ranked.forEach((row, index) => {
295
+ this.strengths.set(row.team, {
296
+ team: row.team,
297
+ goals_against_per_game: Number(row.gaPerGame.toFixed(3)),
298
+ point_pctg: row.pointPctg,
299
+ difficulty: Math.round(100 - (index / lastIndex) * 100)
300
+ });
301
+ });
302
+ }
303
+ catch (error) {
304
+ console.error('[DEBUG] Could not load NHL standings:', error);
305
+ }
306
+ }
307
+ /** Whether opponent-strength data is loaded. */
308
+ hasStandings() {
309
+ return this.strengths.size > 0;
310
+ }
311
+ /** Defensive profile for a club, or null when standings are unavailable. */
312
+ getTeamStrength(abbr) {
313
+ const tricode = toNhlTricode(abbr);
314
+ if (!tricode)
315
+ return null;
316
+ return this.strengths.get(tricode) ?? null;
317
+ }
318
+ // ==========================================
319
+ // 🎯 Cache
320
+ // ==========================================
321
+ cachePath(season) {
322
+ return path.join(this.cacheDir, `${season}.json`);
323
+ }
324
+ readCache(season) {
325
+ try {
326
+ const file = this.cachePath(season);
327
+ if (!fs.existsSync(file))
328
+ return null;
329
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
330
+ if (Date.now() - parsed.fetched_at > CACHE_TTL_MS)
331
+ return null;
332
+ if (Object.keys(parsed.teams ?? {}).length !== NHL_TRICODES.length)
333
+ return null;
334
+ return parsed;
335
+ }
336
+ catch {
337
+ return null;
338
+ }
339
+ }
340
+ writeCache(season) {
341
+ try {
342
+ fs.mkdirSync(this.cacheDir, { recursive: true });
343
+ const payload = {
344
+ season,
345
+ fetched_at: Date.now(),
346
+ teams: Object.fromEntries(this.schedules)
347
+ };
348
+ fs.writeFileSync(this.cachePath(season), JSON.stringify(payload));
349
+ }
350
+ catch (error) {
351
+ // A cache miss costs 8 seconds; it is never worth failing the analysis over.
352
+ console.error('[DEBUG] Could not write NHL schedule cache:', error);
353
+ }
354
+ }
355
+ }
356
+ /** Shared instance — one schedule load serves every analysis in the process. */
357
+ export const NHL_SCHEDULE = new NhlScheduleService();