@chessceo/mcp 0.26.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 +100 -9
- package/dist/pgn/describe.js +221 -0
- package/dist/pgn/mutations.js +1 -1
- package/docs/pgn-authoring.md +12 -0
- 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";
|
|
@@ -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,11 +256,30 @@ 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).",
|
|
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.",
|
|
255
265
|
},
|
|
256
266
|
},
|
|
257
267
|
},
|
|
258
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`." },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
259
283
|
{
|
|
260
284
|
name: "predict_human_move",
|
|
261
285
|
description: "Predicts what a HUMAN of the given rating will most likely play from a position. Rating-conditioned neural net (ResNet-20x256) — you get the top-N most likely moves with their probabilities and a WDL value head (White POV).\n\n" +
|
|
@@ -1038,6 +1062,62 @@ async function applyMutation(args, mutator) {
|
|
|
1038
1062
|
version: savedRow.version,
|
|
1039
1063
|
};
|
|
1040
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
|
+
}
|
|
1041
1121
|
// Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
|
|
1042
1122
|
// endpoints return moves in UCI on the wire — same LLM-readability
|
|
1043
1123
|
// concern as engine PVs, and the same wrapper-only fix. Passes the
|
|
@@ -1154,25 +1234,36 @@ async function callToolInner(name, args) {
|
|
|
1154
1234
|
fetchCompactEval(effectiveFen),
|
|
1155
1235
|
]);
|
|
1156
1236
|
const converted = convertAvailableMovesToSAN(raw, effectiveFen);
|
|
1157
|
-
if (
|
|
1158
|
-
converted
|
|
1237
|
+
if (converted && typeof converted === "object") {
|
|
1238
|
+
trimGamesMovetext(converted);
|
|
1239
|
+
if (ev)
|
|
1240
|
+
converted.eval = ev;
|
|
1241
|
+
}
|
|
1159
1242
|
return converted;
|
|
1160
1243
|
}
|
|
1161
1244
|
case "get_position_stats": {
|
|
1162
1245
|
const fen = resolveFenFromArgs(args);
|
|
1246
|
+
const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
|
|
1247
|
+
const source = rawSource === "main" ? "main" : "gm-classical";
|
|
1163
1248
|
const [raw, ev] = await Promise.all([
|
|
1164
|
-
get(
|
|
1249
|
+
get(`/api/chess/database/${source}`, {
|
|
1165
1250
|
fen,
|
|
1166
|
-
limit: typeof args.limit === "number" ? args.limit :
|
|
1251
|
+
limit: typeof args.limit === "number" ? args.limit : 10,
|
|
1167
1252
|
sort: "relevance",
|
|
1168
1253
|
}),
|
|
1169
1254
|
fetchCompactEval(fen),
|
|
1170
1255
|
]);
|
|
1171
1256
|
const converted = convertAvailableMovesToSAN(raw, fen);
|
|
1172
|
-
if (
|
|
1173
|
-
converted
|
|
1257
|
+
if (converted && typeof converted === "object") {
|
|
1258
|
+
trimGamesMovetext(converted);
|
|
1259
|
+
converted.source = source;
|
|
1260
|
+
if (ev)
|
|
1261
|
+
converted.eval = ev;
|
|
1262
|
+
}
|
|
1174
1263
|
return converted;
|
|
1175
1264
|
}
|
|
1265
|
+
case "describe_position":
|
|
1266
|
+
return describePosition(resolveFenFromArgs(args));
|
|
1176
1267
|
case "predict_human_move": {
|
|
1177
1268
|
const fen = resolveFenFromArgs(args);
|
|
1178
1269
|
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
|
@@ -133,6 +133,18 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
|
|
|
133
133
|
|
|
134
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.}`
|
|
135
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
|
+
|
|
136
148
|
### When prose ADDS to the NAG
|
|
137
149
|
|
|
138
150
|
Prose is worth writing when the NAG *understates* something the human should know:
|
package/package.json
CHANGED