@semanticintent/semantic-chirp-intelligence-mcp 4.0.3 → 4.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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * 📋 Draft Kit Analysis — tiers, flags, and the schedule nobody else knows
3
+ *
4
+ * A conventional draft kit ranks players in the abstract: rankings, tiers,
5
+ * projections, ADP, sleepers. ChirpIQX cannot produce half of that — there is
6
+ * no public source of projections, draft-market consensus, line combinations
7
+ * or injuries, and inventing them would be the same sin as the constants and
8
+ * random numbers this codebase spent v3.2 removing.
9
+ *
10
+ * What it can do is the half that depends on facts: real production, real ages,
11
+ * and the real schedule — including the weeks *your* league plays its playoffs,
12
+ * which no published kit can know.
13
+ *
14
+ * So this tool works two ways, from one engine:
15
+ *
16
+ * • no `rankings` given — builds the board from last season's production
17
+ * • `rankings` pasted — keeps that order as the baseline and annotates it
18
+ *
19
+ * The second mode is the useful one: bring a kit you trust for projections,
20
+ * and let ChirpIQX overlay schedule and flags onto it.
21
+ *
22
+ * 🏛️ Rule 3 (Observable Anchoring): every figure names the season it came from,
23
+ * and the response states plainly what a draft kit normally contains that this
24
+ * one does not.
25
+ */
26
+ import { AnalysisTemplate } from '../template/AnalysisTemplate.js';
27
+ import { ChirpIntelligence } from '../services/ChirpIntelligence.js';
28
+ import { NHL_SCHEDULE, NhlScheduleService } from '../services/NhlScheduleService.js';
29
+ import { NHL_STATS, NhlStatsService } from '../services/NhlStatsService.js';
30
+ import { ROSTER_STORE } from '../services/RosterStore.js';
31
+ /** Positions a kit is organised by, in the order people draft them. */
32
+ const KIT_POSITIONS = ['C', 'LW', 'RW', 'D', 'G'];
33
+ /** NHL position codes map to the winger labels fantasy platforms use. */
34
+ const NHL_TO_FANTASY = { C: 'C', L: 'LW', R: 'RW', D: 'D', G: 'G' };
35
+ export class DraftKitAnalysis extends AnalysisTemplate {
36
+ constructor() {
37
+ super('draft_kit', 'draft_pick');
38
+ }
39
+ // ==========================================
40
+ // Hook 1: Fetch
41
+ // ==========================================
42
+ async fetchData(args) {
43
+ await Promise.all([NHL_STATS.load(), NHL_SCHEDULE.load()]);
44
+ return {};
45
+ }
46
+ // ==========================================
47
+ // Hook 2: Prepare
48
+ // ==========================================
49
+ async prepareData(rawData, args) {
50
+ const window = this.resolvePlayoffWindow(args);
51
+ // Two entry points, one engine.
52
+ const pasted = String(args.rankings ?? '').trim();
53
+ const source = pasted ? 'pasted rankings' : 'NHL production';
54
+ const { players, unresolved } = pasted
55
+ ? this.fromPastedRankings(pasted)
56
+ : { players: this.fromProduction(), unresolved: [] };
57
+ return { kitPlayers: players, unresolved, window, source };
58
+ }
59
+ /**
60
+ * Resolve a pasted ranked list, preserving its order.
61
+ *
62
+ * The list's own ordering is the baseline rank — that is the whole point of
63
+ * the overlay mode. Someone else did the projection work; this adds what
64
+ * they could not know.
65
+ */
66
+ fromPastedRankings(text) {
67
+ const report = ROSTER_STORE.parseRoster(text);
68
+ const players = report.resolved
69
+ .map(p => NHL_STATS.getById(p.player_id))
70
+ .filter((p) => p !== null);
71
+ return { players, unresolved: [...report.unresolved, ...report.ambiguous] };
72
+ }
73
+ /** Build a board from last season's production when no list is given. */
74
+ fromProduction() {
75
+ return NHL_STATS.getAll()
76
+ .filter(p => (p.stats?.games_played ?? 0) > 0)
77
+ .sort((a, b) => this.productionValue(b) - this.productionValue(a));
78
+ }
79
+ /** Skaters rank on points, goalies on wins — they are not comparable. */
80
+ productionValue(p) {
81
+ return p.position === 'G' ? (p.stats?.wins ?? 0) : (p.stats?.points ?? 0);
82
+ }
83
+ // ==========================================
84
+ // Hook 3: Analyze
85
+ // ==========================================
86
+ async analyzeData(data, args) {
87
+ const d = data;
88
+ const window = d.window;
89
+ const tierSize = Math.max(3, args.tier_size ?? 6);
90
+ const maxPerPosition = Math.max(5, args.max_per_position ?? 24);
91
+ const wanted = (args.positions?.length ? args.positions : KIT_POSITIONS)
92
+ .map(p => p.toUpperCase());
93
+ const annotated = d.kitPlayers.map((p, index) => this.annotate(p, index + 1, window));
94
+ // Tiers are per position, because "when does C dry up" is the question a
95
+ // draft kit exists to answer.
96
+ const byPosition = {};
97
+ for (const pos of wanted) {
98
+ const group = annotated.filter(p => p.position === pos).slice(0, maxPerPosition);
99
+ if (group.length === 0)
100
+ continue;
101
+ byPosition[pos] = {
102
+ count: group.length,
103
+ tiers: this.buildTiers(group, tierSize),
104
+ // Where the position stops being replaceable.
105
+ dries_up_after: group.length >= tierSize ? `tier ${Math.ceil(group.length / tierSize)}` : 'one tier'
106
+ };
107
+ }
108
+ return {
109
+ source: d.source,
110
+ stats_season: NHL_STATS.getSeasons().stats,
111
+ playoff_window: window,
112
+ unresolved: d.unresolved,
113
+ positions: byPosition,
114
+ signals: {
115
+ shooting_rebounds: this.shootingRebounds(annotated),
116
+ decline_risk: this.declineRisk(annotated),
117
+ playoff_schedule_winners: this.playoffWinners(annotated, window),
118
+ category_specialists: this.categorySpecialists(annotated)
119
+ },
120
+ cheat_sheet: this.cheatSheet(byPosition),
121
+ not_included: [
122
+ 'Projections for the coming season — ChirpIQX reports the last completed season, it does not forecast',
123
+ 'ADP or value-vs-market — there is no public source of draft-market consensus',
124
+ 'Line combinations and power-play units — the NHL does not publish them',
125
+ 'Injury status — no public feed',
126
+ 'Whether a player is available in your league — ownership is league-private'
127
+ ]
128
+ };
129
+ }
130
+ annotate(p, rank, window) {
131
+ const s = p.stats;
132
+ const gp = s?.games_played ?? 0;
133
+ const points = s?.points ?? 0;
134
+ const profile = NHL_SCHEDULE.isAvailable() ? NHL_SCHEDULE.getTeamProfile(p.team) : null;
135
+ const playoffGames = window?.resolved && NHL_SCHEDULE.isAvailable()
136
+ ? NHL_SCHEDULE.countGamesInRange(p.team, window.start, window.end)
137
+ : null;
138
+ return {
139
+ rank,
140
+ player_id: p.player_id,
141
+ name: p.name,
142
+ team: p.team,
143
+ position: NHL_TO_FANTASY[p.position] ?? p.position,
144
+ age: NhlStatsService.ageOf(p),
145
+ games_played: gp,
146
+ points,
147
+ points_per_game: gp > 0 ? Number((points / gp).toFixed(2)) : 0,
148
+ playoff_games: playoffGames,
149
+ four_game_weeks: profile?.weeks_with_4_plus ?? null,
150
+ flags: this.flagsFor(p, playoffGames)
151
+ };
152
+ }
153
+ /** Short, human-readable notes that survive being read at speed. */
154
+ flagsFor(p, playoffGames) {
155
+ const flags = [];
156
+ const s = p.stats;
157
+ const age = NhlStatsService.ageOf(p);
158
+ const gp = s?.games_played ?? 0;
159
+ if (!s || gp < 20)
160
+ return flags;
161
+ if (p.position !== 'G') {
162
+ const shots = s.shots ?? 0;
163
+ const shPct = shots > 0 ? ((s.goals ?? 0) / shots) * 100 : 0;
164
+ if (age !== null && age <= 25 && shots >= 150 && shPct < 8) {
165
+ flags.push(`shooting ${shPct.toFixed(1)}% on ${shots} shots — volume without conversion`);
166
+ }
167
+ if (age !== null && age >= 33 && (s.time_on_ice_per_game ?? 0) / 60 >= 19) {
168
+ flags.push(`age ${age} on ${((s.time_on_ice_per_game ?? 0) / 60).toFixed(1)} min — usage may not hold`);
169
+ }
170
+ if ((s.penalty_minutes ?? 0) / gp >= 1.2) {
171
+ flags.push(`${((s.penalty_minutes ?? 0) / gp).toFixed(2)} PIM/gm`);
172
+ }
173
+ if ((s.shots ?? 0) / gp >= 3.2) {
174
+ flags.push(`${((s.shots ?? 0) / gp).toFixed(1)} shots/gm`);
175
+ }
176
+ }
177
+ else if ((s.save_percentage ?? 0) >= 0.915 && (s.wins ?? 0) < 28) {
178
+ flags.push(`${(s.save_percentage ?? 0).toFixed(3)} SV% on only ${s.wins} wins — rate beats the record`);
179
+ }
180
+ if (playoffGames !== null && playoffGames >= 11)
181
+ flags.push(`${playoffGames} playoff-window games`);
182
+ if (playoffGames !== null && playoffGames <= 8)
183
+ flags.push(`only ${playoffGames} playoff-window games`);
184
+ return flags;
185
+ }
186
+ buildTiers(group, tierSize) {
187
+ const tiers = [];
188
+ for (let i = 0; i < group.length; i += tierSize) {
189
+ const slice = group.slice(i, i + tierSize);
190
+ tiers.push({
191
+ tier: tiers.length + 1,
192
+ players: slice.map(p => ({
193
+ rank: p.rank,
194
+ name: p.name,
195
+ team: p.team,
196
+ age: p.age,
197
+ ppg: p.points_per_game,
198
+ playoff_games: p.playoff_games,
199
+ flags: p.flags
200
+ }))
201
+ });
202
+ }
203
+ return tiers;
204
+ }
205
+ // ==========================================
206
+ // 🎯 Signals
207
+ // ==========================================
208
+ shootingRebounds(players) {
209
+ return players
210
+ .filter(p => p.flags.some(f => f.includes('volume without conversion')))
211
+ .slice(0, 8)
212
+ .map(p => ({ name: p.name, team: p.team, position: p.position, age: p.age, note: p.flags[0] }));
213
+ }
214
+ declineRisk(players) {
215
+ return players
216
+ .filter(p => p.flags.some(f => f.includes('usage may not hold')))
217
+ .slice(0, 8)
218
+ .map(p => ({ name: p.name, team: p.team, position: p.position, age: p.age, note: p.flags.find(f => f.includes('usage')) }));
219
+ }
220
+ playoffWinners(players, window) {
221
+ if (!window?.resolved)
222
+ return [];
223
+ return players
224
+ .filter(p => (p.playoff_games ?? 0) >= 11)
225
+ .sort((a, b) => b.points_per_game - a.points_per_game)
226
+ .slice(0, 10)
227
+ .map(p => ({
228
+ name: p.name, team: p.team, position: p.position,
229
+ ppg: p.points_per_game, playoff_games: p.playoff_games
230
+ }));
231
+ }
232
+ categorySpecialists(players) {
233
+ const pick = (needle, limit = 5) => players
234
+ .filter(p => p.flags.some(f => f.includes(needle)))
235
+ .slice(0, limit)
236
+ .map(p => ({ name: p.name, team: p.team, position: p.position, note: p.flags.find(f => f.includes(needle)) }));
237
+ return {
238
+ penalty_minutes: pick('PIM/gm'),
239
+ shot_volume: pick('shots/gm'),
240
+ goalie_rate_over_record: pick('rate beats the record')
241
+ };
242
+ }
243
+ /** The condensed board — the format people actually take to a draft. */
244
+ cheatSheet(byPosition) {
245
+ const sheet = {};
246
+ for (const [pos, data] of Object.entries(byPosition)) {
247
+ sheet[pos] = data.tiers.map((t) => `T${t.tier}: ` + t.players
248
+ .map((p) => `${p.name}${p.playoff_games !== null ? ` (${p.playoff_games})` : ''}`)
249
+ .join(', '));
250
+ }
251
+ return {
252
+ note: 'Numbers in brackets are games during your playoff window.',
253
+ board: sheet
254
+ };
255
+ }
256
+ // ==========================================
257
+ // Hooks 4 & 5
258
+ // ==========================================
259
+ async generateChirp(analysisResults, semanticContract, data) {
260
+ const contract = this.mergeContractWithDefaults(semanticContract);
261
+ const enhanced = ChirpIntelligence.enhance(this.toolName, analysisResults, contract);
262
+ const winners = analysisResults.signals.playoff_schedule_winners;
263
+ const rebounds = analysisResults.signals.shooting_rebounds;
264
+ const parts = [];
265
+ parts.push(analysisResults.source === 'pasted rankings'
266
+ ? 'Working from your list — the order is theirs, the schedule and flags are mine.'
267
+ : `Board built from ${analysisResults.stats_season} production.`);
268
+ if (winners?.length) {
269
+ parts.push(`${winners[0].name} is the best producer on an 11-game playoff club. That is your tiebreaker.`);
270
+ }
271
+ if (rebounds?.length) {
272
+ parts.push(`${rebounds[0].name} is shooting well under his volume — that usually corrects.`);
273
+ }
274
+ if (!analysisResults.playoff_window?.resolved) {
275
+ parts.push('Pass playoff_start_week and playoff_end_week and the schedule half of this becomes real.');
276
+ }
277
+ return {
278
+ ...enhanced,
279
+ chirp_intelligence: { ...enhanced.chirp_intelligence, analysis_chirp: parts.join(' ') }
280
+ };
281
+ }
282
+ async formatResponse(chirpEnhanced, data) {
283
+ const recommendations = (chirpEnhanced.signals?.playoff_schedule_winners ?? []).slice(0, 5).map((p, i) => ({
284
+ priority: i < 2 ? 'HIGH' : 'MEDIUM',
285
+ action: 'target',
286
+ reasoning: `${p.name} (${p.team} ${p.position}) — ${p.ppg} P/gm and ${p.playoff_games} games in your playoff window`
287
+ }));
288
+ const analysisInsights = {
289
+ source: chirpEnhanced.source,
290
+ stats_season: chirpEnhanced.stats_season,
291
+ schedule_source: NHL_SCHEDULE.isAvailable()
292
+ ? `NHL public API (season ${NHL_SCHEDULE.getSeason()})`
293
+ : `UNAVAILABLE - ${NHL_SCHEDULE.getUnavailableReason()}`,
294
+ playoff_window: chirpEnhanced.playoff_window,
295
+ positions: chirpEnhanced.positions,
296
+ signals: chirpEnhanced.signals,
297
+ cheat_sheet: chirpEnhanced.cheat_sheet,
298
+ not_included: chirpEnhanced.not_included,
299
+ ...(chirpEnhanced.unresolved?.length ? { rankings_not_matched: chirpEnhanced.unresolved } : {})
300
+ };
301
+ const metadata = {
302
+ analysis_type: this.analysisType,
303
+ timestamp: new Date().toISOString(),
304
+ team_context: { team_name: 'Draft Kit' },
305
+ semantic_contract_applied: true
306
+ };
307
+ return {
308
+ analysis_insights: analysisInsights,
309
+ recommendations,
310
+ chirp_intelligence: chirpEnhanced.chirp_intelligence,
311
+ metadata
312
+ };
313
+ }
314
+ /** Same anchoring as the other draft tools: week 1 is the NHL opener. */
315
+ resolvePlayoffWindow(args) {
316
+ const startWeek = Number(args?.playoff_start_week ?? 0);
317
+ const endWeek = Number(args?.playoff_end_week ?? 0);
318
+ const seasonStart = NHL_SCHEDULE.getSeasonStartDate();
319
+ if (!startWeek || !endWeek || startWeek > endWeek || !seasonStart) {
320
+ return { resolved: false, start: null, end: null, weeks: [] };
321
+ }
322
+ const week1Monday = NhlScheduleService.weekStart(seasonStart);
323
+ return {
324
+ resolved: true,
325
+ week_1_anchor: `${week1Monday} (NHL season opener)`,
326
+ playoff_start_week: startWeek,
327
+ end_week: endWeek,
328
+ start: NhlScheduleService.addDays(week1Monday, (startWeek - 1) * 7),
329
+ end: NhlScheduleService.addDays(week1Monday, endWeek * 7 - 1),
330
+ weeks: Array.from({ length: endWeek - startWeek + 1 }, (_, i) => NhlScheduleService.addDays(week1Monday, (startWeek - 1 + i) * 7))
331
+ };
332
+ }
333
+ }
@@ -22,6 +22,7 @@ import { NHL_SCHEDULE, NhlScheduleService } from '../services/NhlScheduleService
22
22
  import { toNhlTricode } from '../domain/nhl-teams.js';
