@chessceo/mcp 0.31.2 → 0.34.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.
package/dist/index.js CHANGED
@@ -87,8 +87,17 @@ const AUTHED_TOOLS = new Set([
87
87
  "set_tag",
88
88
  "apply_mutations",
89
89
  "auto_evaluate",
90
+ "auto_evaluate_status",
91
+ "auto_evaluate_cancel",
92
+ "deep_analyse",
93
+ "deep_analyse_status",
94
+ "deep_analyse_cancel",
90
95
  "quote_engine_eval",
91
96
  "predict_human_move",
97
+ "prepare_opponent",
98
+ "get_prep_position",
99
+ "list_prep_sessions",
100
+ "delete_prep_session",
92
101
  ]);
93
102
  function isAuthedToolCall(body) {
94
103
  if (!body || typeof body !== "object")
@@ -195,55 +204,80 @@ const TOOLS = [
195
204
  },
196
205
  },
197
206
  {
198
- name: "get_player_preparation",
199
- description: "For a given player, colour and starting position, return both the moves the player actually chose (frequency + win rate) and the underlying games. Position is specified either as a move sequence in SAN (`line`) or a raw FEN. Use `line` iteratively to walk the opening tree: call once with empty `line`, pick a move, call again with `line` extended by that move, etc.\n\n" +
200
- "GROUNDING: every claim about the opponent's repertoire must trace back to this tool's output. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at the actual game counts / win rates in the response. Don't invent 'the opponent typically plays X' check first. Compute is cheap: run this on more branches instead of pattern-matching from a chess book.\n\n" +
201
- "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read at the requested position (top move + score + PV) and the corresponding NAG. Do NOT fire a separate cloud_analyse for the same FEN — the eval is right there.\n\n" +
202
- "Reading the response CRITICAL:\n" +
203
- " Win % is one weight, not a verdict. Recommend 1.b3 over 1.d4 because 60% > 50% is wrong. Sample size matters (3 games at 66% is noise; 300 at 55% is signal); avgWhite / avgBlack per move show the rating context (a big score often means a rating gap, not repertoire truth).\n" +
204
- " Prep is symmetric information both sides see the same history. Assume the opponent knows the weakness you spotted; a weak opponent won't patch it, a strong or improving one already has (but structural weaknesses like 'bad in Catalan structures' hold anyway).\n" +
205
- "• Recency > career. The last 12-24 months dominate. This endpoint's compact/LLM view deliberately omits per-move `fashionScore` at the individual level it's trailing noise. The general DB endpoint (`get_position_stats`) keeps it, where it's real fashion signal (what the top field is playing this month).\n" +
206
- " Opponent will deviate early. Prep is a tree, not a line cover the 2 most likely replies at each real branching point, not one 20-move line.\n" +
207
- "• Surprise is a scalpel. Don't tell a lifelong 1.e4 player to switch to 1.d4 — meta-signal screams prep. Rare secondary lines within the user's existing repertoire (e.g. 6.Bc4 instead of usual 6.Bg5 vs the Najdorf) are where surprise is real.\n\n" +
208
- "For the full guide call the `read_prep_strategy_guide` tool.",
207
+ name: "prepare_opponent",
208
+ description: "Create a prep SESSION combining games from one or more sources FIDE database, Chess.com account, Lichess account with optional filters (colour, date range, time control). Returns a session `token` you pass to `get_prep_position` to query stats at any position within that filtered corpus.\n\n" +
209
+ "This is the main opponent-prep tool. Use it whenever a user asks 'prep me against X' call once with the right sources+filters, then walk the tree with `get_prep_position(session_token, ...)`. Sessions are cached on the server (list existing ones with `list_prep_sessions` to avoid rebuilding).\n\n" +
210
+ "SOURCES (1-10 per call, combined into one gameset):\n" +
211
+ "- `fide`needs `fideId`. Optional filters: `color`, `startMonth`/`endMonth`, `timeControl` (`classical`|`rapid`|`blitz`), `excludeOnline`.\n" +
212
+ "- `chesscom` needs `username`. Filters: `color`, `startMonth`/`endMonth` (**required** for chesscom/lichess), `timeControl`.\n" +
213
+ "- `lichess`needs `username`. Same filters as chesscom; `timeControl` also accepts `bullet` on Lichess.\n\n" +
214
+ "Multi-source example: one player with both a FIDE ID and a Lichess account two sources in one call, all their games combined into one session.\n\n" +
215
+ "GROUNDING: every claim about the opponent's repertoire must trace back to a `get_prep_position` call on this session. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at actual game counts / win rates in the response. Prep is a two-player game — see `read_prep_strategy_guide` before recommending an opening plan.",
209
216
  inputSchema: {
210
217
  type: "object",
211
218
  properties: {
212
- fide_id: { type: "integer", description: "FIDE ID from search_player." },
213
- color: {
214
- type: "string",
215
- enum: ["white", "black"],
216
- description: "Which colour the player is analysed with.",
217
- },
218
- file_id: {
219
- type: "string",
220
- description: "Prep file id to address the position from. Combine with `node_id` — the server derives the FEN from the tree, so you don't paste a FEN string. **Prefer this over `fen`/`line`/`moves` when a prep file is open.**",
221
- },
222
- node_id: {
223
- type: "string",
224
- description: "Node id inside `file_id` whose position to query. Root is 'r'. When set, overrides `fen`/`line`/`moves`.",
225
- },
226
- line: {
227
- type: "string",
228
- description: "Move sequence in SAN from the starting position, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for startpos. Alias: `moves` (same thing). Only used if `node_id` is not set.",
229
- },
230
- moves: {
231
- type: "string",
232
- description: "SAN moves to apply on top of `fen` (or on top of startpos if no fen). Same shape as `line`. Wins over `line` if both are set. Only used if `node_id` is not set.",
233
- },
234
- fen: {
235
- type: "string",
236
- description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone. Only used if `node_id` is not set.",
237
- },
238
- limit: {
239
- type: "integer",
240
- minimum: 1,
241
- maximum: 10,
242
- description: "Number of games to return (max 10 per request; page with offset).",
219
+ sources: {
220
+ type: "array",
221
+ minItems: 1,
222
+ maxItems: 10,
223
+ description: "1-10 game sources, all combined into one filtered session.",
224
+ items: {
225
+ type: "object",
226
+ properties: {
227
+ type: { type: "string", enum: ["fide", "chesscom", "lichess"], description: "Source type." },
228
+ fide_id: { type: "integer", description: "FIDE ID (required for type='fide')." },
229
+ username: { type: "string", description: "Platform username (required for type='chesscom'/'lichess')." },
230
+ color: { type: "string", enum: ["white", "black"], description: "Filter: only games where this side is played by the source player. Omit for both colours." },
231
+ start_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games from this month onwards, format 'YYYY/MM'. **Required** for chesscom/lichess." },
232
+ end_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games up to this month, format 'YYYY/MM'. **Required** for chesscom/lichess." },
233
+ time_control: { type: "string", enum: ["classical", "rapid", "blitz", "bullet"], description: "Filter: only this time control. `bullet` is Lichess-only." },
234
+ exclude_online: { type: "boolean", description: "FIDE-only: exclude online-flagged games (default false)." },
235
+ },
236
+ required: ["type"],
237
+ },
243
238
  },
239
+ },
240
+ required: ["sources"],
241
+ },
242
+ },
243
+ {
244
+ name: "get_prep_position",
245
+ description: "Query one position within a prep session created by `prepare_opponent`. Returns move statistics (frequency + win rate + last-played date per move) plus the actual games played from that position, in one call.\n\n" +
246
+ "Position input: prefer `file_id`+`node_id` when inside a prep file (server derives FEN from the tree). Otherwise pass `fen`.\n\n" +
247
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` (Stockfish + Lc0 read at the position) so you don't need a separate cloud_analyse.\n\n" +
248
+ "Reading the response — CRITICAL:\n" +
249
+ "• Win % is one weight, not a verdict. Sample size matters (3 games at 66% is noise; 300 at 55% is signal).\n" +
250
+ "• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted.\n" +
251
+ "• Recency > career. The last 12-24 months dominate — filter your session with `start_month` if the player's repertoire shifted.\n" +
252
+ "• Opponent will deviate early. Prep is a tree — cover the 2 most likely replies at each real branching point, not one 20-move line.\n\n" +
253
+ "For the full guide call `read_prep_strategy_guide`.",
254
+ inputSchema: {
255
+ type: "object",
256
+ properties: {
257
+ session_token: { type: "string", description: "Session token from `prepare_opponent`." },
258
+ file_id: { type: "string", description: "Prep file id — combine with `node_id` for tree-addressed position lookup." },
259
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`." },
260
+ fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
261
+ limit: { type: "integer", minimum: 1, maximum: 50, description: "Games to return (default 10)." },
244
262
  offset: { type: "integer", minimum: 0 },
245
263
  },
246
- required: ["fide_id", "color"],
264
+ required: ["session_token"],
265
+ },
266
+ },
267
+ {
268
+ name: "list_prep_sessions",
269
+ description: "List the caller's active prep sessions with their tokens and metadata. Call this BEFORE `prepare_opponent` to reuse an existing session instead of rebuilding — sessions cost real backend work for chesscom/lichess (downloading months of games), so re-using saves time. Response includes source description, game count, and creation time per session.",
270
+ inputSchema: { type: "object", properties: {} },
271
+ },
272
+ {
273
+ name: "delete_prep_session",
274
+ description: "Delete one prep session by token. Free-form cleanup — sessions do expire automatically, but this is useful when you're done with one or when you want to force a rebuild after upstream data changed.",
275
+ inputSchema: {
276
+ type: "object",
277
+ properties: {
278
+ session_token: { type: "string", description: "Token from `list_prep_sessions` or the response of `prepare_opponent`." },
279
+ },
280
+ required: ["session_token"],
247
281
  },
248
282
  },
