@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.
@@ -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).
@@ -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`:
@@ -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
- 1. `read_prep_file`see what's there. Every node has an `id` you'll pass to the mutation and engine/DB tools.
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)`. It's pure computation (no engine, ~1ms) and returns the same board state a human sees:
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
- - All piece placements per colour (SAN-style: `Nf3`, `Bg7`, `e4`)
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
- 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.
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
- `set_annotations(id, path, {arrows, highlights})` sets both together (an atomic replacement pass both current and new). Passing empty arrays clears.
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 signalpick the most important ones. Highlights are labels, not decoration.
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
  ```
@@ -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.
@@ -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 MUST branch.** If you're introducing a move nobody's played, "the DB has no games" is not a reason to stop the reader will actually reach this position over the board and needs an answer. Predict the opponent's replies with `predict_human_move` (rating-conditioned to their level), sanity-check with Stockfish (objective best) and Lc0 (practical alternatives), then branch on the top ~2 predicted responses. This is precisely where all three signals matter most, because there's no historical answer to lean on. See also "Novelty burns" — save deep novelty-prep for the games that actually warrant it.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.36.4",
3
+ "version": "0.39.1",
4
4
  "description": "Model Context Protocol server for chess.ceo — 11.7M+ games, ~1.5M FIDE player profiles, opening preparation, live broadcasts.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- hits = sorted(groups.values(), key=lambda r: (-r['chars'], -r['subtree']))
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