@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.
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """Read a PGN chapter's subtree at a position — the READ half of fenfind.
3
+
4
+ readpgn.py --file-id 1528 --fen "<FEN>" subtree at that position
5
+ readpgn.py --file-id 1528 whole file from move 1
6
+ readpgn.py --file-id 1528 --chapter "..." --fen "..." pick a specific chapter
7
+ readpgn.py --file-id 1528 --fen "..." --max-plies-below 40 deeper subtree
8
+
9
+ Emits JSON with the matching chapter's metadata and its subtree at the
10
+ requested position as PGN text (comments, NAGs, arrows preserved).
11
+ Depth-capped to keep responses small; the LLM can widen `max_plies_below`
12
+ or walk to a different position to explore further.
13
+
14
+ Position resolution: FEN is matched by polyglot Zobrist hash (same as
15
+ fenfind), so move-order transpositions still find the right node.
16
+ """
17
+ import chess, chess.pgn, chess.polyglot as zbl, sqlite3, argparse, os, sys, json, io
18
+
19
+ HERE = os.path.dirname(os.path.abspath(__file__))
20
+ DB = os.environ.get('FENFIND_DB') or next(
21
+ (p for p in (os.path.join(HERE, 'positions.db'),
22
+ os.path.expanduser('~/chess/positions.db'),
23
+ os.path.expanduser('~/positions.db')) if os.path.exists(p)),
24
+ os.path.join(HERE, 'positions.db'))
25
+
26
+
27
+ def find_node_by_hash(game, target_hash):
28
+ """DFS from game root; return the first node whose position hashes to
29
+ target_hash. Match on Zobrist so move-order variants (transpositions)
30
+ of the same position still find each other."""
31
+ board = game.board()
32
+ stack = [(game, board.copy(), iter(game.variations))]
33
+ while stack:
34
+ node, bd, iter_children = stack[-1]
35
+ if zbl.zobrist_hash(bd) == target_hash:
36
+ return node, bd
37
+ try:
38
+ child = next(iter_children)
39
+ except StopIteration:
40
+ stack.pop()
41
+ continue
42
+ child_board = bd.copy()
43
+ try:
44
+ child_board.push(child.move)
45
+ except Exception:
46
+ continue
47
+ stack.append((child, child_board, iter(child.variations)))
48
+ return None, None
49
+
50
+
51
+ def truncate_subtree(source_node, target_node, max_plies_below):
52
+ """Build a new Game rooted at target_node's position, copying the
53
+ subtree from target_node up to `max_plies_below` plies deep. Comments,
54
+ NAGs, and (implicitly via chess.pgn) [%cal]/[%csl] tags inside comments
55
+ are preserved. Headers are carried over from the source game."""
56
+ from chess.pgn import Game
57
+ new = Game()
58
+ for k, v in source_node.headers.items():
59
+ new.headers[k] = v
60
+ # If we're not starting from the game root, set the FEN header so
61
+ # the exported PGN is standalone-parseable.
62
+ if target_node.parent is not None:
63
+ setup_board = target_node.board()
64
+ new.headers['FEN'] = setup_board.fen()
65
+ new.headers['SetUp'] = '1'
66
+ if target_node.comment:
67
+ new.comment = target_node.comment
68
+ if target_node.nags:
69
+ new.nags = set(target_node.nags)
70
+
71
+ def copy_children(src, dst, depth_remaining):
72
+ if depth_remaining <= 0:
73
+ return
74
+ for v in src.variations:
75
+ child = dst.add_variation(v.move)
76
+ if v.comment:
77
+ child.comment = v.comment
78
+ if v.nags:
79
+ child.nags = set(v.nags)
80
+ copy_children(v, child, depth_remaining - 1)
81
+
82
+ copy_children(target_node, new, max_plies_below)
83
+ return new
84
+
85
+
86
+ def main():
87
+ ap = argparse.ArgumentParser()
88
+ ap.add_argument('--file-id', type=int, required=True)
89
+ ap.add_argument('--fen', default=None,
90
+ help='FEN to walk to. Omit to return the chapter from move 1.')
91
+ ap.add_argument('--moves', default=None,
92
+ help='SAN moves from startpos, alternative to --fen.')
93
+ ap.add_argument('--chapter', default=None,
94
+ help='Substring match on the game White header (usually the chapter title). Omit to match the first game containing the position.')
95
+ ap.add_argument('--max-plies-below', type=int, default=20,
96
+ help='How deep to include the subtree below the target position. Cap at 200.')
97
+ a = ap.parse_args()
98
+
99
+ max_below = max(0, min(200, a.max_plies_below))
100
+
101
+ con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
102
+ row = con.execute("SELECT path, name, course, author FROM files WHERE id=?", (a.file_id,)).fetchone()
103
+ if not row:
104
+ json.dump({"found": False, "error": f"unknown file_id={a.file_id}"}, sys.stdout); print()
105
+ return
106
+ path, name, course, author = row['path'], row['name'], row['course'], row['author']
107
+ if not os.path.exists(path):
108
+ json.dump({"found": False, "error": f"file no longer on disk: {path}"}, sys.stdout); print()
109
+ return
110
+
111
+ # Compute the target hash (or None → return from move 1).
112
+ target_hash = None
113
+ if a.fen:
114
+ board = chess.Board(a.fen if len(a.fen.split()) >= 6 else a.fen + ' 0 1')
115
+ target_hash = zbl.zobrist_hash(board)
116
+ elif a.moves:
117
+ board = chess.Board()
118
+ import re as _re
119
+ for tok in _re.sub(r'\d+\.(\.\.)?', ' ', a.moves).split():
120
+ if tok in ('*', '1-0', '0-1', '1/2-1/2'):
121
+ continue
122
+ try:
123
+ board.push_san(tok)
124
+ except Exception:
125
+ json.dump({"found": False, "error": f"illegal move token: {tok}"}, sys.stdout); print()
126
+ return
127
+ target_hash = zbl.zobrist_hash(board)
128
+
129
+ # Open the PGN, iterate games. If --chapter, filter by substring match
130
+ # on White header (which is what the indexer used as the chapter
131
+ # label). Otherwise take the first game that contains the target.
132
+ fh = open(path, encoding='utf-8', errors='replace')
133
+ chapters_tried = 0
134
+ while True:
135
+ try:
136
+ game = chess.pgn.read_game(fh)
137
+ except Exception:
138
+ break
139
+ if game is None:
140
+ break
141
+ chapter = game.headers.get('White', '') or ''
142
+ if a.chapter and a.chapter.lower() not in chapter.lower():
143
+ continue
144
+ chapters_tried += 1
145
+ # Locate the target position (or take the game root when no
146
+ # position specified — "give me the whole chapter").
147
+ if target_hash is None:
148
+ target_node = game
149
+ target_board = game.board()
150
+ else:
151
+ target_node, target_board = find_node_by_hash(game, target_hash)
152
+ if target_node is None:
153
+ continue
154
+
155
+ new_game = truncate_subtree(game, target_node, max_below)
156
+ pgn_out = io.StringIO()
157
+ exporter = chess.pgn.FileExporter(pgn_out)
158
+ new_game.accept(exporter)
159
+
160
+ # Reconstruct the move sequence from the game root to the target
161
+ # (helpful for the LLM to know "you're at ply N after these moves").
162
+ moves_to_here = []
163
+ if target_node.parent is not None:
164
+ node = target_node
165
+ while node.parent is not None:
166
+ moves_to_here.append(node.san())
167
+ node = node.parent
168
+ moves_to_here.reverse()
169
+
170
+ payload = {
171
+ "found": True,
172
+ "file_id": a.file_id,
173
+ "file": name,
174
+ "course": course or None,
175
+ "author": author or None,
176
+ "chapter": chapter or None,
177
+ "line_header": game.headers.get('Black') or None,
178
+ "moves_to_position": ' '.join(moves_to_here),
179
+ "target_fen": target_board.fen(),
180
+ "ply_from_chapter_root": len(moves_to_here),
181
+ "max_plies_below": max_below,
182
+ "subtree_pgn": pgn_out.getvalue().strip(),
183
+ "chapters_searched": chapters_tried,
184
+ }
185
+ json.dump(payload, sys.stdout, ensure_ascii=False); print()
186
+ return
187
+
188
+ json.dump({
189
+ "found": False,
190
+ "file_id": a.file_id,
191
+ "file": name,
192
+ "error": (f"position not found in {'chapter matching '+repr(a.chapter) if a.chapter else 'any chapter'} of this file"
193
+ if target_hash is not None else
194
+ f"no chapter matched --chapter={a.chapter!r}"),
195
+ "chapters_searched": chapters_tried,
196
+ }, sys.stdout); print()
197
+
198
+
199
+ main()
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Thin wrapper: sf_eval.py only needs stdlib, system python3 works.
3
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ exec python3 "$here/sf_eval.py" "$@"
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Stockfish `eval` verbose output → structured JSON.
3
+
4
+ Wraps the Stockfish classical-eval breakdown (13 named contributing
5
+ terms per colour, mg/eg values, plus the total) so the LLM can reason
6
+ about WHY a position is what it is instead of just the final number.
7
+
8
+ Recipe from Kim et al. NAACL 2025 "Concept-guided Chess Commentary"
9
+ (arxiv 2410.20811): feeding these named term values to an LLM roughly
10
+ doubles chess-commentary correctness. The comparison-across-moves
11
+ delta (which term shifted most) is the primary signal — the LLM can
12
+ compute it by calling twice.
13
+
14
+ Uses only stdlib (subprocess + regex parsing of the ASCII table
15
+ Stockfish prints for `eval`). No python-chess dependency; system
16
+ python3 is enough.
17
+
18
+ sf_eval.py --fen "<FEN>"
19
+ sf_eval.py --fen "<FEN>" --stockfish-bin /usr/games/stockfish
20
+ """
21
+ import argparse, json, os, re, subprocess, sys
22
+
23
+
24
+ DEFAULT_BIN = os.environ.get('SF_EVAL_BIN') or '/usr/games/stockfish'
25
+
26
+ # Rows look like: | Material | ---- ---- | ---- ---- | 0.30 0.17 |
27
+ # with "----" for the material / imbalance rows (NNUE substitutes for
28
+ # per-colour, only the totals are printed).
29
+ ROW_RE = re.compile(r'^\|\s*([A-Za-z][A-Za-z ]*?)\s*\|(.*?)\|(.*?)\|(.*?)\|$')
30
+
31
+
32
+ def parse_pair(s: str):
33
+ """Parse '0.13 -0.02' → (0.13, -0.02); '---- ----' → (None, None)."""
34
+ parts = s.strip().split()
35
+ if len(parts) < 2 or parts[0].startswith('-'*3):
36
+ return None, None
37
+ try:
38
+ return float(parts[0]), float(parts[1])
39
+ except ValueError:
40
+ return None, None
41
+
42
+
43
+ def parse_eval(text: str) -> dict:
44
+ """Extract the "Contributing terms for the classical eval" table.
45
+ Returns {terms: {term_name: {white_mg, white_eg, black_mg, black_eg,
46
+ total_mg, total_eg}}, total: {mg, eg}}."""
47
+ terms = {}
48
+ total = None
49
+ in_table = False
50
+ for raw in text.splitlines():
51
+ if 'Contributing terms' in raw:
52
+ in_table = True
53
+ continue
54
+ if not in_table:
55
+ continue
56
+ if '+---' in raw:
57
+ continue
58
+ m = ROW_RE.match(raw)
59
+ if not m:
60
+ # Blank line after the closing +---+ ends the section.
61
+ if raw.strip() == '':
62
+ break
63
+ continue
64
+ name = m.group(1).strip()
65
+ # Header row "| Term |..." isn't a data row; parse_pair returns None.
66
+ w_mg, w_eg = parse_pair(m.group(2))
67
+ b_mg, b_eg = parse_pair(m.group(3))
68
+ t_mg, t_eg = parse_pair(m.group(4))
69
+ if name.lower() == 'term':
70
+ continue
71
+ if name.lower() == 'total':
72
+ total = {'mg': t_mg, 'eg': t_eg}
73
+ continue
74
+ terms[name.lower().replace(' ', '_')] = {
75
+ 'white_mg': w_mg, 'white_eg': w_eg,
76
+ 'black_mg': b_mg, 'black_eg': b_eg,
77
+ 'total_mg': t_mg, 'total_eg': t_eg,
78
+ }
79
+ return {'terms': terms, 'total': total}
80
+
81
+
82
+ def run_stockfish(bin_path: str, fen: str) -> str:
83
+ """Send position + eval + quit; return combined stdout."""
84
+ cmds = f"position fen {fen}\neval\nquit\n"
85
+ p = subprocess.run(
86
+ [bin_path],
87
+ input=cmds,
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=10,
91
+ )
92
+ if p.returncode != 0:
93
+ raise RuntimeError(f"stockfish exited {p.returncode}: {p.stderr.strip() or p.stdout[-200:]}")
94
+ return p.stdout
95
+
96
+
97
+ def main():
98
+ ap = argparse.ArgumentParser()
99
+ ap.add_argument('--fen', required=True)
100
+ ap.add_argument('--stockfish-bin', default=DEFAULT_BIN,
101
+ help=f'path to stockfish binary (default: $SF_EVAL_BIN or {DEFAULT_BIN})')
102
+ a = ap.parse_args()
103
+
104
+ if not os.path.exists(a.stockfish_bin):
105
+ json.dump({
106
+ "found": False,
107
+ "error": f"stockfish binary not at {a.stockfish_bin} — install stockfish or set SF_EVAL_BIN",
108
+ }, sys.stdout); print()
109
+ return
110
+
111
+ try:
112
+ text = run_stockfish(a.stockfish_bin, a.fen)
113
+ except subprocess.TimeoutExpired:
114
+ json.dump({"found": False, "error": "stockfish eval timed out (10s)"}, sys.stdout); print()
115
+ return
116
+ except Exception as e:
117
+ json.dump({"found": False, "error": f"stockfish invocation failed: {e}"}, sys.stdout); print()
118
+ return
119
+
120
+ payload = parse_eval(text)
121
+ if not payload['terms']:
122
+ json.dump({
123
+ "found": False,
124
+ "fen": a.fen,
125
+ "error": "no eval terms parsed — stockfish output may have changed format",
126
+ "raw_head": text[:400],
127
+ }, sys.stdout); print()
128
+ return
129
+
130
+ payload['found'] = True
131
+ payload['fen'] = a.fen
132
+ payload['stockfish_bin'] = a.stockfish_bin
133
+ json.dump(payload, sys.stdout, ensure_ascii=False); print()
134
+
135
+
136
+ main()