@chessceo/mcp 0.36.4 → 0.39.1
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 +529 -105
- package/dist/pgn/describe.js +268 -2
- package/docs/engine-usage.md +38 -0
- package/docs/pgn-authoring.md +30 -10
- package/docs/prep-files-guide.md +4 -0
- package/docs/prep-strategy.md +26 -1
- package/package.json +1 -1
- package/tools/fenfind/fenfind.py +30 -2
- package/tools/fenfind/readpgn +11 -0
- package/tools/fenfind/readpgn.py +199 -0
- package/tools/sf_eval/sf_eval +4 -0
- package/tools/sf_eval/sf_eval.py +136 -0
package/dist/pgn/describe.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// cloud_analyse.
|
|
11
11
|
import { parseFen } from "chessops/fen";
|
|
12
12
|
import { Chess } from "chessops/chess";
|
|
13
|
-
import { attacks } from "chessops/attacks";
|
|
14
|
-
import { makeSquare, squareRank } from "chessops/util";
|
|
13
|
+
import { attacks, pawnAttacks } from "chessops/attacks";
|
|
14
|
+
import { makeSquare, squareRank, squareFile } from "chessops/util";
|
|
15
15
|
import { makeSan } from "chessops/san";
|
|
16
|
+
import { SquareSet } from "chessops/squareSet";
|
|
16
17
|
const ROLE_LETTER = {
|
|
17
18
|
pawn: "", knight: "N", bishop: "B", rook: "R", queen: "Q", king: "K",
|
|
18
19
|
};
|
|
@@ -99,6 +100,7 @@ export function describePosition(fen) {
|
|
|
99
100
|
for (const sq of ctx.checkers)
|
|
100
101
|
checkers.push(pieceSANFromBoard(board, sq));
|
|
101
102
|
const legalMoves = generateLegalMoves(pos);
|
|
103
|
+
const structural = computeStructural(pos);
|
|
102
104
|
return {
|
|
103
105
|
fen,
|
|
104
106
|
sideToMove: pos.turn,
|
|
@@ -113,11 +115,275 @@ export function describePosition(fen) {
|
|
|
113
115
|
pieces: { white: whitePieces, black: blackPieces },
|
|
114
116
|
contested,
|
|
115
117
|
hanging: { white: hangingWhite, black: hangingBlack },
|
|
118
|
+
pawnStructure: structural.pawnStructure,
|
|
119
|
+
weakSquares: structural.weakSquares,
|
|
120
|
+
outposts: structural.outposts,
|
|
121
|
+
bishops: structural.bishops,
|
|
122
|
+
space: structural.space,
|
|
116
123
|
castling: { white: castlingW, black: castlingB },
|
|
117
124
|
epSquare: typeof pos.epSquare === "number" ? makeSquare(pos.epSquare) : null,
|
|
118
125
|
legalMoves,
|
|
119
126
|
};
|
|
120
127
|
}
|
|
128
|
+
// ── Structural analysis ──────────────────────────────────────────────
|
|
129
|
+
// Concepts a human sees at a glance but the LLM cannot derive from a
|
|
130
|
+
// FEN. All chess-primitive computations on top of chessops bitboards;
|
|
131
|
+
// no engine.
|
|
132
|
+
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
|
133
|
+
// Which files are occupied by pawns of each colour (bitmask, 8 bits).
|
|
134
|
+
function pawnFilesMask(pos, color) {
|
|
135
|
+
let mask = 0;
|
|
136
|
+
for (const sq of pos.board.pawn.intersect(pos.board[color])) {
|
|
137
|
+
mask |= 1 << squareFile(sq);
|
|
138
|
+
}
|
|
139
|
+
return mask;
|
|
140
|
+
}
|
|
141
|
+
function computeStructural(pos) {
|
|
142
|
+
const whitePawns = pos.board.pawn.intersect(pos.board.white);
|
|
143
|
+
const blackPawns = pos.board.pawn.intersect(pos.board.black);
|
|
144
|
+
const wFileMask = pawnFilesMask(pos, "white");
|
|
145
|
+
const bFileMask = pawnFilesMask(pos, "black");
|
|
146
|
+
// File status per column.
|
|
147
|
+
const files = {};
|
|
148
|
+
for (let f = 0; f < 8; f++) {
|
|
149
|
+
const wHas = (wFileMask & (1 << f)) !== 0;
|
|
150
|
+
const bHas = (bFileMask & (1 << f)) !== 0;
|
|
151
|
+
if (!wHas && !bHas)
|
|
152
|
+
files[FILES[f]] = "open";
|
|
153
|
+
else if (!bHas && wHas)
|
|
154
|
+
files[FILES[f]] = "half_open_for_black"; // black has no pawn → open for black's rooks
|
|
155
|
+
else if (!wHas && bHas)
|
|
156
|
+
files[FILES[f]] = "half_open_for_white";
|
|
157
|
+
else
|
|
158
|
+
files[FILES[f]] = "closed";
|
|
159
|
+
}
|
|
160
|
+
// Islands: count runs of consecutive occupied files.
|
|
161
|
+
const countIslands = (mask) => {
|
|
162
|
+
let count = 0, prev = false;
|
|
163
|
+
for (let f = 0; f < 8; f++) {
|
|
164
|
+
const has = (mask & (1 << f)) !== 0;
|
|
165
|
+
if (has && !prev)
|
|
166
|
+
count++;
|
|
167
|
+
prev = has;
|
|
168
|
+
}
|
|
169
|
+
return count;
|
|
170
|
+
};
|
|
171
|
+
// Isolated pawns: own pawn with no friend on adjacent files.
|
|
172
|
+
const isolated = (color) => {
|
|
173
|
+
const mask = color === "white" ? wFileMask : bFileMask;
|
|
174
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const sq of own) {
|
|
177
|
+
const f = squareFile(sq);
|
|
178
|
+
const left = f > 0 && ((mask >> (f - 1)) & 1);
|
|
179
|
+
const right = f < 7 && ((mask >> (f + 1)) & 1);
|
|
180
|
+
if (!left && !right)
|
|
181
|
+
out.push(makeSquare(sq));
|
|
182
|
+
}
|
|
183
|
+
return out.sort();
|
|
184
|
+
};
|
|
185
|
+
// Doubled: files with >1 own pawn.
|
|
186
|
+
const doubled = (color) => {
|
|
187
|
+
const counts = {};
|
|
188
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
189
|
+
for (const sq of own) {
|
|
190
|
+
const f = squareFile(sq);
|
|
191
|
+
counts[f] = (counts[f] || 0) + 1;
|
|
192
|
+
}
|
|
193
|
+
return Object.entries(counts).filter(([, c]) => c > 1).map(([f]) => FILES[Number(f)]).sort();
|
|
194
|
+
};
|
|
195
|
+
// Passed pawns: no enemy pawn on same or adjacent file ahead of it.
|
|
196
|
+
const passed = (color) => {
|
|
197
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
198
|
+
const enemyMask = color === "white" ? bFileMask : wFileMask;
|
|
199
|
+
const enemyPawns = color === "white" ? blackPawns : whitePawns;
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const sq of own) {
|
|
202
|
+
const f = squareFile(sq);
|
|
203
|
+
const r = squareRank(sq);
|
|
204
|
+
let blocked = false;
|
|
205
|
+
for (const ef of [f - 1, f, f + 1]) {
|
|
206
|
+
if (ef < 0 || ef > 7)
|
|
207
|
+
continue;
|
|
208
|
+
if (((enemyMask >> ef) & 1) === 0)
|
|
209
|
+
continue;
|
|
210
|
+
// any enemy pawn on this file ahead of us?
|
|
211
|
+
for (const esq of enemyPawns) {
|
|
212
|
+
if (squareFile(esq) !== ef)
|
|
213
|
+
continue;
|
|
214
|
+
const er = squareRank(esq);
|
|
215
|
+
if (color === "white" ? er > r : er < r) {
|
|
216
|
+
blocked = true;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (blocked)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
if (!blocked)
|
|
224
|
+
out.push(makeSquare(sq));
|
|
225
|
+
}
|
|
226
|
+
return out.sort();
|
|
227
|
+
};
|
|
228
|
+
// Backward: own pawn attacked by enemy pawn, unable to safely advance
|
|
229
|
+
// (advance square attacked by enemy pawn and undefended by own pawn).
|
|
230
|
+
const backward = (color) => {
|
|
231
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
232
|
+
const dir = color === "white" ? 8 : -8;
|
|
233
|
+
const enemyColor = color === "white" ? "black" : "white";
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const sq of own) {
|
|
236
|
+
const advanceSq = (sq + dir);
|
|
237
|
+
if (advanceSq < 0 || advanceSq > 63)
|
|
238
|
+
continue;
|
|
239
|
+
// Attacked by enemy pawn?
|
|
240
|
+
const enemyAttackers = pawnAttacks(enemyColor, advanceSq).intersect(pos.board.pawn).intersect(pos.board[enemyColor]);
|
|
241
|
+
if (enemyAttackers.isEmpty())
|
|
242
|
+
continue;
|
|
243
|
+
// Defended by own pawn?
|
|
244
|
+
const ownDefenders = pawnAttacks(color, advanceSq).intersect(pos.board.pawn).intersect(pos.board[color]);
|
|
245
|
+
if (ownDefenders.nonEmpty())
|
|
246
|
+
continue;
|
|
247
|
+
out.push(makeSquare(sq));
|
|
248
|
+
}
|
|
249
|
+
return out.sort();
|
|
250
|
+
};
|
|
251
|
+
// Weak squares (holes): squares in ranks 3-6 that no friendly pawn can
|
|
252
|
+
// ever attack. For White the "own attack" is a pawn on rank-1 diagonal
|
|
253
|
+
// behind (files ±1). We check every square in the middle four ranks.
|
|
254
|
+
const weakSquares = (color) => {
|
|
255
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
256
|
+
const ownMask = color === "white" ? wFileMask : bFileMask;
|
|
257
|
+
const out = [];
|
|
258
|
+
const rankRange = color === "white" ? [3, 4, 5] : [2, 3, 4]; // 0-indexed 4,5,6 / 3,4,5 in real ranks
|
|
259
|
+
for (const r of rankRange) {
|
|
260
|
+
for (let f = 0; f < 8; f++) {
|
|
261
|
+
const sq = (r * 8 + f);
|
|
262
|
+
// Any friendly pawn on adjacent files that could ever attack this square?
|
|
263
|
+
// For White, an attack on square (f,r) comes from (f±1, r-1);
|
|
264
|
+
// any pawn on those adjacent files with rank < r could eventually reach.
|
|
265
|
+
let canBeAttacked = false;
|
|
266
|
+
for (const df of [-1, 1]) {
|
|
267
|
+
const nf = f + df;
|
|
268
|
+
if (nf < 0 || nf > 7)
|
|
269
|
+
continue;
|
|
270
|
+
if (((ownMask >> nf) & 1) === 0)
|
|
271
|
+
continue;
|
|
272
|
+
// Any pawn on this file at a rank behind the target?
|
|
273
|
+
for (const psq of own) {
|
|
274
|
+
if (squareFile(psq) !== nf)
|
|
275
|
+
continue;
|
|
276
|
+
const pr = squareRank(psq);
|
|
277
|
+
if (color === "white" ? pr < r : pr > r) {
|
|
278
|
+
canBeAttacked = true;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (canBeAttacked)
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
if (!canBeAttacked)
|
|
286
|
+
out.push(makeSquare(sq));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out.sort();
|
|
290
|
+
};
|
|
291
|
+
const wWeak = weakSquares("white");
|
|
292
|
+
const bWeak = weakSquares("black");
|
|
293
|
+
const wWeakSet = new Set(wWeak);
|
|
294
|
+
const bWeakSet = new Set(bWeak);
|
|
295
|
+
// Outposts: friendly N/B on a hole (WEAKNESS of the enemy) in enemy
|
|
296
|
+
// territory, defended by an own pawn.
|
|
297
|
+
const outposts = (color) => {
|
|
298
|
+
const enemyWeak = color === "white" ? bWeakSet : wWeakSet;
|
|
299
|
+
const own = pos.board[color];
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const sq of own) {
|
|
302
|
+
const piece = pos.board.get(sq);
|
|
303
|
+
if (!piece || (piece.role !== "knight" && piece.role !== "bishop"))
|
|
304
|
+
continue;
|
|
305
|
+
const sqName = makeSquare(sq);
|
|
306
|
+
if (!enemyWeak.has(sqName))
|
|
307
|
+
continue;
|
|
308
|
+
// Defended by own pawn?
|
|
309
|
+
const enemyColor = color === "white" ? "black" : "white";
|
|
310
|
+
// A pawn defending sq for `color` = pawn one rank behind on adjacent file.
|
|
311
|
+
const defenders = pawnAttacks(enemyColor, sq).intersect(pos.board.pawn).intersect(pos.board[color]);
|
|
312
|
+
if (defenders.isEmpty())
|
|
313
|
+
continue;
|
|
314
|
+
out.push({ piece: ROLE_LETTER_INV[piece.role], square: sqName });
|
|
315
|
+
}
|
|
316
|
+
return out.sort((a, b) => a.square.localeCompare(b.square));
|
|
317
|
+
};
|
|
318
|
+
// Bishops: quality based on own pawns on the bishop's colour.
|
|
319
|
+
const bishopColor = (sq) => {
|
|
320
|
+
// a1 is dark, b1 is light. (file+rank) even = dark.
|
|
321
|
+
return (squareFile(sq) + squareRank(sq)) % 2 === 0 ? "dark" : "light";
|
|
322
|
+
};
|
|
323
|
+
const bishops = (color) => {
|
|
324
|
+
const own = pos.board.bishop.intersect(pos.board[color]);
|
|
325
|
+
const ownPawns = color === "white" ? whitePawns : blackPawns;
|
|
326
|
+
let pawnsLight = 0, pawnsDark = 0;
|
|
327
|
+
for (const p of ownPawns) {
|
|
328
|
+
if (bishopColor(p) === "light")
|
|
329
|
+
pawnsLight++;
|
|
330
|
+
else
|
|
331
|
+
pawnsDark++;
|
|
332
|
+
}
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const sq of own) {
|
|
335
|
+
const c = bishopColor(sq);
|
|
336
|
+
const onColor = c === "light" ? pawnsLight : pawnsDark;
|
|
337
|
+
const q = onColor <= 3 ? "good" : onColor >= 5 ? "bad" : "mixed";
|
|
338
|
+
out.push({ square: makeSquare(sq), colorSquare: c, ownPawnsOnColor: onColor, quality: q });
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
};
|
|
342
|
+
// Space: squares in the enemy half controlled by side's pieces + pawns.
|
|
343
|
+
const space = (color) => {
|
|
344
|
+
const attackedByColor = SquareSet.empty();
|
|
345
|
+
let acc = attackedByColor;
|
|
346
|
+
for (const sq of pos.board[color]) {
|
|
347
|
+
const piece = pos.board.get(sq);
|
|
348
|
+
acc = acc.union(attacks(piece, sq, pos.board.occupied));
|
|
349
|
+
}
|
|
350
|
+
// Enemy half: white attacks ranks 5-8 (rank index 4-7); black attacks 1-4 (0-3).
|
|
351
|
+
let count = 0;
|
|
352
|
+
for (const sq of acc) {
|
|
353
|
+
const r = squareRank(sq);
|
|
354
|
+
if (color === "white" && r >= 4)
|
|
355
|
+
count++;
|
|
356
|
+
else if (color === "black" && r <= 3)
|
|
357
|
+
count++;
|
|
358
|
+
}
|
|
359
|
+
return count;
|
|
360
|
+
};
|
|
361
|
+
return {
|
|
362
|
+
pawnStructure: {
|
|
363
|
+
files,
|
|
364
|
+
islandsWhite: countIslands(wFileMask),
|
|
365
|
+
islandsBlack: countIslands(bFileMask),
|
|
366
|
+
isolated: { white: isolated("white"), black: isolated("black") },
|
|
367
|
+
doubled: { white: doubled("white"), black: doubled("black") },
|
|
368
|
+
passed: { white: passed("white"), black: passed("black") },
|
|
369
|
+
backward: { white: backward("white"), black: backward("black") },
|
|
370
|
+
},
|
|
371
|
+
weakSquares: { white: wWeak, black: bWeak },
|
|
372
|
+
outposts: { white: outposts("white"), black: outposts("black") },
|
|
373
|
+
bishops: {
|
|
374
|
+
white: bishops("white"),
|
|
375
|
+
black: bishops("black"),
|
|
376
|
+
pair: {
|
|
377
|
+
white: pos.board.bishop.intersect(pos.board.white).size() >= 2,
|
|
378
|
+
black: pos.board.bishop.intersect(pos.board.black).size() >= 2,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
space: {
|
|
382
|
+
whiteSquaresInBlackHalf: space("white"),
|
|
383
|
+
blackSquaresInWhiteHalf: space("black"),
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
121
387
|
// All legal moves for the side to move, in SAN. Sorted for stable output.
|
|
122
388
|
// Handles promotion by expanding each pawn-promotion destination into
|
|
123
389
|
// the four promotion pieces (=Q, =R, =B, =N).
|
package/docs/engine-usage.md
CHANGED
|
@@ -66,6 +66,27 @@ Trust Lc0 for:
|
|
|
66
66
|
- Lc0 higher: probably a long-term positional factor beyond Stockfish's horizon
|
|
67
67
|
- Never dismiss either engine — the disagreement itself is the signal
|
|
68
68
|
|
|
69
|
+
## 0.00 is not "dead draw"
|
|
70
|
+
|
|
71
|
+
The single most common misread of an engine number. **0.00 means "objectively balanced with optimal play from both sides".** It says nothing about how often the position actually draws over the board — a sharp middlegame with imbalances and only-moves can sit at 0.00 for 40 plies and be a lively fighting game.
|
|
72
|
+
|
|
73
|
+
An eval quantifies WHO STANDS BETTER, not HOW LIKELY A DRAW IS. The two questions are independent.
|
|
74
|
+
|
|
75
|
+
- **A wrong reasoning pattern** (documented failure): the LLM sees `SF 0.00, depth 58, 6.6B nodes` on a Bg5 sideline, concludes "actually a dead draw", and marks the line `?!` as a dead end. Wrong. All that eval says is neither side is objectively better with optimal play — the practical drawing chance is a separate question the SF number doesn't answer.
|
|
76
|
+
|
|
77
|
+
Signals that actually correlate with high drawing chance:
|
|
78
|
+
|
|
79
|
+
- **Forced repetition visible in the PV** (`Nf3 Nf6 Ng1 Ng8 …`) — that's a draw, not the eval.
|
|
80
|
+
- **Reduced material + 0.00** — opposite-coloured bishops, queen + rook endgames with balanced pawns, king-and-pawn structures with no breakthrough. The material context makes the draw likely, not the eval.
|
|
81
|
+
- **Symmetrical structures with no imbalances and no plan for either side** — engine says 0.00, humans agree by move 25.
|
|
82
|
+
|
|
83
|
+
If you actually want to estimate PRACTICAL drawing chance, ask the tools that answer that question:
|
|
84
|
+
|
|
85
|
+
- **`predict_human_move.wdlWhitePov`** — game-outcome prediction from the neural net, rating-conditioned. The `draw` component IS the answer to "how often does this position draw at this level".
|
|
86
|
+
- **Lc0's practical eval** — often diverges from SF at 0.00 to say "still slightly better for White in a real game". That divergence is where the practical winning chance hides.
|
|
87
|
+
|
|
88
|
+
**Never conflate the objective eval with the practical outcome.** A 0.00 middlegame with a piece imbalance, opposite-side attacks, or a rating gap is a fighting game; the number just told you nobody has a forced win.
|
|
89
|
+
|
|
69
90
|
## Lc0 contempt
|
|
70
91
|
|
|
71
92
|
Contempt is an Lc0 option that skews its evaluation and move choice toward one side. Passing `contempt` to `cloud_analyse` sets it on the Lc0 leg only; Stockfish always analyzes objectively.
|
|
@@ -103,6 +124,23 @@ The gap (1.4 - 0.2 = 1.2) is roughly the value of the tempo Black has to spend d
|
|
|
103
124
|
|
|
104
125
|
**Gotcha:** flipping side-to-move invalidates the en passant target field (if the last move was a two-square pawn push, the ep square is now stale). Also, both sides are counted as still having whichever castling rights are in the FEN — don't do this in the middle of a castling sequence. For opening / middlegame threat-checking it's a very useful idiom.
|
|
105
126
|
|
|
127
|
+
## Why the position is what it is: `describe_position.engineEvalTerms`
|
|
128
|
+
|
|
129
|
+
`describe_position` returns Stockfish's classical eval decomposed into 13 named terms (Material, Imbalance, Pawns, Knights, Bishops, Rooks, Queens, Mobility, King safety, Threats, Passed, Space, Winnable) alongside the chess-primitive observations. Each term carries white / black / total values in middlegame + endgame. This is how you answer "WHY is Black better here" beyond pointing at `-0.35`.
|
|
130
|
+
|
|
131
|
+
Primary pattern — **the delta**. Call `describe_position` on the position BEFORE and AFTER a candidate move, compare `engineEvalTerms`; the term with the biggest shift tells you what the move CHANGED. Kim et al. NAACL 2025 showed feeding these named deltas to an LLM roughly doubles chess-commentary correctness vs a bare eval number. Example:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
before 12.Nd5: king_safety = +0.10, mobility = -0.05, threats = 0.00
|
|
135
|
+
after 12.Nd5: king_safety = +0.40, mobility = +0.30, threats = +0.55
|
|
136
|
+
→ the delta is dominated by threats + king safety
|
|
137
|
+
→ commentary: "12.Nd5 lands an outpost that both eyes the kingside (threats) and improves piece coordination (mobility)"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Absolute values are useful on their own too — a large "King safety" term flags an exposed king regardless of what caused it.
|
|
141
|
+
|
|
142
|
+
Complements `cloud_analyse` (best move + PV): different questions, two angles on the same position.
|
|
143
|
+
|
|
106
144
|
## Deep Stockfish thinks: `deep_analyse`
|
|
107
145
|
|
|
108
146
|
`cloud_analyse` runs both engines with a movetime cap of 10 s — deliberately fast because most opening-tree questions are answered in 2–3 s. For **one specific critical position** where you want depth 35+ instead of the usual depth 22, use `deep_analyse`:
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -40,7 +40,8 @@ All mutations **auto-save** with optimistic locking. Response includes the new `
|
|
|
40
40
|
|
|
41
41
|
## Typical build order
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
0. **`read_example_prep_files`** — do this once per session, before writing any prose. Not optional. Log analysis shows most sessions skip this and produce documented anti-patterns (long PVs in prose, restating what the app renders, verbose citations). Reading the two bundled reference files once inoculates the LLM against those.
|
|
44
|
+
1. `read_prep_file` — see what's there. Every node has an `id` you'll pass to the mutation and engine/DB tools. Use `view: "compact"` (default) plus `node_id` + `max_depth` to scope; the full tree of a 500+-node file can blow the token limit.
|
|
44
45
|
2. `apply_mutations([...])` — one call with your whole intended build (a mix of `add_move` / `add_line` for structure, 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).
|
|
45
46
|
3. `auto_evaluate(id)` — spawns a background job that PERSISTS 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. Grab the returned `job_id` and either (a) poll `auto_evaluate_status(job_id)` every ~10-30s until done, or (b) fire and do useful work meanwhile (write more of the tree, walk the opponent's repertoire) and check back later — engine walk-time serialises on the per-combo semaphore, so it takes roughly `target_count × movetime_ms` in wall time.
|
|
46
47
|
4. Once the job reports `done:true`, 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).
|
|
@@ -240,15 +241,22 @@ Contempt scale is signed 0-100 (same as the web UI's ContemptStrength slider). T
|
|
|
240
241
|
|
|
241
242
|
### Before you write any commentary: describe_position
|
|
242
243
|
|
|
243
|
-
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)`.
|
|
244
|
+
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)`. Pure computation (~1 ms, no engine), returns two layers:
|
|
244
245
|
|
|
245
|
-
|
|
246
|
-
- Material balance in pawn units (`"white +1 (39 vs 38)"`)
|
|
247
|
-
- Every contested piece: attackers + defenders (e.g. `{target: "e5", color: "black", attackers: ["Nf3"], defenders: ["Nc6"]}`)
|
|
248
|
-
- Hanging list — attacked and undefended
|
|
249
|
-
- Check state + checkers, castling rights, en passant
|
|
246
|
+
**Board state** — piece placements, material, contested pieces (attackers + defenders), hanging list, check state, castling, en passant, legal moves. Fixes the *"Black's queen on c7 is defended by the knight on d5"* failure when actually there's no knight on d5 and the queen is on c8.
|
|
250
247
|
|
|
251
|
-
|
|
248
|
+
**Structural analysis** — the concepts a strong player reads at a glance but the LLM can't derive from a FEN:
|
|
249
|
+
|
|
250
|
+
- `pawnStructure.files` — open / half-open / closed per column (rook targets).
|
|
251
|
+
- `pawnStructure.isolated`, `doubled`, `passed`, `backward` — structural weaknesses (and strengths, for passed).
|
|
252
|
+
- `weakSquares` — holes no friendly pawn can ever attack.
|
|
253
|
+
- `outposts` — friendly N/B on an enemy hole defended by own pawn.
|
|
254
|
+
- `bishops` — good / mixed / bad per bishop, based on own pawns on its colour. `bishops.pair` flags who has both.
|
|
255
|
+
- `space` — squares in the enemy half controlled by each side.
|
|
256
|
+
|
|
257
|
+
Use these instead of inventing structural claims. Commentary that says *"Black has the bad light-squared bishop"* should trace to `bishops.black[0].quality === "bad"`, not to a memory of similar Carlsbad positions.
|
|
258
|
+
|
|
259
|
+
The same call also returns `engineEvalTerms` — Stockfish's classical-eval breakdown into 13 named terms per colour (mg/eg). Compare it before/after a candidate move: the term that shifts most tells you WHAT the move changed. See the engine-usage guide for the delta pattern.
|
|
252
260
|
|
|
253
261
|
### When prose ADDS to the NAG
|
|
254
262
|
|
|
@@ -274,7 +282,19 @@ set_nags(id, path, ["$14"])
|
|
|
274
282
|
|
|
275
283
|
## Visual annotations (arrows + coloured squares)
|
|
276
284
|
|
|
277
|
-
|
|
285
|
+
**Use them.** An arrow says what a sentence would. If a comment mentions a move, a plan, a square, a target — draw it. The reader sees the board; a green f2→f4 arrow replaces "White's plan is to break with f4"; a red square on h6 replaces "Black's weak point is h6". Annotations are the fastest LLM→reader channel we have.
|
|
286
|
+
|
|
287
|
+
**When to reach for one (any of these triggers → add):**
|
|
288
|
+
|
|
289
|
+
- Naming a pawn break in prose → arrow from the pawn to its target square (`green` for your break, `red` for the opponent's).
|
|
290
|
+
- Naming a plan involving specific moves (castle long, Nc4-a5, Bxh6) → arrow(s) for the piece routes.
|
|
291
|
+
- Naming a key square / weak square / target → highlight (`green` = key square for you, `red` = weak square you exploit or that Black attacks).
|
|
292
|
+
- Naming a piece under pressure or a piece that will become the target → highlight the square it's on.
|
|
293
|
+
- Showing multiple candidate moves in one comment → arrow per candidate, different colours.
|
|
294
|
+
|
|
295
|
+
If the comment could be *replaced* by two arrows and a highlighted square, then the arrows + squares alone are better and the prose is redundant. Try that direction first.
|
|
296
|
+
|
|
297
|
+
`set_annotations(id, node_id, {arrows, highlights})` sets both together — atomic replacement, pass both current and new. Empty arrays clear.
|
|
278
298
|
|
|
279
299
|
Colours (both arrows and squares): `green`, `red`, `yellow`, `light-blue`, `dark-blue`, `orange`.
|
|
280
300
|
|
|
@@ -289,7 +309,7 @@ Usage conventions:
|
|
|
289
309
|
| dark-blue | Alternative / secondary idea |
|
|
290
310
|
| orange | Attention / warning |
|
|
291
311
|
|
|
292
|
-
**Keep it light.** 1–3 arrows per node and 2–3 highlighted squares is plenty. Twenty arrows is noise, not
|
|
312
|
+
**Keep it light.** 1–3 arrows per node and 2–3 highlighted squares is plenty. Twenty arrows is noise. Highlights are labels, not decoration. **But zero arrows on a strategic branch-point comment is usually a miss** — if the comment names a plan, a break, a square, or a piece, the corresponding annotation almost certainly exists and is worth adding.
|
|
293
313
|
|
|
294
314
|
Example:
|
|
295
315
|
```
|
package/docs/prep-files-guide.md
CHANGED
|
@@ -31,6 +31,10 @@ Every mutation call takes a `node_id` (or `parent_id` for add-style ops) and aut
|
|
|
31
31
|
|
|
32
32
|
Duplicate files are the #1 way to lose your user's trust in this system.
|
|
33
33
|
|
|
34
|
+
## Before writing any prose: read the examples
|
|
35
|
+
|
|
36
|
+
**Call `read_example_prep_files` early in the session, before your first `set_comment` or comment-carrying `apply_mutations` batch.** Not once per project — once per LLM session. It returns two reference PGNs by a strong human coach and is the single biggest quality lift documented in this MCP; log analysis shows <5% of sessions call it and the ones that don't produce the exact anti-patterns the authoring guide warns against. Reading the two files takes one tool call and inoculates against most commentary failure modes.
|
|
37
|
+
|
|
34
38
|
## PGN authoring — separate concern
|
|
35
39
|
|
|
36
40
|
Everything about **how to write good PGN** (structure, NAGs, arrows, coloured squares, variation discipline, common pitfalls) lives in `pgn-authoring.md` — call `read_pgn_authoring_guide` for the full doc. That guide is universal to any chess file you might write; this guide is about the prep-files *feature* on top.
|
package/docs/prep-strategy.md
CHANGED
|
@@ -80,6 +80,30 @@ For a rare position where the opponent has 0–2 games as their side, it's often
|
|
|
80
80
|
|
|
81
81
|
Do this by dropping the `color` filter on `prepare_opponent`, or making a second session with the opposite colour — then compare game counts. When density is scarce, information from the "wrong" colour is worth more than no information at all. Just be honest about what it is: knowledge, not prediction.
|
|
82
82
|
|
|
83
|
+
## Using the user's course library (`find_position_in_courses` + `read_course_at_position`)
|
|
84
|
+
|
|
85
|
+
The user has a private index of their own Chessable / PGN files (dozens of GB of prep material). This is a **reference library, not memory** — nobody remembers what's in it, but the LLM can query on demand and cite specific chapters.
|
|
86
|
+
|
|
87
|
+
Two-tool workflow:
|
|
88
|
+
|
|
89
|
+
1. **`find_position_in_courses(fen)`** → metadata: which courses cover this position, ranked most-recently-updated first (10-year-old material is less trustworthy than 2-month-old). Each hit has a `course_file_id`.
|
|
90
|
+
2. **`read_course_at_position({course_file_id, fen})`** → the actual commentary, variations, arrows from that specific chapter. Depth-bounded (`max_plies_below`); widen or call again with a different `fen` to explore further.
|
|
91
|
+
|
|
92
|
+
Query patterns worth reaching for:
|
|
93
|
+
|
|
94
|
+
- **"Does my chosen line have coverage?"** — search from the target position. If several recent courses cover it, read the top hit for background before writing prep.
|
|
95
|
+
- **"What do opposite-colour repertoires recommend against this move?"** — same position, look at hits authored for the OTHER side. That's the LLM's window into "what will opponents have been told to play here".
|
|
96
|
+
- **"Has anyone tried my novelty before?"** — search the post-novelty position. Zero hits = genuine novelty (good). Hits = it's been tried; read what happened.
|
|
97
|
+
- **"Compare how two authors annotate the same critical position"** — two `read_course_at_position` calls with different `course_file_id`s.
|
|
98
|
+
|
|
99
|
+
Discipline:
|
|
100
|
+
|
|
101
|
+
- Course material is a signal, not truth. A course written 2019 may be objectively refuted by current engine analysis; cross-check with `cloud_analyse` before adopting a course's recommendation wholesale.
|
|
102
|
+
- Recency ranking matters most in fast-moving lines (Najdorf, KID, Grünfeld) and less in stable structures (Caro-Kann, Slav mainlines). Weight accordingly.
|
|
103
|
+
- Cite the source when you use it: `{Ganguly (Reinventing the Ragozin, 2025) covers this exact position — recommends 12.Rd1.}` The reader can then go read the chapter themselves. Don't dump the course prose into your comment; point them at it.
|
|
104
|
+
|
|
105
|
+
Not available if fenfind isn't installed on the server; both tools respond with `status: "not_available"` in that case.
|
|
106
|
+
|
|
83
107
|
## Prep is symmetric — both sides know the same things
|
|
84
108
|
|
|
85
109
|
The single biggest LLM error in prep is treating it like writing a book: "here's what you should play against this opponent's weakness." A real chess opponent:
|
|
@@ -173,7 +197,8 @@ Depth calibrates to the *character* of the position, not to whether the DB has g
|
|
|
173
197
|
|
|
174
198
|
- **Forcing → keep going.** Concrete tactics, only-moves, sharp sequences — analyse until the position calms (real endgame, stable middlegame structure). No game data doesn't matter; the moves are forced, so there's nothing to look up anyway. `describe_position` + engine is enough.
|
|
175
199
|
- **Quiet → stop early.** Once the position is strategic — many reasonable moves, no immediate threats — one sentence of plans is worth more than another 6 plies of tree. Stop and describe.
|
|
176
|
-
- **Novelties
|
|
200
|
+
- **Novelties still need coverage — but the position tells you HOW.** If you're introducing a move nobody's played, "the DB has no games" is not a reason to stop; the reader will reach this position over the board and needs an answer. The shape of that coverage follows the same forcing/quiet rule above: if the novelty forces a single reply, cover just that line; if it opens several plausible replies, branch on them. Predict the opponent with `predict_human_move` (rating-conditioned), sanity-check with Stockfish (objective) and Lc0 (practical alternatives). All three signals matter *most* here precisely because no game-database answer exists. See also "Novelty burns" — that's a separate rule about WHEN to spend a novelty.
|
|
201
|
+
- **`predict_human_move.wdlWhitePov.draw` compares directly across positions.** When choosing between two lines for a specific practical goal (must-win, must-hold), call the tool on both and compare the draw share. Actual numbers only from actual calls — do not paste guessed percentages into prose without running the tool first.
|
|
177
202
|
|
|
178
203
|
## How to combine these
|
|
179
204
|
|
package/package.json
CHANGED
package/tools/fenfind/fenfind.py
CHANGED
|
@@ -49,6 +49,8 @@ def main():
|
|
|
49
49
|
ap.add_argument('--games', action='store_true', help='include game-database hits')
|
|
50
50
|
ap.add_argument('--min', type=float, default=400)
|
|
51
51
|
ap.add_argument('-n', type=int, default=25)
|
|
52
|
+
ap.add_argument('--sort', choices=['recency', 'notes'], default='recency',
|
|
53
|
+
help='ranking: recency (default, most recently updated file first) or notes (annotation depth, notes_chars desc)')
|
|
52
54
|
ap.add_argument('--json', action='store_true', help='emit structured JSON on stdout instead of the text table (used by the MCP wrapper)')
|
|
53
55
|
a = ap.parse_args()
|
|
54
56
|
|
|
@@ -60,7 +62,7 @@ def main():
|
|
|
60
62
|
|
|
61
63
|
con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
|
|
62
64
|
rows = [dict(r) for r in con.execute(
|
|
63
|
-
"""SELECT f.name,f.course,f.author,o.chapter,o.line,o.ply,o.subtree,o.chars
|
|
65
|
+
"""SELECT f.id AS file_id,f.name,f.path,f.course,f.author,o.chapter,o.line,o.ply,o.subtree,o.chars
|
|
64
66
|
FROM occ o JOIN files f ON f.id=o.file_id WHERE o.z=?""", (z,))]
|
|
65
67
|
if not rows:
|
|
66
68
|
if a.json:
|
|
@@ -70,6 +72,15 @@ def main():
|
|
|
70
72
|
print("position not found in the collection"); return
|
|
71
73
|
total = len(rows)
|
|
72
74
|
|
|
75
|
+
# Attach file mtime for recency ranking. Cheap — one stat() per row,
|
|
76
|
+
# done here rather than at index time so re-indexing isn't required
|
|
77
|
+
# for the recency signal to work.
|
|
78
|
+
for r in rows:
|
|
79
|
+
try:
|
|
80
|
+
r['mtime'] = os.path.getmtime(r['path'])
|
|
81
|
+
except OSError:
|
|
82
|
+
r['mtime'] = 0
|
|
83
|
+
|
|
73
84
|
ngame = 0
|
|
74
85
|
if not a.games:
|
|
75
86
|
keep = [r for r in rows if not looks_like_gamedb(r['chapter'], r['line'])]
|
|
@@ -99,10 +110,24 @@ def main():
|
|
|
99
110
|
if k not in groups or (r['chars'], r['subtree']) > (groups[k]['chars'], groups[k]['subtree']):
|
|
100
111
|
groups[k] = r
|
|
101
112
|
|
|
102
|
-
|
|
113
|
+
# Ranking. `recency` (default) sorts by file mtime desc — 2-month-old
|
|
114
|
+
# material beats 10-year-old on the assumption that theory shifts. Ties
|
|
115
|
+
# broken by notes_chars so a deep chapter beats a thin one at the same
|
|
116
|
+
# mtime. `notes` sorts by annotation depth alone, useful when the
|
|
117
|
+
# question is "who explains this best regardless of age".
|
|
118
|
+
if a.sort == 'notes':
|
|
119
|
+
hits = sorted(groups.values(), key=lambda r: (-r['chars'], -r['subtree']))
|
|
120
|
+
else: # recency
|
|
121
|
+
hits = sorted(groups.values(), key=lambda r: (-r['mtime'], -r['chars']))
|
|
103
122
|
shown = hits if a.all else [h for h in hits if h['chars'] >= a.min]
|
|
104
123
|
thin = len(hits) - len(shown)
|
|
105
124
|
|
|
125
|
+
import datetime as _dt
|
|
126
|
+
def _updated_at(ts):
|
|
127
|
+
if not ts:
|
|
128
|
+
return None
|
|
129
|
+
return _dt.date.fromtimestamp(ts).isoformat()
|
|
130
|
+
|
|
106
131
|
if a.json:
|
|
107
132
|
# Structured output for the MCP wrapper. Field names spelled out
|
|
108
133
|
# so the LLM reader doesn't need to know the SQLite column names.
|
|
@@ -110,6 +135,7 @@ def main():
|
|
|
110
135
|
"fen": b.fen(),
|
|
111
136
|
"found": True,
|
|
112
137
|
"total_occurrences": total,
|
|
138
|
+
"sort": a.sort,
|
|
113
139
|
"excluded": {
|
|
114
140
|
"game_db_hits": ngame,
|
|
115
141
|
"unmapped_files": ncourse,
|
|
@@ -118,6 +144,7 @@ def main():
|
|
|
118
144
|
},
|
|
119
145
|
"hits": [
|
|
120
146
|
{
|
|
147
|
+
"course_file_id": h['file_id'], # opaque handle; pass to read_course_at_position
|
|
121
148
|
"course": h['course'] or None,
|
|
122
149
|
"file": h['name'],
|
|
123
150
|
"author": h['author'] or None,
|
|
@@ -126,6 +153,7 @@ def main():
|
|
|
126
153
|
"ply": h['ply'],
|
|
127
154
|
"notes_chars": h['chars'],
|
|
128
155
|
"subtree_moves": h['subtree'],
|
|
156
|
+
"updated_at": _updated_at(h['mtime']),
|
|
129
157
|
}
|
|
130
158
|
for h in shown[:a.n]
|
|
131
159
|
],
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run readpgn.py with whichever interpreter has python-chess available.
|
|
3
|
+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
4
|
+
for py in "$here/.venv/bin/python" "$HOME/chess/.venv/bin/python" "$HOME/.venv/bin/python" python3; do
|
|
5
|
+
if "$py" -c 'import chess' 2>/dev/null; then
|
|
6
|
+
exec "$py" "$here/readpgn.py" "$@"
|
|
7
|
+
fi
|
|
8
|
+
done
|
|
9
|
+
echo "readpgn: python-chess not found. Install it, e.g.:" >&2
|
|
10
|
+
echo " python3 -m venv \"$here/.venv\" && \"$here/.venv/bin/pip\" install python-chess" >&2
|
|
11
|
+
exit 1
|