@chessceo/mcp 0.24.0 → 0.26.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
@@ -14,7 +14,7 @@ import { dirname, join } from "node:path";
14
14
  import { Chess } from "chess.js";
15
15
  import { parsePGN } from "./pgn/parser.js";
16
16
  import { exportPGN } from "./pgn/exporter.js";
17
- import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setComment, setNags, setTag, } from "./pgn/mutations.js";
17
+ import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
18
18
  import { PathError } from "./pgn/paths.js";
19
19
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
20
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -188,6 +188,7 @@ const TOOLS = [
188
188
  name: "get_player_preparation",
189
189
  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" +
190
190
  "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" +
191
+ "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" +
191
192
  "Reading the response — CRITICAL:\n" +
192
193
  "• 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" +
193
194
  "• 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" +
@@ -229,7 +230,8 @@ const TOOLS = [
229
230
  },
230
231
  {
231
232
  name: "get_position_stats",
232
- description: "Move statistics from all 11.7M+ indexed games at a given position — game counts, win percentages, top continuations. Answers 'from position P, how often does White play 4. O-O vs 4. d3, and which scores better'.",
233
+ description: "Move statistics from all 11.7M+ indexed games at a given position — game counts, win percentages, top continuations. Answers 'from position P, how often does White play 4. O-O vs 4. d3, and which scores better'.\n\n" +
234
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read (top move + score + PV) and the corresponding NAG. Do NOT fire a separate cloud_analyse for the same FEN — the eval is right there.",
233
235
  inputSchema: {
234
236
  type: "object",
235
237
  properties: {
@@ -669,7 +671,8 @@ const TOOLS = [
669
671
  },
670
672
  {
671
673
  name: "prep_snapshot",
672
- description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).",
674
+ description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).\n\n" +
675
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes a top-level `.eval` (Stockfish + Lc0 read at the shared position) so you get four signals in one call. Do NOT fire cloud_analyse separately for the same FEN.",
673
676
  inputSchema: {
674
677
  type: "object",
675
678
  properties: {
@@ -740,6 +743,17 @@ function uciMoveToSAN(startFen, uci) {
740
743
  return uci;
741
744
  }
742
745
  }
746
+ async function fetchCompactEval(fen) {
747
+ try {
748
+ const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
749
+ const converted = convertCloudSnapshotResponse(raw, fen);
750
+ const stored = analysisToStoredEval(converted, fen);
751
+ return storedEvalToCompact(stored, converted);
752
+ }
753
+ catch {
754
+ return null;
755
+ }
756
+ }
743
757
  // Rewrite the /api/agent/cloud-engines/analyse response (two engines,
744
758
  // each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
745
759
  function convertCloudSnapshotResponse(raw, startFen) {
@@ -794,6 +808,10 @@ function dispatchMutation(file, op) {
794
808
  const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
795
809
  return setAnnotations(file, path, ann);
796
810
  }
811
+ case "set_ceo_eval": {
812
+ const ev = op.ceoEval;
813
+ return setCeoEval(file, path, ev ?? null);
814
+ }
797
815
  case "delete_subtree":
798
816
  return deleteSubtree(file, path);
799
817
  case "promote_variation":
@@ -839,8 +857,10 @@ async function applyBatchMutations(args) {
839
857
  return { ok: true, results, version: savedRow.version };
840
858
  }
841
859
  // Auto-evaluate: walk the tree from `path`, run cloud_analyse on each node,
842
- // convert the SF score to a NAG per the standard thresholds, and apply
843
- // everything in one batch mutation. Uses batching to avoid N saves.
860
+ // stash the compact per-engine eval in the node's `ceoEval` field (which
861
+ // survives across sessions and appears on every read_prep_file), and
862
+ // derive the NAG from the SF score. Both writes go via a single batch
863
+ // mutation at the end so a 200-node evaluate is one save.
844
864
  async function autoEvaluate(args) {
845
865
  const id = String(args.id);
846
866
  const startPath = Array.isArray(args.path) ? args.path : [];
@@ -854,7 +874,7 @@ async function autoEvaluate(args) {
854
874
  const targets = [];
855
875
  const walk = (node, path) => {
856
876
  if (path.length > 0) {
857
- if (!onlyMissing || !(node.nags && node.nags.length > 0)) {
877
+ if (!onlyMissing || !node.ceoEval) {
858
878
  targets.push({ path, fen: node.fen });
859
879
  }
860
880
  }
@@ -866,8 +886,8 @@ async function autoEvaluate(args) {
866
886
  if (targets.length === 0)
867
887
  return { ok: true, evaluated: 0, skipped: 0, version: g.version };
868
888
  // Dispatch cloud_analyse in parallel with a 4-way cap so we don't
869
- // over-saturate the engine's WS connection cap (single-client per port).
870
- const nags = [];
889
+ // over-saturate the engine-ws connection cap (single-client per port).
890
+ const stored = [];
871
891
  const CONCURRENCY = 4;
872
892
  let cursor = 0;
873
893
  async function worker() {
@@ -876,23 +896,27 @@ async function autoEvaluate(args) {
876
896
  const t = targets[idx];
877
897
  try {
878
898
  const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
879
- const nag = analysisToNag(analysis);
880
- if (nag)
881
- nags.push({ path: t.path, nag });
899
+ const ev = analysisToStoredEval(analysis, t.fen);
900
+ if (ev)
901
+ stored.push({ path: t.path, ev });
882
902
  }
883
903
  catch {
884
- // ignore per-node failures best-effort auto-annotation
904
+ // best-effort — a single failed node shouldn't kill the batch
885
905
  }
886
906
  }
887
907
  }
888
908
  await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
889
- if (nags.length === 0)
909
+ if (stored.length === 0)
890
910
  return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
891
- // Apply all NAGs as a single batch.
892
- const batchMutations = nags.map(n => ({ op: "set_nags", path: n.path, nags: [n.nag] }));
911
+ const batchMutations = [];
912
+ for (const s of stored) {
913
+ batchMutations.push({ op: "set_ceo_eval", path: s.path, ceoEval: s.ev });
914
+ if (s.ev.nag)
915
+ batchMutations.push({ op: "set_nags", path: s.path, nags: [s.ev.nag] });
916
+ }
893
917
  const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
894
918
  const sr = saveResult;
895
- return { ok: true, evaluated: nags.length, skipped: targets.length - nags.length, version: sr.version };
919
+ return { ok: true, evaluated: stored.length, skipped: targets.length - stored.length, version: sr.version };
896
920
  }
897
921
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
898
922
  function pathIntoTree(root, path) {
@@ -905,39 +929,81 @@ function pathIntoTree(root, path) {
905
929
  }
906
930
  return cur;
907
931
  }
908
- // Read Stockfish's rank-1 line, convert to White-POV centipawns, and
909
- // bucket into a NAG per the standard threshold table. Returns null if
910
- // the response shape doesn't carry a usable score.
911
- function analysisToNag(analysis) {
932
+ // Read the cloud analyse response and build a StoredEval (compact form —
933
+ // no PVs, White-POV cp / mate, depth, and derived NAG). Returns null if
934
+ // there's no usable Stockfish signal (SF is the source of truth for
935
+ // the NAG per docs).
936
+ function analysisToStoredEval(analysis, fen) {
912
937
  if (!analysis || typeof analysis !== "object")
913
938
  return null;
914
939
  const r = analysis;
915
- const line = r.stockfish?.lines?.[0];
916
- if (!line)
940
+ const whiteToMove = / w /.test(fen);
941
+ const flip = (n) => (whiteToMove ? n : -n);
942
+ const engineEval = (block) => {
943
+ const line = block?.lines?.[0];
944
+ if (!line)
945
+ return undefined;
946
+ const depth = line.depth ?? block?.depth;
947
+ if (typeof line.mate === "number")
948
+ return { mate: flip(line.mate), depth };
949
+ if (typeof line.scoreCp === "number")
950
+ return { cp: flip(line.scoreCp), depth };
951
+ return undefined;
952
+ };
953
+ const sf = engineEval(r.stockfish);
954
+ const lc0 = engineEval(r.lc0);
955
+ if (!sf && !lc0)
917
956
  return null;
918
- // Determine side to move from the FEN so we can convert side-to-move
919
- // POV score to White POV.
920
- const whiteToMove = typeof r.fen === "string" && / w /.test(r.fen);
921
- let cp;
922
- if (typeof line.mate === "number") {
923
- cp = line.mate > 0 ? 10000 : -10000;
924
- }
925
- else if (typeof line.scoreCp === "number") {
926
- cp = line.scoreCp;
927
- }
928
- else {
957
+ const ev = {};
958
+ if (sf)
959
+ ev.sf = sf;
960
+ if (lc0)
961
+ ev.lc0 = lc0;
962
+ ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
963
+ return ev;
964
+ }
965
+ function nagFromCp(cp, mate) {
966
+ let effective;
967
+ if (typeof mate === "number")
968
+ effective = mate > 0 ? 10000 : -10000;
969
+ else if (typeof cp === "number")
970
+ effective = cp;
971
+ else
929
972
  return null;
930
- }
931
- if (!whiteToMove)
932
- cp = -cp;
933
- const abs = Math.abs(cp);
973
+ const abs = Math.abs(effective);
934
974
  if (abs < 25)
935
975
  return "$10";
936
976
  if (abs < 60)
937
- return cp > 0 ? "$14" : "$15";
977
+ return effective > 0 ? "$14" : "$15";
938
978
  if (abs < 130)
939
- return cp > 0 ? "$16" : "$17";
940
- return cp > 0 ? "$18" : "$19";
979
+ return effective > 0 ? "$16" : "$17";
980
+ return effective > 0 ? "$18" : "$19";
981
+ }
982
+ // Adapter for the compact eval attached to live query responses. Same
983
+ // derivation logic; different output shape (needs the .nag + summary
984
+ // used by get_position_stats / prep_snapshot).
985
+ function storedEvalToCompact(ev, analysis) {
986
+ if (!ev)
987
+ return null;
988
+ const a = analysis;
989
+ const compact = { nag: ev.nag ?? null };
990
+ if (ev.sf) {
991
+ compact.stockfish = {
992
+ cp: ev.sf.cp,
993
+ mate: ev.sf.mate,
994
+ bestMove: a.stockfish?.bestMove,
995
+ pv: a.stockfish?.lines?.[0]?.pv,
996
+ };
997
+ }
998
+ if (ev.lc0) {
999
+ compact.lc0 = {
1000
+ cp: ev.lc0.cp,
1001
+ mate: ev.lc0.mate,
1002
+ bestMove: a.lc0?.bestMove,
1003
+ pv: a.lc0?.lines?.[0]?.pv,
1004
+ };
1005
+ }
1006
+ return compact;
941
1007
  }
942
1008
  // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
943
1009
  // save with optimistic lock. Auto-saves so every tool call is atomic;
@@ -1082,17 +1148,30 @@ async function callToolInner(name, args) {
1082
1148
  params.limit = args.limit;
1083
1149
  if (typeof args.offset === "number")
1084
1150
  params.offset = args.offset;
1085
- const raw = await get("/api/chess/prep/by-player", params);
1086
- return convertAvailableMovesToSAN(raw, resolveFenFromArgs(args));
1151
+ const effectiveFen = resolveFenFromArgs(args);
1152
+ const [raw, ev] = await Promise.all([
1153
+ get("/api/chess/prep/by-player", params),
1154
+ fetchCompactEval(effectiveFen),
1155
+ ]);
1156
+ const converted = convertAvailableMovesToSAN(raw, effectiveFen);
1157
+ if (ev && converted && typeof converted === "object")
1158
+ converted.eval = ev;
1159
+ return converted;
1087
1160
  }
1088
1161
  case "get_position_stats": {
1089
1162
  const fen = resolveFenFromArgs(args);
1090
- const raw = await get("/api/chess/database/main", {
1091
- fen,
1092
- limit: typeof args.limit === "number" ? args.limit : 20,
1093
- sort: "relevance",
1094
- });
1095
- return convertAvailableMovesToSAN(raw, fen);
1163
+ const [raw, ev] = await Promise.all([
1164
+ get("/api/chess/database/main", {
1165
+ fen,
1166
+ limit: typeof args.limit === "number" ? args.limit : 20,
1167
+ sort: "relevance",
1168
+ }),
1169
+ fetchCompactEval(fen),
1170
+ ]);
1171
+ const converted = convertAvailableMovesToSAN(raw, fen);
1172
+ if (ev && converted && typeof converted === "object")
1173
+ converted.eval = ev;
1174
+ return converted;
1096
1175
  }
1097
1176
  case "predict_human_move": {
1098
1177
  const fen = resolveFenFromArgs(args);
@@ -1241,13 +1320,15 @@ async function callToolInner(name, args) {
1241
1320
  compact: "true",
1242
1321
  ...(line.length > 0 ? { line } : { fen }),
1243
1322
  });
1244
- const [opponent, you, general] = await Promise.all([
1323
+ const [opponent, you, general, ev] = await Promise.all([
1245
1324
  get("/api/chess/prep/by-player", prepParams(opp, oppColor)),
1246
1325
  get("/api/chess/prep/by-player", prepParams(me, myColor)),
1247
1326
  get("/api/chess/database/main", { fen, limit: 20, sort: "relevance" }),
1327
+ fetchCompactEval(fen),
1248
1328
  ]);
1249
1329
  return {
1250
1330
  position: { line, fen, my_color: myColor },
1331
+ eval: ev,
1251
1332
  opponent: convertAvailableMovesToSAN(opponent, fen),
1252
1333
  you: convertAvailableMovesToSAN(you, fen),
1253
1334
  general: convertAvailableMovesToSAN(general, fen),
@@ -106,23 +106,57 @@ function renderNagsAndComment(node) {
106
106
  }
107
107
  return bits.join(" ");
108
108
  }
109
- // Build the [%cal ...] / [%csl ...] fragments if this node has arrows
110
- // or highlighted squares. Returns "" if none.
109
+ // Build the [%cal ...] / [%csl ...] / [%ceo-eval ...] fragments if
110
+ // this node has any. Returns "" if none.
111
111
  function renderNodeAnnotation(node) {
112
- if (!node.annotations)
113
- return "";
114
112
  const bits = [];
115
- if (node.annotations.arrows.length > 0) {
116
- const entries = node.annotations.arrows
117
- .map(a => `${codeFromColor(a.color)}${a.from}${a.to}`)
118
- .join(",");
119
- bits.push(`[%cal ${entries}]`);
113
+ if (node.annotations) {
114
+ if (node.annotations.arrows.length > 0) {
115
+ const entries = node.annotations.arrows
116
+ .map(a => `${codeFromColor(a.color)}${a.from}${a.to}`)
117
+ .join(",");
118
+ bits.push(`[%cal ${entries}]`);
119
+ }
120
+ if (node.annotations.highlights.length > 0) {
121
+ const entries = node.annotations.highlights
122
+ .map(h => `${codeFromColor(h.color)}${h.square}`)
123
+ .join(",");
124
+ bits.push(`[%csl ${entries}]`);
125
+ }
120
126
  }
121
- if (node.annotations.highlights.length > 0) {
122
- const entries = node.annotations.highlights
123
- .map(h => `${codeFromColor(h.color)}${h.square}`)
124
- .join(",");
125
- bits.push(`[%csl ${entries}]`);
127
+ if (node.ceoEval) {
128
+ const s = renderCeoEval(node.ceoEval);
129
+ if (s)
130
+ bits.push(s);
126
131
  }
127
132
  return bits.join(" ");
128
133
  }
134
+ // Serialise a stored eval as `[%ceo-eval sf=+0.20/38 lc0=+0.35/24 nag=$14]`.
135
+ // Compact: no PVs (regenerable via cloud_analyse), decimal cp for
136
+ // readability, mate as `MN`/`-MN`, depth as `/N`.
137
+ function renderCeoEval(ev) {
138
+ const bits = [];
139
+ if (ev.sf)
140
+ bits.push(`sf=${formatEngineEval(ev.sf)}`);
141
+ if (ev.lc0)
142
+ bits.push(`lc0=${formatEngineEval(ev.lc0)}`);
143
+ if (ev.nag)
144
+ bits.push(`nag=${ev.nag}`);
145
+ return bits.length > 0 ? `[%ceo-eval ${bits.join(" ")}]` : "";
146
+ }
147
+ function formatEngineEval(e) {
148
+ let body;
149
+ if (typeof e.mate === "number") {
150
+ body = e.mate >= 0 ? `+M${e.mate}` : `M${e.mate}`; // "+M5" / "M-5"
151
+ }
152
+ else if (typeof e.cp === "number") {
153
+ const pawns = e.cp / 100;
154
+ body = pawns >= 0 ? `+${pawns.toFixed(2)}` : pawns.toFixed(2);
155
+ }
156
+ else {
157
+ body = "+0.00";
158
+ }
159
+ if (typeof e.depth === "number")
160
+ body += `/${e.depth}`;
161
+ return body;
162
+ }
@@ -108,6 +108,17 @@ export function promoteVariation(file, path) {
108
108
  parent.children = [promoted, ...rest];
109
109
  return { file: { tags: file.tags, root: newRoot }, path: [...parentPath, 0] };
110
110
  }
111
+ // Replace the stored ceoEval on the node at `path`. Passing null clears.
112
+ // This is what auto_evaluate calls per-node; also exposed as an op the
113
+ // LLM can invoke directly if it wants to overwrite a stale eval.
114
+ export function setCeoEval(file, path, ev) {
115
+ const { root: newRoot, target } = cloneOnPath(file.root, path);
116
+ if (ev === null || (!ev.sf && !ev.lc0 && !ev.nag))
117
+ delete target.ceoEval;
118
+ else
119
+ target.ceoEval = ev;
120
+ return { file: { tags: file.tags, root: newRoot }, path };
121
+ }
111
122
  // Set or clear a tag. Passing null / empty removes.
112
123
  export function setTag(file, key, value) {
113
124
  const cleanedKey = key.trim();
@@ -13,8 +13,33 @@ import { opposite } from "chessops/util";
13
13
  import { colorFromCode, } from "./types.js";
14
14
  const CAL_RE = /\[%cal\s+([^\]]+)\]/g;
15
15
  const CSL_RE = /\[%csl\s+([^\]]+)\]/g;
16
- const ANY_CMD_RE = /\[%[a-z]+\s+[^\]]+\]/g;
17
- // Parse a comment string, splitting out visual annotations from prose.
16
+ const CEO_EVAL_RE = /\[%ceo-eval\s+([^\]]+)\]/g;
17
+ const ANY_CMD_RE = /\[%[a-z-]+\s+[^\]]+\]/g;
18
+ // Parse a `sf=+0.20/38` or `lc0=M-5/22` fragment into its numeric parts.
19
+ // Returns null if the value doesn't parse; the parser tolerates missing
20
+ // depth and mate notation.
21
+ function parseEngineEvalFragment(raw) {
22
+ const m = raw.match(/^([+-]?)(?:M(-?\d+)|(\d+(?:\.\d+)?))(?:\/(\d+))?$/i);
23
+ if (!m)
24
+ return null;
25
+ const sign = m[1] === "-" ? -1 : 1;
26
+ const out = {};
27
+ if (m[2] !== undefined) {
28
+ out.mate = sign * parseInt(m[2], 10);
29
+ }
30
+ else if (m[3] !== undefined) {
31
+ // pawn units → centipawns
32
+ out.cp = Math.round(sign * parseFloat(m[3]) * 100);
33
+ }
34
+ else {
35
+ return null;
36
+ }
37
+ if (m[4] !== undefined)
38
+ out.depth = parseInt(m[4], 10);
39
+ return out;
40
+ }
41
+ // Parse a comment string, splitting out visual annotations + stored
42
+ // evals from prose.
18
43
  export function parseCommentAnnotations(comment) {
19
44
  const arrows = [];
20
45
  const highlights = [];
@@ -32,9 +57,36 @@ export function parseCommentAnnotations(comment) {
32
57
  highlights.push({ color: colorFromCode(e[1]), square: e[2].toLowerCase() });
33
58
  }
34
59
  }
60
+ let ceoEval;
61
+ for (const m of comment.matchAll(CEO_EVAL_RE)) {
62
+ const ev = ceoEval ?? {};
63
+ for (const entry of m[1].trim().split(/\s+/)) {
64
+ const eq = entry.indexOf("=");
65
+ if (eq < 0)
66
+ continue;
67
+ const k = entry.slice(0, eq).toLowerCase();
68
+ const v = entry.slice(eq + 1);
69
+ if (k === "sf") {
70
+ const parsed = parseEngineEvalFragment(v);
71
+ if (parsed)
72
+ ev.sf = parsed;
73
+ }
74
+ else if (k === "lc0") {
75
+ const parsed = parseEngineEvalFragment(v);
76
+ if (parsed)
77
+ ev.lc0 = parsed;
78
+ }
79
+ else if (k === "nag") {
80
+ if (/^\$\d+$/.test(v))
81
+ ev.nag = v;
82
+ }
83
+ }
84
+ if (ev.sf || ev.lc0 || ev.nag)
85
+ ceoEval = ev;
86
+ }
35
87
  const text = comment.replace(ANY_CMD_RE, "").replace(/\s+/g, " ").trim();
36
88
  const annotations = arrows.length || highlights.length ? { arrows, highlights } : undefined;
37
- return { text, annotations };
89
+ return { text, annotations, ceoEval };
38
90
  }
39
91
  // Parse full PGN → PrepFile. Throws on unrecoverable errors (no games,
40
92
  // invalid starting FEN). Illegal SAN moves in the movetext are skipped
@@ -62,6 +114,8 @@ export function parsePGN(pgn) {
62
114
  root.comment = parsed.text;
63
115
  if (parsed.annotations)
64
116
  root.annotations = parsed.annotations;
117
+ if (parsed.ceoEval)
118
+ root.ceoEval = parsed.ceoEval;
65
119
  }
66
120
  const build = (childNode, treeParent, pos, ply) => {
67
121
  if (!childNode.data || typeof childNode.data.san !== "string")
@@ -97,6 +151,8 @@ export function parsePGN(pgn) {
97
151
  node.comment = parsed.text;
98
152
  if (parsed.annotations)
99
153
  node.annotations = parsed.annotations;
154
+ if (parsed.ceoEval)
155
+ node.ceoEval = parsed.ceoEval;
100
156
  }
101
157
  if (Array.isArray(childNode.data.nags) && childNode.data.nags.length > 0) {
102
158
  node.nags = childNode.data.nags.map((n) => `$${n}`);
@@ -124,6 +124,8 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
124
124
 
125
125
  **Prefer auto_evaluate for the NAG.** Don't hand-write NAGs when building — call `auto_evaluate(id)` after your build and it assigns the right glyph to every node from actual engine analysis. Manual `set_nags` is for overrides (mark a novelty with $146, flag a `$5 !?` piece sac).
126
126
 
127
+ **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.
128
+
127
129
  **Engine numbers in prose: brief only.** If you must reference a number, keep it compact:
128
130
 
129
131
  - Good: `{... +0.2}` — bare number at the end of a note
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.24.0",
3
+ "version": "0.26.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": {