@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
|
@@ -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,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()
|