@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 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 { readFileSync } from "node:fs";
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 `read_prep_strategy_guide` before recommending an opening plan.",
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 `read_prep_strategy_guide`.",
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: "read_prep_strategy_guide",
858
- description: "Returns the full chess.ceo prep-strategy guide: 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 use move-order tricks with the `trs` field, and how to calibrate surprise (rare secondary lines inside the existing repertoire, not big first-move switches). Call this ONCE per session before recommending an opening plan, especially when the user is preparing for a specific real opponent.",
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: "Returns the guide to the prep-files FEATURE — how the storage works, when to list vs search vs create, optimistic locking with `version`, and naming conventions for the [Event] tag. Call this ONCE per session before your first create_prep_file. For how to actually WRITE PGN (mainline, variations, NAGs, arrows, comments) call read_pgn_authoring_guide instead — separate concern.",
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
- const whiteToMove = / w /.test(fen);
1446
- const flip = (n) => (whiteToMove ? n : -n);
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: flip(line.mate), depth };
1569
+ return { mate: line.mate, depth };
1454
1570
  if (typeof line.scoreCp === "number")
1455
- return { cp: flip(line.scoreCp), depth };
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 "read_prep_strategy_guide":
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.34.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()