23
23
  import { LEAGUE_DATA, LeagueDataService } from '../services/LeagueDataService.js';
24
24
  import { NHL_STATS } from '../services/NhlStatsService.js';
25
+ import { ROSTER_STORE } from '../services/RosterStore.js';
25
26
  /** Skater and goalie slots this analysis reasons about. */
26
27
  const TRACKED_POSITIONS = ['C', 'LW', 'RW', 'D', 'G'];
27
28
  export class DraftPickAnalysis extends AnalysisTemplate {
@@ -49,7 +50,21 @@ export class DraftPickAnalysis extends AnalysisTemplate {
49
50
  // Board state comes entirely from what the user tells us — there is no
50
51
  // platform to ask. `already_drafted` is therefore the source, not a
51
52
  // fallback, and the pick number follows from it unless stated.
52
- const draftedNames = new Set((args.already_drafted ?? []).map(n => this.normalizeName(n)));
53
+ // `already_drafted` is pasted from a draft board, so each entry may carry a
54
+ // pick number, club and position around the name — "1. (1) Connor McDavid
55
+ // EDM - C". Matching those raw against a player name never succeeds, so
56
+ // they run through the same forgiving parser set_roster uses, which
57
+ // resolves each line to an actual NHL player id.
58
+ const draftedText = (args.already_drafted ?? []).join('\n');
59
+ const draftedReport = draftedText.trim()
60
+ ? ROSTER_STORE.parseRoster(draftedText)
61
+ : { resolved: [], unresolved: [], ambiguous: [], lines_read: 0 };
62
+ const draftedIds = new Set(draftedReport.resolved.map(p => p.player_id));
63
+ // Anything the parser could not pin to one player is still excluded by name,
64
+ // so a near-miss removes the right player more often than not — and the
65
+ // unmatched lines are reported rather than silently dropped.
66
+ const draftedNames = new Set([...draftedReport.unresolved, ...draftedReport.ambiguous]
67
+ .map((entry) => this.normalizeName(entry.line)));
53
68
  const rosterPositions = {};
54
69
  for (const p of rawData.roster?.players ?? []) {
55
70
  for (const pos of String(p.position ?? '').split(',')) {
@@ -75,11 +90,12 @@ export class DraftPickAnalysis extends AnalysisTemplate {
75
90
  }));
76
91
  return {
77
92
  draftPool: pool,
78
- draftedIds: new Set(),
93
+ draftedIds,
79
94
  draftedNames,
80
- draftedCount: draftedNames.size,
81
- draftResultsAvailable: false,
82
- manualDraftedCount: draftedNames.size,
95
+ draftedCount: draftedIds.size,
96
+ draftResultsAvailable: draftedIds.size > 0,
97
+ manualDraftedCount: draftedIds.size,
98
+ draftedUnmatched: [...draftedReport.unresolved, ...draftedReport.ambiguous],
83
99
  rosterPositions,
84
100
  playoffWindow: this.resolvePlayoffWindow(args),
85
101
  poolCaveat: rawData.pool_caveat
@@ -92,7 +108,7 @@ export class DraftPickAnalysis extends AnalysisTemplate {
92
108
  const d = data;
93
109
  const maxResults = args.max_results ?? 8;
94
110
  // Pick on the clock: explicit, else inferred from picks already made.
95
- const pickNumber = args.pick_number ?? (Math.max(d.draftedCount, d.draftedNames.size) + 1);
111
+ const pickNumber = args.pick_number ?? (d.draftedCount + 1);
96
112
  const needs = args.roster_needs?.length
97
113
  ? args.roster_needs.map(p => p.toUpperCase())
98
114
  : this.inferNeeds(d.rosterPositions);
@@ -111,7 +127,8 @@ export class DraftPickAnalysis extends AnalysisTemplate {
111
127
  roster_needs: needs,
112
128
  pool_size: d.draftPool.length,
113
129
  available_count: available.length,
114
- players_off_board: d.draftedIds.size + d.draftedNames.size,
130
+ players_off_board: d.draftedIds.size,
131
+ drafted_unmatched: d.draftedUnmatched ?? [],
115
132
  draft_results_available: d.draftResultsAvailable,
116
133
  manual_drafted_count: d.manualDraftedCount,
117
134
  playoff_window: d.playoffWindow,
@@ -188,9 +205,14 @@ export class DraftPickAnalysis extends AnalysisTemplate {
188
205
  pick_number_source: chirpEnhanced.pick_number_source,
189
206
  roster_needs: chirpEnhanced.roster_needs,
190
207
  players_off_board: chirpEnhanced.players_off_board,
191
- board_state: `${chirpEnhanced.players_off_board} player(s) marked as already drafted. ` +
192
- 'Board state comes from `already_drafted` there is no platform to read it from, ' +
193
- 'so tell me who has gone and the board updates.',
208
+ board_state: `${chirpEnhanced.players_off_board} player(s) removed from the board. ` +
209
+ 'Paste picks into `already_drafted` in whatever shape your draft room shows ' +
210
+ 'them pick numbers, clubs and positions around the name are fine.',
211
+ // Never silently swallow a line that did not resolve; on draft day an
212
+ // unmatched pick means a drafted player is still being recommended.
213
+ drafted_not_matched: chirpEnhanced.drafted_unmatched?.length
214
+ ? chirpEnhanced.drafted_unmatched
215
+ : undefined,
194
216
  board_note: chirpEnhanced.pool_caveat,
195
217
  schedule_source: chirpEnhanced.schedule_available
196
218
  ? `NHL public API (season ${NHL_SCHEDULE.getSeason()})`
@@ -157,6 +157,18 @@ export const TOOL_METADATA = {
157
157
  template_version: "1.0.0",
158
158
  analysis_type: "schedule_advantage"
159
159
  },
160
+ draft_kit: {
161
+ chirp_style: "strategic_advantage",
162
+ discovery_tags: ["draft", "kit", "tiers", "cheat_sheet", "rankings", "sleepers"],
163
+ intent_category: "draft_intelligence",
164
+ hockey_context: "draft_preparation",
165
+ chirp_potential: "draft_board_truth",
166
+ // 🆕 Template Pattern Metadata
167
+ uses_template_pattern: true,
168
+ analysis_class: "DraftKitAnalysis",
169
+ template_version: "1.0.0",
170
+ analysis_type: "draft_pick"
171
+ },
160
172
  chirp_draft_pick: {
161
173
  chirp_style: "ice_cold_truth",
162
174
  discovery_tags: ["draft", "pick", "ADP", "value", "roster_build"],
package/build/index.js CHANGED
@@ -24,6 +24,7 @@ import { WeekendStreamAnalysis } from './analyses/WeekendStreamAnalysis.js';
24
24
  import { BreakoutAnalysis } from './analyses/BreakoutAnalysis.js';
25
25
  import { ScheduleValueAnalysis } from './analyses/ScheduleValueAnalysis.js';
26
26
  import { DraftPickAnalysis } from './analyses/DraftPickAnalysis.js';
27
+ import { DraftKitAnalysis } from './analyses/DraftKitAnalysis.js';
27
28
  import { NHL_STATS } from './services/NhlStatsService.js';
28
29
  import { ROSTER_STORE } from './services/RosterStore.js';
29
30
  import { LEAGUE_DATA, NO_ROSTER_MESSAGE, NO_OPPONENT_MESSAGE } from './services/LeagueDataService.js';
@@ -60,6 +61,7 @@ const lineupAnalysis = new LineupAnalysis();
60
61
  const breakoutAnalysis = new BreakoutAnalysis();
61
62
  const scheduleValueAnalysis = new ScheduleValueAnalysis();
62
63
  const draftPickAnalysis = new DraftPickAnalysis();
64
+ const draftKitAnalysis = new DraftKitAnalysis();
63
65
  const weekendStreamAnalysis = new WeekendStreamAnalysis();
64
66
  // Helper function to find current matchup by status
65
67
  function findCurrentMatchup(matchups) {
@@ -1191,6 +1193,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1191
1193
  }
1192
1194
  }
1193
1195
  },
1196
+ {
1197
+ name: "draft_kit",
1198
+ description: "📋 A full draft kit — positional tiers, a cheat sheet, and flags you cannot get elsewhere: playoff-window schedule per club, shooting-luck rebound candidates, age-based decline risk, and category specialists. Works two ways: call it plain and it builds the board from last season's NHL production, or paste a ranked list (from any published kit) and it keeps that order while annotating it with schedule and flags. States plainly what it does not include — no projections, no ADP, no line combos, no injuries.",
1199
+ inputSchema: {
1200
+ type: "object",
1201
+ properties: {
1202
+ playoff_start_week: {
1203
+ type: "number",
1204
+ description: "First week of your fantasy playoffs. Supply this with playoff_end_week and every player gets their club's playoff-window game count."
1205
+ },
1206
+ playoff_end_week: {
1207
+ type: "number",
1208
+ description: "Final week of your fantasy playoffs."
1209
+ },
1210
+ rankings: {
1211
+ type: "string",
1212
+ description: "Optional. Paste a ranked player list — from NHL.com, Dobber, FantasyPros, anywhere — one per line. Its order becomes the baseline rank and CHIRP annotates it rather than replacing it. Omit to have the board built from NHL production instead."
1213
+ },
1214
+ positions: {
1215
+ type: "array",
1216
+ items: { type: "string" },
1217
+ description: "Limit to positions, e.g. [\"C\", \"G\"] (default: all)"
1218
+ },
1219
+ tier_size: { type: "number", description: "Players per tier (default 6)", default: 6 },
1220
+ max_per_position: { type: "number", description: "How deep to go per position (default 24)", default: 24 },
1221
+ ...baseChirpSchema
1222
+ }
1223
+ }
1224
+ },
1194
1225
  {
1195
1226
  name: "chirp_draft_pick",
1196
1227
  description: "❄️ ICE at the draft table — with a pick on the clock, ranks who to take against YOUR draft: who is already gone, what your roster still needs, Yahoo's ADP (so 'value' means the market is wrong here), and each club's schedule during your league's playoff weeks. Pass already_drafted if Yahoo's draft results lag your live draft.",
@@ -1633,6 +1664,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1633
1664
  };
1634
1665
  }
1635
1666
  }
1667
+ case "draft_kit": {
1668
+ try {
1669
+ const result = await draftKitAnalysis.executeAnalysis({
1670
+ playoff_start_week: args?.playoff_start_week,
1671
+ playoff_end_week: args?.playoff_end_week,
1672
+ rankings: args?.rankings,
1673
+ positions: args?.positions,
1674
+ tier_size: args?.tier_size,
1675
+ max_per_position: args?.max_per_position
1676
+ }, {
1677
+ chirp_intensity: args?.chirp_intensity || 'standard',
1678
+ personality_mode: args?.personality_mode || 'analytical',
1679
+ enable_chirp: args?.enable_chirp !== false,
1680
+ semantic_intent: 'user_requested'
1681
+ });
1682
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1683
+ }
1684
+ catch (error) {
1685
+ const errorMessage = error instanceof Error ? error.message : String(error);
1686
+ return {
1687
+ content: [{ type: "text", text: JSON.stringify({
1688
+ error: errorMessage,
1689
+ note: "Draft kit failed - it needs only the NHL public API, so retry shortly"
1690
+ }, null, 2) }],
1691
+ isError: true
1692
+ };
1693
+ }
1694
+ }
1636
1695
  case "chirp_draft_pick": {
1637
1696
  try {
1638
1697
  const semanticContract = {
@@ -25,6 +25,15 @@ const __dirname = path.dirname(__filename);
25
25
  const NHL_API_BASE = 'https://api-web.nhle.com/v1';
26
26
  const GAME_TYPE_REGULAR_SEASON = 2;
27
27
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
28
+ /**
29
+ * Bump whenever the cached player shape changes.
30
+ *
31
+ * The cache is keyed by season, so adding a field to NhlPlayer would otherwise
32
+ * keep serving records without it until the TTL expired — silently, and looking
33
+ * exactly like the field is unavailable from the NHL. Including the version in
34
+ * the filename makes a shape change invalidate the cache immediately.
35
+ */
36
+ const CACHE_SCHEMA_VERSION = 2;
28
37
  export class NhlStatsService {
29
38
  byId = new Map();
30
39
  byNameKey = new Map();
@@ -113,6 +122,7 @@ export class NhlStatsService {
113
122
  name: `${p.firstName?.default ?? ''} ${p.lastName?.default ?? ''}`.trim(),
114
123
  team,
115
124
  position: p.positionCode ?? (group === 'goalies' ? 'G' : '?'),
125
+ birth_date: p.birthDate,
116
126
  stats: statsById.get(id)
117
127
  });
118
128
  }
@@ -235,6 +245,21 @@ export class NhlStatsService {
235
245
  getByTeam(team) {
236
246
  return this.getAll().filter(p => p.team === team);
237
247
  }
248
+ /**
249
+ * Age in years at a given date, or null when the birth date is unknown.
250
+ *
251
+ * Age drives the breakout and decline signals a draft kit needs, and the NHL
252
+ * publishes it on the roster endpoint, so there is no reason to estimate it.
253
+ */
254
+ static ageOf(player, asOf = new Date()) {
255
+ if (!player.birth_date)
256
+ return null;
257
+ const born = Date.parse(`${player.birth_date}T00:00:00Z`);
258
+ if (!Number.isFinite(born))
259
+ return null;
260
+ const years = (asOf.getTime() - born) / (365.2425 * 24 * 60 * 60 * 1000);
261
+ return Number(years.toFixed(1));
262
+ }
238
263
  getPlayerCount() {
239
264
  return this.byId.size;
240
265
  }
@@ -259,7 +284,7 @@ export class NhlStatsService {
259
284
  // 🎯 Cache
260
285
  // ==========================================
261
286
  cachePath(rosterSeason, statsSeason) {
262
- return path.join(this.cacheDir, `players-${rosterSeason}-${statsSeason}.json`);
287
+ return path.join(this.cacheDir, `players-v${CACHE_SCHEMA_VERSION}-${rosterSeason}-${statsSeason}.json`);
263
288
  }
264
289
  readCache(rosterSeason, statsSeason) {
265
290
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semanticintent/semantic-chirp-intelligence-mcp",
3
- "version": "4.0.3",
3
+ "version": "4.1.0",
4
4
  "description": "Universal fantasy hockey intelligence \u2014 a Model Context Protocol server that reads NHL schedule and player data and analyses any roster you paste. No account, no API key, no platform lock-in.",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
package/scripts/smoke.mjs CHANGED
@@ -39,6 +39,7 @@ const TOOLS = [
39
39
  ['analyze_trade', { giving: ['Cale Makar'], receiving: ['Quinn Hughes'] }],
40
40
  ['schedule_value', { teams: ['TOR', 'SEA'], playoff_start_week: 22, playoff_end_week: 24 }],
41
41
  ['chirp_draft_pick', { pick_number: 5, max_results: 5 }],
42
+ ['draft_kit', { playoff_start_week: 22, playoff_end_week: 24, positions: ['C'], max_per_position: 6 }],
42
43
  ];
43
44
 
44
45
  const proc = spawn(process.execPath, ['build/index.js'], { stdio: ['pipe', 'pipe', 'pipe'] });