@chessceo/mcp 0.31.2 → 0.33.1

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,14 @@ 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",
90
92
  "quote_engine_eval",
91
93
  "predict_human_move",
94
+ "prepare_opponent",
95
+ "get_prep_position",
96
+ "list_prep_sessions",
97
+ "delete_prep_session",
92
98
  ]);
93
99
  function isAuthedToolCall(body) {
94
100
  if (!body || typeof body !== "object")
@@ -195,55 +201,80 @@ const TOOLS = [
195
201
  },
196
202
  },
197
203
  {
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.",
204
+ name: "prepare_opponent",
205
+ 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" +
206
+ "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" +
207
+ "SOURCES (1-10 per call, combined into one gameset):\n" +
208
+ "- `fide`needs `fideId`. Optional filters: `color`, `startMonth`/`endMonth`, `timeControl` (`classical`|`rapid`|`blitz`), `excludeOnline`.\n" +
209
+ "- `chesscom` needs `username`. Filters: `color`, `startMonth`/`endMonth` (**required** for chesscom/lichess), `timeControl`.\n" +
210
+ "- `lichess`needs `username`. Same filters as chesscom; `timeControl` also accepts `bullet` on Lichess.\n\n" +
211
+ "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" +
212
+ "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
213
  inputSchema: {
210
214
  type: "object",
211
215
  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).",
216
+ sources: {
217
+ type: "array",
218
+ minItems: 1,
219
+ maxItems: 10,
220
+ description: "1-10 game sources, all combined into one filtered session.",
221
+ items: {
222
+ type: "object",
223
+ properties: {
224
+ type: { type: "string", enum: ["fide", "chesscom", "lichess"], description: "Source type." },
225
+ fide_id: { type: "integer", description: "FIDE ID (required for type='fide')." },
226
+ username: { type: "string", description: "Platform username (required for type='chesscom'/'lichess')." },
227
+ color: { type: "string", enum: ["white", "black"], description: "Filter: only games where this side is played by the source player. Omit for both colours." },
228
+ start_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games from this month onwards, format 'YYYY/MM'. **Required** for chesscom/lichess." },
229
+ end_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games up to this month, format 'YYYY/MM'. **Required** for chesscom/lichess." },
230
+ time_control: { type: "string", enum: ["classical", "rapid", "blitz", "bullet"], description: "Filter: only this time control. `bullet` is Lichess-only." },
231
+ exclude_online: { type: "boolean", description: "FIDE-only: exclude online-flagged games (default false)." },
232
+ },
233
+ required: ["type"],
234
+ },
243
235
  },
236
+ },
237
+ required: ["sources"],
238
+ },
239
+ },
240
+ {
241
+ name: "get_prep_position",
242
+ 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" +
243
+ "Position input: prefer `file_id`+`node_id` when inside a prep file (server derives FEN from the tree). Otherwise pass `fen`.\n\n" +
244
+ "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" +
245
+ "Reading the response — CRITICAL:\n" +
246
+ "• Win % is one weight, not a verdict. Sample size matters (3 games at 66% is noise; 300 at 55% is signal).\n" +
247
+ "• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted.\n" +
248
+ "• Recency > career. The last 12-24 months dominate — filter your session with `start_month` if the player's repertoire shifted.\n" +
249
+ "• 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" +
250
+ "For the full guide call `read_prep_strategy_guide`.",
251
+ inputSchema: {
252
+ type: "object",
253
+ properties: {
254
+ session_token: { type: "string", description: "Session token from `prepare_opponent`." },
255
+ file_id: { type: "string", description: "Prep file id — combine with `node_id` for tree-addressed position lookup." },
256
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`." },
257
+ fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
258
+ limit: { type: "integer", minimum: 1, maximum: 50, description: "Games to return (default 10)." },
244
259
  offset: { type: "integer", minimum: 0 },
245
260
  },
246
- required: ["fide_id", "color"],
261
+ required: ["session_token"],
262
+ },
263
+ },
264
+ {
265
+ name: "list_prep_sessions",
266
+ 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.",
267
+ inputSchema: { type: "object", properties: {} },
268
+ },
269
+ {
270
+ name: "delete_prep_session",
271
+ 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.",
272
+ inputSchema: {
273
+ type: "object",
274
+ properties: {
275
+ session_token: { type: "string", description: "Token from `list_prep_sessions` or the response of `prepare_opponent`." },
276
+ },
277
+ required: ["session_token"],
247
278
  },
248
279
  },
249
280
  {
@@ -437,14 +468,14 @@ const TOOLS = [
437
468
  },
438
469
  {
439
470
  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" +
471
+ 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
472
  "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
473
  "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
474
  "How to read the response:\n" +
444
475
  "• 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
476
  "• 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
477
  "• 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" +
478
+ "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
479
  "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
480
  "For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
450
481
  "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 +506,7 @@ const TOOLS = [
475
506
  type: "integer",
476
507
  minimum: -100,
477
508
  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.",
509
+ 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.",
479
510
  },
480
511
  },
481
512
  },
@@ -500,7 +531,7 @@ const TOOLS = [
500
531
  name: "read_prep_file",
501
532
  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
533
  "**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.",
534
+ "**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
535
  inputSchema: {
505
536
  type: "object",
506
537
  properties: {
@@ -693,10 +724,11 @@ const TOOLS = [
693
724
  },
694
725
  {
695
726
  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.",
727
+ 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" +
728
+ "**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" +
729
+ "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" +
730
+ "**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" +
731
+ "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
732
  inputSchema: {
701
733
  type: "object",
702
734
  properties: {
@@ -704,11 +736,33 @@ const TOOLS = [
704
736
  node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
705
737
  only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
706
738
  movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
707
- expected_version: { type: "integer" },
708
739
  },
709
740
  required: ["id"],
710
741
  },
711
742
  },
743
+ {
744
+ name: "auto_evaluate_status",
745
+ 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" +
746
+ "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.",
747
+ inputSchema: {
748
+ type: "object",
749
+ properties: {
750
+ job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
751
+ },
752
+ required: ["job_id"],
753
+ },
754
+ },
755
+ {
756
+ name: "auto_evaluate_cancel",
757
+ 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.",
758
+ inputSchema: {
759
+ type: "object",
760
+ properties: {
761
+ job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
762
+ },
763
+ required: ["job_id"],
764
+ },
765
+ },
712
766
  {
713
767
  name: "quote_engine_eval",
714
768
  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 +1022,33 @@ async function applyBatchMutations(args) {
968
1022
  const savedRow = saved;
969
1023
  return { ok: true, results, version: savedRow.version };
970
1024
  }
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.
1025
+ const evalJobs = new Map();
1026
+ // GC finished jobs after this long so status polling remains useful
1027
+ // for a while but the map doesn't grow unbounded across long uptimes.
1028
+ const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
1029
+ // Checkpoint interval save progress every N successfully-evaluated
1030
+ // nodes so a mid-run kill leaves the tree partially populated. Small
1031
+ // enough that <15s of work is at risk per checkpoint on a slow combo,
1032
+ // large enough that the save overhead stays a small fraction of the
1033
+ // per-node cost.
1034
+ const SAVE_EVERY_N = 8;
1035
+ function newEvalJobId() {
1036
+ // 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
1037
+ const rand = Math.random().toString(16).slice(2, 8);
1038
+ return `evj_${Date.now().toString(16)}${rand}`;
1039
+ }
1040
+ // Sweep expired jobs on every start/status call — cheap, doesn't need
1041
+ // a background timer, keeps the map bounded to active + recent jobs.
1042
+ function reapExpiredEvalJobs() {
1043
+ const now = Date.now();
1044
+ for (const [k, j] of evalJobs) {
1045
+ if (j.finishedAt && now - j.finishedAt > EVAL_JOB_TTL_MS) {
1046
+ evalJobs.delete(k);
1047
+ }
1048
+ }
1049
+ }
976
1050
  async function autoEvaluate(args) {
1051
+ reapExpiredEvalJobs();
977
1052
  const id = String(args.id);
978
1053
  const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
979
1054
  ? String(args.node_id)
@@ -1001,45 +1076,159 @@ async function autoEvaluate(args) {
1001
1076
  // If the caller anchored at the root, skip evaluating the root itself
1002
1077
  // (no move); otherwise the anchor node IS a real move and gets evaluated.
1003
1078
  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];
1079
+ // Nothing to do → return a done job synthetically so the caller doesn't
1080
+ // need to special-case the empty response.
1081
+ if (targets.length === 0) {
1082
+ const jobId = newEvalJobId();
1083
+ evalJobs.set(jobId, {
1084
+ id: jobId,
1085
+ fileId: id,
1086
+ status: "done",
1087
+ targetCount: 0,
1088
+ evaluated: 0,
1089
+ errored: 0,
1090
+ finalVersion: g.version,
1091
+ startedAt: Date.now(),
1092
+ finishedAt: Date.now(),
1093
+ cancelled: false,
1094
+ });
1095
+ return { job_id: jobId, target_count: 0, status: "done", version: g.version };
1096
+ }
1097
+ const jobId = newEvalJobId();
1098
+ const job = {
1099
+ id: jobId,
1100
+ fileId: id,
1101
+ status: "running",
1102
+ targetCount: targets.length,
1103
+ evaluated: 0,
1104
+ errored: 0,
1105
+ startedAt: Date.now(),
1106
+ cancelled: false,
1107
+ };
1108
+ evalJobs.set(jobId, job);
1109
+ // Unawaited — runs concurrently with the tool response. Any thrown
1110
+ // error gets recorded on the job so the LLM's status poll surfaces
1111
+ // it instead of the process seeing an unhandled rejection.
1112
+ void runEvalJob(job, id, targets, movetimeMs).catch(err => {
1113
+ job.status = "error";
1114
+ job.error = err instanceof Error ? err.message : String(err);
1115
+ job.finishedAt = Date.now();
1116
+ });
1117
+ return {
1118
+ job_id: jobId,
1119
+ target_count: targets.length,
1120
+ status: "running",
1121
+ // Rough time estimate at the current default movetime. Serialization
1122
+ // on the per-combo semaphore means walltime ≈ target_count × movetime.
1123
+ estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
1124
+ };
1125
+ }
1126
+ // Worker body — walks targets sequentially (concurrency > 1 is a lie
1127
+ // against the per-combo semaphore in the backend anyway), checkpoints
1128
+ // every SAVE_EVERY_N successfully-evaluated nodes so partial progress
1129
+ // is durable, and re-anchors the version after each save.
1130
+ async function runEvalJob(job, fileId, targets, movetimeMs) {
1131
+ const pending = [];
1132
+ const flush = async () => {
1133
+ if (pending.length === 0)
1134
+ return;
1135
+ // No expected_version — auto_evaluate treats concurrent edits by
1136
+ // the LLM as last-write-wins on the ceoEval field specifically.
1137
+ // Safe because set_ceo_eval is idempotent per node and other
1138
+ // mutations (add_move / set_comment / etc.) don't touch ceoEval.
1139
+ const saved = await applyBatchMutations({
1140
+ id: fileId,
1141
+ mutations: pending,
1142
+ });
1143
+ const sr = saved;
1144
+ if (typeof sr.version === "number")
1145
+ job.finalVersion = sr.version;
1146
+ pending.length = 0;
1147
+ };
1148
+ for (const t of targets) {
1149
+ if (job.cancelled)
1150
+ break;
1151
+ try {
1152
+ const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
1153
+ const ev = analysisToStoredEval(analysis, t.fen);
1154
+ if (ev) {
1155
+ pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
1156
+ job.evaluated++;
1157
+ }
1158
+ else {
1159
+ job.errored++;
1160
+ }
1161
+ }
1162
+ catch {
1163
+ // Per-node failure — record and continue; a bad FEN or a transient
1164
+ // engine hiccup on one node shouldn't kill the whole walk.
1165
+ job.errored++;
1166
+ }
1167
+ if (pending.length >= SAVE_EVERY_N) {
1015
1168
  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 });
1169
+ await flush();
1020
1170
  }
1021
1171
  catch {
1022
- // best-effort a single failed node shouldn't kill the batch
1172
+ // Save failure is bad but not fatal try again on the next
1173
+ // checkpoint or at the end. Progress remains in `pending`
1174
+ // so nothing is lost as long as the process stays alive.
1023
1175
  }
1024
1176
  }
1025
1177
  }
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 });
1178
+ // Final flush regardless of cancellation durably persist whatever
1179
+ // work was completed before the user asked to stop.
1180
+ try {
1181
+ await flush();
1182
+ }
1183
+ catch (err) {
1184
+ job.error = err instanceof Error ? err.message : String(err);
1185
+ job.status = "error";
1186
+ job.finishedAt = Date.now();
1187
+ return;
1188
+ }
1189
+ job.status = job.cancelled ? "cancelled" : "done";
1190
+ job.finishedAt = Date.now();
1191
+ }
1192
+ function autoEvaluateStatus(args) {
1193
+ reapExpiredEvalJobs();
1194
+ const jobId = String(args.job_id || "").trim();
1195
+ if (!jobId)
1196
+ throw new Error("`job_id` is required");
1197
+ const job = evalJobs.get(jobId);
1198
+ if (!job) {
1199
+ return {
1200
+ status: "not_found",
1201
+ 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.",
1202
+ };
1203
+ }
1204
+ return {
1205
+ job_id: job.id,
1206
+ status: job.status,
1207
+ target_count: job.targetCount,
1208
+ evaluated: job.evaluated,
1209
+ errored: job.errored,
1210
+ remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
1211
+ done: job.status !== "running",
1212
+ error: job.error,
1213
+ version: job.finalVersion,
1214
+ started_at_ms: job.startedAt,
1215
+ finished_at_ms: job.finishedAt,
1216
+ };
1217
+ }
1218
+ function autoEvaluateCancel(args) {
1219
+ const jobId = String(args.job_id || "").trim();
1220
+ if (!jobId)
1221
+ throw new Error("`job_id` is required");
1222
+ const job = evalJobs.get(jobId);
1223
+ if (!job)
1224
+ return { status: "not_found" };
1225
+ if (job.status !== "running") {
1226
+ return { status: job.status, note: "Job already finished; nothing to cancel." };
1039
1227
  }
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 };
1228
+ job.cancelled = true;
1229
+ // status transitions to "cancelled" on the next per-node iteration
1230
+ // inside runEvalJob, after the final flush persists progress.
1231
+ return { status: "cancelling", evaluated_so_far: job.evaluated };
1043
1232
  }
1044
1233
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1045
1234
  function pathIntoTree(root, path) {
@@ -1312,6 +1501,46 @@ function resolveFenFromArgs(args) {
1312
1501
  }
1313
1502
  return board.fen();
1314
1503
  }
1504
+ // Normalize one MCP `prepare_opponent` source into the shape the backend's
1505
+ // /api/chess/prep/prepare-multi expects. Handles two impedance mismatches:
1506
+ // - snake_case → camelCase (fide_id → fideId, start_month → startMonth, etc.)
1507
+ // - the unified `time_control` string → per-source-type filter:
1508
+ // * fide / chesscom → timeFormats: ["Classical" | "Rapid" | "Blitz"]
1509
+ // * lichess → perfType: "classical" | "rapid" | "blitz" | "bullet"
1510
+ // Backend validates required fields per source type, so we don't need to
1511
+ // pre-reject missing username/fideId here — it'll come back as a 400 the
1512
+ // LLM can act on.
1513
+ function normalizeSourceForBackend(src, idx) {
1514
+ const type = typeof src.type === "string" ? src.type : "";
1515
+ if (type !== "fide" && type !== "chesscom" && type !== "lichess") {
1516
+ throw new Error(`sources[${idx}].type must be one of fide|chesscom|lichess (got ${JSON.stringify(src.type)})`);
1517
+ }
1518
+ const out = { type };
1519
+ if (typeof src.fide_id === "number")
1520
+ out.fideId = src.fide_id;
1521
+ if (typeof src.username === "string" && src.username.trim() !== "")
1522
+ out.username = src.username.trim();
1523
+ if (typeof src.color === "string" && (src.color === "white" || src.color === "black"))
1524
+ out.color = src.color;
1525
+ if (typeof src.start_month === "string" && src.start_month.trim() !== "")
1526
+ out.startMonth = src.start_month.trim();
1527
+ if (typeof src.end_month === "string" && src.end_month.trim() !== "")
1528
+ out.endMonth = src.end_month.trim();
1529
+ if (typeof src.exclude_online === "boolean")
1530
+ out.excludeOnline = src.exclude_online;
1531
+ const tc = typeof src.time_control === "string" ? src.time_control : "";
1532
+ if (tc) {
1533
+ if (type === "lichess") {
1534
+ out.perfType = tc;
1535
+ }
1536
+ else {
1537
+ // fide + chesscom take a titlecased timeFormats array.
1538
+ const titled = tc.charAt(0).toUpperCase() + tc.slice(1);
1539
+ out.timeFormats = [titled];
1540
+ }
1541
+ }
1542
+ return out;
1543
+ }
1315
1544
  // Async resolver used by every engine / DB tool. If the caller supplied
1316
1545
  // file_id + node_id (preferred), load the file, resolve the node, and
1317
1546
  // return both the FEN and a handle we can use to persist ceoEval later.
@@ -1406,27 +1635,31 @@ async function callToolInner(name, args) {
1406
1635
  return get("/api/chess/players/search/simple", { q: String(args.name), view: "llm" });
1407
1636
  case "get_player_profile":
1408
1637
  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.
1638
+ case "prepare_opponent": {
1639
+ const rawSources = Array.isArray(args.sources) ? args.sources : [];
1640
+ if (rawSources.length === 0)
1641
+ throw new Error("`sources` array required");
1642
+ const sources = rawSources.map((s, i) => normalizeSourceForBackend(s, i));
1643
+ return authedRequest("POST", "/api/chess/prep/prepare-multi", { sources });
1644
+ }
1645
+ case "get_prep_position": {
1646
+ const token = String(args.session_token || "").trim();
1647
+ if (!token)
1648
+ throw new Error("`session_token` required — create one with prepare_opponent");
1413
1649
  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,
1650
+ const fen = resolved.fen;
1651
+ const qs = {
1652
+ token,
1653
+ fen,
1654
+ limit: typeof args.limit === "number" ? args.limit : 10,
1420
1655
  };
1421
- if (typeof args.limit === "number")
1422
- params.limit = args.limit;
1423
1656
  if (typeof args.offset === "number")
1424
- params.offset = args.offset;
1657
+ qs.offset = args.offset;
1425
1658
  const [raw, ev] = await Promise.all([
1426
- get("/api/chess/prep/by-player", params),
1427
- fetchCompactEval(effectiveFen),
1659
+ get("/api/chess/prep/unified", qs),
1660
+ fetchCompactEval(fen),
1428
1661
  ]);
1429
- const converted = convertAvailableMovesToSAN(raw, effectiveFen);
1662
+ const converted = convertAvailableMovesToSAN(raw, fen);
1430
1663
  if (converted && typeof converted === "object") {
1431
1664
  trimGamesMovetext(converted);
1432
1665
  stripPositionResponse(converted);
@@ -1435,6 +1668,14 @@ async function callToolInner(name, args) {
1435
1668
  }
1436
1669
  return converted;
1437
1670
  }
1671
+ case "list_prep_sessions":
1672
+ return authedRequest("GET", "/api/chess/prep/sessions");
1673
+ case "delete_prep_session": {
1674
+ const token = String(args.session_token || "").trim();
1675
+ if (!token)
1676
+ throw new Error("`session_token` required");
1677
+ return authedRequest("DELETE", `/api/chess/prep/sessions/${encodeURIComponent(token)}`);
1678
+ }
1438
1679
  case "get_position_stats": {
1439
1680
  const resolved = await resolveFromNodeOrFen(args);
1440
1681
  const fen = resolved.fen;
@@ -1588,6 +1829,10 @@ async function callToolInner(name, args) {
1588
1829
  return applyBatchMutations(args);
1589
1830
  case "auto_evaluate":
1590
1831
  return autoEvaluate(args);
1832
+ case "auto_evaluate_status":
1833
+ return autoEvaluateStatus(args);
1834
+ case "auto_evaluate_cancel":
1835
+ return autoEvaluateCancel(args);
1591
1836
  case "quote_engine_eval": {
1592
1837
  const fileId = String(args.id);
1593
1838
  const nodeId = argNodeId(args);
@@ -1714,7 +1959,7 @@ Preparation workflow — follow the steps in order and be explicit about which t
1714
1959
  - Rapid and blitz reveal what they play under time pressure but may include experiments.
1715
1960
  - Online games are useful but noisier (blitz gambits, alt accounts).
1716
1961
 
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:
1962
+ 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
1963
  - **Weak lines**: variations where the opponent scores below 40% as their side.
1719
1964
  - **Shallow lines**: openings the opponent has played only a few times — probably less deeply prepared.
1720
1965
  - **Abandoned lines**: openings they used to play but stopped. Something went wrong; may not want to revisit.
@@ -1756,7 +2001,7 @@ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
1756
2001
  1. \`search_player\` to get their FIDE ID.
1757
2002
  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
2003
  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.
2004
+ 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
2005
  5. Deliver: current strength, characteristic openings, one-sentence style read, biggest wins, biggest losses / recurring weakness. Cite the numbers.`;
1761
2006
  break;
1762
2007
  }
@@ -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
@@ -106,10 +111,10 @@ Two calls:
106
111
  1. `cloud_analyse(fen=<position after 6...e5>, multipv=2)` — get both engines' top choices with movetime=2000.
107
112
  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
113
 
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.
114
+ 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
115
 
111
116
  ## What NOT to do
112
117
 
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.
118
+ - **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
119
  - **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
120
  - **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.33.1",
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": {