249
283
  {
@@ -437,14 +471,14 @@ const TOOLS = [
437
471
  },
438
472
  {
439
473
  name: "cloud_analyse",
440
- description: "Runs a synchronous ~2s analysis on the user's running combo instance and returns both Stockfish and Lc0's final read for the FEN — depth, top-N candidate moves with scores (centipawns from side-to-move POV, or mate distance), and each engine's principal variation.\n\n" +
474
+ description: "Runs a synchronous ~2s analysis on the user's running combo instance and returns both Stockfish and Lc0's final read for the FEN — depth, top-N candidate moves with scores (**scoreCp is White-POV centipawns**: +20 = White is +0.20 pawns better regardless of whose turn it is; matches the sign convention used everywhere else in this MCP, including the stored ceoEval). Mate is White-POV plies-to-mate (+5 = White mates in 5). Also returns each engine's principal variation.\n\n" +
441
475
  "GROUNDING: every claim you make about a position must trace back to actual engine output from a call in THIS session. Don't invent evaluations, don't name 'best moves' you haven't seen the engine list, don't fabricate variations that 'look plausible.' Compute is cheap — call this 5-10 times while walking a tree rather than pattern-matching from your training data. When you don't have data for the position, either run the tool or say so; don't fill the gap with chess prose the user can't distinguish from measured output.\n\n" +
442
476
  "Auto-picks the caller's only running combo instance; errors clearly if there are zero (start one first with start_cloud_engine) or more than one (destroy the extras first).\n\n" +
443
477
  "How to read the response:\n" +
444
478
  "• Stockfish is objective truth — trust it for 'does this line hold?' 'is there a tactic?' 'is this endgame drawn?' A Stockfish 0.00 means 'objectively equal', NOT 'trivial draw' — one side can still be much harder to play in practice.\n" +
445
479
  "• Lc0 is practical eval — trust it for 'which side is easier?' 'which candidate is best when Stockfish shows several as equal?' Lc0 sees long-term positional factors Stockfish's fixed search can miss.\n" +
446
480
  "• When they agree → high confidence. When they disagree → look at both scores and reason WHY (Stockfish sharply higher = tactic Lc0 missed; Lc0 higher = long-term positional edge past Stockfish's horizon). Never dismiss either — the disagreement is the signal.\n\n" +
447
- "Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Practical range -20..+20. Use it to find non-objective 'practical' ideas or when the user needs to steer toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
481
+ "Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Signed 0-100 strength — same scale as the web UI's ContemptStrength slider (the server multiplies by 8 to produce Lc0's internal cp bias). Typical values: ±15 for a light nudge, ±30-60 for real fighting play, ±80-100 for maximum steer. Use it to find non-objective 'practical' ideas or when the user needs to lean toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
448
482
  "Also useful: pass `moves` on top of `fen` to explore a variation without computing FENs yourself (e.g. fen='<tabiya>', moves='b4 a5 c3'). And the flip-side-to-move threat check documented in the guide is a great free trick.\n\n" +
449
483
  "For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
450
484
  "Not for casual questions — this costs real money per second. Use `get_position_stats` for anything that doesn't require deep prep.\n\n" +
@@ -475,7 +509,12 @@ const TOOLS = [
475
509
  type: "integer",
476
510
  minimum: -100,
477
511
  maximum: 100,
478
- description: "Lc0 contempt bias. 0 = objective (default). Positive favours White, negative favours Black; stay within -20..+20 in practice. Not applied to Stockfish. See engine_usage_primer prompt for when to use.",
512
+ description: "Lc0 contempt bias. Signed 0-100 strength (same scale as the web UI's ContemptStrength slider — server multiplies by 8 to get the internal cp bias). 0 = objective (default). Positive favours White, negative favours Black. Typical: ±15 light nudge, ±30-60 real fighting play, ±80-100 maximum steer. Not applied to Stockfish. See engine_usage_primer for when to use.",
513
+ },
514
+ engines: {
515
+ type: "array",
516
+ items: { type: "string", enum: ["stockfish", "lc0"] },
517
+ description: "Which engines to run. Default = both. Use `[\"lc0\"]` to skip Stockfish (e.g. while a deep_analyse job is holding the SF slot on the same combo). Use `[\"stockfish\"]` when only the objective read matters. The skipped engine's field is omitted from the response.",
479
518
  },
480
519
  },
481
520
  },
