@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 +130 -49
- package/dist/pgn/exporter.js +48 -14
- package/dist/pgn/mutations.js +11 -0
- package/dist/pgn/parser.js +59 -3
- package/docs/pgn-authoring.md +2 -0
- package/package.json +1 -1
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
|
-
//
|
|
843
|
-
//
|
|
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 || !
|
|
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
|
|
870
|
-
const
|
|
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
|
|
880
|
-
if (
|
|
881
|
-
|
|
899
|
+
const ev = analysisToStoredEval(analysis, t.fen);
|
|
900
|
+
if (ev)
|
|
901
|
+
stored.push({ path: t.path, ev });
|
|
882
902
|
}
|
|
883
903
|
catch {
|
|
884
|
-
//
|
|
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 (
|
|
909
|
+
if (stored.length === 0)
|
|
890
910
|
return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
|
|
891
|
-
|
|
892
|
-
const
|
|
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:
|
|
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
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
|
|
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
|
|
916
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
977
|
+
return effective > 0 ? "$14" : "$15";
|
|
938
978
|
if (abs < 130)
|
|
939
|
-
return
|
|
940
|
-
return
|
|
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
|
|
1086
|
-
|
|
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
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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),
|
package/dist/pgn/exporter.js
CHANGED
|
@@ -106,23 +106,57 @@ function renderNagsAndComment(node) {
|
|
|
106
106
|
}
|
|
107
107
|
return bits.join(" ");
|
|
108
108
|
}
|
|
109
|
-
// Build the [%cal ...] / [%csl ...]
|
|
110
|
-
//
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
.
|
|
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
|
+
}
|
package/dist/pgn/mutations.js
CHANGED
|
@@ -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();
|
package/dist/pgn/parser.js
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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}`);
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -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