@chessceo/mcp 0.24.0 → 0.29.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 +222 -50
- package/dist/pgn/describe.js +221 -0
- package/dist/pgn/exporter.js +48 -14
- package/dist/pgn/mutations.js +12 -1
- package/dist/pgn/parser.js +59 -3
- package/docs/pgn-authoring.md +14 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,8 @@ 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 {
|
|
17
|
+
import { describePosition } from "./pgn/describe.js";
|
|
18
|
+
import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
18
19
|
import { PathError } from "./pgn/paths.js";
|
|
19
20
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
21
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -188,6 +189,7 @@ const TOOLS = [
|
|
|
188
189
|
name: "get_player_preparation",
|
|
189
190
|
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
191
|
"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" +
|
|
192
|
+
"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
193
|
"Reading the response — CRITICAL:\n" +
|
|
192
194
|
"• 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
195
|
"• 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 +231,12 @@ const TOOLS = [
|
|
|
229
231
|
},
|
|
230
232
|
{
|
|
231
233
|
name: "get_position_stats",
|
|
232
|
-
description: "Move statistics
|
|
234
|
+
description: "Move statistics + example games at a position. Answers 'how often is 4.O-O vs 4.d3 played here and which scores better'.\n\n" +
|
|
235
|
+
"SOURCE (default: `gm-classical`) selects a pre-aggregated database shard:\n" +
|
|
236
|
+
"- `gm-classical` — GM classical games (both players ≥2500, real thinking-time). BEST for opening prep — every move is signal, avgElo ~2600 across all listed moves.\n" +
|
|
237
|
+
"- `main` — the whole 11.7M-game DB. Widest coverage but noisiest (includes 1000-Elo blunder-fests in the move stats). Use as fallback when gm-classical's totalCount is too small to be informative.\n\n" +
|
|
238
|
+
"Game movetext is trimmed to the moves AFTER the queried position (using each game's plyNumber). Saves ~70% of the bytes vs full movetext.\n\n" +
|
|
239
|
+
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN.",
|
|
233
240
|
inputSchema: {
|
|
234
241
|
type: "object",
|
|
235
242
|
properties: {
|
|
@@ -249,8 +256,27 @@ const TOOLS = [
|
|
|
249
256
|
type: "integer",
|
|
250
257
|
minimum: 1,
|
|
251
258
|
maximum: 50,
|
|
252
|
-
description: "Number of
|
|
259
|
+
description: "Number of example games to return (default 10).",
|
|
253
260
|
},
|
|
261
|
+
source: {
|
|
262
|
+
type: "string",
|
|
263
|
+
enum: ["gm-classical", "main"],
|
|
264
|
+
description: "Which database shard to query. Default `gm-classical`. Switch to `main` only when gm-classical's totalCount is too low.",
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: "describe_position",
|
|
271
|
+
description: "Structured facts about a chess position: piece placements per colour (SAN-style letters), material balance in pawn units, list of every contested piece (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant square, side to move, and the full list of LEGAL MOVES for the side to move. Pure computation — no engine, ~1 ms per call.\n\n" +
|
|
272
|
+
"USE THIS BEFORE COMMENTING ON A POSITION. LLMs are not reliable at reading FEN strings — you'll misplace pieces or invent captures. This tool gives you the same board state a human sees.\n\n" +
|
|
273
|
+
"ALSO USE THIS if `add_move` rejects an illegal SAN — the `.legalMoves` array shows exactly what's playable from that position.\n\n" +
|
|
274
|
+
"Position input is flexible — pass `fen`, or `moves` from startpos, or `fen + moves` to walk from there.",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: {
|
|
278
|
+
fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
|
|
279
|
+
moves: { type: "string", description: "Optional SAN moves to apply on top of `fen`." },
|
|
254
280
|
},
|
|
255
281
|
},
|
|
256
282
|
},
|
|
@@ -669,7 +695,8 @@ const TOOLS = [
|
|
|
669
695
|
},
|
|
670
696
|
{
|
|
671
697
|
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)
|
|
698
|
+
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" +
|
|
699
|
+
"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
700
|
inputSchema: {
|
|
674
701
|
type: "object",
|
|
675
702
|
properties: {
|
|
@@ -740,6 +767,17 @@ function uciMoveToSAN(startFen, uci) {
|
|
|
740
767
|
return uci;
|
|
741
768
|
}
|
|
742
769
|
}
|
|
770
|
+
async function fetchCompactEval(fen) {
|
|
771
|
+
try {
|
|
772
|
+
const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
|
|
773
|
+
const converted = convertCloudSnapshotResponse(raw, fen);
|
|
774
|
+
const stored = analysisToStoredEval(converted, fen);
|
|
775
|
+
return storedEvalToCompact(stored, converted);
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
743
781
|
// Rewrite the /api/agent/cloud-engines/analyse response (two engines,
|
|
744
782
|
// each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
|
|
745
783
|
function convertCloudSnapshotResponse(raw, startFen) {
|
|
@@ -794,6 +832,10 @@ function dispatchMutation(file, op) {
|
|
|
794
832
|
const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
|
|
795
833
|
return setAnnotations(file, path, ann);
|
|
796
834
|
}
|
|
835
|
+
case "set_ceo_eval": {
|
|
836
|
+
const ev = op.ceoEval;
|
|
837
|
+
return setCeoEval(file, path, ev ?? null);
|
|
838
|
+
}
|
|
797
839
|
case "delete_subtree":
|
|
798
840
|
return deleteSubtree(file, path);
|
|
799
841
|
case "promote_variation":
|
|
@@ -839,8 +881,10 @@ async function applyBatchMutations(args) {
|
|
|
839
881
|
return { ok: true, results, version: savedRow.version };
|
|
840
882
|
}
|
|
841
883
|
// Auto-evaluate: walk the tree from `path`, run cloud_analyse on each node,
|
|
842
|
-
//
|
|
843
|
-
//
|
|
884
|
+
// stash the compact per-engine eval in the node's `ceoEval` field (which
|
|
885
|
+
// survives across sessions and appears on every read_prep_file), and
|
|
886
|
+
// derive the NAG from the SF score. Both writes go via a single batch
|
|
887
|
+
// mutation at the end so a 200-node evaluate is one save.
|
|
844
888
|
async function autoEvaluate(args) {
|
|
845
889
|
const id = String(args.id);
|
|
846
890
|
const startPath = Array.isArray(args.path) ? args.path : [];
|
|
@@ -854,7 +898,7 @@ async function autoEvaluate(args) {
|
|
|
854
898
|
const targets = [];
|
|
855
899
|
const walk = (node, path) => {
|
|
856
900
|
if (path.length > 0) {
|
|
857
|
-
if (!onlyMissing || !
|
|
901
|
+
if (!onlyMissing || !node.ceoEval) {
|
|
858
902
|
targets.push({ path, fen: node.fen });
|
|
859
903
|
}
|
|
860
904
|
}
|
|
@@ -866,8 +910,8 @@ async function autoEvaluate(args) {
|
|
|
866
910
|
if (targets.length === 0)
|
|
867
911
|
return { ok: true, evaluated: 0, skipped: 0, version: g.version };
|
|
868
912
|
// Dispatch cloud_analyse in parallel with a 4-way cap so we don't
|
|
869
|
-
// over-saturate the engine
|
|
870
|
-
const
|
|
913
|
+
// over-saturate the engine-ws connection cap (single-client per port).
|
|
914
|
+
const stored = [];
|
|
871
915
|
const CONCURRENCY = 4;
|
|
872
916
|
let cursor = 0;
|
|
873
917
|
async function worker() {
|
|
@@ -876,23 +920,27 @@ async function autoEvaluate(args) {
|
|
|
876
920
|
const t = targets[idx];
|
|
877
921
|
try {
|
|
878
922
|
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
879
|
-
const
|
|
880
|
-
if (
|
|
881
|
-
|
|
923
|
+
const ev = analysisToStoredEval(analysis, t.fen);
|
|
924
|
+
if (ev)
|
|
925
|
+
stored.push({ path: t.path, ev });
|
|
882
926
|
}
|
|
883
927
|
catch {
|
|
884
|
-
//
|
|
928
|
+
// best-effort — a single failed node shouldn't kill the batch
|
|
885
929
|
}
|
|
886
930
|
}
|
|
887
931
|
}
|
|
888
932
|
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
889
|
-
if (
|
|
933
|
+
if (stored.length === 0)
|
|
890
934
|
return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
|
|
891
|
-
|
|
892
|
-
const
|
|
935
|
+
const batchMutations = [];
|
|
936
|
+
for (const s of stored) {
|
|
937
|
+
batchMutations.push({ op: "set_ceo_eval", path: s.path, ceoEval: s.ev });
|
|
938
|
+
if (s.ev.nag)
|
|
939
|
+
batchMutations.push({ op: "set_nags", path: s.path, nags: [s.ev.nag] });
|
|
940
|
+
}
|
|
893
941
|
const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
|
|
894
942
|
const sr = saveResult;
|
|
895
|
-
return { ok: true, evaluated:
|
|
943
|
+
return { ok: true, evaluated: stored.length, skipped: targets.length - stored.length, version: sr.version };
|
|
896
944
|
}
|
|
897
945
|
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
898
946
|
function pathIntoTree(root, path) {
|
|
@@ -905,39 +953,81 @@ function pathIntoTree(root, path) {
|
|
|
905
953
|
}
|
|
906
954
|
return cur;
|
|
907
955
|
}
|
|
908
|
-
// Read
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
|
|
956
|
+
// Read the cloud analyse response and build a StoredEval (compact form —
|
|
957
|
+
// no PVs, White-POV cp / mate, depth, and derived NAG). Returns null if
|
|
958
|
+
// there's no usable Stockfish signal (SF is the source of truth for
|
|
959
|
+
// the NAG per docs).
|
|
960
|
+
function analysisToStoredEval(analysis, fen) {
|
|
912
961
|
if (!analysis || typeof analysis !== "object")
|
|
913
962
|
return null;
|
|
914
963
|
const r = analysis;
|
|
915
|
-
const
|
|
916
|
-
|
|
964
|
+
const whiteToMove = / w /.test(fen);
|
|
965
|
+
const flip = (n) => (whiteToMove ? n : -n);
|
|
966
|
+
const engineEval = (block) => {
|
|
967
|
+
const line = block?.lines?.[0];
|
|
968
|
+
if (!line)
|
|
969
|
+
return undefined;
|
|
970
|
+
const depth = line.depth ?? block?.depth;
|
|
971
|
+
if (typeof line.mate === "number")
|
|
972
|
+
return { mate: flip(line.mate), depth };
|
|
973
|
+
if (typeof line.scoreCp === "number")
|
|
974
|
+
return { cp: flip(line.scoreCp), depth };
|
|
975
|
+
return undefined;
|
|
976
|
+
};
|
|
977
|
+
const sf = engineEval(r.stockfish);
|
|
978
|
+
const lc0 = engineEval(r.lc0);
|
|
979
|
+
if (!sf && !lc0)
|
|
917
980
|
return null;
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
981
|
+
const ev = {};
|
|
982
|
+
if (sf)
|
|
983
|
+
ev.sf = sf;
|
|
984
|
+
if (lc0)
|
|
985
|
+
ev.lc0 = lc0;
|
|
986
|
+
ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
|
|
987
|
+
return ev;
|
|
988
|
+
}
|
|
989
|
+
function nagFromCp(cp, mate) {
|
|
990
|
+
let effective;
|
|
991
|
+
if (typeof mate === "number")
|
|
992
|
+
effective = mate > 0 ? 10000 : -10000;
|
|
993
|
+
else if (typeof cp === "number")
|
|
994
|
+
effective = cp;
|
|
995
|
+
else
|
|
929
996
|
return null;
|
|
930
|
-
|
|
931
|
-
if (!whiteToMove)
|
|
932
|
-
cp = -cp;
|
|
933
|
-
const abs = Math.abs(cp);
|
|
997
|
+
const abs = Math.abs(effective);
|
|
934
998
|
if (abs < 25)
|
|
935
999
|
return "$10";
|
|
936
1000
|
if (abs < 60)
|
|
937
|
-
return
|
|
1001
|
+
return effective > 0 ? "$14" : "$15";
|
|
938
1002
|
if (abs < 130)
|
|
939
|
-
return
|
|
940
|
-
return
|
|
1003
|
+
return effective > 0 ? "$16" : "$17";
|
|
1004
|
+
return effective > 0 ? "$18" : "$19";
|
|
1005
|
+
}
|
|
1006
|
+
// Adapter for the compact eval attached to live query responses. Same
|
|
1007
|
+
// derivation logic; different output shape (needs the .nag + summary
|
|
1008
|
+
// used by get_position_stats / prep_snapshot).
|
|
1009
|
+
function storedEvalToCompact(ev, analysis) {
|
|
1010
|
+
if (!ev)
|
|
1011
|
+
return null;
|
|
1012
|
+
const a = analysis;
|
|
1013
|
+
const compact = { nag: ev.nag ?? null };
|
|
1014
|
+
if (ev.sf) {
|
|
1015
|
+
compact.stockfish = {
|
|
1016
|
+
cp: ev.sf.cp,
|
|
1017
|
+
mate: ev.sf.mate,
|
|
1018
|
+
bestMove: a.stockfish?.bestMove,
|
|
1019
|
+
pv: a.stockfish?.lines?.[0]?.pv,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
if (ev.lc0) {
|
|
1023
|
+
compact.lc0 = {
|
|
1024
|
+
cp: ev.lc0.cp,
|
|
1025
|
+
mate: ev.lc0.mate,
|
|
1026
|
+
bestMove: a.lc0?.bestMove,
|
|
1027
|
+
pv: a.lc0?.lines?.[0]?.pv,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
return compact;
|
|
941
1031
|
}
|
|
942
1032
|
// Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
|
|
943
1033
|
// save with optimistic lock. Auto-saves so every tool call is atomic;
|
|
@@ -972,6 +1062,62 @@ async function applyMutation(args, mutator) {
|
|
|
972
1062
|
version: savedRow.version,
|
|
973
1063
|
};
|
|
974
1064
|
}
|
|
1065
|
+
// Trim every game's `moves` field to just the plies AFTER the queried
|
|
1066
|
+
// position, using each game's `plyNumber`. Massive token save — a game
|
|
1067
|
+
// 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
|
|
1068
|
+
// the frontend's GamesTable.getMoveDisplay() trim logic.
|
|
1069
|
+
function trimGamesMovetext(response) {
|
|
1070
|
+
if (!response || typeof response !== "object")
|
|
1071
|
+
return;
|
|
1072
|
+
const r = response;
|
|
1073
|
+
if (!Array.isArray(r.games))
|
|
1074
|
+
return;
|
|
1075
|
+
for (const g of r.games) {
|
|
1076
|
+
if (typeof g.moves === "string" && typeof g.plyNumber === "number" && g.plyNumber > 0) {
|
|
1077
|
+
g.moves = trimMovesToPly(g.moves, g.plyNumber);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function trimMovesToPly(moves, plyNumber) {
|
|
1082
|
+
// Split into plain SAN tokens, dropping standalone move-number tokens
|
|
1083
|
+
// ("1.", "12...") and any glued number prefix on a SAN token ("1.e4").
|
|
1084
|
+
// Result markers ("*", "1-0", "0-1", "1/2-1/2") are stripped so they
|
|
1085
|
+
// don't get counted as plies.
|
|
1086
|
+
const tokens = [];
|
|
1087
|
+
for (const chunk of moves.split(/\s+/)) {
|
|
1088
|
+
if (!chunk)
|
|
1089
|
+
continue;
|
|
1090
|
+
const cleaned = chunk.replace(/^\d+\.+/, "");
|
|
1091
|
+
if (!cleaned)
|
|
1092
|
+
continue;
|
|
1093
|
+
if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(cleaned))
|
|
1094
|
+
continue;
|
|
1095
|
+
tokens.push(cleaned);
|
|
1096
|
+
}
|
|
1097
|
+
const remaining = tokens.slice(plyNumber);
|
|
1098
|
+
if (remaining.length === 0)
|
|
1099
|
+
return "";
|
|
1100
|
+
// Reconstruct with move numbering. First move gets "N..." if it's
|
|
1101
|
+
// Black's move (starting the slice mid-move-pair), so the reader knows
|
|
1102
|
+
// moves were dropped.
|
|
1103
|
+
const out = [];
|
|
1104
|
+
let ply = plyNumber;
|
|
1105
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
1106
|
+
const san = remaining[i];
|
|
1107
|
+
const moveNumber = Math.floor(ply / 2) + 1;
|
|
1108
|
+
if (ply % 2 === 0) {
|
|
1109
|
+
out.push(`${moveNumber}. ${san}`);
|
|
1110
|
+
}
|
|
1111
|
+
else if (i === 0) {
|
|
1112
|
+
out.push(`${moveNumber}... ${san}`);
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
out.push(san);
|
|
1116
|
+
}
|
|
1117
|
+
ply++;
|
|
1118
|
+
}
|
|
1119
|
+
return out.join(" ");
|
|
1120
|
+
}
|
|
975
1121
|
// Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
|
|
976
1122
|
// endpoints return moves in UCI on the wire — same LLM-readability
|
|
977
1123
|
// concern as engine PVs, and the same wrapper-only fix. Passes the
|
|
@@ -1082,18 +1228,42 @@ async function callToolInner(name, args) {
|
|
|
1082
1228
|
params.limit = args.limit;
|
|
1083
1229
|
if (typeof args.offset === "number")
|
|
1084
1230
|
params.offset = args.offset;
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1231
|
+
const effectiveFen = resolveFenFromArgs(args);
|
|
1232
|
+
const [raw, ev] = await Promise.all([
|
|
1233
|
+
get("/api/chess/prep/by-player", params),
|
|
1234
|
+
fetchCompactEval(effectiveFen),
|
|
1235
|
+
]);
|
|
1236
|
+
const converted = convertAvailableMovesToSAN(raw, effectiveFen);
|
|
1237
|
+
if (converted && typeof converted === "object") {
|
|
1238
|
+
trimGamesMovetext(converted);
|
|
1239
|
+
if (ev)
|
|
1240
|
+
converted.eval = ev;
|
|
1241
|
+
}
|
|
1242
|
+
return converted;
|
|
1087
1243
|
}
|
|
1088
1244
|
case "get_position_stats": {
|
|
1089
1245
|
const fen = resolveFenFromArgs(args);
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1246
|
+
const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
|
|
1247
|
+
const source = rawSource === "main" ? "main" : "gm-classical";
|
|
1248
|
+
const [raw, ev] = await Promise.all([
|
|
1249
|
+
get(`/api/chess/database/${source}`, {
|
|
1250
|
+
fen,
|
|
1251
|
+
limit: typeof args.limit === "number" ? args.limit : 10,
|
|
1252
|
+
sort: "relevance",
|
|
1253
|
+
}),
|
|
1254
|
+
fetchCompactEval(fen),
|
|
1255
|
+
]);
|
|
1256
|
+
const converted = convertAvailableMovesToSAN(raw, fen);
|
|
1257
|
+
if (converted && typeof converted === "object") {
|
|
1258
|
+
trimGamesMovetext(converted);
|
|
1259
|
+
converted.source = source;
|
|
1260
|
+
if (ev)
|
|
1261
|
+
converted.eval = ev;
|
|
1262
|
+
}
|
|
1263
|
+
return converted;
|
|
1096
1264
|
}
|
|
1265
|
+
case "describe_position":
|
|
1266
|
+
return describePosition(resolveFenFromArgs(args));
|
|
1097
1267
|
case "predict_human_move": {
|
|
1098
1268
|
const fen = resolveFenFromArgs(args);
|
|
1099
1269
|
const qs = new URLSearchParams();
|
|
@@ -1241,13 +1411,15 @@ async function callToolInner(name, args) {
|
|
|
1241
1411
|
compact: "true",
|
|
1242
1412
|
...(line.length > 0 ? { line } : { fen }),
|
|
1243
1413
|
});
|
|
1244
|
-
const [opponent, you, general] = await Promise.all([
|
|
1414
|
+
const [opponent, you, general, ev] = await Promise.all([
|
|
1245
1415
|
get("/api/chess/prep/by-player", prepParams(opp, oppColor)),
|
|
1246
1416
|
get("/api/chess/prep/by-player", prepParams(me, myColor)),
|
|
1247
1417
|
get("/api/chess/database/main", { fen, limit: 20, sort: "relevance" }),
|
|
1418
|
+
fetchCompactEval(fen),
|
|
1248
1419
|
]);
|
|
1249
1420
|
return {
|
|
1250
1421
|
position: { line, fen, my_color: myColor },
|
|
1422
|
+
eval: ev,
|
|
1251
1423
|
opponent: convertAvailableMovesToSAN(opponent, fen),
|
|
1252
1424
|
you: convertAvailableMovesToSAN(you, fen),
|
|
1253
1425
|
general: convertAvailableMovesToSAN(general, fen),
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// describe_position — structured facts about a position that LLMs can't
|
|
2
|
+
// reliably read out of a FEN string on their own. Pure computation
|
|
3
|
+
// (chessops); no engine needed.
|
|
4
|
+
//
|
|
5
|
+
// Output covers piece placements, material balance in pawn units,
|
|
6
|
+
// attackers/defenders on every piece that is currently contested, a
|
|
7
|
+
// hanging list (attacked and undefended), checkers if in check, and
|
|
8
|
+
// castling / ep state. NOT covered: pins, discovered attacks, tactical
|
|
9
|
+
// patterns — those need the engine and are already available via
|
|
10
|
+
// cloud_analyse.
|
|
11
|
+
import { parseFen } from "chessops/fen";
|
|
12
|
+
import { Chess } from "chessops/chess";
|
|
13
|
+
import { attacks } from "chessops/attacks";
|
|
14
|
+
import { makeSquare, squareRank } from "chessops/util";
|
|
15
|
+
import { makeSan } from "chessops/san";
|
|
16
|
+
const ROLE_LETTER = {
|
|
17
|
+
pawn: "", knight: "N", bishop: "B", rook: "R", queen: "Q", king: "K",
|
|
18
|
+
};
|
|
19
|
+
// SAN convention: pawn is empty prefix. For inventory listings we use "P"
|
|
20
|
+
// so counts are readable.
|
|
21
|
+
const ROLE_LETTER_INV = {
|
|
22
|
+
pawn: "P", knight: "N", bishop: "B", rook: "R", queen: "Q", king: "K",
|
|
23
|
+
};
|
|
24
|
+
const ROLE_VALUE = {
|
|
25
|
+
pawn: 1, knight: 3, bishop: 3, rook: 5, queen: 9, king: 0,
|
|
26
|
+
};
|
|
27
|
+
function pieceSAN(piece, sq) {
|
|
28
|
+
return `${ROLE_LETTER[piece.role]}${makeSquare(sq)}`;
|
|
29
|
+
}
|
|
30
|
+
export function describePosition(fen) {
|
|
31
|
+
const setup = parseFen(fen);
|
|
32
|
+
if (setup.isErr)
|
|
33
|
+
throw new Error(`bad fen: ${setup.error}`);
|
|
34
|
+
const posResult = Chess.fromSetup(setup.value);
|
|
35
|
+
if (posResult.isErr)
|
|
36
|
+
throw new Error(`bad position: ${posResult.error}`);
|
|
37
|
+
const pos = posResult.value;
|
|
38
|
+
const board = pos.board;
|
|
39
|
+
const whitePieces = [];
|
|
40
|
+
const blackPieces = [];
|
|
41
|
+
const materialW = { P: 0, N: 0, B: 0, R: 0, Q: 0, K: 0 };
|
|
42
|
+
const materialB = { P: 0, N: 0, B: 0, R: 0, Q: 0, K: 0 };
|
|
43
|
+
for (const [sq, piece] of board) {
|
|
44
|
+
const invLetter = ROLE_LETTER_INV[piece.role];
|
|
45
|
+
const entry = { piece: invLetter, square: makeSquare(sq) };
|
|
46
|
+
if (piece.color === "white") {
|
|
47
|
+
whitePieces.push(entry);
|
|
48
|
+
materialW[invLetter]++;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
blackPieces.push(entry);
|
|
52
|
+
materialB[invLetter]++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
whitePieces.sort(pieceSortKey);
|
|
56
|
+
blackPieces.sort(pieceSortKey);
|
|
57
|
+
const wValue = pieceValueSum(materialW);
|
|
58
|
+
const bValue = pieceValueSum(materialB);
|
|
59
|
+
const diff = wValue - bValue;
|
|
60
|
+
const summary = diff === 0 ? `even (both ${wValue})` :
|
|
61
|
+
diff > 0 ? `white +${diff} (${wValue} vs ${bValue})` :
|
|
62
|
+
`black +${-diff} (${wValue} vs ${bValue})`;
|
|
63
|
+
// Contested + hanging computation: for every piece, find who attacks
|
|
64
|
+
// it and who defends it. attacks() returns the squares a piece
|
|
65
|
+
// controls given the occupied board — we invert it into "attackers-of"
|
|
66
|
+
// by iterating all pieces once and testing membership.
|
|
67
|
+
const contested = [];
|
|
68
|
+
const hangingWhite = [];
|
|
69
|
+
const hangingBlack = [];
|
|
70
|
+
for (const [sq, piece] of board) {
|
|
71
|
+
const both = attackersOf(pos, sq);
|
|
72
|
+
const oppSide = piece.color === "white" ? "black" : "white";
|
|
73
|
+
const attackers = both[oppSide];
|
|
74
|
+
const defenders = both[piece.color];
|
|
75
|
+
if (attackers.length === 0)
|
|
76
|
+
continue;
|
|
77
|
+
const targetName = pieceSAN(piece, sq);
|
|
78
|
+
contested.push({
|
|
79
|
+
target: targetName,
|
|
80
|
+
color: piece.color,
|
|
81
|
+
attackers: attackers.map(a => pieceSANFromBoard(board, a)),
|
|
82
|
+
defenders: defenders.map(d => pieceSANFromBoard(board, d)),
|
|
83
|
+
});
|
|
84
|
+
if (defenders.length === 0) {
|
|
85
|
+
if (piece.color === "white")
|
|
86
|
+
hangingWhite.push(targetName);
|
|
87
|
+
else
|
|
88
|
+
hangingBlack.push(targetName);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Order contested by target square for stable output the LLM can
|
|
92
|
+
// scan.
|
|
93
|
+
contested.sort((a, b) => a.target.slice(-2).localeCompare(b.target.slice(-2)));
|
|
94
|
+
// Castling rights encoded in the standard FEN letters (KQkq).
|
|
95
|
+
const castlingW = castlingLetters(pos, "white");
|
|
96
|
+
const castlingB = castlingLetters(pos, "black");
|
|
97
|
+
const checkers = [];
|
|
98
|
+
const ctx = pos.ctx();
|
|
99
|
+
for (const sq of ctx.checkers)
|
|
100
|
+
checkers.push(pieceSANFromBoard(board, sq));
|
|
101
|
+
const legalMoves = generateLegalMoves(pos);
|
|
102
|
+
return {
|
|
103
|
+
fen,
|
|
104
|
+
sideToMove: pos.turn,
|
|
105
|
+
inCheck: pos.isCheck(),
|
|
106
|
+
checkers,
|
|
107
|
+
material: {
|
|
108
|
+
white: materialW,
|
|
109
|
+
black: materialB,
|
|
110
|
+
diff,
|
|
111
|
+
summary,
|
|
112
|
+
},
|
|
113
|
+
pieces: { white: whitePieces, black: blackPieces },
|
|
114
|
+
contested,
|
|
115
|
+
hanging: { white: hangingWhite, black: hangingBlack },
|
|
116
|
+
castling: { white: castlingW, black: castlingB },
|
|
117
|
+
epSquare: typeof pos.epSquare === "number" ? makeSquare(pos.epSquare) : null,
|
|
118
|
+
legalMoves,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// All legal moves for the side to move, in SAN. Sorted for stable output.
|
|
122
|
+
// Handles promotion by expanding each pawn-promotion destination into
|
|
123
|
+
// the four promotion pieces (=Q, =R, =B, =N).
|
|
124
|
+
function generateLegalMoves(pos) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [from, piece] of pos.board) {
|
|
127
|
+
if (piece.color !== pos.turn)
|
|
128
|
+
continue;
|
|
129
|
+
const dests = pos.dests(from);
|
|
130
|
+
for (const to of dests) {
|
|
131
|
+
const isPromotion = piece.role === "pawn" &&
|
|
132
|
+
((piece.color === "white" && squareRank(to) === 7) ||
|
|
133
|
+
(piece.color === "black" && squareRank(to) === 0));
|
|
134
|
+
if (isPromotion) {
|
|
135
|
+
for (const promo of ["queen", "rook", "bishop", "knight"]) {
|
|
136
|
+
const san = makeSan(pos, { from, to, promotion: promo });
|
|
137
|
+
if (san)
|
|
138
|
+
out.push(san);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const san = makeSan(pos, { from, to });
|
|
143
|
+
if (san)
|
|
144
|
+
out.push(san);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out.sort();
|
|
149
|
+
}
|
|
150
|
+
// Attackers-of a square, split by colour. A piece cannot attack its own
|
|
151
|
+
// square, so we skip the target index. The `occupied` for sliding
|
|
152
|
+
// pieces is the whole board minus nothing — chessops's `attacks()`
|
|
153
|
+
// takes care of blockers.
|
|
154
|
+
function attackersOf(pos, target) {
|
|
155
|
+
const white = [];
|
|
156
|
+
const black = [];
|
|
157
|
+
for (const [sq, piece] of pos.board) {
|
|
158
|
+
if (sq === target)
|
|
159
|
+
continue;
|
|
160
|
+
const set = attacks(piece, sq, pos.board.occupied);
|
|
161
|
+
if (set.has(target)) {
|
|
162
|
+
if (piece.color === "white")
|
|
163
|
+
white.push(sq);
|
|
164
|
+
else
|
|
165
|
+
black.push(sq);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { white, black };
|
|
169
|
+
}
|
|
170
|
+
function pieceSANFromBoard(board, sq) {
|
|
171
|
+
const piece = board.get(sq);
|
|
172
|
+
if (!piece)
|
|
173
|
+
return makeSquare(sq);
|
|
174
|
+
return pieceSAN(piece, sq);
|
|
175
|
+
}
|
|
176
|
+
// Sort: kings first, then queens, rooks, bishops, knights, pawns; within
|
|
177
|
+
// each type, by rank descending then file ascending. Matches the visual
|
|
178
|
+
// scan a human does.
|
|
179
|
+
const ROLE_ORDER = ["king", "queen", "rook", "bishop", "knight", "pawn"];
|
|
180
|
+
function pieceSortKey(a, b) {
|
|
181
|
+
const roleA = roleOfEntry(a);
|
|
182
|
+
const roleB = roleOfEntry(b);
|
|
183
|
+
const orderA = ROLE_ORDER.indexOf(roleA);
|
|
184
|
+
const orderB = ROLE_ORDER.indexOf(roleB);
|
|
185
|
+
if (orderA !== orderB)
|
|
186
|
+
return orderA - orderB;
|
|
187
|
+
return a.square.localeCompare(b.square);
|
|
188
|
+
}
|
|
189
|
+
function roleOfEntry(e) {
|
|
190
|
+
switch (e.piece) {
|
|
191
|
+
case "K": return "king";
|
|
192
|
+
case "Q": return "queen";
|
|
193
|
+
case "R": return "rook";
|
|
194
|
+
case "B": return "bishop";
|
|
195
|
+
case "N": return "knight";
|
|
196
|
+
default: return "pawn";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function pieceValueSum(m) {
|
|
200
|
+
return m.P * ROLE_VALUE.pawn +
|
|
201
|
+
m.N * ROLE_VALUE.knight +
|
|
202
|
+
m.B * ROLE_VALUE.bishop +
|
|
203
|
+
m.R * ROLE_VALUE.rook +
|
|
204
|
+
m.Q * ROLE_VALUE.queen;
|
|
205
|
+
}
|
|
206
|
+
// Convert chessops castling representation to the standard FEN-letter
|
|
207
|
+
// form for the given color: "K" (kingside), "Q" (queenside), "KQ",
|
|
208
|
+
// or "" if none remain.
|
|
209
|
+
function castlingLetters(pos, color) {
|
|
210
|
+
let out = "";
|
|
211
|
+
const castles = pos.castles;
|
|
212
|
+
// castles.rooksSideOf(color, 'a') and .rooksSideOf(color, 'h') return
|
|
213
|
+
// the rook square or undefined per side. Fall back to inspecting the
|
|
214
|
+
// stored castling squares — chessops's public API varies by version.
|
|
215
|
+
const rooks = castles.rook;
|
|
216
|
+
if (rooks?.[color]?.h !== undefined)
|
|
217
|
+
out += color === "white" ? "K" : "k";
|
|
218
|
+
if (rooks?.[color]?.a !== undefined)
|
|
219
|
+
out += color === "white" ? "Q" : "q";
|
|
220
|
+
return out;
|
|
221
|
+
}
|
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();
|
|
@@ -133,7 +144,7 @@ function validateSan(fen, san) {
|
|
|
133
144
|
const board = pos.value;
|
|
134
145
|
const move = parseSan(board, san);
|
|
135
146
|
if (!move) {
|
|
136
|
-
throw new MutationError(`illegal SAN "${san}" at fen "${fen}"
|
|
147
|
+
throw new MutationError(`illegal SAN "${san}" at fen "${fen}". Call describe_position on this fen to see the full list of legal moves.`);
|
|
137
148
|
}
|
|
138
149
|
return move;
|
|
139
150
|
}
|
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
|
|
@@ -131,6 +133,18 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
|
|
|
131
133
|
|
|
132
134
|
**Don't name the engine unless it adds signal.** "SF: +0.15" for a routine position is noise. Name it when there's a mismatch worth flagging: `{Human predictor gives Black 47% win despite the -0.35 objective eval — Elo gap does the work.}`
|
|
133
135
|
|
|
136
|
+
### Before you write any commentary: describe_position
|
|
137
|
+
|
|
138
|
+
LLMs are not reliable at reading FEN strings — you'll swap files/ranks, invent captures, miscount pieces. Before you write a comment describing what's happening in a position, call `describe_position(fen)`. It's pure computation (no engine, ~1ms) and returns the same board state a human sees:
|
|
139
|
+
|
|
140
|
+
- All piece placements per colour (SAN-style: `Nf3`, `Bg7`, `e4`)
|
|
141
|
+
- Material balance in pawn units (`"white +1 (39 vs 38)"`)
|
|
142
|
+
- Every contested piece: attackers + defenders (e.g. `{target: "e5", color: "black", attackers: ["Nf3"], defenders: ["Nc6"]}`)
|
|
143
|
+
- Hanging list — attacked and undefended
|
|
144
|
+
- Check state + checkers, castling rights, en passant
|
|
145
|
+
|
|
146
|
+
Use it whenever you're about to describe a position from memory or from your reading of a FEN. The failure mode this prevents: `{Black's queen on c7 is defended by the knight on d5.}` when actually there's no knight on d5 and the queen is on c8.
|
|
147
|
+
|
|
134
148
|
### When prose ADDS to the NAG
|
|
135
149
|
|
|
136
150
|
Prose is worth writing when the NAG *understates* something the human should know:
|
package/package.json
CHANGED