@chessceo/mcp 0.34.0 → 0.35.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 +129 -11
- package/package.json +3 -2
- package/tools/fenfind/README.md +51 -0
- package/tools/fenfind/fenfind +11 -0
- package/tools/fenfind/fenfind.py +158 -0
package/dist/index.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// No API key, no auth, no state; the API's own rate limits apply.
|
|
9
9
|
import { createServer as createHttpServer } from "node:http";
|
|
10
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
|
-
import {
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
import { dirname, join } from "node:path";
|
|
14
15
|
import { Chess } from "chess.js";
|
|
@@ -92,6 +93,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
92
93
|
"deep_analyse",
|
|
93
94
|
"deep_analyse_status",
|
|
94
95
|
"deep_analyse_cancel",
|
|
96
|
+
"find_position_in_courses",
|
|
95
97
|
"quote_engine_eval",
|
|
96
98
|
"predict_human_move",
|
|
97
99
|
"prepare_opponent",
|
|
@@ -212,7 +214,7 @@ const TOOLS = [
|
|
|
212
214
|
"- `chesscom` — needs `username`. Filters: `color`, `startMonth`/`endMonth` (**required** for chesscom/lichess), `timeControl`.\n" +
|
|
213
215
|
"- `lichess` — needs `username`. Same filters as chesscom; `timeControl` also accepts `bullet` on Lichess.\n\n" +
|
|
214
216
|
"Multi-source example: one player with both a FIDE ID and a Lichess account → two sources in one call, all their games combined into one session.\n\n" +
|
|
215
|
-
"GROUNDING: every claim about the opponent's repertoire must trace back to a `get_prep_position` call on this session. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at actual game counts / win rates in the response. Prep is a two-player game — see `
|
|
217
|
+
"GROUNDING: every claim about the opponent's repertoire must trace back to a `get_prep_position` call on this session. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at actual game counts / win rates in the response. Prep is a two-player game — see `read_opening_prep_guide` before recommending an opening plan.",
|
|
216
218
|
inputSchema: {
|
|
217
219
|
type: "object",
|
|
218
220
|
properties: {
|
|
@@ -250,7 +252,7 @@ const TOOLS = [
|
|
|
250
252
|
"• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted.\n" +
|
|
251
253
|
"• Recency > career. The last 12-24 months dominate — filter your session with `start_month` if the player's repertoire shifted.\n" +
|
|
252
254
|
"• Opponent will deviate early. Prep is a tree — cover the 2 most likely replies at each real branching point, not one 20-move line.\n\n" +
|
|
253
|
-
"For the full guide call `
|
|
255
|
+
"For the full guide call `read_opening_prep_guide`.",
|
|
254
256
|
inputSchema: {
|
|
255
257
|
type: "object",
|
|
256
258
|
properties: {
|
|
@@ -821,6 +823,27 @@ const TOOLS = [
|
|
|
821
823
|
required: ["job_id"],
|
|
822
824
|
},
|
|
823
825
|
},
|
|
826
|
+
{
|
|
827
|
+
name: "find_position_in_courses",
|
|
828
|
+
description: "Look up which of the USER's own Chessable / PGN courses cover a position, ranked by how much ANNOTATED material sits below that position in each course. This is the LLM's window into what the user has personally studied — not a general database. Complements engine + big-DB tools: the general database says what the world plays, this says what the user has literature on.\n\n" +
|
|
829
|
+
"Ranking: files/chapters where the position sits at a branching point with lots of text under it rank first. A chapter that merely passes through the position on its way somewhere else ranks last, which is the whole point — the LLM wants to point the user at where the material actually explains this specific position.\n\n" +
|
|
830
|
+
"Use this to: cite specific chapters the user already owns when recommending an opening decision, cross-check whether the user has coverage of a rare line, find author overlap when the user asks 'what do I have on this?'\n\n" +
|
|
831
|
+
"Returns: `{fen, found, total_occurrences, excluded: {game_db_hits, unmapped_files, thin_entries_below_min, min_notes_chars}, hits: [{course, file, author, chapter, line, ply, notes_chars, subtree_moves}], truncated}`. `notes_chars` is the total characters of commentary in the subtree rooted at this occurrence — the primary rank signal. `ply` tells you how deep in the chapter's game tree this position sits (small ply = near the chapter's start).\n\n" +
|
|
832
|
+
"Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
|
|
833
|
+
inputSchema: {
|
|
834
|
+
type: "object",
|
|
835
|
+
properties: {
|
|
836
|
+
file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree." },
|
|
837
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
|
|
838
|
+
fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
|
|
839
|
+
moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
|
|
840
|
+
include_games: { type: "boolean", description: "Include hits from game-database PGNs (player headers instead of course/chapter titles). Default false — those are noise for course-lookup." },
|
|
841
|
+
chapters_mode: { type: "boolean", description: "Return every chapter separately rather than best-per-course. Default false. Useful when a course has multiple chapters covering the same position." },
|
|
842
|
+
min_notes_chars: { type: "number", description: "Minimum notes_chars per hit to be included. Default 400 (~a paragraph of prose). Set to 0 to see every occurrence." },
|
|
843
|
+
limit: { type: "integer", description: "Max hits to return (default 25)." },
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
},
|
|
824
847
|
{
|
|
825
848
|
name: "quote_engine_eval",
|
|
826
849
|
description: "Return the stored engine eval for a node, or null if that node was never analysed. **Call this before writing prose or NAGs that quote engine numbers** — if it returns null, you have no measurement to cite. Do NOT infer an eval for the node from siblings or children; either analyse it (cloud_analyse with node_id) or omit the number from your prose.\n\n" +
|
|
@@ -854,13 +877,16 @@ const TOOLS = [
|
|
|
854
877
|
inputSchema: { type: "object", properties: {} },
|
|
855
878
|
},
|
|
856
879
|
{
|
|
857
|
-
name: "
|
|
858
|
-
description: "
|
|
880
|
+
name: "read_opening_prep_guide",
|
|
881
|
+
description: "**CALL WHEN**: the user asks about OPENING PREPARATION — 'prep me against X', 'what should I play vs the Najdorf', 'help me build a repertoire against 1.e4', 'walk this opponent's Sveshnikov'. This guide is chess-and-analysis philosophy, not storage semantics.\n\n" +
|
|
882
|
+
"Covers: why win% is one weight not a verdict, why prep is a two-player game with symmetric information (opponent sees your history too), how sample size and recency change the reading, when 'revealed weaknesses' are actionable vs already patched, how to choose between the GM-classical DB and the main DB, when to combine chesscom/lichess sources with FIDE, the three chess.com profile shapes (consistent / eclectic / split-personality), the reversed-colours scarcity trick, how to calibrate surprise (rare secondary lines inside the existing repertoire, not big first-move switches).\n\n" +
|
|
883
|
+
"Different tool: `read_prep_files_guide` covers the FILE STORAGE feature (how to list/create/save prep files) — call that only when about to manipulate files, not for opening questions.",
|
|
859
884
|
inputSchema: { type: "object", properties: {} },
|
|
860
885
|
},
|
|
861
886
|
{
|
|
862
887
|
name: "read_prep_files_guide",
|
|
863
|
-
description: "
|
|
888
|
+
description: "**CALL WHEN**: you're about to CREATE, LIST, SAVE, or DELETE a prep file — the persistent file storage feature. Not for opening prep philosophy (that's `read_opening_prep_guide`) and not for how to write PGN (that's `read_pgn_authoring_guide`).\n\n" +
|
|
889
|
+
"Covers: the AI Prep folder, when to list vs search vs create (avoid duplicate 'Prep vs Firouzja' files), optimistic locking with `version`, naming conventions for the [Event] tag, node-id addressing basics.",
|
|
864
890
|
inputSchema: { type: "object", properties: {} },
|
|
865
891
|
},
|
|
866
892
|
{
|
|
@@ -1423,6 +1449,87 @@ function deepAnalyseCancel(args) {
|
|
|
1423
1449
|
// flight abort of the HTTP call is a follow-up).
|
|
1424
1450
|
return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
|
|
1425
1451
|
}
|
|
1452
|
+
// ── find_position_in_courses: fenfind subprocess wrapper ───────────
|
|
1453
|
+
//
|
|
1454
|
+
// fenfind is a small python tool that indexes chess PGN files by
|
|
1455
|
+
// polyglot Zobrist hash. Given a position it returns which of the
|
|
1456
|
+
// user's Chessable / PGN files cover it, ranked by how much annotated
|
|
1457
|
+
// material sits below that position in each course. Runs as a
|
|
1458
|
+
// subprocess of the MCP so we can reuse python-chess's polyglot
|
|
1459
|
+
// hashing (matching the pre-built positions.db) instead of porting
|
|
1460
|
+
// the hash function to TS.
|
|
1461
|
+
//
|
|
1462
|
+
// Path resolution order (`FENFIND_PATH` env var overrides):
|
|
1463
|
+
// 1. $FENFIND_PATH/fenfind
|
|
1464
|
+
// 2. <package-root>/tools/fenfind/fenfind (ships with the npm package)
|
|
1465
|
+
// The bash wrapper picks a python interpreter with python-chess
|
|
1466
|
+
// available (venv at $here/.venv/bin/python preferred, then falls back
|
|
1467
|
+
// to system python3). DB path is resolved inside fenfind.py itself
|
|
1468
|
+
// (FENFIND_DB env, then ~/positions.db).
|
|
1469
|
+
const FENFIND_SCRIPT = (() => {
|
|
1470
|
+
const envPath = process.env.FENFIND_PATH?.trim();
|
|
1471
|
+
if (envPath) {
|
|
1472
|
+
const p = join(envPath, "fenfind");
|
|
1473
|
+
return existsSync(p) ? p : null;
|
|
1474
|
+
}
|
|
1475
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1476
|
+
const bundled = join(here, "..", "tools", "fenfind", "fenfind");
|
|
1477
|
+
return existsSync(bundled) ? bundled : null;
|
|
1478
|
+
})();
|
|
1479
|
+
// Cap on how long we let fenfind run — SQLite query with a hash index
|
|
1480
|
+
// should return in well under a second, but a stuck subprocess
|
|
1481
|
+
// shouldn't pin the MCP handler.
|
|
1482
|
+
const FENFIND_TIMEOUT_MS = 15_000;
|
|
1483
|
+
async function findPositionInCourses(args) {
|
|
1484
|
+
if (!FENFIND_SCRIPT) {
|
|
1485
|
+
return {
|
|
1486
|
+
status: "not_available",
|
|
1487
|
+
note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind` script and positions.db, or install the tools/fenfind bundle shipped in the npm package.",
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1491
|
+
const cliArgs = [resolved.fen, "--json"];
|
|
1492
|
+
if (args.include_games)
|
|
1493
|
+
cliArgs.push("--games");
|
|
1494
|
+
if (args.chapters_mode)
|
|
1495
|
+
cliArgs.push("--chapters");
|
|
1496
|
+
if (typeof args.min_notes_chars === "number")
|
|
1497
|
+
cliArgs.push("--min", String(args.min_notes_chars));
|
|
1498
|
+
if (typeof args.limit === "number")
|
|
1499
|
+
cliArgs.push("-n", String(args.limit));
|
|
1500
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
1501
|
+
const p = spawn(FENFIND_SCRIPT, cliArgs, {
|
|
1502
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1503
|
+
});
|
|
1504
|
+
let out = "";
|
|
1505
|
+
let err = "";
|
|
1506
|
+
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
1507
|
+
p.stderr.on("data", d => { err += d.toString("utf8"); });
|
|
1508
|
+
const to = setTimeout(() => {
|
|
1509
|
+
try {
|
|
1510
|
+
p.kill("SIGTERM");
|
|
1511
|
+
}
|
|
1512
|
+
catch { /* already dead */ }
|
|
1513
|
+
reject(new Error(`fenfind timed out after ${FENFIND_TIMEOUT_MS}ms`));
|
|
1514
|
+
}, FENFIND_TIMEOUT_MS);
|
|
1515
|
+
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
1516
|
+
p.on("close", code => {
|
|
1517
|
+
clearTimeout(to);
|
|
1518
|
+
if (code !== 0) {
|
|
1519
|
+
reject(new Error(`fenfind exited ${code}: ${err.slice(0, 500)}`));
|
|
1520
|
+
}
|
|
1521
|
+
else {
|
|
1522
|
+
resolve(out);
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
try {
|
|
1527
|
+
return JSON.parse(stdout);
|
|
1528
|
+
}
|
|
1529
|
+
catch (e) {
|
|
1530
|
+
throw new Error(`fenfind returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1426
1533
|
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
1427
1534
|
function pathIntoTree(root, path) {
|
|
1428
1535
|
let cur = root;
|
|
@@ -1442,17 +1549,26 @@ function analysisToStoredEval(analysis, fen) {
|
|
|
1442
1549
|
if (!analysis || typeof analysis !== "object")
|
|
1443
1550
|
return null;
|
|
1444
1551
|
const r = analysis;
|
|
1445
|
-
|
|
1446
|
-
|
|
1552
|
+
// NOTE: the backend already returns White-POV cp/mate (engine-ws flips
|
|
1553
|
+
// in ParseInfo based on side-to-move, then cloud_snapshot passes the
|
|
1554
|
+
// number through). Previously this function ALSO flipped on
|
|
1555
|
+
// black-to-move — a double flip that silently inverted every
|
|
1556
|
+
// Black-to-move position's stored eval, so half of every prep tree
|
|
1557
|
+
// had wrong-sign ceoEval and the auto-attached .eval on those
|
|
1558
|
+
// positions reported White as better when Black was. Any ceoEval
|
|
1559
|
+
// written on a Black-to-move node prior to 2026-07-26 is inverted;
|
|
1560
|
+
// re-run auto_evaluate on affected files to overwrite.
|
|
1561
|
+
// Unused `fen` kept as an arg for signature stability with callers.
|
|
1562
|
+
void fen;
|
|
1447
1563
|
const engineEval = (block) => {
|
|
1448
1564
|
const line = block?.lines?.[0];
|
|
1449
1565
|
if (!line)
|
|
1450
1566
|
return undefined;
|
|
1451
1567
|
const depth = line.depth ?? block?.depth;
|
|
1452
1568
|
if (typeof line.mate === "number")
|
|
1453
|
-
return { mate:
|
|
1569
|
+
return { mate: line.mate, depth };
|
|
1454
1570
|
if (typeof line.scoreCp === "number")
|
|
1455
|
-
return { cp:
|
|
1571
|
+
return { cp: line.scoreCp, depth };
|
|
1456
1572
|
return undefined;
|
|
1457
1573
|
};
|
|
1458
1574
|
const sf = engineEval(r.stockfish);
|
|
@@ -1972,6 +2088,8 @@ async function callToolInner(name, args) {
|
|
|
1972
2088
|
return deepAnalyseStatus(args);
|
|
1973
2089
|
case "deep_analyse_cancel":
|
|
1974
2090
|
return deepAnalyseCancel(args);
|
|
2091
|
+
case "find_position_in_courses":
|
|
2092
|
+
return findPositionInCourses(args);
|
|
1975
2093
|
case "list_prep_files":
|
|
1976
2094
|
return authedRequest("GET", "/api/agent/prep-files");
|
|
1977
2095
|
case "search_prep_files":
|
|
@@ -2049,7 +2167,7 @@ async function callToolInner(name, args) {
|
|
|
2049
2167
|
}
|
|
2050
2168
|
case "read_engine_usage_guide":
|
|
2051
2169
|
return { guide: ENGINE_USAGE_DOC };
|
|
2052
|
-
case "
|
|
2170
|
+
case "read_opening_prep_guide":
|
|
2053
2171
|
return { guide: PREP_STRATEGY_DOC };
|
|
2054
2172
|
case "read_prep_files_guide":
|
|
2055
2173
|
return { guide: PREP_FILES_DOC };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chessceo/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
12
|
"docs",
|
|
13
|
-
"README.md"
|
|
13
|
+
"README.md",
|
|
14
|
+
"tools"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
16
17
|
"build": "tsc",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# fenfind — find which of your PGN files cover a position
|
|
2
|
+
|
|
3
|
+
Small python tool the MCP shells out to for the `find_position_in_courses`
|
|
4
|
+
tool. Given a FEN it looks up every course/chapter that reaches that
|
|
5
|
+
position, ranked by how much annotated material sits below it (comment
|
|
6
|
+
chars in the subtree). Uses polyglot Zobrist hashing so the FEN → hash
|
|
7
|
+
map matches the pre-built `positions.db` index.
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
| File | Purpose |
|
|
12
|
+
|------|---------|
|
|
13
|
+
| `fenfind` | bash wrapper; picks a python with `python-chess` installed |
|
|
14
|
+
| `fenfind.py` | the actual query tool (`--json` for MCP consumption) |
|
|
15
|
+
| `_INDEXER.py` | one-off script that builds `positions.db` from a folder of PGNs (kept out of the npm package via `.npmignore`) |
|
|
16
|
+
|
|
17
|
+
## Runtime dependencies
|
|
18
|
+
|
|
19
|
+
- **python-chess** — either at `./venv/bin/python` (the wrapper's preferred path) or system-wide `python3`.
|
|
20
|
+
Install locally: `python3 -m venv .venv && .venv/bin/pip install python-chess`.
|
|
21
|
+
- **positions.db** — the pre-built index. Resolved via, in order:
|
|
22
|
+
1. `$FENFIND_DB` env var (absolute path).
|
|
23
|
+
2. `<script-dir>/positions.db`
|
|
24
|
+
3. `~/chess/positions.db`
|
|
25
|
+
4. `~/positions.db`
|
|
26
|
+
|
|
27
|
+
The DB is a runtime asset and never ships in the npm package. Put it at
|
|
28
|
+
one of the paths above on any host that runs the MCP.
|
|
29
|
+
|
|
30
|
+
## MCP integration
|
|
31
|
+
|
|
32
|
+
`src/index.ts` resolves the script via, in order:
|
|
33
|
+
1. `$FENFIND_PATH` env var (directory containing the `fenfind` script).
|
|
34
|
+
2. `<package-root>/tools/fenfind/fenfind` (this directory, shipped in the
|
|
35
|
+
npm package).
|
|
36
|
+
|
|
37
|
+
If neither path exists, the `find_position_in_courses` tool returns a
|
|
38
|
+
clear `status: "not_available"` response with the note above rather
|
|
39
|
+
than erroring.
|
|
40
|
+
|
|
41
|
+
## Rebuilding the index
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
python3 _INDEXER.py # expects PGNs at /home/lucas/chess/raw and a
|
|
45
|
+
# course-mapping CSV at /home/lucas/chess/chessable/_originals.csv
|
|
46
|
+
# — edit the paths at the top of the file for
|
|
47
|
+
# your own layout.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Full re-index of ~2k PGN files is a few minutes; produces the ~2 GB
|
|
51
|
+
positions.db in place.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run fenfind.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/fenfind.py" "$@"
|
|
7
|
+
fi
|
|
8
|
+
done
|
|
9
|
+
echo "fenfind: 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
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find which courses/chapters cover a position.
|
|
3
|
+
|
|
4
|
+
fenfind.py "<FEN>" look up a position
|
|
5
|
+
fenfind.py -m "1.e4 c5 2.Nf3 d6" give moves instead of a FEN
|
|
6
|
+
fenfind.py -m "..." --courses only files mapped to a known course
|
|
7
|
+
fenfind.py -m "..." --all don't hide thin entries
|
|
8
|
+
fenfind.py -m "..." --chapters list every chapter, not best-per-file
|
|
9
|
+
fenfind.py -m "..." --min 500 custom "worth reading" threshold (note chars)
|
|
10
|
+
|
|
11
|
+
Ranking: entries are sorted by how much ANNOTATED material sits below the
|
|
12
|
+
position at that point -- i.e. words of explanation first, moves second. A
|
|
13
|
+
chapter that merely passes through the position on its way somewhere else
|
|
14
|
+
ranks last, which is the whole point.
|
|
15
|
+
"""
|
|
16
|
+
import chess, chess.polyglot as zbl, sqlite3, argparse, re, os, json, sys
|
|
17
|
+
|
|
18
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
DB = os.environ.get('FENFIND_DB') or next(
|
|
20
|
+
(p for p in (os.path.join(HERE, 'positions.db'),
|
|
21
|
+
os.path.expanduser('~/chess/positions.db'),
|
|
22
|
+
os.path.expanduser('~/positions.db')) if os.path.exists(p)),
|
|
23
|
+
os.path.join(HERE, 'positions.db'))
|
|
24
|
+
|
|
25
|
+
def board_from_moves(s):
|
|
26
|
+
b = chess.Board()
|
|
27
|
+
for tok in re.sub(r'\d+\.(\.\.)?', ' ', s).split():
|
|
28
|
+
if tok in ('*', '1-0', '0-1', '1/2-1/2'):
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
b.push_san(tok)
|
|
32
|
+
except Exception:
|
|
33
|
+
raise SystemExit(f"illegal/unparsable move: {tok}")
|
|
34
|
+
return b
|
|
35
|
+
|
|
36
|
+
def looks_like_gamedb(chapter, line):
|
|
37
|
+
"""Game collections put player names in the header; courses put chapter titles.
|
|
38
|
+
'Surname, Firstname' in both slots is the giveaway."""
|
|
39
|
+
return bool(re.match(r'^[A-Z][\w.\'-]+,\s*[A-Z]', chapter or '')) and \
|
|
40
|
+
bool(re.match(r'^[A-Z][\w.\'-]+,\s*[A-Z]', line or ''))
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
ap = argparse.ArgumentParser()
|
|
44
|
+
ap.add_argument('pos')
|
|
45
|
+
ap.add_argument('-m', '--moves', action='store_true')
|
|
46
|
+
ap.add_argument('--all', action='store_true')
|
|
47
|
+
ap.add_argument('--courses', action='store_true')
|
|
48
|
+
ap.add_argument('--chapters', action='store_true')
|
|
49
|
+
ap.add_argument('--games', action='store_true', help='include game-database hits')
|
|
50
|
+
ap.add_argument('--min', type=float, default=400)
|
|
51
|
+
ap.add_argument('-n', type=int, default=25)
|
|
52
|
+
ap.add_argument('--json', action='store_true', help='emit structured JSON on stdout instead of the text table (used by the MCP wrapper)')
|
|
53
|
+
a = ap.parse_args()
|
|
54
|
+
|
|
55
|
+
b = board_from_moves(a.pos) if a.moves else chess.Board(
|
|
56
|
+
a.pos if len(a.pos.split()) >= 6 else a.pos + ' 0 1')
|
|
57
|
+
z = zbl.zobrist_hash(b)
|
|
58
|
+
if z >= (1 << 63):
|
|
59
|
+
z -= (1 << 64)
|
|
60
|
+
|
|
61
|
+
con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
|
|
62
|
+
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
|
|
64
|
+
FROM occ o JOIN files f ON f.id=o.file_id WHERE o.z=?""", (z,))]
|
|
65
|
+
if not rows:
|
|
66
|
+
if a.json:
|
|
67
|
+
json.dump({"fen": b.fen(), "found": False, "total_occurrences": 0, "hits": []}, sys.stdout)
|
|
68
|
+
print()
|
|
69
|
+
return
|
|
70
|
+
print("position not found in the collection"); return
|
|
71
|
+
total = len(rows)
|
|
72
|
+
|
|
73
|
+
ngame = 0
|
|
74
|
+
if not a.games:
|
|
75
|
+
keep = [r for r in rows if not looks_like_gamedb(r['chapter'], r['line'])]
|
|
76
|
+
ngame = len(rows) - len(keep); rows = keep
|
|
77
|
+
ncourse = 0
|
|
78
|
+
if a.courses:
|
|
79
|
+
keep = [r for r in rows if r['course']]
|
|
80
|
+
ncourse = len(rows) - len(keep); rows = keep
|
|
81
|
+
|
|
82
|
+
# collapse duplicate FILES: "Foo.pgn", "Foo (2).pgn" and re-exports of the same
|
|
83
|
+
# course produce byte-identical hits -- keep one, remember how many copies.
|
|
84
|
+
def norm_file(n):
|
|
85
|
+
return re.sub(r'\s*\(\d+\)(?=\.pgn$)', '', n).lower()
|
|
86
|
+
for r in rows:
|
|
87
|
+
r['key'] = (r['course'] or norm_file(r['name']))
|
|
88
|
+
|
|
89
|
+
if a.chapters:
|
|
90
|
+
groups = {}
|
|
91
|
+
for r in rows:
|
|
92
|
+
k = (r['key'], r['chapter'])
|
|
93
|
+
if k not in groups or r['chars'] > groups[k]['chars']:
|
|
94
|
+
groups[k] = r
|
|
95
|
+
else:
|
|
96
|
+
groups = {} # best chapter per course/file
|
|
97
|
+
for r in rows:
|
|
98
|
+
k = r['key']
|
|
99
|
+
if k not in groups or (r['chars'], r['subtree']) > (groups[k]['chars'], groups[k]['subtree']):
|
|
100
|
+
groups[k] = r
|
|
101
|
+
|
|
102
|
+
hits = sorted(groups.values(), key=lambda r: (-r['chars'], -r['subtree']))
|
|
103
|
+
shown = hits if a.all else [h for h in hits if h['chars'] >= a.min]
|
|
104
|
+
thin = len(hits) - len(shown)
|
|
105
|
+
|
|
106
|
+
if a.json:
|
|
107
|
+
# Structured output for the MCP wrapper. Field names spelled out
|
|
108
|
+
# so the LLM reader doesn't need to know the SQLite column names.
|
|
109
|
+
payload = {
|
|
110
|
+
"fen": b.fen(),
|
|
111
|
+
"found": True,
|
|
112
|
+
"total_occurrences": total,
|
|
113
|
+
"excluded": {
|
|
114
|
+
"game_db_hits": ngame,
|
|
115
|
+
"unmapped_files": ncourse,
|
|
116
|
+
"thin_entries_below_min": thin,
|
|
117
|
+
"min_notes_chars": a.min,
|
|
118
|
+
},
|
|
119
|
+
"hits": [
|
|
120
|
+
{
|
|
121
|
+
"course": h['course'] or None,
|
|
122
|
+
"file": h['name'],
|
|
123
|
+
"author": h['author'] or None,
|
|
124
|
+
"chapter": h['chapter'] or None,
|
|
125
|
+
"line": h['line'] or None,
|
|
126
|
+
"ply": h['ply'],
|
|
127
|
+
"notes_chars": h['chars'],
|
|
128
|
+
"subtree_moves": h['subtree'],
|
|
129
|
+
}
|
|
130
|
+
for h in shown[:a.n]
|
|
131
|
+
],
|
|
132
|
+
"truncated": max(0, len(shown) - a.n),
|
|
133
|
+
}
|
|
134
|
+
json.dump(payload, sys.stdout, ensure_ascii=False)
|
|
135
|
+
print()
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
print(f"\nposition: {b.fen()}")
|
|
139
|
+
note = [f"{total:,} indexed occurrences"]
|
|
140
|
+
if ngame: note.append(f"{ngame:,} game-db hits excluded (--games to keep)")
|
|
141
|
+
if ncourse: note.append(f"{ncourse:,} unmapped files excluded")
|
|
142
|
+
note.append(f"{len(hits):,} distinct {'chapters' if a.chapters else 'courses'}")
|
|
143
|
+
if thin: note.append(f"{thin:,} thin entries hidden (--all to show)")
|
|
144
|
+
print(" " + "\n ".join(note) + "\n")
|
|
145
|
+
|
|
146
|
+
print(f"{'notes':>7} {'moves':>6} {'ply':>4} course")
|
|
147
|
+
print("-" * 96)
|
|
148
|
+
for h in shown[:a.n]:
|
|
149
|
+
title = h['course'] or h['name'][:-4]
|
|
150
|
+
who = f" [{h['author']}]" if h['author'] else ''
|
|
151
|
+
print(f"{h['chars']:>7} {h['subtree']:>6} {h['ply']:>4} {title[:70]}{who}")
|
|
152
|
+
ch = h['chapter'] or '?'
|
|
153
|
+
extra = f" | {h['line']}" if h['line'] and h['line'] != ch else ''
|
|
154
|
+
print(f"{'':>19} ch: {ch}{extra}")
|
|
155
|
+
if len(shown) > a.n:
|
|
156
|
+
print(f"\n... {len(shown)-a.n:,} more (-n to raise the limit)")
|
|
157
|
+
|
|
158
|
+
main()
|