@@ -500,7 +539,7 @@ const TOOLS = [
500
539
  name: "read_prep_file",
501
540
  description: "Read one prep file. Returns a compact tree (each node carries a stable `id`, `san`, `fen`, `ply`, optional `comment`/`nags`/`annotations`/`ceoEval`, and `children`) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN — all edits go through the mutation tools (add_move / add_line / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
502
541
  "**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash derived from its parent's id + its SAN. Sibling insertions, deletions and variation promotions do NOT change any other node's id. Pass this id as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
503
- "**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_player_preparation, quote_engine_eval). Use it whenever a file is open — the server derives the FEN from the tree, so you can't 'analyse the wrong position' by mis-typing a FEN.",
542
+ "**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_prep_position, quote_engine_eval). Use it whenever a file is open — the server derives the FEN from the tree, so you can't 'analyse the wrong position' by mis-typing a FEN.",
504
543
  inputSchema: {
505
544
  type: "object",
506
545
  properties: {
@@ -693,10 +732,11 @@ const TOOLS = [
693
732
  },
694
733
  {
695
734
  name: "auto_evaluate",
696
- description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. Requires a running cloud combo instance.\n\n" +
697
- "**Does NOT set visible NAGs.** NAG placement is your call, not the engine's an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move (that's just noise on the board), and a `!` on a novelty or a `?!` on a risky committal is a judgment call the engine can't make. Use this tool to persist the raw numbers on every node, then re-read the file and hand-pick NAGs on the moves where a glyph carries real signal.\n\n" +
698
- "On re-read every evaluated node carries `ceoEval: { sf: {cp, depth}, lc0: {cp, depth} }` the numbers travel with the file. Read those back with quote_engine_eval before writing prose that references engine numbers. Use `only_missing=true` (default) to skip nodes already evaluated on repeat runs.\n\n" +
699
- "Costs real money hits cloud_analyse per node. A 200-node tree at 1.5s/node = ~5 min of engine time. Runs 4 evaluations in parallel to save wall time.",
735
+ description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval` on every descendant via cloud_analyse. Requires a running cloud combo instance.\n\n" +
736
+ "**Async job returns immediately.** Response: `{ job_id, target_count, status: 'running', estimated_seconds }`. Then poll `auto_evaluate_status(job_id)` until `done: true`. Cancel a run with `auto_evaluate_cancel(job_id)` partial progress is preserved. Do useful other work between polls (write more of the tree, walk the opponent's repertoire) the engine runs in the background.\n\n" +
737
+ "Progress is checkpointed to the prep file every 8 successfully-evaluated nodes, so a cancel / crash / MCP restart mid-run leaves the tree partially populated rather than losing everything. On MCP restart the job record disappears; re-run auto_evaluate and `only_missing=true` naturally skips what was already saved.\n\n" +
738
+ "**Does NOT set visible NAGs.** NAG placement is your call, not the engine's — an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move. Use quote_engine_eval on individual nodes before writing prose that references engine numbers.\n\n" +
739
+ "Costs real money — one cloud_analyse per node. A 200-node walk at default movetime is ~5 min of engine time (calls serialise on the per-combo semaphore in the backend).",
700
740
  inputSchema: {
701
741
  type: "object",
702
742
  properties: {
@@ -704,11 +744,83 @@ const TOOLS = [
704
744
  node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
705
745
  only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
706
746
  movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
707
- expected_version: { type: "integer" },
708
747
  },
709
748
  required: ["id"],
710
749
  },
711
750
  },
751
+ {
752
+ name: "auto_evaluate_status",
753
+ description: "Poll the status of an auto_evaluate job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', target_count, evaluated, errored, remaining, done, error?, version? }`. When `status: 'not_found'` the job either expired (kept ~15 min after completion), never existed, or the MCP restarted since it was created — re-run auto_evaluate.\n\n" +
754
+ "Typical poll cadence: every 3-5 s for small walks, every 10-30 s for large ones. Don't hammer — status is a pure in-memory read but polling doesn't speed the engine up.",
755
+ inputSchema: {
756
+ type: "object",
757
+ properties: {
758
+ job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
759
+ },
760
+ required: ["job_id"],
761
+ },
762
+ },
763
+ {
764
+ name: "auto_evaluate_cancel",
765
+ description: "Ask a running auto_evaluate job to stop as soon as its current node finishes. Whatever progress was completed before cancellation is durably saved (checkpoint on cancel). Idempotent — cancelling an already-finished job is a no-op with a clear note in the response.",
766
+ inputSchema: {
767
+ type: "object",
768
+ properties: {
769
+ job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
770
+ },
771
+ required: ["job_id"],
772
+ },
773
+ },
774
+ {
775
+ name: "deep_analyse",
776
+ description: "Start a long Stockfish think on a single position (up to 5 min movetime). Returns a `job_id` immediately; poll `deep_analyse_status(job_id)` for the result, cancel with `deep_analyse_cancel(job_id)`. Runs SF only — Lc0 doesn't benefit from long thinks past a handful of seconds — and **holds only the SF engine slot on the combo, so `cloud_analyse(..., engines: [\"lc0\"])` stays available for other work in parallel**.\n\n" +
777
+ "Use this when a specific critical position deserves depth — a novelty candidate, a hairy tactical shot, a difficult endgame — and you want Stockfish at depth 35+ rather than the ~depth 22 you get from a 2s cloud_analyse. Movetime is in ms; typical: 30_000-60_000 for 'careful check', 120_000-300_000 for 'find the truth'.\n\n" +
778
+ "Result shape when done matches cloud_analyse's Stockfish leg (depth, top-N candidates with scoreCp/mate, best move, PV). Auto-stores the eval on `file_id`+`node_id` when both are supplied, same as cloud_analyse.",
779
+ inputSchema: {
780
+ type: "object",
781
+ properties: {
782
+ file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree AND persist the result on the node's ceoEval." },
783
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
784
+ fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
785
+ moves: { type: "string", description: "Optional SAN moves on top of `fen`. Only used if `node_id` is not set." },
786
+ movetime_ms: {
787
+ type: "integer",
788
+ minimum: 5_000,
789
+ maximum: 300_000,
790
+ description: "Think time in ms. Default 60_000 (1 min). Max 300_000 (5 min).",
791
+ },
792
+ multipv: {
793
+ type: "integer",
794
+ minimum: 1,
795
+ maximum: 10,
796
+ description: "Number of candidate lines (default 3).",
797
+ },
798
+ },
799
+ },
800
+ },
801
+ {
802
+ name: "deep_analyse_status",
803
+ description: "Poll a deep_analyse job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', elapsed_ms, movetime_ms, result?, error? }`. `result` shape when done: `{ engine, depth, timeMs, bestMove, lines: [{rank, depth, scoreCp?, mate?, pv, nodes?}] }` — the SF leg of a cloud_analyse response.\n\n" +
804
+ "Poll cadence: every ~15-30s for long thinks; there's no penalty for polling more often but the engine progresses at its own pace.",
805
+ inputSchema: {
806
+ type: "object",
807
+ properties: {
808
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
809
+ },
810
+ required: ["job_id"],
811
+ },
812
+ },
813
+ {
814
+ name: "deep_analyse_cancel",
815
+ description: "Ask a running deep_analyse job to stop early. The engine returns whatever it's found so far as the final result. Useful when a partial result at depth 25 is enough and you don't want to wait for depth 40. Idempotent for already-finished jobs.",
816
+ inputSchema: {
817
+ type: "object",
818
+ properties: {
819
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
820
+ },
821
+ required: ["job_id"],
822
+ },
823
+ },
712
824
  {
713
825
  name: "quote_engine_eval",
714
826
  description: "Return the stored engine eval for a node, or null if that node was never analysed. **Call this before writing prose or NAGs that quote engine numbers** — if it returns null, you have no measurement to cite. Do NOT infer an eval for the node from siblings or children; either analyse it (cloud_analyse with node_id) or omit the number from your prose.\n\n" +
@@ -968,12 +1080,33 @@ async function applyBatchMutations(args) {
968
1080
  const savedRow = saved;
969
1081
  return { ok: true, results, version: savedRow.version };
970
1082
  }
971
- // Auto-evaluate: walk the tree from `path`, run cloud_analyse on each node,
972
- // stash the compact per-engine eval in the node's `ceoEval` field (which
973
- // survives across sessions and appears on every read_prep_file), and
974
- // derive the NAG from the SF score. Both writes go via a single batch
975
- // mutation at the end so a 200-node evaluate is one save.
1083
+ const evalJobs = new Map();
1084
+ // GC finished jobs after this long so status polling remains useful
1085
+ // for a while but the map doesn't grow unbounded across long uptimes.
1086
+ const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
1087
+ // Checkpoint interval save progress every N successfully-evaluated
1088
+ // nodes so a mid-run kill leaves the tree partially populated. Small
1089
+ // enough that <15s of work is at risk per checkpoint on a slow combo,
1090
+ // large enough that the save overhead stays a small fraction of the
1091
+ // per-node cost.
1092
+ const SAVE_EVERY_N = 8;
1093
+ function newEvalJobId() {
1094
+ // 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
1095
+ const rand = Math.random().toString(16).slice(2, 8);
1096
+ return `evj_${Date.now().toString(16)}${rand}`;
1097
+ }
1098
+ // Sweep expired jobs on every start/status call — cheap, doesn't need
1099
+ // a background timer, keeps the map bounded to active + recent jobs.
1100
+ function reapExpiredEvalJobs() {
1101
+ const now = Date.now();
1102
+ for (const [k, j] of evalJobs) {
1103
+ if (j.finishedAt && now - j.finishedAt > EVAL_JOB_TTL_MS) {
1104
+ evalJobs.delete(k);
1105
+ }
1106
+ }
1107
+ }
976
1108
  async function autoEvaluate(args) {
1109
+ reapExpiredEvalJobs();
977
1110
  const id = String(args.id);
978
1111
  const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
979
1112
  ? String(args.node_id)
@@ -1001,45 +1134,294 @@ async function autoEvaluate(args) {
1001
1134
  // If the caller anchored at the root, skip evaluating the root itself
1002
1135
  // (no move); otherwise the anchor node IS a real move and gets evaluated.
1003
1136
  walk(startNode, startNode.id === ROOT_ID);
1004
- if (targets.length === 0)
1005
- return { ok: true, evaluated: 0, skipped: 0, version: g.version };
1006
- // Dispatch cloud_analyse in parallel with a 4-way cap so we don't
1007
- // over-saturate the engine-ws connection cap (single-client per port).
1008
- const stored = [];
1009
- const CONCURRENCY = 4;
1010
- let cursor = 0;
1011
- async function worker() {
1012
- while (cursor < targets.length) {
1013
- const idx = cursor++;
1014
- const t = targets[idx];
1137
+ // Nothing to do → return a done job synthetically so the caller doesn't
1138
+ // need to special-case the empty response.
1139
+ if (targets.length === 0) {
1140
+ const jobId = newEvalJobId();
1141
+ evalJobs.set(jobId, {
1142
+ id: jobId,
1143
+ fileId: id,
1144
+ status: "done",
1145
+ targetCount: 0,
1146
+ evaluated: 0,
1147
+ errored: 0,
1148
+ finalVersion: g.version,
1149
+ startedAt: Date.now(),
1150
+ finishedAt: Date.now(),
1151
+ cancelled: false,
1152
+ });
1153
+ return { job_id: jobId, target_count: 0, status: "done", version: g.version };
1154
+ }
1155
+ const jobId = newEvalJobId();
1156
+ const job = {
1157
+ id: jobId,
1158
+ fileId: id,
1159
+ status: "running",
1160
+ targetCount: targets.length,
1161
+ evaluated: 0,
1162
+ errored: 0,
1163
+ startedAt: Date.now(),
1164
+ cancelled: false,
1165
+ };
1166
+ evalJobs.set(jobId, job);
1167
+ // Unawaited — runs concurrently with the tool response. Any thrown
1168
+ // error gets recorded on the job so the LLM's status poll surfaces
1169
+ // it instead of the process seeing an unhandled rejection.
1170
+ void runEvalJob(job, id, targets, movetimeMs).catch(err => {
1171
+ job.status = "error";
1172
+ job.error = err instanceof Error ? err.message : String(err);
1173
+ job.finishedAt = Date.now();
1174
+ });
1175
+ return {
1176
+ job_id: jobId,
1177
+ target_count: targets.length,
1178
+ status: "running",
1179
+ // Rough time estimate at the current default movetime. Serialization
1180
+ // on the per-combo semaphore means walltime ≈ target_count × movetime.
1181
+ estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
1182
+ };
1183
+ }
1184
+ // Worker body — walks targets sequentially (concurrency > 1 is a lie
1185
+ // against the per-combo semaphore in the backend anyway), checkpoints
1186
+ // every SAVE_EVERY_N successfully-evaluated nodes so partial progress
1187
+ // is durable, and re-anchors the version after each save.
1188
+ async function runEvalJob(job, fileId, targets, movetimeMs) {
1189
+ const pending = [];
1190
+ const flush = async () => {
1191
+ if (pending.length === 0)
1192
+ return;
1193
+ // No expected_version — auto_evaluate treats concurrent edits by
1194
+ // the LLM as last-write-wins on the ceoEval field specifically.
1195
+ // Safe because set_ceo_eval is idempotent per node and other
1196
+ // mutations (add_move / set_comment / etc.) don't touch ceoEval.
1197
+ const saved = await applyBatchMutations({
1198
+ id: fileId,
1199
+ mutations: pending,
1200
+ });
1201
+ const sr = saved;
1202
+ if (typeof sr.version === "number")
1203
+ job.finalVersion = sr.version;
1204
+ pending.length = 0;
1205
+ };
1206
+ for (const t of targets) {
1207
+ if (job.cancelled)
1208
+ break;
1209
+ try {
1210
+ const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
1211
+ const ev = analysisToStoredEval(analysis, t.fen);
1212
+ if (ev) {
1213
+ pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
1214
+ job.evaluated++;
1215
+ }
1216
+ else {
1217
+ job.errored++;
1218
+ }
1219
+ }
1220
+ catch {
1221
+ // Per-node failure — record and continue; a bad FEN or a transient
1222
+ // engine hiccup on one node shouldn't kill the whole walk.
1223
+ job.errored++;
1224
+ }
1225
+ if (pending.length >= SAVE_EVERY_N) {
1015
1226
  try {
1016
- const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
1017
- const ev = analysisToStoredEval(analysis, t.fen);
1018
- if (ev)
1019
- stored.push({ nodeId: t.nodeId, ev });
1227
+ await flush();
1228
+ }
1229
+ catch {
1230
+ // Save failure is bad but not fatal — try again on the next
1231
+ // checkpoint or at the end. Progress remains in `pending`
1232
+ // so nothing is lost as long as the process stays alive.
1233
+ }
1234
+ }
1235
+ }
1236
+ // Final flush regardless of cancellation — durably persist whatever
1237
+ // work was completed before the user asked to stop.
1238
+ try {
1239
+ await flush();
1240
+ }
1241
+ catch (err) {
1242
+ job.error = err instanceof Error ? err.message : String(err);
1243
+ job.status = "error";
1244
+ job.finishedAt = Date.now();
1245
+ return;
1246
+ }
1247
+ job.status = job.cancelled ? "cancelled" : "done";
1248
+ job.finishedAt = Date.now();
1249
+ }
1250
+ function autoEvaluateStatus(args) {
1251
+ reapExpiredEvalJobs();
1252
+ const jobId = String(args.job_id || "").trim();
1253
+ if (!jobId)
1254
+ throw new Error("`job_id` is required");
1255
+ const job = evalJobs.get(jobId);
1256
+ if (!job) {
1257
+ return {
1258
+ status: "not_found",
1259
+ note: "Job unknown — either expired (kept ~15 min after completion), never existed, or the MCP process restarted since it was created. Re-run auto_evaluate to start over; the `only_missing` default will skip nodes already evaluated in the prep file.",
1260
+ };
1261
+ }
1262
+ return {
1263
+ job_id: job.id,
1264
+ status: job.status,
1265
+ target_count: job.targetCount,
1266
+ evaluated: job.evaluated,
1267
+ errored: job.errored,
1268
+ remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
1269
+ done: job.status !== "running",
1270
+ error: job.error,
1271
+ version: job.finalVersion,
1272
+ started_at_ms: job.startedAt,
1273
+ finished_at_ms: job.finishedAt,
1274
+ };
1275
+ }
1276
+ function autoEvaluateCancel(args) {
1277
+ const jobId = String(args.job_id || "").trim();
1278
+ if (!jobId)
1279
+ throw new Error("`job_id` is required");
1280
+ const job = evalJobs.get(jobId);
1281
+ if (!job)
1282
+ return { status: "not_found" };
1283
+ if (job.status !== "running") {
1284
+ return { status: job.status, note: "Job already finished; nothing to cancel." };
1285
+ }
1286
+ job.cancelled = true;
1287
+ // status transitions to "cancelled" on the next per-node iteration
1288
+ // inside runEvalJob, after the final flush persists progress.
1289
+ return { status: "cancelling", evaluated_so_far: job.evaluated };
1290
+ }
1291
+ const deepJobs = new Map();
1292
+ const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
1293
+ function newDeepJobId() {
1294
+ const rand = Math.random().toString(16).slice(2, 8);
1295
+ return `deep_${Date.now().toString(16)}${rand}`;
1296
+ }
1297
+ function reapExpiredDeepJobs() {
1298
+ const now = Date.now();
1299
+ for (const [k, j] of deepJobs) {
1300
+ if (j.finishedAt && now - j.finishedAt > DEEP_JOB_TTL_MS) {
1301
+ deepJobs.delete(k);
1302
+ }
1303
+ }
1304
+ }
1305
+ async function deepAnalyseStart(args) {
1306
+ reapExpiredDeepJobs();
1307
+ const resolved = await resolveFromNodeOrFen(args);
1308
+ const fen = resolved.fen;
1309
+ const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
1310
+ const multipv = typeof args.multipv === "number" ? args.multipv : 3;
1311
+ const jobId = newDeepJobId();
1312
+ const job = {
1313
+ id: jobId,
1314
+ status: "running",
1315
+ fileHandle: resolved.file,
1316
+ fen,
1317
+ movetimeMs,
1318
+ multipv,
1319
+ startedAt: Date.now(),
1320
+ cancelController: new AbortController(),
1321
+ };
1322
+ deepJobs.set(jobId, job);
1323
+ // Kick off the long HTTP call unawaited — resolves when the backend
1324
+ // returns the SF snapshot. authedRequest is a plain fetch under the
1325
+ // hood; abort signal flows via cancelController.
1326
+ void runDeepJob(job).catch(err => {
1327
+ job.status = "error";
1328
+ job.error = err instanceof Error ? err.message : String(err);
1329
+ job.finishedAt = Date.now();
1330
+ });
1331
+ return {
1332
+ job_id: jobId,
1333
+ status: "running",
1334
+ movetime_ms: movetimeMs,
1335
+ fen,
1336
+ };
1337
+ }
1338
+ async function runDeepJob(job) {
1339
+ const body = {
1340
+ fen: job.fen,
1341
+ movetime_ms: job.movetimeMs,
1342
+ multipv: job.multipv,
1343
+ engines: ["stockfish"],
1344
+ };
1345
+ let raw;
1346
+ try {
1347
+ // TODO(future): plumb an AbortSignal through authedRequest for
1348
+ // real mid-flight cancellation. For now, cancel just marks the
1349
+ // job so the caller stops polling; the backend still runs the
1350
+ // engine to completion and the result is stored on the job
1351
+ // record but flagged cancelled.
1352
+ raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1353
+ }
1354
+ catch (err) {
1355
+ job.status = "error";
1356
+ job.error = err instanceof Error ? err.message : String(err);
1357
+ job.finishedAt = Date.now();
1358
+ return;
1359
+ }
1360
+ const converted = convertCloudSnapshotResponse(raw, job.fen);
1361
+ const sf = converted.stockfish;
1362
+ if (job.cancelController.signal.aborted) {
1363
+ job.status = "cancelled";
1364
+ }
1365
+ else {
1366
+ job.status = "done";
1367
+ }
1368
+ job.result = sf ?? null;
1369
+ job.finishedAt = Date.now();
1370
+ // Same node-persistence as cloud_analyse: if the caller anchored on
1371
+ // file_id+node_id, store the SF-only eval as the node's ceoEval so
1372
+ // quote_engine_eval can cite it later. We build a StoredEval that has
1373
+ // only the sf leg — no Lc0 was run.
1374
+ if (job.fileHandle && sf) {
1375
+ const ev = analysisToStoredEval({ stockfish: sf }, job.fen);
1376
+ if (ev) {
1377
+ try {
1378
+ await storeEvalOnNode(job.fileHandle, ev);
1020
1379
  }
1021
1380
  catch {
1022
- // best-effort — a single failed node shouldn't kill the batch
1381
+ // best-effort — the analysis result is what the LLM asked for
1023
1382
  }
1024
1383
  }
1025
1384
  }
1026
- await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
1027
- if (stored.length === 0)
1028
- return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
1029
- // Persist the raw numbers only — never touch visible NAGs. NAG glyphs
1030
- // are the LLM's editorial call (novelty, sharp choice, real mistake),
1031
- // not an automatic mapping from the engine number. Stamping `$10` on
1032
- // every 0.00 position in an opening tree just clutters the board.
1033
- // The threshold-derived NAG still lives INSIDE `ceoEval.nag` so the
1034
- // LLM can read it back and decide whether to promote it to a visible
1035
- // NAG on a case-by-case basis.
1036
- const batchMutations = [];
1037
- for (const s of stored) {
1038
- batchMutations.push({ op: "set_ceo_eval", node_id: s.nodeId, ceoEval: s.ev });
1385
+ }
1386
+ function deepAnalyseStatus(args) {
1387
+ reapExpiredDeepJobs();
1388
+ const jobId = String(args.job_id || "").trim();
1389
+ if (!jobId)
1390
+ throw new Error("`job_id` is required");
1391
+ const job = deepJobs.get(jobId);
1392
+ if (!job) {
1393
+ return {
1394
+ status: "not_found",
1395
+ note: "Job unknown — expired (kept ~15 min after completion), never existed, or the MCP process restarted.",
1396
+ };
1039
1397
  }
1040
- const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
1041
- const sr = saveResult;
1042
- return { ok: true, evaluated: stored.length, skipped: targets.length - stored.length, version: sr.version };
1398
+ return {
1399
+ job_id: job.id,
1400
+ status: job.status,
1401
+ movetime_ms: job.movetimeMs,
1402
+ elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
1403
+ fen: job.fen,
1404
+ result: job.result,
1405
+ error: job.error,
1406
+ started_at_ms: job.startedAt,
1407
+ finished_at_ms: job.finishedAt,
1408
+ };
1409
+ }
1410
+ function deepAnalyseCancel(args) {
1411
+ const jobId = String(args.job_id || "").trim();
1412
+ if (!jobId)
1413
+ throw new Error("`job_id` is required");
1414
+ const job = deepJobs.get(jobId);
1415
+ if (!job)
1416
+ return { status: "not_found" };
1417
+ if (job.status !== "running") {
1418
+ return { status: job.status, note: "Job already finished; nothing to cancel." };
1419
+ }
1420
+ job.cancelController.abort();
1421
+ // Status flips to "cancelled" when runDeepJob observes the abort on
1422
+ // completion. Backend keeps churning until movetime elapses (mid-
1423
+ // flight abort of the HTTP call is a follow-up).
1424
+ return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
1043
1425
  }
1044
1426
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1045
1427
  function pathIntoTree(root, path) {
@@ -1312,6 +1694,46 @@ function resolveFenFromArgs(args) {
1312
1694
  }
1313
1695
  return board.fen();
1314
1696
  }
1697
+ // Normalize one MCP `prepare_opponent` source into the shape the backend's
1698
+ // /api/chess/prep/prepare-multi expects. Handles two impedance mismatches:
1699
+ // - snake_case → camelCase (fide_id → fideId, start_month → startMonth, etc.)
1700
+ // - the unified `time_control` string → per-source-type filter:
1701
+ // * fide / chesscom → timeFormats: ["Classical" | "Rapid" | "Blitz"]
1702
+ // * lichess → perfType: "classical" | "rapid" | "blitz" | "bullet"
1703
+ // Backend validates required fields per source type, so we don't need to
1704
+ // pre-reject missing username/fideId here — it'll come back as a 400 the
1705
+ // LLM can act on.
1706
+ function normalizeSourceForBackend(src, idx) {
1707
+ const type = typeof src.type === "string" ? src.type : "";
1708
+ if (type !== "fide" && type !== "chesscom" && type !== "lichess") {
1709
+ throw new Error(`sources[${idx}].type must be one of fide|chesscom|lichess (got ${JSON.stringify(src.type)})`);
1710
+ }
1711
+ const out = { type };
1712
+ if (typeof src.fide_id === "number")
1713
+ out.fideId = src.fide_id;
1714
+ if (typeof src.username === "string" && src.username.trim() !== "")
1715
+ out.username = src.username.trim();
1716
+ if (typeof src.color === "string" && (src.color === "white" || src.color === "black"))
1717
+ out.color = src.color;
1718
+ if (typeof src.start_month === "string" && src.start_month.trim() !== "")
1719
+ out.startMonth = src.start_month.trim();
1720
+ if (typeof src.end_month === "string" && src.end_month.trim() !== "")
1721
+ out.endMonth = src.end_month.trim();
1722
+ if (typeof src.exclude_online === "boolean")
1723
+ out.excludeOnline = src.exclude_online;
1724
+ const tc = typeof src.time_control === "string" ? src.time_control : "";
1725
+ if (tc) {
1726
+ if (type === "lichess") {
1727
+ out.perfType = tc;
1728
+ }
1729
+ else {
1730
+ // fide + chesscom take a titlecased timeFormats array.
1731
+ const titled = tc.charAt(0).toUpperCase() + tc.slice(1);
1732
+ out.timeFormats = [titled];
1733
+ }
1734
+ }
1735
+ return out;
1736
+ }
1315
1737
  // Async resolver used by every engine / DB tool. If the caller supplied
1316
1738
  // file_id + node_id (preferred), load the file, resolve the node, and
1317
1739
  // return both the FEN and a handle we can use to persist ceoEval later.
@@ -1406,27 +1828,31 @@ async function callToolInner(name, args) {
1406
1828
  return get("/api/chess/players/search/simple", { q: String(args.name), view: "llm" });
1407
1829
  case "get_player_profile":
1408
1830
  return get("/api/chess/players/profile", { fideId: Number(args.fide_id) });
1409
- case "get_player_preparation": {
1410
- // Node-first: if file_id+node_id was given, derive the FEN from the
1411
- // tree (never trust an LLM-supplied FEN when a node reference is
1412
- // available). Otherwise fall back to fen/moves/line.
1831
+ case "prepare_opponent": {
1832
+ const rawSources = Array.isArray(args.sources) ? args.sources : [];
1833
+ if (rawSources.length === 0)
1834
+ throw new Error("`sources` array required");
1835
+ const sources = rawSources.map((s, i) => normalizeSourceForBackend(s, i));
1836
+ return authedRequest("POST", "/api/chess/prep/prepare-multi", { sources });
1837
+ }
1838
+ case "get_prep_position": {
1839
+ const token = String(args.session_token || "").trim();
1840
+ if (!token)
1841
+ throw new Error("`session_token` required — create one with prepare_opponent");
1413
1842
  const resolved = await resolveFromNodeOrFen(args);
1414
- const effectiveFen = resolved.fen;
1415
- const params = {
1416
- fideId: Number(args.fide_id),
1417
- color: String(args.color),
1418
- compact: "true",
1419
- fen: effectiveFen,
1843
+ const fen = resolved.fen;
1844
+ const qs = {
1845
+ token,
1846
+ fen,
1847
+ limit: typeof args.limit === "number" ? args.limit : 10,
1420
1848
  };
1421
- if (typeof args.limit === "number")
1422
- params.limit = args.limit;
1423
1849
  if (typeof args.offset === "number")
1424
- params.offset = args.offset;
1850
+ qs.offset = args.offset;
1425
1851
  const [raw, ev] = await Promise.all([
1426
- get("/api/chess/prep/by-player", params),
1427
- fetchCompactEval(effectiveFen),
1852
+ get("/api/chess/prep/unified", qs),
1853
+ fetchCompactEval(fen),
1428
1854
  ]);
1429
- const converted = convertAvailableMovesToSAN(raw, effectiveFen);
1855
+ const converted = convertAvailableMovesToSAN(raw, fen);
1430
1856
  if (converted && typeof converted === "object") {
1431
1857
  trimGamesMovetext(converted);
1432
1858
  stripPositionResponse(converted);
@@ -1435,6 +1861,14 @@ async function callToolInner(name, args) {
1435
1861
  }
1436
1862
  return converted;
1437
1863
  }
1864
+ case "list_prep_sessions":
1865
+ return authedRequest("GET", "/api/chess/prep/sessions");
1866
+ case "delete_prep_session": {
1867
+ const token = String(args.session_token || "").trim();
1868
+ if (!token)
1869
+ throw new Error("`session_token` required");
1870
+ return authedRequest("DELETE", `/api/chess/prep/sessions/${encodeURIComponent(token)}`);
1871
+ }
1438
1872
  case "get_position_stats": {
1439
1873
  const resolved = await resolveFromNodeOrFen(args);
1440
1874
  const fen = resolved.fen;
@@ -1515,6 +1949,8 @@ async function callToolInner(name, args) {
1515
1949
  body.multipv = args.multipv;
1516
1950
  if (typeof args.contempt === "number")
1517
1951
  body.contempt = args.contempt;
1952
+ if (Array.isArray(args.engines))
1953
+ body.engines = args.engines;
1518
1954
  const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1519
1955
  const converted = convertCloudSnapshotResponse(raw, fen);
1520
1956
  // Node-addressed calls: persist the result on the node's ceoEval
@@ -1530,6 +1966,12 @@ async function callToolInner(name, args) {
1530
1966
  }
1531
1967
  return converted;
1532
1968
  }
1969
+ case "deep_analyse":
1970
+ return deepAnalyseStart(args);
1971
+ case "deep_analyse_status":
1972
+ return deepAnalyseStatus(args);
1973
+ case "deep_analyse_cancel":
1974
+ return deepAnalyseCancel(args);
1533
1975
  case "list_prep_files":
1534
1976
  return authedRequest("GET", "/api/agent/prep-files");
1535
1977
  case "search_prep_files":
@@ -1588,6 +2030,10 @@ async function callToolInner(name, args) {
1588
2030
  return applyBatchMutations(args);
1589
2031
  case "auto_evaluate":
1590
2032
  return autoEvaluate(args);
2033
+ case "auto_evaluate_status":
2034
+ return autoEvaluateStatus(args);
2035
+ case "auto_evaluate_cancel":
2036
+ return autoEvaluateCancel(args);
1591
2037
  case "quote_engine_eval": {
1592
2038
  const fileId = String(args.id);
1593
2039
  const nodeId = argNodeId(args);
@@ -1714,7 +2160,7 @@ Preparation workflow — follow the steps in order and be explicit about which t
1714
2160
  - Rapid and blitz reveal what they play under time pressure but may include experiments.
1715
2161
  - Online games are useful but noisier (blitz gambits, alt accounts).
1716
2162
 
1717
- 4. **Walk the opponent's repertoire against ${me}'s color.** Call \`get_player_preparation\` on the opponent for the color they'll have in this game. Iterate: start from move 1, pick the opponent's most-played reply, call again with \`line\` extended. Look for:
2163
+ 4. **Walk the opponent's repertoire against ${me}'s color.** Call \`prepare_opponent\` once with the opponent's FIDE ID (add \`chesscom\` / \`lichess\` sources if you know their handles), filtered to the color they'll have in this game — plus \`start_month\` (last 12-24 months) and \`time_control: "classical"\` if you want the strongest signal. That returns a session \`token\`. Then walk the tree with \`get_prep_position(session_token=token, node_id="r")\`, pick the opponent's most-played reply, call again as you descend. Look for:
1718
2164
  - **Weak lines**: variations where the opponent scores below 40% as their side.
1719
2165
  - **Shallow lines**: openings the opponent has played only a few times — probably less deeply prepared.
1720
2166
  - **Abandoned lines**: openings they used to play but stopped. Something went wrong; may not want to revisit.
@@ -1756,7 +2202,7 @@ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
1756
2202
  1. \`search_player\` to get their FIDE ID.
1757
2203
  2. \`get_player_profile\` — pull rating history, career splits by color and time control, opening repertoire, opponent analysis, top events, notable wins and losses.
1758
2204
  3. Weight the data: recent (last 12-24 months) > older, classical OTB > rapid/blitz > online.
1759
- 4. \`get_player_preparation\` for both colors from the starting position to summarise their opening choices with actual frequencies and win rates.
2205
+ 4. \`prepare_opponent\` twice (once per colour, or once with two sources), then \`get_prep_position(session_token, node_id="r")\` to summarise their opening choices with actual frequencies and win rates. Filter with \`start_month\` if you only care about their current repertoire.
1760
2206
  5. Deliver: current strength, characteristic openings, one-sentence style read, biggest wins, biggest losses / recurring weakness. Cite the numbers.`;
1761
2207
  break;
1762
2208
  }
@@ -70,19 +70,24 @@ Trust Lc0 for:
70
70
 
71
71
  Contempt is an Lc0 option that skews its evaluation and move choice toward one side. Passing `contempt` to `cloud_analyse` sets it on the Lc0 leg only; Stockfish always analyzes objectively.
72
72
 
73
- **Range:** -100 to +100 accepted; **practical range: -20 to +20**. Beyond ±20 Lc0 starts picking objectively bad moves that only look good under an extreme bias.
73
+ **Scale:** signed 0-100 strength the same value the user's web UI shows on its ContemptStrength slider. The server multiplies by 8 to produce Lc0's internal centipawn bias, so `contempt=25` sends 200 cp, `contempt=50` sends 400 cp, `contempt=100` sends 800 cp. Positive favours White, negative favours Black, zero is objective.
74
74
 
75
- - **Positive contempt** (e.g. `+15`): Lc0 assumes it's playing *from White's side* against equal or slightly weaker opposition. It picks more ambitious, complicated moves and avoids quick simplifications. The eval reads higher than pure objective. Concrete effect: with White, Lc0 will less often steer into the Exchange Slav or a symmetrical Berlin.
76
- - **Negative contempt** (e.g. `-15`): Same but from Black's side. Lc0 will play sharper, more provocative lines with Black more likely to pick a Sveshnikov over a Petroff, or a Grünfeld over a Slav.
75
+ **Practical magnitudes:**
76
+ - `±10-20` a light nudge. Small preference shift, mostly for tie-breaking between candidates Lc0 already sees as near-equal.
77
+ - `±30-60` — real fighting play. Lc0 avoids simplifications, prefers keeping tension, favours the more ambitious side of a choice.
78
+ - `±80-100` — maximum steer. Use when the user needs Lc0 to surface *only* fighting ideas for a must-win, at the cost of ignoring objectively safer choices.
77
79
  - **Zero** (default): pure objective Lc0.
78
80
 
81
+ - **Positive contempt** (e.g. `+30`): Lc0 assumes it's playing *from White's side* against equal or slightly weaker opposition. It picks more ambitious, complicated moves and avoids quick simplifications. The eval reads higher than pure objective. Concrete effect: with White, Lc0 will less often steer into the Exchange Slav or a symmetrical Berlin.
82
+ - **Negative contempt** (e.g. `-30`): Same but from Black's side. Lc0 will play sharper, more provocative lines with Black — more likely to pick a Sveshnikov over a Petroff, or a Grünfeld over a Slav.
83
+
79
84
  The eval Lc0 returns when contempt is set is **not** the objective eval — it's Lc0's assessment under the contempt bias. If you also want the objective read, look at the Stockfish leg of the same response.
80
85
 
81
86
  ### When to set contempt
82
87
 
83
- - **Finding new ideas** — set contempt in the direction you're preparing to see moves the objective engine wouldn't consider "safe enough." Lines Lc0 rejects at contempt=0 but likes at contempt=±15 are candidate surprises.
84
- - **Building a solid repertoire** — small positive contempt for your color if the user wants to take positions seriously (real, competitive games) without gambling on unclear complications.
85
- - **Playing for a win with the "worse" side** — negative contempt if the user needs to avoid drawing lines with Black in a must-win situation.
88
+ - **Finding new ideas** — set contempt in the direction you're preparing to see moves the objective engine wouldn't consider "safe enough." Lines Lc0 rejects at contempt=0 but likes at contempt=±30 are candidate surprises.
89
+ - **Building a solid repertoire** — small positive contempt (`+10` to `+20`) for your color if the user wants to take positions seriously (real, competitive games) without gambling on unclear complications.
90
+ - **Playing for a win with the "worse" side** — negative contempt (`-30` to `-60`) if the user needs to avoid drawing lines with Black in a must-win situation.
86
91
  - **Never with zero direction** — always know *why* you're skewing before you set contempt. It's a specific tool for a specific question, not a default knob.
87
92
 
88
93
  ## Tricks worth knowing
@@ -98,6 +103,31 @@ The gap (1.4 - 0.2 = 1.2) is roughly the value of the tempo Black has to spend d
98
103
 
99
104
  **Gotcha:** flipping side-to-move invalidates the en passant target field (if the last move was a two-square pawn push, the ep square is now stale). Also, both sides are counted as still having whichever castling rights are in the FEN — don't do this in the middle of a castling sequence. For opening / middlegame threat-checking it's a very useful idiom.
100
105
 
106
+ ## Deep Stockfish thinks: `deep_analyse`
107
+
108
+ `cloud_analyse` runs both engines with a movetime cap of 10 s — deliberately fast because most opening-tree questions are answered in 2–3 s. For **one specific critical position** where you want depth 35+ instead of the usual depth 22, use `deep_analyse`:
109
+
110
+ - SF-only (Lc0 saturates in a handful of seconds — no benefit past ~5 s of movetime).
111
+ - Movetime up to 5 min.
112
+ - Async: returns a `job_id` immediately, poll `deep_analyse_status(job_id)`, cancel with `deep_analyse_cancel(job_id)` if you decide partial depth is enough.
113
+ - **Holds only the Stockfish slot on the combo.** Lc0 remains callable for other positions via `cloud_analyse({ engines: ["lc0"], … })` while the deep think runs. Use that during the wait — walk other branches, sanity-check candidates.
114
+
115
+ Typical movetimes:
116
+ - `30_000 – 60_000` (30–60 s) — careful check on a candidate move
117
+ - `120_000 – 300_000` (2–5 min) — "find the truth" on a novelty, tactical shot, or difficult endgame
118
+
119
+ Only use it when you actually need the depth. Regular `cloud_analyse` handles the ~5–10 branches of a typical prep walk perfectly well.
120
+
121
+ ## Splitting engines on `cloud_analyse`
122
+
123
+ `cloud_analyse` accepts an `engines` list to run only one leg:
124
+
125
+ - `engines: ["lc0"]` — Lc0 only, useful while a `deep_analyse` is holding the Stockfish slot.
126
+ - `engines: ["stockfish"]` — SF only, when only the objective read matters and you want to skip the Lc0 latency.
127
+ - Default (omitted) — both engines. This is what you want for real prep decisions.
128
+
129
+ The skipped engine's field is omitted from the response (not present as an empty object).
130
+
101
131
  ## Worked example
102
132
 
103
133
  User is preparing Black against a 2600 opponent who plays 1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be3 e5. You want to know if 7.Nb3 or 7.Nf3 is more testing.
@@ -106,10 +136,10 @@ Two calls:
106
136
  1. `cloud_analyse(fen=<position after 6...e5>, multipv=2)` — get both engines' top choices with movetime=2000.
107
137
  2. If Stockfish scores them equal but Lc0 prefers one by 0.10-0.20, that's your practical answer. The user will find that line harder to face.
108
138
 
109
- If the user is specifically preparing to *play* the black side in a must-win, add `contempt=-15` on a follow-up call to see which lines Lc0 finds most fighting for Black. Compare against Stockfish's objective read to make sure the fighting choice isn't just losing.
139
+ If the user is specifically preparing to *play* the black side in a must-win, add `contempt=-30` (or up to `-60` for a harder steer) on a follow-up call to see which lines Lc0 finds most fighting for Black. Compare against Stockfish's objective read to make sure the fighting choice isn't just losing.
110
140
 
111
141
  ## What NOT to do
112
142
 
113
- - **Don't quote Lc0's contempt-biased eval as objective.** If you tell the user "Lc0 gives Black +0.30 here" without disclosing you set contempt=-15, that's misleading.
143
+ - **Don't quote Lc0's contempt-biased eval as objective.** If you tell the user "Lc0 gives Black +0.30 here" without disclosing you set contempt=-30, that's misleading.
114
144
  - **Don't run cloud analysis just for casual questions.** `cloud_analyse` costs the user real money per second. If the question is "is 1.e4 or 1.d4 better?", the free `analyse` (single Stockfish, 2s) or `get_position_stats` (11.7M-game database) is enough.
115
145
  - **Don't ignore the disagreement.** When Stockfish and Lc0 diverge sharply, that's exactly when you should explain *why* to the user — not paper over it.
@@ -23,7 +23,7 @@ Building (use these — one call for many ops):
23
23
 
24
24
  - **`apply_mutations(id, mutations[])`** — batch. This is the primary build tool. Send 100 mutations in one call → one load-parse-mutate-save cycle instead of 100. When you're writing a repertoire from scratch, EVERYTHING should go through this. Individual mutation tools are for surgical follow-up edits, not for bulk work.
25
25
  - **`add_line(id, parent_id, sans[])`** — a whole linear variation in one op. Cleaner than N `add_move` ops for straight lines. Overlapping prefixes with an existing branch **join** (same SAN from the same position IS the same move) — see below.
26
- - **`auto_evaluate(id, node_id?)`** — walk from a node and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. **Does NOT set visible NAGs** — the numbers land in a hidden `[%ceo-eval]` tag so you can read them back later. NAG placement (`$1` `!`, `$14` `⩲`, `$16` `±`, `$18` `+−`, etc.) is your editorial call. Skip nodes that already have a stored eval by default.
26
+ - **`auto_evaluate(id, node_id?)`** — walk from a node and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. **Async job**: returns `{job_id, target_count, estimated_seconds}` immediately, does the work in the background. Poll with `auto_evaluate_status(job_id)` until `done:true`; cancel with `auto_evaluate_cancel(job_id)` if you change your mind (partial progress is checkpointed every 8 nodes so a cancel never loses more than a handful of evaluations). **Does NOT set visible NAGs** — the numbers land in a hidden `[%ceo-eval]` tag so you can read them back later. NAG placement (`$1` `!`, `$14` `⩲`, `$16` `±`, `$18` `+−`, etc.) is your editorial call. Skip nodes that already have a stored eval by default.
27
27
 
28
28
  Per-op mutation tools (single-op calls, use for edits after the bulk build):
29
29
 
@@ -42,8 +42,8 @@ All mutations **auto-save** with optimistic locking. Response includes the new `
42
42
 
43
43
  1. `read_prep_file` — see what's there. Every node has an `id` you'll pass to the mutation and engine/DB tools.
44
44
  2. `apply_mutations([...])` — one call with your whole intended build (a mix of `add_move` / `add_line` for structure, plus any `set_comment`/`set_annotations` you already know at author time, plus any `set_nags` where you already have a clear judgment — novelty `$146`, `!?` speculative sac, obvious `?` blunder in a sideline you're rejecting).
45
- 3. `auto_evaluate(id)` — walk the tree and PERSIST engine numbers on every node. Does not touch visible NAGs. Cheap way to get every position's Stockfish + Lc0 read baked into the file for later reference.
46
- 4. Re-read the file and add NAGs where they carry real signal (see NAG discipline below). Use individual mutation tools for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
45
+ 3. `auto_evaluate(id)` — spawns a background job that PERSISTS engine numbers on every node. Does not touch visible NAGs. Cheap way to get every position's Stockfish + Lc0 read baked into the file for later reference. Grab the returned `job_id` and either (a) poll `auto_evaluate_status(job_id)` every ~10-30s until done, or (b) fire and do useful work meanwhile (write more of the tree, walk the opponent's repertoire) and check back later — engine walk-time serialises on the per-combo semaphore, so it takes roughly `target_count × movetime_ms` in wall time.
46
+ 4. Once the job reports `done:true`, re-read the file and add NAGs where they carry real signal (see NAG discipline below). Use individual mutation tools for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
47
47
 
48
48
  The build-cost math: a 200-move file via individual `add_move` calls is 200 saves ≈ 100+ seconds of tool overhead. The same file via one `apply_mutations` call is one save ≈ 500ms. Use batch by default.
49
49
 
@@ -194,7 +194,7 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
194
194
  | `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
195
195
  | Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
196
196
 
197
- **NAGs are editorial, not automatic.** `auto_evaluate` persists engine numbers on every node but does NOT set visible NAGs — because a repertoire full of 0.00 opening theory does not want a `$10` (=) glyph plastered on every move. That's noise, not signal. NAGs are your call: mark moves that carry a judgment a reader can't derive by looking at the board.
197
+ **NAGs are editorial, not automatic.** `auto_evaluate` (and its `auto_evaluate_status` poll) persists engine numbers on every node but does NOT set visible NAGs — because a repertoire full of 0.00 opening theory does not want a `$10` (=) glyph plastered on every move. That's noise, not signal. NAGs are your call: mark moves that carry a judgment a reader can't derive by looking at the board.
198
198
 
199
199
  Where NAGs actually earn their place:
200
200
 
@@ -210,7 +210,7 @@ Where NAGs are noise: every `$10` "=" glyph on every equal position. If the whol
210
210
 
211
211
  When reading back a file, `node.ceoEval.nag` carries the threshold-derived NAG (`$10` / `$14` / `$16` / `$18` etc.) — treat it as a *suggestion*, not an automatic write. Promote it to a visible NAG only when a glyph on that move actually helps the reader.
212
212
 
213
- **Persisted evals travel with the file.** Every node with an eval gets `ceoEval: { sf: {cp: 25, depth: 32}, lc0: {cp: 30, depth: 18}, nag: "$14" }` on subsequent `read_prep_file` calls. Stored as `[%ceo-eval sf=+0.25/32 lc0=+0.30/18 nag=$14]` inside the PGN comment (an escape tag the app hides from the board view, just like [%cal] / [%csl]). So a re-read after auto_evaluate gives you every position's number without re-running cloud_analyse. Query tools (get_position_stats, get_player_preparation, prep_snapshot) also auto-attach a live `eval` at the request position when a cloud engine is running.
213
+ **Persisted evals travel with the file.** Every node with an eval gets `ceoEval: { sf: {cp: 25, depth: 32}, lc0: {cp: 30, depth: 18}, nag: "$14" }` on subsequent `read_prep_file` calls. Stored as `[%ceo-eval sf=+0.25/32 lc0=+0.30/18 nag=$14]` inside the PGN comment (an escape tag the app hides from the board view, just like [%cal] / [%csl]). So a re-read after auto_evaluate gives you every position's number without re-running cloud_analyse. Query tools (get_position_stats, get_prep_position, prep_snapshot) also auto-attach a live `eval` at the request position when a cloud engine is running.
214
214
 
215
215
  **Engine numbers in prose: brief only.** If you must reference a number, keep it compact:
216
216
 
@@ -221,12 +221,14 @@ When reading back a file, `node.ceoEval.nag` carries the threshold-derived NAG (
221
221
 
222
222
  **Only quote engine numbers for positions you actually called `cloud_analyse` on** (or that carry a `ceoEval` from an earlier auto_evaluate, or an auto-attached `.eval` from a query tool). Do NOT infer an eval for a parent position from its children and then write "both engines 0.00" on the parent — that reads as a measurement but is a guess. If the position wasn't analysed, either analyse it or omit the number: prose can say "both continuations run to 0.00, so this looks balanced" without claiming an eval that wasn't taken.
223
223
 
224
- **When you set `contempt`, attribute it to Lc0 only.** Contempt affects Lc0 exclusively — Stockfish always analyses objectively. So don't write "both engines at contempt −15" — that misleads the reader into thinking SF was biased too. Correct phrasings:
224
+ **When you set `contempt`, attribute it to Lc0 only.** Contempt affects Lc0 exclusively — Stockfish always analyses objectively. So don't write "both engines at contempt −30" — that misleads the reader into thinking SF was biased too. Correct phrasings:
225
225
 
226
- - `{Lc0 (contempt −15): 0.00 — even biased toward Black, still balanced. SF objective: 0.00.}`
227
- - `{With Lc0 nudged toward Black (contempt −15) it still picks Qg6 at 0.00; SF's objective read also 0.00.}`
226
+ - `{Lc0 (contempt −30): 0.00 — even biased toward Black, still balanced. SF objective: 0.00.}`
227
+ - `{With Lc0 nudged toward Black (contempt −30) it still picks Qg6 at 0.00; SF's objective read also 0.00.}`
228
228
 
229
- If you only quote the Lc0 number, disclose the bias in the same sentence — `{Lc0 gives Black +0.30}` with an undisclosed `contempt=-15` is a false objectivity claim.
229
+ If you only quote the Lc0 number, disclose the bias in the same sentence — `{Lc0 gives Black +0.30}` with an undisclosed `contempt=-30` is a false objectivity claim.
230
+
231
+ Contempt scale is signed 0-100 (same as the web UI's ContemptStrength slider). Typical: `±10-20` a light nudge, `±30-60` real fighting play, `±80-100` maximum steer.
230
232
 
231
233
  ### Before you write any commentary: describe_position
232
234
 
@@ -4,20 +4,20 @@ Opening prep is not a monologue with the position-stats and player-prep endpoint
4
4
 
5
5
  ## Grounding: cite tools, don't fill gaps with chess prose
6
6
 
7
- Every recommendation you make must trace back to an actual tool call in this session — engine output from `cloud_analyse` / `analyse` for evaluations and lines, `get_player_preparation` / `get_position_stats` for statistics, `get_player_profile` / `get_head_to_head` for style and history. **Compute is cheap.** When you don't have the data to justify a claim, run the tool — do not paper over the gap with generic chess wisdom you didn't verify.
7
+ Every recommendation you make must trace back to an actual tool call in this session — engine output from `cloud_analyse` for evaluations and lines, `prepare_opponent` + `get_prep_position` for opponent statistics, `get_position_stats` for the general database, `get_player_profile` / `get_head_to_head` for style and history. **Compute is cheap.** When you don't have the data to justify a claim, run the tool — do not paper over the gap with generic chess wisdom you didn't verify.
8
8
 
9
9
  **Concrete failure modes to catch yourself doing:**
10
10
 
11
11
  - "This is a good line because Black gets the two bishops." → Did Lc0 or Stockfish score it that way? If not, drop the claim.
12
- - "Your opponent hates isolated queen pawn positions." → Did you actually look at their IQP games in `get_player_preparation`? Or is this a training-data pattern?
12
+ - "Your opponent hates isolated queen pawn positions." → Did you actually look at their IQP games via a `prepare_opponent` session? Or is this a training-data pattern?
13
13
  - "The Petroff is a solid choice here." → Did you check the opponent's score as White vs the Petroff, or against Black openings in general? If not, drop it.
14
14
  - "Aim for a Catalan setup, they historically struggle there." → Point at concrete games where they lost in Catalan structures. If you can't, don't say it.
15
- - Inventing move sequences that "look like typical prep" without walking the actual tree via `get_player_preparation` / `prep_snapshot`.
15
+ - Inventing move sequences that "look like typical prep" without walking the actual tree via `get_prep_position` / `prep_snapshot`.
16
16
  - Asserting an opponent's style ("sharp tactician", "endgame grinder") without pointing at their profile data or specific games.
17
17
 
18
18
  The failure mode is *authoritative-sounding recipe = 30% real tool output + 70% chess-book filler*. The user cannot tell which is which; from their side it all looks like analysis. That's worse than saying "I don't have data on this yet — should I run X?"
19
19
 
20
- **Do this instead:** cite the specific numbers ("opponent scores 32% as White vs the Sveshnikov over 47 games, 2023-2025"), the tool that produced them ("via `get_player_preparation`"), and reason from there. If the reasoning wants to extend beyond what the data supports, either run more tools or flag it as your read, not the data's.
20
+ **Do this instead:** cite the specific numbers ("opponent scores 32% as White vs the Sveshnikov over 47 games, 2023-2025"), the tool that produced them ("via a `prepare_opponent` session filtered to classical since 2024"), and reason from there. If the reasoning wants to extend beyond what the data supports, either run more tools or flag it as your read, not the data's.
21
21
 
22
22
  ## Numbers are inputs, not verdicts
23
23
 
@@ -27,6 +27,58 @@ The move statistics endpoints return win %, game counts, and (in the big DB) a `
27
27
  - **Score doesn't automatically indicate a level gap.** A 60% variation isn't necessarily "stronger players crushing weaker ones." Look at the per-move `avgWhite` / `avgBlack` fields (returned on every move statistic) before drawing conclusions about who is playing whom.
28
28
  - **Don't recommend the higher-percentage move just because it's higher.** If 1.b3 scores 60% and 1.d4 scores 50% against a given opponent, that is *not* on its own a case for playing 1.b3 — style, prep depth, transposition risk, and the practical questions below all matter more.
29
29
 
30
+ ## Which DB source: signal vs population
31
+
32
+ Two shards on `get_position_stats`, answering different questions:
33
+
34
+ - **`gm-classical`** (default): pre-aggregated GM classical games (both players ~2500+, real thinking time). Every move is signal, no noise. Sample sizes are smaller. Answers: *what do good players actually play here, and how well does each option score at that level?*
35
+ - **`main`**: the full 11.7M-game database. Wider coverage, cheaper positions included (blitz, weak opponents, blunder-fests). Noisier. Answers: *has this position/move been reached at all, and by whom?*
36
+
37
+ They're not either-or. Reasonable to hit both on the same position for different questions:
38
+
39
+ - **What GMs play here** → `gm-classical`. Even a small count (5-10 games at avgRating 2600) is often the honest answer for a specific tabiya — that's just how many GMs have gone there. Don't dismiss small numbers if the rating context supports them.
40
+ - **Does anyone play this at all** → `main`. Especially useful for a move you're considering that hasn't appeared in `gm-classical`. Zero in the GM DB doesn't automatically mean bad — it might just mean unfashionable. Check `main`: if it has some volume at reasonable avgRating (~2200+), the move has real support even without top-level play; if it's only 1400-rated games or bots, it doesn't.
41
+ - **Sanity-checking a candidate** → both. If a move you're considering shows 3 games in `gm-classical` but 800 in `main` at avgRating 2100, that's a coherent picture (unfashionable at the very top but played by strong players); if `main` shows the same 3 games and nothing else, the move is genuinely rare and you're pioneering.
42
+
43
+ When you do read a `main` result, always eyeball `avgRating` per move before quoting the win%. A 60% score across 200 games at avgRating 1400 is not a recommendation — it's a trap that catches beginners. The same 60% across 200 games at avgRating 2400 is a real finding.
44
+
45
+ Cross-reference tip: if `main` shows a specific move dominating that `gm-classical` doesn't touch, look at *who* — correspondence engines, blitz-only players, and bot accounts skew the `main` totals. A move with 5000 games in `main` at avgRating 1900 and zero in `gm-classical` is probably not something to recommend as GM prep.
46
+
47
+ ## Which prep source: match data density to what you're preparing
48
+
49
+ `prepare_opponent` accepts fide, chesscom, lichess sources — each with per-source colour, date-range, time-control filters. Which sources to combine depends on how much data the opponent actually has and what specific question you're answering:
50
+
51
+ - **Well-known player, well-known question** ("what does Firouzja play against 1.e4?"): FIDE, `time_control: "classical"`, `start_month` = 12–24 months ago. Cleanest signal, big enough sample. Don't dilute with online.
52
+ - **Well-known player, sideline question** ("what would he play in this obscure 20-ply position?"): FIDE all-time-controls first — classical is unlikely to have games in a rare position. If still nothing, add rapid; add online only if you must.
53
+ - **Junior / sub-2400 / comeback player**: FIDE classical alone might give you 5 games. Add FIDE rapid + chesscom + lichess (all filtered to the last 12 months). Signal density improves, but recognize online games are a mixed signal — see next section.
54
+ - **Multiple accounts / handles**: pass them all as sources in one call — `[{type:"fide",fide_id:X}, {type:"chesscom",username:"..."}, {type:"lichess",username:"..."}]`. Same session, more games, no rebuild, all combined into one filtered corpus.
55
+
56
+ The trade-off is always the same: signal quality vs data density. When density is high, prefer quality; when density is low, take what you can get and lower your confidence in the read explicitly ("only 12 games at this branch — the 66% is noisier than the 55% we saw at move 2").
57
+
58
+ ## Reading a chess.com / lichess profile: three shapes
59
+
60
+ Online games are a *mixed* signal. Before treating chesscom/lichess data the same weight as FIDE, spot which of these three profiles the opponent fits — misclassifying is worse than not using online data at all.
61
+
62
+ 1. **Consistent** — plays roughly the same repertoire online and OTB. Gold. Suddenly you have thousands of extra games to see reactions against sidelines and to see where he makes mistakes in the early middlegame under time pressure. Weight online data almost the same as classical FIDE.
63
+
64
+ 2. **Eclectic** — online they play everything under the sun; OTB they have a real narrow repertoire. Still usable, but for a *different* question. Online tells you: (a) which openings he has *seen* and roughly *knows*, (b) which moves he *actually recalls under pressure*, (c) where he makes early mistakes. Online does NOT reliably tell you what he'll play in the game.
65
+
66
+ 3. **Split personality** — online repertoire genuinely differs from OTB. Classic example: a 1.e4 Ruy Lopez player over the board who plays only the King's Gambit on lichess blitz because it's fun. Online is the playground; OTB is serious. **Recognize this and discount the online games entirely** for opening-choice prediction — he's not playing his lichess repertoire in a rated classical game. Online may still surface middlegame tendencies, but "what will he play" belongs to FIDE only.
67
+
68
+ How to distinguish, quickly: compare the top openings from `get_player_profile` (FIDE-based) against the moves he plays from the starting position in an online-only `prepare_opponent` session. High overlap → shape 1. Modest overlap with FIDE openings as a subset of online → shape 2. Different openings entirely → shape 3.
69
+
70
+ Write the profile-shape into the prep file's overview comment as soon as you decide it — future re-reads (and the user) benefit from knowing you already made the call.
71
+
72
+ ## Reversed colours as a scarcity trick
73
+
74
+ For a rare position where the opponent has 0–2 games as their side, it's often worth also checking what they've done in the same position from the OTHER colour. This doesn't tell you what they'd play — it tells you what they've SEEN. Useful when you have no direct data:
75
+
76
+ - **Surprise calculus**: "he's had this position from the White side against people playing it against him — so seeing it from the Black side isn't a real surprise, he knows the ideas."
77
+ - **Structural familiarity**: "he's played this pawn structure before, just from the other colour — his over-the-board understanding will carry over."
78
+ - **Cuts both ways**: if the opponent has played it a lot with the OTHER colour AND had good results, assume he knows both sides thoroughly.
79
+
80
+ Do this by dropping the `color` filter on `prepare_opponent`, or making a second session with the opposite colour — then compare game counts. When density is scarce, information from the "wrong" colour is worth more than no information at all. Just be honest about what it is: knowledge, not prediction.
81
+
30
82
  ## Prep is symmetric — both sides know the same things
31
83
 
32
84
  The single biggest LLM error in prep is treating it like writing a book: "here's what you should play against this opponent's weakness." A real chess opponent:
@@ -41,7 +93,7 @@ Every recommendation should be filtered through: "does this survive the fact tha
41
93
 
42
94
  The opponent's last 12-24 months matter far more than a 10-year career average. Repertoires evolve — a lifelong Najdorf player might have quietly become a Petroff player last year, and their old career stats will lie to you if you skim.
43
95
 
44
- **Related product note (2026-07-23):** the `get_player_preparation` endpoint (compact / LLM view) deliberately strips the per-move `fashionScore` field. At the individual-player level, fashion is trailing noise — the opponent's opening trend is already captured in game dates. `fashionScore` stays on the general-DB endpoint (`get_position_stats`), where it's genuinely useful: it's what the whole top field is playing this month.
96
+ **Related product note:** `get_prep_position` (from a `prepare_opponent` session) deliberately strips the per-move `fashionScore` field. At the individual-player level, fashion is trailing noise — the opponent's opening trend is already captured in game dates. `fashionScore` stays on the general-DB endpoint (`get_position_stats`), where it's genuinely useful: it's what the whole top field is playing this month.
45
97
 
46
98
  ## Prep is a tree, not a line
47
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.31.2",
3
+ "version": "0.34.0",
4
4
  "description": "Model Context Protocol server for chess.ceo — 11.7M+ games, ~1.5M FIDE player profiles, opening preparation, live broadcasts.",
5
5
  "type": "module",
6
6
  "bin": {