@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
package/dist/index.js
CHANGED
|
@@ -76,6 +76,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
76
76
|
"list_prep_files",
|
|
77
77
|
"search_prep_files",
|
|
78
78
|
"read_prep_file",
|
|
79
|
+
"list_nodes",
|
|
79
80
|
"create_prep_file",
|
|
80
81
|
"delete_prep_file",
|
|
81
82
|
"add_move",
|
|
@@ -94,6 +95,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
94
95
|
"deep_analyse_status",
|
|
95
96
|
"deep_analyse_cancel",
|
|
96
97
|
"find_position_in_courses",
|
|
98
|
+
"read_course_at_position",
|
|
97
99
|
"quote_engine_eval",
|
|
98
100
|
"predict_human_move",
|
|
99
101
|
"prepare_opponent",
|
|
@@ -329,10 +331,21 @@ const TOOLS = [
|
|
|
329
331
|
},
|
|
330
332
|
{
|
|
331
333
|
name: "describe_position",
|
|
332
|
-
description: "
|
|
333
|
-
"
|
|
334
|
-
"
|
|
335
|
-
"
|
|
334
|
+
description: "Everything you need to understand a position in one call. USE BEFORE COMMENTING — pieces get misplaced when reading a FEN, hanging pieces missed, 'the knight on d5' turns out to not exist. ~50-100 ms per call (chess-primitive analysis is instant; the Stockfish leg dominates wall time).\n\n" +
|
|
335
|
+
"Returns three layers:\n\n" +
|
|
336
|
+
"**Board state** — piece placements per colour, material balance in pawn units, contested pieces (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant, side to move, full LEGAL MOVES list. Use `.legalMoves` when `add_move` rejects an illegal SAN.\n\n" +
|
|
337
|
+
"**Structural analysis** — chess-concept observations a human sees at a glance:\n" +
|
|
338
|
+
" • `pawnStructure.files` — each file `open`/`half_open_for_white`/`half_open_for_black`/`closed`. Half-open files are natural rook targets.\n" +
|
|
339
|
+
" • `pawnStructure.islands` — count per colour (more = weaker structure).\n" +
|
|
340
|
+
" • `pawnStructure.isolated` / `doubled` / `passed` / `backward` — structural weaknesses (and strengths, for passed).\n" +
|
|
341
|
+
" • `weakSquares` — holes in ranks 3-6 that no friendly pawn can ever attack. Prime real estate for enemy pieces.\n" +
|
|
342
|
+
" • `outposts` — friendly N/B on an enemy hole defended by own pawn. Classic strong squares.\n" +
|
|
343
|
+
" • `bishops` — per-bishop `good`/`mixed`/`bad` from own pawns on its colour. `bishops.pair` flags who has both.\n" +
|
|
344
|
+
" • `space` — squares controlled in the enemy half.\n\n" +
|
|
345
|
+
"**Engine eval terms** (`engineEvalTerms`) — Stockfish's classical eval decomposed into 13 named contributing terms (Material, Imbalance, Pawns, Knights, Bishops, Rooks, Queens, Mobility, King safety, Threats, Passed, Space, Winnable), each with white / black / total values in mg + eg. Stockfish's own answer to WHY the position stands the way it does.\n" +
|
|
346
|
+
" → **Primary use: the delta pattern.** Call `describe_position` on the position BEFORE and AFTER a candidate move, compare `engineEvalTerms` — the term with the biggest shift tells you WHAT the move changed (king safety collapsed → move exposed the king; mobility jumped → move improved coordination). Kim et al. NAACL 2025 showed this named-delta pattern roughly doubles LLM chess-commentary correctness vs a bare eval number.\n" +
|
|
347
|
+
" → Omitted from the response if Stockfish isn't installed on the server.\n\n" +
|
|
348
|
+
"Position input: prefer `file_id`+`node_id` if inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
|
|
336
349
|
inputSchema: {
|
|
337
350
|
type: "object",
|
|
338
351
|
properties: {
|
|
@@ -345,15 +358,11 @@ const TOOLS = [
|
|
|
345
358
|
},
|
|
346
359
|
{
|
|
347
360
|
name: "predict_human_move",
|
|
348
|
-
description: "
|
|
349
|
-
"
|
|
350
|
-
"
|
|
351
|
-
"
|
|
352
|
-
"
|
|
353
|
-
"• After you've found what the opponent SHOULD do (with the engine), check what they'll ACTUALLY do at their rating. If the top human move is a mistake, you have a real practical advantage.\n" +
|
|
354
|
-
"• The WDL head is rating-aware — a 400-point gap will show up as a big win probability even in equal positions (the model has learned that human errors compound).\n" +
|
|
355
|
-
"• Pass `prev_fen` (most recent first) when analysing mid-trade positions — without history the model treats the position as quiet.\n\n" +
|
|
356
|
-
"Position input: prefer `file_id`+`node_id` when inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`. Default rating 2400 both sides. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
|
|
361
|
+
description: "Neural net (ResNet-20x256) trained on real games. Always evaluated at **2850 vs 2850** (top-level play) — the rating is fixed on purpose, so cross-position comparisons stay apples-to-apples. Returns two signals — both useful, treat as independent:\n\n" +
|
|
362
|
+
"1. **Top-N most likely moves** (`moves: [{san, p}, ...]`) — what a top player will actually pick. Different question from engines: cloud_analyse says objectively best, this says what the human will play. If the human top move is a mistake, that's a real practical advantage.\n\n" +
|
|
363
|
+
"2. **`wdlWhitePov: {win, draw, loss}`** — game-outcome prediction, White POV. Directly comparable across positions: call on two positions, compare `draw` to find which line is drawier / more forcing. Two-line comparisons are how you answer 'must-win with Black, which of these openings gives more play'.\n\n" +
|
|
364
|
+
"Pass `prev_fens` (most recent first) when the position is mid-trade — without history the model treats it as quiet, which under-counts practical chances.\n\n" +
|
|
365
|
+
"Position input: prefer `file_id`+`node_id` when inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
|
|
357
366
|
inputSchema: {
|
|
358
367
|
type: "object",
|
|
359
368
|
properties: {
|
|
@@ -364,18 +373,6 @@ const TOOLS = [
|
|
|
364
373
|
type: "string",
|
|
365
374
|
description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
366
375
|
},
|
|
367
|
-
white_elo: {
|
|
368
|
-
type: "integer",
|
|
369
|
-
minimum: 100,
|
|
370
|
-
maximum: 3400,
|
|
371
|
-
description: "White's rating (default 2400).",
|
|
372
|
-
},
|
|
373
|
-
black_elo: {
|
|
374
|
-
type: "integer",
|
|
375
|
-
minimum: 100,
|
|
376
|
-
maximum: 3400,
|
|
377
|
-
description: "Black's rating (default 2400).",
|
|
378
|
-
},
|
|
379
376
|
top: {
|
|
380
377
|
type: "integer",
|
|
381
378
|
minimum: 1,
|
|
@@ -545,17 +542,50 @@ const TOOLS = [
|
|
|
545
542
|
},
|
|
546
543
|
{
|
|
547
544
|
name: "read_prep_file",
|
|
548
|
-
description: "Read one prep file.
|
|
549
|
-
"**
|
|
545
|
+
description: "Read one prep file. Response always includes `id`, `version`, `tags`. The tree/PGN part is controlled by `view` and `node_id`/`max_depth` — large files (500+ nodes) can otherwise blow the LLM's token limit.\n\n" +
|
|
546
|
+
"**Views** (pick the smallest one that answers your question):\n" +
|
|
547
|
+
" • `compact` (default) — per-node: `id`, `san`, `ply`, `nags`, `comment`, `ceoEval`, `children`. Drops `fen` and `annotations`. Typical size: ~120 chars/node vs ~330 in `full`.\n" +
|
|
548
|
+
" • `full` — everything (`fen`, `annotations` too). Use when you actually need the FEN inline or want to inspect arrows/highlights. On a 500+-node file this can exceed token limits.\n" +
|
|
549
|
+
" • `spine` — mainline only (children[0] recursively). Great for a 'what does this repertoire cover' summary.\n" +
|
|
550
|
+
" • `pgn` — subtree as raw PGN text (comments, NAGs, [%cal] arrows all preserved). Useful for sanity-checking formatting against reference material.\n\n" +
|
|
551
|
+
"**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash of parent-id + SAN. Sibling insertions, deletions, variation promotions never shift ids. Pass as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
|
|
552
|
+
"**Scoping.** `node_id` starts the tree from a subtree root (default `'r'`). `max_depth` caps the tree at that many plies below the anchor (default unlimited). Use both to drill into a specific branch without dumping the whole file — the LLM never needs to see the full 800-node tree at once.\n\n" +
|
|
553
|
+
"For querying the tree without reading it (\"which nodes have no ceoEval?\", \"give me the mainline spine\") call `list_nodes` — cheaper than parsing a full read.\n\n" +
|
|
550
554
|
"**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_prep_position, quote_engine_eval). Use it whenever a file is open — the server derives the FEN from the tree, so you can't 'analyse the wrong position' by mis-typing a FEN.",
|
|
551
555
|
inputSchema: {
|
|
552
556
|
type: "object",
|
|
553
557
|
properties: {
|
|
554
558
|
id: { type: "string", description: "Prep file id, from list_prep_files or search_prep_files." },
|
|
559
|
+
view: { type: "string", enum: ["compact", "full", "spine", "pgn"], description: "Response shape. Default `compact` — drops fen + annotations to keep token count sane. See tool description for when to use each." },
|
|
560
|
+
node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
|
|
561
|
+
max_depth: { type: "integer", minimum: 0, description: "Cap the returned tree at this many plies below `node_id`. Omit for unlimited." },
|
|
555
562
|
},
|
|
556
563
|
required: ["id"],
|
|
557
564
|
},
|
|
558
565
|
},
|
|
566
|
+
{
|
|
567
|
+
name: "list_nodes",
|
|
568
|
+
description: "Cheap tree queries without reading the whole file. Returns only the node ids matching the filter (plus san, ply, and any filter-specific bits), so the LLM can find what it needs in ~KBs instead of MBs.\n\n" +
|
|
569
|
+
"Filters:\n" +
|
|
570
|
+
" • `missing_eval` — nodes without a stored `ceoEval`. Use before `auto_evaluate` to know how much work is left, or to target a small batch.\n" +
|
|
571
|
+
" • `has_comment` — nodes with a text comment. Use to audit what's been annotated.\n" +
|
|
572
|
+
" • `has_annotations` — nodes with arrows or highlighted squares.\n" +
|
|
573
|
+
" • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
|
|
574
|
+
" • `novelties` — nodes carrying the `$146` NAG.\n" +
|
|
575
|
+
" • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
|
|
576
|
+
" • `all` — every node id. Use only when you really need the whole list.\n\n" +
|
|
577
|
+
"Response: `{ file_id, filter, count, nodes: [{node_id, san, ply, ...}] }`. `...` is filter-specific — e.g. `has_comment` includes the first 80 chars of the comment; `missing_eval` includes nothing extra (just the addressing).",
|
|
578
|
+
inputSchema: {
|
|
579
|
+
type: "object",
|
|
580
|
+
properties: {
|
|
581
|
+
id: { type: "string", description: "Prep file id." },
|
|
582
|
+
filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "all"], description: "Which nodes to list." },
|
|
583
|
+
node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
|
|
584
|
+
max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
|
|
585
|
+
},
|
|
586
|
+
required: ["id", "filter"],
|
|
587
|
+
},
|
|
588
|
+
},
|
|
559
589
|
{
|
|
560
590
|
name: "create_prep_file",
|
|
561
591
|
description: "Create a new (empty) prep file. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
|
|
@@ -831,10 +861,13 @@ const TOOLS = [
|
|
|
831
861
|
},
|
|
832
862
|
{
|
|
833
863
|
name: "find_position_in_courses",
|
|
834
|
-
description: "Look up which of the USER's own Chessable / PGN courses cover a position
|
|
835
|
-
"
|
|
836
|
-
"
|
|
837
|
-
"
|
|
864
|
+
description: "Look up which of the USER's own Chessable / PGN courses cover a position. This is the LLM's window into what the user has personally studied — not a general database. Two-step: `find_position_in_courses` returns metadata (course, chapter, author, updated_at, notes_chars, `course_file_id`); `read_course_at_position` fetches the actual commentary + variations from a specific hit.\n\n" +
|
|
865
|
+
"Use it as a reference library, not memory. Query patterns:\n" +
|
|
866
|
+
" • 'Does my chosen line have coverage?' → search from the position, see which repertoires cover it.\n" +
|
|
867
|
+
" • 'What do opposite-colour repertoires recommend against this move?' → search, filter by author/course to the other side.\n" +
|
|
868
|
+
" • 'Has anyone tried my novelty before?' → search the position, if hits exist read the ones that reach it.\n\n" +
|
|
869
|
+
"Default sort is `recency` (most-recently-updated file first — theory shifts, 10-year-old material is less trustworthy than 2-month-old). Switch to `notes` when you specifically want the deepest annotated chapter regardless of age.\n\n" +
|
|
870
|
+
"Returns: `{fen, found, total_occurrences, sort, excluded, hits: [{course_file_id, course, file, author, chapter, line, ply, notes_chars, subtree_moves, updated_at}], truncated}`. Pass `course_file_id` to `read_course_at_position` to actually see the material.\n\n" +
|
|
838
871
|
"Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
|
|
839
872
|
inputSchema: {
|
|
840
873
|
type: "object",
|
|
@@ -843,6 +876,7 @@ const TOOLS = [
|
|
|
843
876
|
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
|
|
844
877
|
fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
|
|
845
878
|
moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
|
|
879
|
+
sort: { type: "string", enum: ["recency", "notes"], description: "Ranking. `recency` (default) = most-recently-updated file first. `notes` = deepest annotation first regardless of age." },
|
|
846
880
|
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." },
|
|
847
881
|
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." },
|
|
848
882
|
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." },
|
|
@@ -850,6 +884,27 @@ const TOOLS = [
|
|
|
850
884
|
},
|
|
851
885
|
},
|
|
852
886
|
},
|
|
887
|
+
{
|
|
888
|
+
name: "read_course_at_position",
|
|
889
|
+
description: "Read the actual commentary + variations from a course file at a specific position. Second half of the find→read pair — `find_position_in_courses` returns metadata; this returns the material itself.\n\n" +
|
|
890
|
+
"Response includes the subtree as PGN (comments, NAGs, `[%cal]`/`[%csl]` arrows all preserved), plus the moves-to-position and chapter metadata. Depth-capped by `max_plies_below` (default 20) to keep responses small — widen when you want to see deeper analysis, or call with a different `fen` to jump to another position in the same file.\n\n" +
|
|
891
|
+
"Usage patterns:\n" +
|
|
892
|
+
" • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
|
|
893
|
+
" • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
|
|
894
|
+
" • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
|
|
895
|
+
" • Compare how two authors annotate the same position → two calls with different `course_file_id`s.",
|
|
896
|
+
inputSchema: {
|
|
897
|
+
type: "object",
|
|
898
|
+
properties: {
|
|
899
|
+
course_file_id: { type: "integer", description: "File id from a `find_position_in_courses` hit (`course_file_id` field)." },
|
|
900
|
+
fen: { type: "string", description: "Position to walk to (matched by polyglot Zobrist hash, so move-order transpositions work). Omit to return the chapter from move 1." },
|
|
901
|
+
moves: { type: "string", description: "Alternative to `fen`: SAN moves from startpos." },
|
|
902
|
+
chapter: { type: "string", description: "Substring match on chapter title (the White header in the PGN). Omit to auto-pick the first chapter containing the position; supply when a course has multiple chapters and you want a specific one." },
|
|
903
|
+
max_plies_below: { type: "integer", minimum: 0, maximum: 200, description: "How many plies of subtree to include below the target position. Default 20. Cap 200." },
|
|
904
|
+
},
|
|
905
|
+
required: ["course_file_id"],
|
|
906
|
+
},
|
|
907
|
+
},
|
|
853
908
|
{
|
|
854
909
|
name: "quote_engine_eval",
|
|
855
910
|
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" +
|
|
@@ -902,9 +957,9 @@ const TOOLS = [
|
|
|
902
957
|
},
|
|
903
958
|
{
|
|
904
959
|
name: "read_example_prep_files",
|
|
905
|
-
description: "
|
|
906
|
-
"
|
|
907
|
-
"Response: `{ overview: <pgn>, repertoire: <pgn> }
|
|
960
|
+
description: "**CALL WHEN**: about to write ANY prose commentary in a prep file, ever. Even one comment. Even one variation. This is not optional and not once-per-project — call it early in the session and read the examples before your first `set_comment` or `apply_mutations` batch that includes comments. Log analysis showed <5% of sessions call this despite it being the single biggest quality lift documented in this MCP; that's the mistake this description is trying to fix.\n\n" +
|
|
961
|
+
"Why: `read_pgn_authoring_guide` tells you the rules in prose. These files show you the *sound* of them applied by a strong human coach — comment density (short and load-bearing, not verbose), how citations look in-line (`WeiYi-Svidler` not `\"Svidler's choice at the FIDE World Blitz Team, June 2026\"`), when `$146` / `$3` / `$44` earn their place, when a bare `[%csl Rf7]` says everything a sentence would say. LLMs default to florid, restate-what's-visible commentary; reading these once inoculates against that.\n\n" +
|
|
962
|
+
"Two files bundled with the MCP (not the user's own): one general opening overview (Italian Fried Liver, both sides, 1600+ audience) and one one-sided repertoire (Najdorf 6.f4 for White, 2200+ audience). Response: `{ overview: <pgn>, repertoire: <pgn> }` — raw PGN with comments, arrows, NAGs, stored evals intact.",
|
|
908
963
|
inputSchema: { type: "object", properties: {} },
|
|
909
964
|
},
|
|
910
965
|
{
|
|
@@ -1177,6 +1232,7 @@ async function autoEvaluate(args) {
|
|
|
1177
1232
|
targetCount: 0,
|
|
1178
1233
|
evaluated: 0,
|
|
1179
1234
|
errored: 0,
|
|
1235
|
+
failedNodeIds: [],
|
|
1180
1236
|
finalVersion: g.version,
|
|
1181
1237
|
startedAt: Date.now(),
|
|
1182
1238
|
finishedAt: Date.now(),
|
|
@@ -1192,6 +1248,7 @@ async function autoEvaluate(args) {
|
|
|
1192
1248
|
targetCount: targets.length,
|
|
1193
1249
|
evaluated: 0,
|
|
1194
1250
|
errored: 0,
|
|
1251
|
+
failedNodeIds: [],
|
|
1195
1252
|
startedAt: Date.now(),
|
|
1196
1253
|
cancelled: false,
|
|
1197
1254
|
};
|
|
@@ -1235,8 +1292,15 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
|
|
|
1235
1292
|
job.finalVersion = sr.version;
|
|
1236
1293
|
pending.length = 0;
|
|
1237
1294
|
};
|
|
1295
|
+
// Consecutive-failure abort. If N cloud_analyse calls in a row error,
|
|
1296
|
+
// the engine is almost certainly dead (vanished contract, network to
|
|
1297
|
+
// VastAI down) and burning through the rest of the tree just wastes
|
|
1298
|
+
// time. Bail with an explicit reason so a targeted retry is possible.
|
|
1299
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
1300
|
+
let consecutiveFailures = 0;
|
|
1301
|
+
let aborted = false;
|
|
1238
1302
|
for (const t of targets) {
|
|
1239
|
-
if (job.cancelled)
|
|
1303
|
+
if (job.cancelled || aborted)
|
|
1240
1304
|
break;
|
|
1241
1305
|
try {
|
|
1242
1306
|
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
@@ -1244,15 +1308,25 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
|
|
|
1244
1308
|
if (ev) {
|
|
1245
1309
|
pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
|
|
1246
1310
|
job.evaluated++;
|
|
1311
|
+
consecutiveFailures = 0;
|
|
1247
1312
|
}
|
|
1248
1313
|
else {
|
|
1249
1314
|
job.errored++;
|
|
1315
|
+
job.failedNodeIds.push(t.nodeId);
|
|
1316
|
+
consecutiveFailures++;
|
|
1250
1317
|
}
|
|
1251
1318
|
}
|
|
1252
1319
|
catch {
|
|
1253
|
-
// Per-node failure — record
|
|
1254
|
-
//
|
|
1320
|
+
// Per-node failure — record the node_id so the caller can retry
|
|
1321
|
+
// just those, and count consecutive failures for the abort check.
|
|
1255
1322
|
job.errored++;
|
|
1323
|
+
job.failedNodeIds.push(t.nodeId);
|
|
1324
|
+
consecutiveFailures++;
|
|
1325
|
+
}
|
|
1326
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
1327
|
+
aborted = true;
|
|
1328
|
+
job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
|
|
1329
|
+
break;
|
|
1256
1330
|
}
|
|
1257
1331
|
if (pending.length >= SAVE_EVERY_N) {
|
|
1258
1332
|
try {
|
|
@@ -1297,6 +1371,8 @@ function autoEvaluateStatus(args) {
|
|
|
1297
1371
|
target_count: job.targetCount,
|
|
1298
1372
|
evaluated: job.evaluated,
|
|
1299
1373
|
errored: job.errored,
|
|
1374
|
+
failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
|
|
1375
|
+
aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
|
|
1300
1376
|
remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
|
|
1301
1377
|
done: job.status !== "running",
|
|
1302
1378
|
error: job.error,
|
|
@@ -1475,41 +1551,74 @@ function deepAnalyseCancel(args) {
|
|
|
1475
1551
|
// available (venv at $here/.venv/bin/python preferred, then falls back
|
|
1476
1552
|
// to system python3). DB path is resolved inside fenfind.py itself
|
|
1477
1553
|
// (FENFIND_DB env, then ~/positions.db).
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1554
|
+
// Path resolution shared by both fenfind + readpgn. FENFIND_PATH env
|
|
1555
|
+
// overrides the bundled tools/fenfind/ directory.
|
|
1556
|
+
// Path to sf_eval helper (spawns local stockfish, parses its `eval`
|
|
1557
|
+
// verbose output). Uses the same resolution pattern as FENFIND_DIR.
|
|
1558
|
+
const SF_EVAL_SCRIPT = (() => {
|
|
1559
|
+
const envPath = process.env.SF_EVAL_PATH?.trim();
|
|
1560
|
+
if (envPath && existsSync(join(envPath, "sf_eval")))
|
|
1561
|
+
return join(envPath, "sf_eval");
|
|
1484
1562
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1485
|
-
const bundled = join(here, "..", "tools", "
|
|
1563
|
+
const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
|
|
1486
1564
|
return existsSync(bundled) ? bundled : null;
|
|
1487
1565
|
})();
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
const FENFIND_TIMEOUT_MS = 15_000;
|
|
1492
|
-
async function findPositionInCourses(args) {
|
|
1493
|
-
if (!FENFIND_SCRIPT) {
|
|
1566
|
+
const SF_EVAL_TIMEOUT_MS = 12_000;
|
|
1567
|
+
async function runSfEval(fen) {
|
|
1568
|
+
if (!SF_EVAL_SCRIPT) {
|
|
1494
1569
|
return {
|
|
1495
|
-
|
|
1496
|
-
|
|
1570
|
+
found: false,
|
|
1571
|
+
error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
|
|
1497
1572
|
};
|
|
1498
1573
|
}
|
|
1499
|
-
const resolved = await resolveFromNodeOrFen(args);
|
|
1500
|
-
const cliArgs = [resolved.fen, "--json"];
|
|
1501
|
-
if (args.include_games)
|
|
1502
|
-
cliArgs.push("--games");
|
|
1503
|
-
if (args.chapters_mode)
|
|
1504
|
-
cliArgs.push("--chapters");
|
|
1505
|
-
if (typeof args.min_notes_chars === "number")
|
|
1506
|
-
cliArgs.push("--min", String(args.min_notes_chars));
|
|
1507
|
-
if (typeof args.limit === "number")
|
|
1508
|
-
cliArgs.push("-n", String(args.limit));
|
|
1509
1574
|
const stdout = await new Promise((resolve, reject) => {
|
|
1510
|
-
const p = spawn(
|
|
1511
|
-
|
|
1575
|
+
const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1576
|
+
let out = "";
|
|
1577
|
+
let err = "";
|
|
1578
|
+
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
1579
|
+
p.stderr.on("data", d => { err += d.toString("utf8"); });
|
|
1580
|
+
const to = setTimeout(() => {
|
|
1581
|
+
try {
|
|
1582
|
+
p.kill("SIGTERM");
|
|
1583
|
+
}
|
|
1584
|
+
catch { /* already dead */ }
|
|
1585
|
+
reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
|
|
1586
|
+
}, SF_EVAL_TIMEOUT_MS);
|
|
1587
|
+
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
1588
|
+
p.on("close", code => {
|
|
1589
|
+
clearTimeout(to);
|
|
1590
|
+
if (code !== 0)
|
|
1591
|
+
reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
|
|
1592
|
+
else
|
|
1593
|
+
resolve(out);
|
|
1512
1594
|
});
|
|
1595
|
+
});
|
|
1596
|
+
try {
|
|
1597
|
+
return JSON.parse(stdout);
|
|
1598
|
+
}
|
|
1599
|
+
catch (e) {
|
|
1600
|
+
throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const FENFIND_DIR = (() => {
|
|
1604
|
+
const envPath = process.env.FENFIND_PATH?.trim();
|
|
1605
|
+
if (envPath && existsSync(join(envPath, "fenfind")))
|
|
1606
|
+
return envPath;
|
|
1607
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1608
|
+
const bundled = join(here, "..", "tools", "fenfind");
|
|
1609
|
+
return existsSync(join(bundled, "fenfind")) ? bundled : null;
|
|
1610
|
+
})();
|
|
1611
|
+
// Cap on how long we let the subprocess run. SQLite hash lookup returns
|
|
1612
|
+
// sub-second; PGN read from a course file is O(chapter size) and rarely
|
|
1613
|
+
// exceeds a second. 15s is a stuck-process backstop, not a real limit.
|
|
1614
|
+
const FENFIND_TIMEOUT_MS = 15_000;
|
|
1615
|
+
async function runFenfindScript(scriptName, args) {
|
|
1616
|
+
if (!FENFIND_DIR) {
|
|
1617
|
+
throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
|
|
1618
|
+
}
|
|
1619
|
+
const script = join(FENFIND_DIR, scriptName);
|
|
1620
|
+
return new Promise((resolve, reject) => {
|
|
1621
|
+
const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1513
1622
|
let out = "";
|
|
1514
1623
|
let err = "";
|
|
1515
1624
|
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
@@ -1519,25 +1628,71 @@ async function findPositionInCourses(args) {
|
|
|
1519
1628
|
p.kill("SIGTERM");
|
|
1520
1629
|
}
|
|
1521
1630
|
catch { /* already dead */ }
|
|
1522
|
-
reject(new Error(
|
|
1631
|
+
reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
|
|
1523
1632
|
}, FENFIND_TIMEOUT_MS);
|
|
1524
1633
|
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
1525
1634
|
p.on("close", code => {
|
|
1526
1635
|
clearTimeout(to);
|
|
1527
|
-
if (code !== 0)
|
|
1528
|
-
reject(new Error(
|
|
1529
|
-
|
|
1530
|
-
else {
|
|
1636
|
+
if (code !== 0)
|
|
1637
|
+
reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
|
|
1638
|
+
else
|
|
1531
1639
|
resolve(out);
|
|
1532
|
-
}
|
|
1533
1640
|
});
|
|
1534
1641
|
});
|
|
1642
|
+
}
|
|
1643
|
+
function parseFenfindJson(scriptName, stdout) {
|
|
1535
1644
|
try {
|
|
1536
1645
|
return JSON.parse(stdout);
|
|
1537
1646
|
}
|
|
1538
1647
|
catch (e) {
|
|
1539
|
-
throw new Error(
|
|
1648
|
+
throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
async function findPositionInCourses(args) {
|
|
1652
|
+
if (!FENFIND_DIR) {
|
|
1653
|
+
return {
|
|
1654
|
+
status: "not_available",
|
|
1655
|
+
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.",
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1659
|
+
const cliArgs = [resolved.fen, "--json"];
|
|
1660
|
+
if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
|
|
1661
|
+
cliArgs.push("--sort", args.sort);
|
|
1662
|
+
}
|
|
1663
|
+
if (args.include_games)
|
|
1664
|
+
cliArgs.push("--games");
|
|
1665
|
+
if (args.chapters_mode)
|
|
1666
|
+
cliArgs.push("--chapters");
|
|
1667
|
+
if (typeof args.min_notes_chars === "number")
|
|
1668
|
+
cliArgs.push("--min", String(args.min_notes_chars));
|
|
1669
|
+
if (typeof args.limit === "number")
|
|
1670
|
+
cliArgs.push("-n", String(args.limit));
|
|
1671
|
+
const stdout = await runFenfindScript("fenfind", cliArgs);
|
|
1672
|
+
return parseFenfindJson("fenfind", stdout);
|
|
1673
|
+
}
|
|
1674
|
+
async function readCourseAtPosition(args) {
|
|
1675
|
+
if (!FENFIND_DIR) {
|
|
1676
|
+
return {
|
|
1677
|
+
status: "not_available",
|
|
1678
|
+
note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
|
|
1682
|
+
if (!Number.isFinite(fileId) || fileId <= 0) {
|
|
1683
|
+
throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
|
|
1540
1684
|
}
|
|
1685
|
+
const cliArgs = ["--file-id", String(fileId)];
|
|
1686
|
+
if (typeof args.fen === "string" && args.fen.trim() !== "")
|
|
1687
|
+
cliArgs.push("--fen", args.fen.trim());
|
|
1688
|
+
if (typeof args.moves === "string" && args.moves.trim() !== "")
|
|
1689
|
+
cliArgs.push("--moves", args.moves.trim());
|
|
1690
|
+
if (typeof args.chapter === "string" && args.chapter.trim() !== "")
|
|
1691
|
+
cliArgs.push("--chapter", args.chapter.trim());
|
|
1692
|
+
if (typeof args.max_plies_below === "number")
|
|
1693
|
+
cliArgs.push("--max-plies-below", String(args.max_plies_below));
|
|
1694
|
+
const stdout = await runFenfindScript("readpgn", cliArgs);
|
|
1695
|
+
return parseFenfindJson("readpgn", stdout);
|
|
1541
1696
|
}
|
|
1542
1697
|
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
1543
1698
|
function pathIntoTree(root, path) {
|
|
@@ -1665,6 +1820,174 @@ async function applyMutation(args, mutator) {
|
|
|
1665
1820
|
version: savedRow.version,
|
|
1666
1821
|
};
|
|
1667
1822
|
}
|
|
1823
|
+
async function loadPrepFile(id) {
|
|
1824
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
1825
|
+
const g = raw;
|
|
1826
|
+
if (typeof g.pgnContent !== "string")
|
|
1827
|
+
throw new Error("prep file missing pgnContent");
|
|
1828
|
+
return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: g.id, pgn: g.pgnContent };
|
|
1829
|
+
}
|
|
1830
|
+
// Recursively project a PrepNode into the requested view. `depthLeft`
|
|
1831
|
+
// null → unlimited; 0 → just the node without children.
|
|
1832
|
+
function projectNode(node, view, depthLeft) {
|
|
1833
|
+
const base = {
|
|
1834
|
+
id: node.id,
|
|
1835
|
+
san: node.san,
|
|
1836
|
+
ply: node.ply,
|
|
1837
|
+
};
|
|
1838
|
+
if (node.nags && node.nags.length > 0)
|
|
1839
|
+
base.nags = node.nags;
|
|
1840
|
+
if (node.comment)
|
|
1841
|
+
base.comment = node.comment;
|
|
1842
|
+
if (node.ceoEval)
|
|
1843
|
+
base.ceoEval = node.ceoEval;
|
|
1844
|
+
if (view === "full") {
|
|
1845
|
+
base.fen = node.fen;
|
|
1846
|
+
if (node.annotations)
|
|
1847
|
+
base.annotations = node.annotations;
|
|
1848
|
+
}
|
|
1849
|
+
// Children handling depends on view + depth budget.
|
|
1850
|
+
const showChildren = depthLeft === null || depthLeft > 0;
|
|
1851
|
+
const childDepth = depthLeft === null ? null : depthLeft - 1;
|
|
1852
|
+
if (showChildren && node.children.length > 0) {
|
|
1853
|
+
if (view === "spine") {
|
|
1854
|
+
// Only follow children[0] — collapses the tree to the mainline.
|
|
1855
|
+
base.children = [projectNode(node.children[0], view, childDepth)];
|
|
1856
|
+
}
|
|
1857
|
+
else {
|
|
1858
|
+
base.children = node.children.map(c => projectNode(c, view, childDepth));
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
else {
|
|
1862
|
+
base.children = [];
|
|
1863
|
+
}
|
|
1864
|
+
return base;
|
|
1865
|
+
}
|
|
1866
|
+
async function readPrepFile(args) {
|
|
1867
|
+
const id = String(args.id);
|
|
1868
|
+
const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
|
|
1869
|
+
? args.view
|
|
1870
|
+
: "compact";
|
|
1871
|
+
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
|
|
1872
|
+
const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
|
|
1873
|
+
const { file, version, fileIdEcho, pgn } = await loadPrepFile(id);
|
|
1874
|
+
const idIndex = buildIdIndex(file.root);
|
|
1875
|
+
const path = resolveNodeId(idIndex, startNodeId);
|
|
1876
|
+
const anchor = getNodeByPath(file.root, path);
|
|
1877
|
+
const header = {
|
|
1878
|
+
id: fileIdEcho ?? id,
|
|
1879
|
+
version,
|
|
1880
|
+
tags: file.tags,
|
|
1881
|
+
view,
|
|
1882
|
+
node_id: startNodeId,
|
|
1883
|
+
max_depth: maxDepth,
|
|
1884
|
+
};
|
|
1885
|
+
if (view === "pgn") {
|
|
1886
|
+
// For the root, just return the file's actual PGN as-is. For a
|
|
1887
|
+
// subtree, build a mini-Game from the anchor and export it. Keeps
|
|
1888
|
+
// formatting identical to what the app renders.
|
|
1889
|
+
if (startNodeId === ROOT_ID && (maxDepth === null || maxDepth >= 999)) {
|
|
1890
|
+
return { ...header, pgn };
|
|
1891
|
+
}
|
|
1892
|
+
// Truncate to a subtree with max_depth. Simple: walk the anchor's
|
|
1893
|
+
// subtree, produce a synthetic PGN starting from the anchor's FEN.
|
|
1894
|
+
const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
|
|
1895
|
+
return { ...header, pgn: subtreePgn };
|
|
1896
|
+
}
|
|
1897
|
+
return { ...header, tree: projectNode(anchor, view, maxDepth) };
|
|
1898
|
+
}
|
|
1899
|
+
// Produce a PGN string for a subtree rooted at `anchor`, truncated
|
|
1900
|
+
// at `maxDepth` plies below (null = unlimited). Reuses the exporter
|
|
1901
|
+
// by building a synthetic PrepFile whose root is a shallow clone of
|
|
1902
|
+
// the anchor with its children trimmed to depth.
|
|
1903
|
+
function exportSubtreePgn(file, anchor, maxDepth) {
|
|
1904
|
+
const trim = (n, depthLeft) => {
|
|
1905
|
+
if (depthLeft !== null && depthLeft <= 0)
|
|
1906
|
+
return { ...n, children: [] };
|
|
1907
|
+
const next = depthLeft === null ? null : depthLeft - 1;
|
|
1908
|
+
return { ...n, children: n.children.map(c => trim(c, next)) };
|
|
1909
|
+
};
|
|
1910
|
+
const trimmedAnchor = trim(anchor, maxDepth);
|
|
1911
|
+
// If the anchor IS the root, exporter handles it. If it's an inner
|
|
1912
|
+
// node, we set the root's FEN to the anchor's position and hang the
|
|
1913
|
+
// trimmed subtree off it. Tags carried over.
|
|
1914
|
+
if (anchor.id === ROOT_ID) {
|
|
1915
|
+
return exportPGN({ tags: file.tags, root: trimmedAnchor });
|
|
1916
|
+
}
|
|
1917
|
+
const syntheticRoot = {
|
|
1918
|
+
id: ROOT_ID,
|
|
1919
|
+
san: null,
|
|
1920
|
+
fen: anchor.fen,
|
|
1921
|
+
ply: 0,
|
|
1922
|
+
children: trimmedAnchor.children,
|
|
1923
|
+
};
|
|
1924
|
+
const tags = { ...file.tags, FEN: anchor.fen, SetUp: "1" };
|
|
1925
|
+
return exportPGN({ tags, root: syntheticRoot });
|
|
1926
|
+
}
|
|
1927
|
+
async function listNodes(args) {
|
|
1928
|
+
const id = String(args.id);
|
|
1929
|
+
const filter = String(args.filter || "");
|
|
1930
|
+
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
|
|
1931
|
+
const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
|
|
1932
|
+
const { file } = await loadPrepFile(id);
|
|
1933
|
+
const idIndex = buildIdIndex(file.root);
|
|
1934
|
+
const path = resolveNodeId(idIndex, startNodeId);
|
|
1935
|
+
const anchor = getNodeByPath(file.root, path);
|
|
1936
|
+
const hits = [];
|
|
1937
|
+
const walk = (node, depthLeft, spineOnly) => {
|
|
1938
|
+
// Root has no san — never emit it as a match. Everything else is fair game.
|
|
1939
|
+
if (node.id !== ROOT_ID) {
|
|
1940
|
+
let include = false;
|
|
1941
|
+
let extra = {};
|
|
1942
|
+
switch (filter) {
|
|
1943
|
+
case "missing_eval":
|
|
1944
|
+
include = !node.ceoEval;
|
|
1945
|
+
break;
|
|
1946
|
+
case "has_comment":
|
|
1947
|
+
include = !!(node.comment && node.comment.length > 0);
|
|
1948
|
+
if (include)
|
|
1949
|
+
extra.comment_preview = (node.comment || "").slice(0, 80);
|
|
1950
|
+
break;
|
|
1951
|
+
case "has_annotations":
|
|
1952
|
+
include = !!(node.annotations && (node.annotations.arrows.length > 0 || node.annotations.highlights.length > 0));
|
|
1953
|
+
break;
|
|
1954
|
+
case "novelties":
|
|
1955
|
+
include = !!(node.nags && node.nags.includes("$146"));
|
|
1956
|
+
break;
|
|
1957
|
+
case "leaves":
|
|
1958
|
+
include = node.children.length === 0;
|
|
1959
|
+
break;
|
|
1960
|
+
case "mainline":
|
|
1961
|
+
include = spineOnly;
|
|
1962
|
+
break;
|
|
1963
|
+
case "all":
|
|
1964
|
+
include = true;
|
|
1965
|
+
break;
|
|
1966
|
+
default:
|
|
1967
|
+
throw new Error(`unknown filter: ${filter}`);
|
|
1968
|
+
}
|
|
1969
|
+
if (include) {
|
|
1970
|
+
const hit = { node_id: node.id, san: node.san, ply: node.ply };
|
|
1971
|
+
Object.assign(hit, extra);
|
|
1972
|
+
hits.push(hit);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (depthLeft !== null && depthLeft <= 0)
|
|
1976
|
+
return;
|
|
1977
|
+
const nextDepth = depthLeft === null ? null : depthLeft - 1;
|
|
1978
|
+
if (filter === "mainline" && spineOnly) {
|
|
1979
|
+
if (node.children.length > 0)
|
|
1980
|
+
walk(node.children[0], nextDepth, true);
|
|
1981
|
+
}
|
|
1982
|
+
else {
|
|
1983
|
+
for (const c of node.children)
|
|
1984
|
+
walk(c, nextDepth, filter === "mainline");
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
const rootIsSpineForFilter = filter === "mainline";
|
|
1988
|
+
walk(anchor, maxDepth, rootIsSpineForFilter);
|
|
1989
|
+
return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
|
|
1990
|
+
}
|
|
1668
1991
|
// Strip cruft the LLM doesn't need from the DB-position response.
|
|
1669
1992
|
// Called AFTER trimGamesMovetext so plyNumber survives long enough to
|
|
1670
1993
|
// slice each game's movetext. Also renames the `transpositions` field
|
|
@@ -1852,10 +2175,21 @@ function normalizeSourceForBackend(src, idx) {
|
|
|
1852
2175
|
}
|
|
1853
2176
|
return out;
|
|
1854
2177
|
}
|
|
1855
|
-
// Async resolver used by every engine / DB tool.
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
2178
|
+
// Async resolver used by every engine / DB tool. Three paths:
|
|
2179
|
+
//
|
|
2180
|
+
// 1. file_id + node_id → load file, resolve node, return FEN + handle
|
|
2181
|
+
// to persist ceoEval later. Cheapest, most explicit.
|
|
2182
|
+
//
|
|
2183
|
+
// 2. file_id + (fen | moves | line) → load file, resolve FEN
|
|
2184
|
+
// client-side, then scan the file's nodes for one matching that
|
|
2185
|
+
// FEN. If found, return the same handle as (1) so cloud_analyse
|
|
2186
|
+
// auto-stores on the matching node. Fixes the previous footgun
|
|
2187
|
+
// where `cloud_analyse({file_id, moves})` silently dropped the
|
|
2188
|
+
// eval because the server didn't try to match the resulting FEN
|
|
2189
|
+
// back to a node.
|
|
2190
|
+
//
|
|
2191
|
+
// 3. Just fen | moves | line, no file_id → scratch mode, no
|
|
2192
|
+
// persistence. Same as before.
|
|
1859
2193
|
async function resolveFromNodeOrFen(args) {
|
|
1860
2194
|
const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
|
|
1861
2195
|
const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
|
|
@@ -1870,18 +2204,51 @@ async function resolveFromNodeOrFen(args) {
|
|
|
1870
2204
|
const node = getNodeByPath(parsedFile.root, nodePath);
|
|
1871
2205
|
return {
|
|
1872
2206
|
fen: node.fen,
|
|
1873
|
-
file: {
|
|
1874
|
-
id: fileId,
|
|
1875
|
-
version: g.version ?? 0,
|
|
1876
|
-
parsedFile,
|
|
1877
|
-
idIndex,
|
|
1878
|
-
nodePath,
|
|
1879
|
-
fen: node.fen,
|
|
1880
|
-
},
|
|
2207
|
+
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath, fen: node.fen },
|
|
1881
2208
|
};
|
|
1882
2209
|
}
|
|
2210
|
+
if (fileId) {
|
|
2211
|
+
// file_id only — resolve FEN from fen/moves/line, then look it up
|
|
2212
|
+
// in the file's nodes. If a node has that FEN, treat this as if
|
|
2213
|
+
// node_id had been supplied (auto-persist on match).
|
|
2214
|
+
const fen = resolveFenFromArgs(args);
|
|
2215
|
+
try {
|
|
2216
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
|
|
2217
|
+
const g = raw;
|
|
2218
|
+
if (typeof g.pgnContent === "string") {
|
|
2219
|
+
const parsedFile = parsePGN(g.pgnContent);
|
|
2220
|
+
const match = findNodeByFen(parsedFile.root, fen);
|
|
2221
|
+
if (match) {
|
|
2222
|
+
const idIndex = buildIdIndex(parsedFile.root);
|
|
2223
|
+
return {
|
|
2224
|
+
fen,
|
|
2225
|
+
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
catch {
|
|
2231
|
+
// Best-effort: if the file load fails, fall through to scratch mode.
|
|
2232
|
+
}
|
|
2233
|
+
return { fen };
|
|
2234
|
+
}
|
|
1883
2235
|
return { fen: resolveFenFromArgs(args) };
|
|
1884
2236
|
}
|
|
2237
|
+
// Search the tree for a node whose FEN matches. Full-tree scan — trees
|
|
2238
|
+
// max out ~1000 nodes so this is fine. FEN comparison is exact string
|
|
2239
|
+
// match (both come from the same chessops normalisation).
|
|
2240
|
+
function findNodeByFen(root, targetFen) {
|
|
2241
|
+
const stack = [{ node: root, path: [] }];
|
|
2242
|
+
while (stack.length > 0) {
|
|
2243
|
+
const { node, path } = stack.pop();
|
|
2244
|
+
if (node.fen === targetFen)
|
|
2245
|
+
return { node, path };
|
|
2246
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
2247
|
+
stack.push({ node: node.children[i], path: [...path, i] });
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
1885
2252
|
// Local wrapper — the mutation module re-exports paths.getNode so this
|
|
1886
2253
|
// import stays consistent with the rest of the file's imports.
|
|
1887
2254
|
function getNodeByPath(root, path) {
|
|
@@ -1924,19 +2291,50 @@ function stringifyForLog(v) {
|
|
|
1924
2291
|
}
|
|
1925
2292
|
return s;
|
|
1926
2293
|
}
|
|
2294
|
+
// Version tag stamped on every log line so a bug report can be traced
|
|
2295
|
+
// to the exact MCP release that produced it. process.env.npm_package_version
|
|
2296
|
+
// is set by npm when the package is run via `npx` / `npm start` (and by
|
|
2297
|
+
// our systemd unit which uses npx); falls back to reading package.json
|
|
2298
|
+
// during dev when we `node dist/index.js` directly. "unknown" if all
|
|
2299
|
+
// else fails — better than pretending we know.
|
|
2300
|
+
const MCP_VERSION = (() => {
|
|
2301
|
+
const fromEnv = process.env.npm_package_version?.trim();
|
|
2302
|
+
if (fromEnv)
|
|
2303
|
+
return fromEnv;
|
|
2304
|
+
try {
|
|
2305
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2306
|
+
// dist/index.js is one level below package.json; src/index.ts is two
|
|
2307
|
+
// (src/index.ts → ../package.json). Try both.
|
|
2308
|
+
for (const p of [join(here, "..", "package.json"), join(here, "..", "..", "package.json")]) {
|
|
2309
|
+
if (existsSync(p)) {
|
|
2310
|
+
const pkg = JSON.parse(readFileSync(p, "utf8"));
|
|
2311
|
+
if (pkg.version)
|
|
2312
|
+
return pkg.version;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
catch {
|
|
2317
|
+
// fall through
|
|
2318
|
+
}
|
|
2319
|
+
return "unknown";
|
|
2320
|
+
})();
|
|
2321
|
+
const MCP_TAG = `[mcp v${MCP_VERSION}]`;
|
|
2322
|
+
// One-shot startup line so tailing the log from the beginning shows
|
|
2323
|
+
// the running version immediately, before any tool call.
|
|
2324
|
+
console.error(`${MCP_TAG} chessceo-mcp startup`);
|
|
1927
2325
|
async function callTool(name, args) {
|
|
1928
2326
|
const started = Date.now();
|
|
1929
|
-
console.error(
|
|
2327
|
+
console.error(`${MCP_TAG} IN ${name} args=${stringifyForLog(args)}`);
|
|
1930
2328
|
try {
|
|
1931
2329
|
const result = await callToolInner(name, args);
|
|
1932
2330
|
const dur = Date.now() - started;
|
|
1933
|
-
console.error(
|
|
2331
|
+
console.error(`${MCP_TAG} OUT ${name} ok ${dur}ms result=${stringifyForLog(result)}`);
|
|
1934
2332
|
return result;
|
|
1935
2333
|
}
|
|
1936
2334
|
catch (err) {
|
|
1937
2335
|
const dur = Date.now() - started;
|
|
1938
2336
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1939
|
-
console.error(
|
|
2337
|
+
console.error(`${MCP_TAG} OUT ${name} err ${dur}ms error=${JSON.stringify(msg)}`);
|
|
1940
2338
|
throw err;
|
|
1941
2339
|
}
|
|
1942
2340
|
}
|
|
@@ -2012,17 +2410,37 @@ async function callToolInner(name, args) {
|
|
|
2012
2410
|
}
|
|
2013
2411
|
case "describe_position": {
|
|
2014
2412
|
const resolved = await resolveFromNodeOrFen(args);
|
|
2015
|
-
|
|
2413
|
+
// Chess-primitive analysis (structural, ~1 ms) + Stockfish eval-
|
|
2414
|
+
// term breakdown (~50-100 ms) in parallel. Merge into one response
|
|
2415
|
+
// so the LLM sees the whole position in one call — chess-concepts,
|
|
2416
|
+
// structural weaknesses, AND engine's per-term reasoning.
|
|
2417
|
+
// If Stockfish isn't installed the eval leg returns {found: false,
|
|
2418
|
+
// error} and we drop it from the response so callers see the same
|
|
2419
|
+
// shape either way (just without `engineEvalTerms`).
|
|
2420
|
+
const [structural, evalRaw] = await Promise.all([
|
|
2421
|
+
Promise.resolve(describePosition(resolved.fen)),
|
|
2422
|
+
runSfEval(resolved.fen).catch(err => ({
|
|
2423
|
+
found: false,
|
|
2424
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2425
|
+
})),
|
|
2426
|
+
]);
|
|
2427
|
+
const merged = structural;
|
|
2428
|
+
const ev = evalRaw;
|
|
2429
|
+
if (ev && ev.found === true) {
|
|
2430
|
+
merged.engineEvalTerms = { terms: ev.terms, total: ev.total };
|
|
2431
|
+
}
|
|
2432
|
+
return merged;
|
|
2016
2433
|
}
|
|
2017
2434
|
case "predict_human_move": {
|
|
2018
2435
|
const resolved = await resolveFromNodeOrFen(args);
|
|
2019
2436
|
const fen = resolved.fen;
|
|
2437
|
+
// Rating is fixed at 2850 vs 2850. Not exposed to the LLM —
|
|
2438
|
+
// cross-position comparisons only mean something at a constant
|
|
2439
|
+
// rating, and top-level is the useful reference point for prep.
|
|
2020
2440
|
const qs = new URLSearchParams();
|
|
2021
2441
|
qs.set("fen", fen);
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
if (typeof args.black_elo === "number")
|
|
2025
|
-
qs.set("black_elo", String(args.black_elo));
|
|
2442
|
+
qs.set("white_elo", "2850");
|
|
2443
|
+
qs.set("black_elo", "2850");
|
|
2026
2444
|
if (typeof args.top === "number")
|
|
2027
2445
|
qs.set("top", String(args.top));
|
|
2028
2446
|
if (Array.isArray(args.prev_fens)) {
|
|
@@ -2031,7 +2449,20 @@ async function callToolInner(name, args) {
|
|
|
2031
2449
|
qs.append("prev_fen", p);
|
|
2032
2450
|
}
|
|
2033
2451
|
}
|
|
2034
|
-
|
|
2452
|
+
const raw = await authedRequest("GET", `/api/agent/predict-move?${qs.toString()}`);
|
|
2453
|
+
// Strip uci from each move — san is enough for the LLM and the
|
|
2454
|
+
// duplicate field is context bloat. Also drop whiteElo/blackElo
|
|
2455
|
+
// from the response (always 2850 now — echoing them adds nothing).
|
|
2456
|
+
if (raw && typeof raw === "object") {
|
|
2457
|
+
const r = raw;
|
|
2458
|
+
if (Array.isArray(r.moves)) {
|
|
2459
|
+
for (const m of r.moves)
|
|
2460
|
+
delete m.uci;
|
|
2461
|
+
}
|
|
2462
|
+
delete r.whiteElo;
|
|
2463
|
+
delete r.blackElo;
|
|
2464
|
+
}
|
|
2465
|
+
return raw;
|
|
2035
2466
|
}
|
|
2036
2467
|
case "get_head_to_head":
|
|
2037
2468
|
return get("/api/chess/players/h2h", {
|
|
@@ -2094,23 +2525,16 @@ async function callToolInner(name, args) {
|
|
|
2094
2525
|
return deepAnalyseCancel(args);
|
|
2095
2526
|
case "find_position_in_courses":
|
|
2096
2527
|
return findPositionInCourses(args);
|
|
2528
|
+
case "read_course_at_position":
|
|
2529
|
+
return readCourseAtPosition(args);
|
|
2097
2530
|
case "list_prep_files":
|
|
2098
2531
|
return authedRequest("GET", "/api/agent/prep-files");
|
|
2099
2532
|
case "search_prep_files":
|
|
2100
2533
|
return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
|
|
2101
|
-
case "read_prep_file":
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
throw new Error("prep file missing pgnContent");
|
|
2106
|
-
const file = parsePGN(g.pgnContent);
|
|
2107
|
-
return {
|
|
2108
|
-
id: g.id,
|
|
2109
|
-
version: g.version,
|
|
2110
|
-
tags: file.tags,
|
|
2111
|
-
tree: file.root,
|
|
2112
|
-
};
|
|
2113
|
-
}
|
|
2534
|
+
case "read_prep_file":
|
|
2535
|
+
return readPrepFile(args);
|
|
2536
|
+
case "list_nodes":
|
|
2537
|
+
return listNodes(args);
|
|
2114
2538
|
case "create_prep_file":
|
|
2115
2539
|
return authedRequest("POST", "/api/agent/prep-files", {
|
|
2116
2540
|
name: String(args.name),
|