@chessceo/mcp 0.26.0 → 0.30.2
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 +149 -16
- package/dist/pgn/describe.js +221 -0
- package/dist/pgn/mutations.js +1 -1
- package/docs/pgn-authoring.md +40 -5
- package/docs/prep-strategy.md +3 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,6 +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 { describePosition } from "./pgn/describe.js";
|
|
17
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";
|
|
@@ -192,7 +193,7 @@ const TOOLS = [
|
|
|
192
193
|
"Reading the response — CRITICAL:\n" +
|
|
193
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" +
|
|
194
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" +
|
|
195
|
-
"• Recency > career. The last 12-24 months dominate. This endpoint's compact/LLM view deliberately omits per-move `
|
|
196
|
+
"• 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" +
|
|
196
197
|
"• 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" +
|
|
197
198
|
"• 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" +
|
|
198
199
|
"For the full guide call the `read_prep_strategy_guide` tool.",
|
|
@@ -230,8 +231,12 @@ const TOOLS = [
|
|
|
230
231
|
},
|
|
231
232
|
{
|
|
232
233
|
name: "get_position_stats",
|
|
233
|
-
description: "Move statistics
|
|
234
|
-
"
|
|
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.",
|
|
235
240
|
inputSchema: {
|
|
236
241
|
type: "object",
|
|
237
242
|
properties: {
|
|
@@ -251,8 +256,27 @@ const TOOLS = [
|
|
|
251
256
|
type: "integer",
|
|
252
257
|
minimum: 1,
|
|
253
258
|
maximum: 50,
|
|
254
|
-
description: "Number of
|
|
259
|
+
description: "Number of example games to return (default 10).",
|
|
255
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`." },
|
|
256
280
|
},
|
|
257
281
|
},
|
|
258
282
|
},
|
|
@@ -620,15 +644,16 @@ const TOOLS = [
|
|
|
620
644
|
},
|
|
621
645
|
{
|
|
622
646
|
name: "auto_evaluate",
|
|
623
|
-
description: "Walk the tree from `path` (default `[]` = whole file) and
|
|
624
|
-
"
|
|
625
|
-
"
|
|
647
|
+
description: "Walk the tree from `path` (default `[]` = whole file) and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every node via cloud_analyse. Requires a running cloud combo instance.\n\n" +
|
|
648
|
+
"**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 (novelty, sharp choice, real mistake, decisive advantage).\n\n" +
|
|
649
|
+
"On re-read every node with a stored eval carries `ceoEval: { sf: {cp, depth}, lc0: {cp, depth} }` — the numbers travel with the file. Use `only_missing=true` (default) to skip nodes already evaluated on repeat runs.\n\n" +
|
|
650
|
+
"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.",
|
|
626
651
|
inputSchema: {
|
|
627
652
|
type: "object",
|
|
628
653
|
properties: {
|
|
629
654
|
id: { type: "string" },
|
|
630
655
|
path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Subtree root (default = whole file)." },
|
|
631
|
-
only_missing: { type: "boolean", description: "Skip nodes that already carry a
|
|
656
|
+
only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
|
|
632
657
|
movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
|
|
633
658
|
expected_version: { type: "integer" },
|
|
634
659
|
},
|
|
@@ -911,8 +936,6 @@ async function autoEvaluate(args) {
|
|
|
911
936
|
const batchMutations = [];
|
|
912
937
|
for (const s of stored) {
|
|
913
938
|
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
939
|
}
|
|
917
940
|
const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
|
|
918
941
|
const sr = saveResult;
|
|
@@ -1038,6 +1061,103 @@ async function applyMutation(args, mutator) {
|
|
|
1038
1061
|
version: savedRow.version,
|
|
1039
1062
|
};
|
|
1040
1063
|
}
|
|
1064
|
+
// Strip cruft the LLM doesn't need from the DB-position response.
|
|
1065
|
+
// Called AFTER trimGamesMovetext so plyNumber survives long enough to
|
|
1066
|
+
// slice each game's movetext. Also renames the `transpositions` field
|
|
1067
|
+
// to something the LLM can parse without knowing chess-DB jargon.
|
|
1068
|
+
function stripPositionResponse(r) {
|
|
1069
|
+
if (!r || typeof r !== "object")
|
|
1070
|
+
return;
|
|
1071
|
+
const t = r;
|
|
1072
|
+
delete t.hash; // internal zobrist string
|
|
1073
|
+
delete t.source; // internal "database" marker; we overwrite with our own .source
|
|
1074
|
+
delete t.totalGames; // duplicates statistics.totalCount often; hasMore covers pagination
|
|
1075
|
+
if (Array.isArray(t.moves)) {
|
|
1076
|
+
for (const m of t.moves) {
|
|
1077
|
+
if (typeof m.transpositions === "number") {
|
|
1078
|
+
m.reachedViaTransposition = m.transpositions;
|
|
1079
|
+
delete m.transpositions;
|
|
1080
|
+
}
|
|
1081
|
+
// Backend calls it "hotness" — a 0-100 time-decayed popularity score
|
|
1082
|
+
// (recent + played often = high). Rename to something an LLM can read
|
|
1083
|
+
// without guessing it means "on a winning streak".
|
|
1084
|
+
if (typeof m.hotness === "number") {
|
|
1085
|
+
m.fashionScore = m.hotness;
|
|
1086
|
+
delete m.hotness;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (Array.isArray(t.games)) {
|
|
1091
|
+
for (const g of t.games) {
|
|
1092
|
+
delete g.gameId;
|
|
1093
|
+
delete g.whiteTitle;
|
|
1094
|
+
delete g.blackTitle;
|
|
1095
|
+
delete g.whiteTeam;
|
|
1096
|
+
delete g.blackTeam;
|
|
1097
|
+
delete g.round;
|
|
1098
|
+
delete g.plyNumber;
|
|
1099
|
+
delete g.relevance;
|
|
1100
|
+
delete g.site;
|
|
1101
|
+
delete g.ply;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// Trim every game's `moves` field to just the plies AFTER the queried
|
|
1106
|
+
// position, using each game's `plyNumber`. Massive token save — a game
|
|
1107
|
+
// 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
|
|
1108
|
+
// the frontend's GamesTable.getMoveDisplay() trim logic.
|
|
1109
|
+
function trimGamesMovetext(response) {
|
|
1110
|
+
if (!response || typeof response !== "object")
|
|
1111
|
+
return;
|
|
1112
|
+
const r = response;
|
|
1113
|
+
if (!Array.isArray(r.games))
|
|
1114
|
+
return;
|
|
1115
|
+
for (const g of r.games) {
|
|
1116
|
+
if (typeof g.moves === "string" && typeof g.plyNumber === "number" && g.plyNumber > 0) {
|
|
1117
|
+
g.moves = trimMovesToPly(g.moves, g.plyNumber);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function trimMovesToPly(moves, plyNumber) {
|
|
1122
|
+
// Split into plain SAN tokens, dropping standalone move-number tokens
|
|
1123
|
+
// ("1.", "12...") and any glued number prefix on a SAN token ("1.e4").
|
|
1124
|
+
// Result markers ("*", "1-0", "0-1", "1/2-1/2") are stripped so they
|
|
1125
|
+
// don't get counted as plies.
|
|
1126
|
+
const tokens = [];
|
|
1127
|
+
for (const chunk of moves.split(/\s+/)) {
|
|
1128
|
+
if (!chunk)
|
|
1129
|
+
continue;
|
|
1130
|
+
const cleaned = chunk.replace(/^\d+\.+/, "");
|
|
1131
|
+
if (!cleaned)
|
|
1132
|
+
continue;
|
|
1133
|
+
if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(cleaned))
|
|
1134
|
+
continue;
|
|
1135
|
+
tokens.push(cleaned);
|
|
1136
|
+
}
|
|
1137
|
+
const remaining = tokens.slice(plyNumber);
|
|
1138
|
+
if (remaining.length === 0)
|
|
1139
|
+
return "";
|
|
1140
|
+
// Reconstruct with move numbering. First move gets "N..." if it's
|
|
1141
|
+
// Black's move (starting the slice mid-move-pair), so the reader knows
|
|
1142
|
+
// moves were dropped.
|
|
1143
|
+
const out = [];
|
|
1144
|
+
let ply = plyNumber;
|
|
1145
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
1146
|
+
const san = remaining[i];
|
|
1147
|
+
const moveNumber = Math.floor(ply / 2) + 1;
|
|
1148
|
+
if (ply % 2 === 0) {
|
|
1149
|
+
out.push(`${moveNumber}. ${san}`);
|
|
1150
|
+
}
|
|
1151
|
+
else if (i === 0) {
|
|
1152
|
+
out.push(`${moveNumber}... ${san}`);
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
out.push(san);
|
|
1156
|
+
}
|
|
1157
|
+
ply++;
|
|
1158
|
+
}
|
|
1159
|
+
return out.join(" ");
|
|
1160
|
+
}
|
|
1041
1161
|
// Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
|
|
1042
1162
|
// endpoints return moves in UCI on the wire — same LLM-readability
|
|
1043
1163
|
// concern as engine PVs, and the same wrapper-only fix. Passes the
|
|
@@ -1154,25 +1274,38 @@ async function callToolInner(name, args) {
|
|
|
1154
1274
|
fetchCompactEval(effectiveFen),
|
|
1155
1275
|
]);
|
|
1156
1276
|
const converted = convertAvailableMovesToSAN(raw, effectiveFen);
|
|
1157
|
-
if (
|
|
1158
|
-
converted
|
|
1277
|
+
if (converted && typeof converted === "object") {
|
|
1278
|
+
trimGamesMovetext(converted);
|
|
1279
|
+
stripPositionResponse(converted);
|
|
1280
|
+
if (ev)
|
|
1281
|
+
converted.eval = ev;
|
|
1282
|
+
}
|
|
1159
1283
|
return converted;
|
|
1160
1284
|
}
|
|
1161
1285
|
case "get_position_stats": {
|
|
1162
1286
|
const fen = resolveFenFromArgs(args);
|
|
1287
|
+
const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
|
|
1288
|
+
const source = rawSource === "main" ? "main" : "gm-classical";
|
|
1163
1289
|
const [raw, ev] = await Promise.all([
|
|
1164
|
-
get(
|
|
1290
|
+
get(`/api/chess/database/${source}`, {
|
|
1165
1291
|
fen,
|
|
1166
|
-
limit: typeof args.limit === "number" ? args.limit :
|
|
1292
|
+
limit: typeof args.limit === "number" ? args.limit : 10,
|
|
1167
1293
|
sort: "relevance",
|
|
1168
1294
|
}),
|
|
1169
1295
|
fetchCompactEval(fen),
|
|
1170
1296
|
]);
|
|
1171
1297
|
const converted = convertAvailableMovesToSAN(raw, fen);
|
|
1172
|
-
if (
|
|
1173
|
-
converted
|
|
1298
|
+
if (converted && typeof converted === "object") {
|
|
1299
|
+
trimGamesMovetext(converted);
|
|
1300
|
+
stripPositionResponse(converted);
|
|
1301
|
+
converted.source = source;
|
|
1302
|
+
if (ev)
|
|
1303
|
+
converted.eval = ev;
|
|
1304
|
+
}
|
|
1174
1305
|
return converted;
|
|
1175
1306
|
}
|
|
1307
|
+
case "describe_position":
|
|
1308
|
+
return describePosition(resolveFenFromArgs(args));
|
|
1176
1309
|
case "predict_human_move": {
|
|
1177
1310
|
const fen = resolveFenFromArgs(args);
|
|
1178
1311
|
const qs = new URLSearchParams();
|
|
@@ -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/mutations.js
CHANGED
|
@@ -144,7 +144,7 @@ function validateSan(fen, san) {
|
|
|
144
144
|
const board = pos.value;
|
|
145
145
|
const move = parseSan(board, san);
|
|
146
146
|
if (!move) {
|
|
147
|
-
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.`);
|
|
148
148
|
}
|
|
149
149
|
return move;
|
|
150
150
|
}
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -23,7 +23,7 @@ Reading:
|
|
|
23
23
|
Building (use these — one call for many ops):
|
|
24
24
|
|
|
25
25
|
- **`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.
|
|
26
|
-
- **`auto_evaluate(id, path?)`** — walk the tree from `path` and
|
|
26
|
+
- **`auto_evaluate(id, path?)`** — walk the tree from `path` and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every node by running cloud_analyse on each position. **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, not an automatic mapping — see the section on NAG discipline below. 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
|
|
|
@@ -40,9 +40,9 @@ All mutations **auto-save** with optimistic locking. Response includes the new `
|
|
|
40
40
|
## Typical build order
|
|
41
41
|
|
|
42
42
|
1. `read_prep_file` — see what's there.
|
|
43
|
-
2. `apply_mutations([...])` — one call with your whole intended build (all `add_move` for the moves and variations, plus any `set_comment`/`set_annotations` you already know at author time
|
|
44
|
-
3. `auto_evaluate(id)` — walk the tree
|
|
45
|
-
4.
|
|
43
|
+
2. `apply_mutations([...])` — one call with your whole intended build (all `add_move` for the moves and variations, 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).
|
|
44
|
+
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.
|
|
45
|
+
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).
|
|
46
46
|
|
|
47
47
|
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.
|
|
48
48
|
|
|
@@ -122,7 +122,21 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
|
|
|
122
122
|
| `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
|
|
123
123
|
| Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
|
|
124
124
|
|
|
125
|
-
**
|
|
125
|
+
**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.
|
|
126
|
+
|
|
127
|
+
Where NAGs actually earn their place:
|
|
128
|
+
|
|
129
|
+
- **`$146` (novelty)** — a move that hasn't appeared at this level. Only set if you actually know this (e.g. saw 0 games via `get_position_stats`).
|
|
130
|
+
- **`$5 !?` (interesting/speculative)** — a committal move you're recommending anyway (a piece sac, a positional pawn concession).
|
|
131
|
+
- **`$1 !` / `$3 !!`** — objectively good moves the reader might miss.
|
|
132
|
+
- **`$2 ?` / `$4 ??` / `$6 ?!`** — mistakes in a sideline you're showing to reject.
|
|
133
|
+
- **`$14/$15` (⩲/⩱)** and **`$16/$17` (±/∓)** — assessments on positions where the direction of pressure matters for the plan you're teaching. Skip these on 0.00 theory nodes.
|
|
134
|
+
- **`$18/$19` (+−/−+)** — decisive advantages, use where the reader should recognise the position is winning.
|
|
135
|
+
- **`$44` (compensation)** on a genuine gambit; **`$13` (∞)** on a genuinely sharp/unclear position that engines don't resolve.
|
|
136
|
+
|
|
137
|
+
Where NAGs are noise: every `$10` "=" glyph on every equal position. If the whole tree is 0.00, that's the *default state* — leave it unmarked and the reader understands.
|
|
138
|
+
|
|
139
|
+
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.
|
|
126
140
|
|
|
127
141
|
**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
142
|
|
|
@@ -133,6 +147,27 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
|
|
|
133
147
|
|
|
134
148
|
**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.}`
|
|
135
149
|
|
|
150
|
+
**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.
|
|
151
|
+
|
|
152
|
+
**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:
|
|
153
|
+
|
|
154
|
+
- `{Lc0 (contempt −15): 0.00 — even biased toward Black, still balanced. SF objective: 0.00.}`
|
|
155
|
+
- `{With Lc0 nudged toward Black (contempt −15) it still picks Qg6 at 0.00; SF's objective read also 0.00.}`
|
|
156
|
+
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
### Before you write any commentary: describe_position
|
|
160
|
+
|
|
161
|
+
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:
|
|
162
|
+
|
|
163
|
+
- All piece placements per colour (SAN-style: `Nf3`, `Bg7`, `e4`)
|
|
164
|
+
- Material balance in pawn units (`"white +1 (39 vs 38)"`)
|
|
165
|
+
- Every contested piece: attackers + defenders (e.g. `{target: "e5", color: "black", attackers: ["Nf3"], defenders: ["Nc6"]}`)
|
|
166
|
+
- Hanging list — attacked and undefended
|
|
167
|
+
- Check state + checkers, castling rights, en passant
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
136
171
|
### When prose ADDS to the NAG
|
|
137
172
|
|
|
138
173
|
Prose is worth writing when the NAG *understates* something the human should know:
|
package/docs/prep-strategy.md
CHANGED
|
@@ -21,7 +21,7 @@ The failure mode is *authoritative-sounding recipe = 30% real tool output + 70%
|
|
|
21
21
|
|
|
22
22
|
## Numbers are inputs, not verdicts
|
|
23
23
|
|
|
24
|
-
The move statistics endpoints return win %, game counts, and (in the big DB)
|
|
24
|
+
The move statistics endpoints return win %, game counts, and (in the big DB) a `fashionScore` (0–100, how in-fashion the move is right now at the top level — recent + played often). Treat every one of these as a *weight*, not a *rule*.
|
|
25
25
|
|
|
26
26
|
- **Sample size scales trust.** 3 games at 66% is noise; 300 at 55% is signal. A great score is nice — with volume. When the opponent has only 2-4 games in a variation, the "opponent-specific" score is basically the general-DB score anyway.
|
|
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.
|
|
@@ -41,7 +41,7 @@ Every recommendation should be filtered through: "does this survive the fact tha
|
|
|
41
41
|
|
|
42
42
|
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
43
|
|
|
44
|
-
**Related product note (2026-07-23):** the `get_player_preparation` endpoint (compact / LLM view) deliberately strips the per-move `
|
|
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.
|
|
45
45
|
|
|
46
46
|
## Prep is a tree, not a line
|
|
47
47
|
|
|
@@ -68,7 +68,7 @@ Deep prep is a weapon you spend, not one you own. A prepared novelty against a s
|
|
|
68
68
|
|
|
69
69
|
## Move-order tricks aren't visible in raw win rates
|
|
70
70
|
|
|
71
|
-
The `
|
|
71
|
+
The `reachedViaTransposition` field on move statistics tells you how many of the games at that position arrived via a different move order — a hint at how much the line folds into related structures. Move-order tricks are legitimate prep and position stats alone can't recommend them.
|
|
72
72
|
|
|
73
73
|
- Considering 1.Nf3 or 1.c4 as a duck around 1.d4? First check the opponent's repertoire *against 1.d4*. If they play the King's Indian, most 1.Nf3/1.c4 lines transpose anyway — the trick doesn't help.
|
|
74
74
|
- Move orders are useful when the opponent plays something that specifically depends on the move order — e.g. a Nimzo player who never gets to play the Nimzo because you go 1.Nf3-2.g3.
|
package/package.json
CHANGED