@chessceo/mcp 0.34.0 → 0.35.0
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 +107 -1
- 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",
|
|
@@ -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" +
|
|
@@ -1423,6 +1446,87 @@ function deepAnalyseCancel(args) {
|
|
|
1423
1446
|
// flight abort of the HTTP call is a follow-up).
|
|
1424
1447
|
return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
|
|
1425
1448
|
}
|
|
1449
|
+
// ── find_position_in_courses: fenfind subprocess wrapper ───────────
|
|
1450
|
+
//
|
|
1451
|
+
// fenfind is a small python tool that indexes chess PGN files by
|
|
1452
|
+
// polyglot Zobrist hash. Given a position it returns which of the
|
|
1453
|
+
// user's Chessable / PGN files cover it, ranked by how much annotated
|
|
1454
|
+
// material sits below that position in each course. Runs as a
|
|
1455
|
+
// subprocess of the MCP so we can reuse python-chess's polyglot
|
|
1456
|
+
// hashing (matching the pre-built positions.db) instead of porting
|
|
1457
|
+
// the hash function to TS.
|
|
1458
|
+
//
|
|
1459
|
+
// Path resolution order (`FENFIND_PATH` env var overrides):
|
|
1460
|
+
// 1. $FENFIND_PATH/fenfind
|
|
1461
|
+
// 2. <package-root>/tools/fenfind/fenfind (ships with the npm package)
|
|
1462
|
+
// The bash wrapper picks a python interpreter with python-chess
|
|
1463
|
+
// available (venv at $here/.venv/bin/python preferred, then falls back
|
|
1464
|
+
// to system python3). DB path is resolved inside fenfind.py itself
|
|
1465
|
+
// (FENFIND_DB env, then ~/positions.db).
|
|
1466
|
+
const FENFIND_SCRIPT = (() => {
|
|
1467
|
+
const envPath = process.env.FENFIND_PATH?.trim();
|
|
1468
|
+
if (envPath) {
|
|
1469
|
+
const p = join(envPath, "fenfind");
|
|
1470
|
+
return existsSync(p) ? p : null;
|
|
1471
|
+
}
|
|
1472
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1473
|
+
const bundled = join(here, "..", "tools", "fenfind", "fenfind");
|
|
1474
|
+
return existsSync(bundled) ? bundled : null;
|
|
1475
|
+
})();
|
|
1476
|
+
// Cap on how long we let fenfind run — SQLite query with a hash index
|
|
1477
|
+
// should return in well under a second, but a stuck subprocess
|
|
1478
|
+
// shouldn't pin the MCP handler.
|
|
1479
|
+
const FENFIND_TIMEOUT_MS = 15_000;
|
|
1480
|
+
async function findPositionInCourses(args) {
|
|
1481
|
+
if (!FENFIND_SCRIPT) {
|
|
1482
|
+
return {
|
|
1483
|
+
status: "not_available",
|
|
1484
|
+
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.",
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1488
|
+
const cliArgs = [resolved.fen, "--json"];
|
|
1489
|
+
if (args.include_games)
|
|
1490
|
+
cliArgs.push("--games");
|
|
1491
|
+
if (args.chapters_mode)
|
|
1492
|
+
cliArgs.push("--chapters");
|
|
1493
|
+
if (typeof args.min_notes_chars === "number")
|
|
1494
|
+
cliArgs.push("--min", String(args.min_notes_chars));
|
|
1495
|
+
if (typeof args.limit === "number")
|
|
1496
|
+
cliArgs.push("-n", String(args.limit));
|
|
1497
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
1498
|
+
const p = spawn(FENFIND_SCRIPT, cliArgs, {
|
|
1499
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1500
|
+
});
|
|
1501
|
+
let out = "";
|
|
1502
|
+
let err = "";
|
|
1503
|
+
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
1504
|
+
p.stderr.on("data", d => { err += d.toString("utf8"); });
|
|
1505
|
+
const to = setTimeout(() => {
|
|
1506
|
+
try {
|
|
1507
|
+
p.kill("SIGTERM");
|
|
1508
|
+
}
|
|
1509
|
+
catch { /* already dead */ }
|
|
1510
|
+
reject(new Error(`fenfind timed out after ${FENFIND_TIMEOUT_MS}ms`));
|
|
1511
|
+
}, FENFIND_TIMEOUT_MS);
|
|
1512
|
+
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
1513
|
+
p.on("close", code => {
|
|
1514
|
+
clearTimeout(to);
|
|
1515
|
+
if (code !== 0) {
|
|
1516
|
+
reject(new Error(`fenfind exited ${code}: ${err.slice(0, 500)}`));
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
resolve(out);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
try {
|
|
1524
|
+
return JSON.parse(stdout);
|
|
1525
|
+
}
|
|
1526
|
+
catch (e) {
|
|
1527
|
+
throw new Error(`fenfind returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1426
1530
|
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
1427
1531
|
function pathIntoTree(root, path) {
|
|
1428
1532
|
let cur = root;
|
|
@@ -1972,6 +2076,8 @@ async function callToolInner(name, args) {
|
|
|
1972
2076
|
return deepAnalyseStatus(args);
|
|
1973
2077
|
case "deep_analyse_cancel":
|
|
1974
2078
|
return deepAnalyseCancel(args);
|
|
2079
|
+
case "find_position_in_courses":
|
|
2080
|
+
return findPositionInCourses(args);
|
|
1975
2081
|
case "list_prep_files":
|
|
1976
2082
|
return authedRequest("GET", "/api/agent/prep-files");
|
|
1977
2083
|
case "search_prep_files":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chessceo/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
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()
|