@chessceo/mcp 0.36.6 → 0.40.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 +673 -117
- package/dist/pgn/describe.js +268 -2
- package/dist/pgn/mutations.js +22 -0
- package/dist/pgn/paths.js +32 -0
- package/docs/engine-usage.md +40 -0
- package/docs/pgn-authoring.md +26 -8
- package/docs/prep-files-guide.md +4 -0
- package/docs/prep-strategy.md +27 -0
- 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
|
@@ -16,8 +16,8 @@ import { Chess } from "chess.js";
|
|
|
16
16
|
import { parsePGN } from "./pgn/parser.js";
|
|
17
17
|
import { exportPGN } from "./pgn/exporter.js";
|
|
18
18
|
import { describePosition } from "./pgn/describe.js";
|
|
19
|
-
import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
20
|
-
import { buildIdIndex, NodeIdError, PathError, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
|
|
19
|
+
import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setCeoEvalMany, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
20
|
+
import { buildFenIndex, buildIdIndex, NodeIdError, PathError, positionKey, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
|
|
21
21
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
23
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -76,6 +76,8 @@ const AUTHED_TOOLS = new Set([
|
|
|
76
76
|
"list_prep_files",
|
|
77
77
|
"search_prep_files",
|
|
78
78
|
"read_prep_file",
|
|
79
|
+
"list_nodes",
|
|
80
|
+
"list_transpositions",
|
|
79
81
|
"create_prep_file",
|
|
80
82
|
"delete_prep_file",
|
|
81
83
|
"add_move",
|
|
@@ -94,6 +96,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
94
96
|
"deep_analyse_status",
|
|
95
97
|
"deep_analyse_cancel",
|
|
96
98
|
"find_position_in_courses",
|
|
99
|
+
"read_course_at_position",
|
|
97
100
|
"quote_engine_eval",
|
|
98
101
|
"predict_human_move",
|
|
99
102
|
"prepare_opponent",
|
|
@@ -329,10 +332,21 @@ const TOOLS = [
|
|
|
329
332
|
},
|
|
330
333
|
{
|
|
331
334
|
name: "describe_position",
|
|
332
|
-
description: "
|
|
333
|
-
"
|
|
334
|
-
"
|
|
335
|
-
"
|
|
335
|
+
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" +
|
|
336
|
+
"Returns three layers:\n\n" +
|
|
337
|
+
"**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" +
|
|
338
|
+
"**Structural analysis** — chess-concept observations a human sees at a glance:\n" +
|
|
339
|
+
" • `pawnStructure.files` — each file `open`/`half_open_for_white`/`half_open_for_black`/`closed`. Half-open files are natural rook targets.\n" +
|
|
340
|
+
" • `pawnStructure.islands` — count per colour (more = weaker structure).\n" +
|
|
341
|
+
" • `pawnStructure.isolated` / `doubled` / `passed` / `backward` — structural weaknesses (and strengths, for passed).\n" +
|
|
342
|
+
" • `weakSquares` — holes in ranks 3-6 that no friendly pawn can ever attack. Prime real estate for enemy pieces.\n" +
|
|
343
|
+
" • `outposts` — friendly N/B on an enemy hole defended by own pawn. Classic strong squares.\n" +
|
|
344
|
+
" • `bishops` — per-bishop `good`/`mixed`/`bad` from own pawns on its colour. `bishops.pair` flags who has both.\n" +
|
|
345
|
+
" • `space` — squares controlled in the enemy half.\n\n" +
|
|
346
|
+
"**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" +
|
|
347
|
+
" → **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" +
|
|
348
|
+
" → Omitted from the response if Stockfish isn't installed on the server.\n\n" +
|
|
349
|
+
"Position input: prefer `file_id`+`node_id` if inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
|
|
336
350
|
inputSchema: {
|
|
337
351
|
type: "object",
|
|
338
352
|
properties: {
|
|
@@ -345,15 +359,11 @@ const TOOLS = [
|
|
|
345
359
|
},
|
|
346
360
|
{
|
|
347
361
|
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.",
|
|
362
|
+
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" +
|
|
363
|
+
"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" +
|
|
364
|
+
"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" +
|
|
365
|
+
"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" +
|
|
366
|
+
"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
367
|
inputSchema: {
|
|
358
368
|
type: "object",
|
|
359
369
|
properties: {
|
|
@@ -364,18 +374,6 @@ const TOOLS = [
|
|
|
364
374
|
type: "string",
|
|
365
375
|
description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
366
376
|
},
|
|
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
377
|
top: {
|
|
380
378
|
type: "integer",
|
|
381
379
|
minimum: 1,
|
|
@@ -545,13 +543,61 @@ const TOOLS = [
|
|
|
545
543
|
},
|
|
546
544
|
{
|
|
547
545
|
name: "read_prep_file",
|
|
548
|
-
description: "Read one prep file.
|
|
549
|
-
"**
|
|
546
|
+
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" +
|
|
547
|
+
"**Views** (pick the smallest one that answers your question):\n" +
|
|
548
|
+
" • `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" +
|
|
549
|
+
" • `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" +
|
|
550
|
+
" • `spine` — mainline only (children[0] recursively). Great for a 'what does this repertoire cover' summary.\n" +
|
|
551
|
+
" • `pgn` — subtree as raw PGN text (comments, NAGs, [%cal] arrows all preserved). Useful for sanity-checking formatting against reference material.\n\n" +
|
|
552
|
+
"**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" +
|
|
553
|
+
"**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" +
|
|
554
|
+
"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
555
|
"**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
556
|
inputSchema: {
|
|
552
557
|
type: "object",
|
|
553
558
|
properties: {
|
|
554
559
|
id: { type: "string", description: "Prep file id, from list_prep_files or search_prep_files." },
|
|
560
|
+
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." },
|
|
561
|
+
node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
|
|
562
|
+
max_depth: { type: "integer", minimum: 0, description: "Cap the returned tree at this many plies below `node_id`. Omit for unlimited." },
|
|
563
|
+
},
|
|
564
|
+
required: ["id"],
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "list_nodes",
|
|
569
|
+
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" +
|
|
570
|
+
"Filters:\n" +
|
|
571
|
+
" • `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" +
|
|
572
|
+
" • `has_comment` — nodes with a text comment. Use to audit what's been annotated.\n" +
|
|
573
|
+
" • `has_annotations` — nodes with arrows or highlighted squares.\n" +
|
|
574
|
+
" • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
|
|
575
|
+
" • `novelties` — nodes carrying the `$146` NAG.\n" +
|
|
576
|
+
" • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
|
|
577
|
+
" • `transpositions` — nodes that share their position with at least one other node in the same file (piece placement + side to move + castling rights match). Response includes `transposes_to: [node_id, …]` per hit so you can see the partners without a second call. Use this BEFORE auto_evaluate on a large branch to see where analysis will double up, and BEFORE writing prose to know which nodes can share commentary via 'transposes to line X'.\n" +
|
|
578
|
+
" • `all` — every node id. Use only when you really need the whole list.\n\n" +
|
|
579
|
+
"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; `transpositions` includes `transposes_to`; `missing_eval` includes nothing extra (just the addressing).",
|
|
580
|
+
inputSchema: {
|
|
581
|
+
type: "object",
|
|
582
|
+
properties: {
|
|
583
|
+
id: { type: "string", description: "Prep file id." },
|
|
584
|
+
filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "transpositions", "all"], description: "Which nodes to list." },
|
|
585
|
+
node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
|
|
586
|
+
max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
|
|
587
|
+
},
|
|
588
|
+
required: ["id", "filter"],
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
name: "list_transpositions",
|
|
593
|
+
description: "Group every position in a prep file that appears more than once — the same piece placement + side-to-move + castling rights reached by different move orders. Chess move orders diverge and re-converge constantly (1.d4 Nf6 2.c4 e6 3.Nc3 vs 1.c4 e6 2.Nc3 Nf6 3.d4 land on the same position); if you analyse both branches independently or write the same commentary twice, you're wasting engine time and inviting inconsistency.\n\n" +
|
|
594
|
+
"Call this BEFORE `auto_evaluate` on a big subtree to see how much work will actually be new, and BEFORE writing prose to know which nodes can share a comment or should point at each other with 'transposes to line X'.\n\n" +
|
|
595
|
+
"Note: engine evals auto-propagate — when `cloud_analyse({file_id, node_id})` stores `ceoEval` on a node, it also stamps every transposition of that position in the same file (see the response's `also_stored_on`). And `auto_evaluate({only_missing: true})` naturally skips the twin because it now has an eval. So detection is cheap AND propagation is automatic; this tool is for prose planning and one-shot audits, not for gating engine work.\n\n" +
|
|
596
|
+
"Response: `{ file_id, group_count, node_count, groups: [{ position_key, size, node_ids, sans }] }`. `position_key` is the 3-field FEN prefix used as the match key; `size` is how many nodes share it; `sans` are the moves that led to each occurrence (parallel with `node_ids`, DFS order — first entry is the earliest/mainline-preferred occurrence). Only groups with size ≥ 2 are returned; sorted by size descending.",
|
|
597
|
+
inputSchema: {
|
|
598
|
+
type: "object",
|
|
599
|
+
properties: {
|
|
600
|
+
id: { type: "string", description: "Prep file id." },
|
|
555
601
|
},
|
|
556
602
|
required: ["id"],
|
|
557
603
|
},
|
|
@@ -831,10 +877,15 @@ const TOOLS = [
|
|
|
831
877
|
},
|
|
832
878
|
{
|
|
833
879
|
name: "find_position_in_courses",
|
|
834
|
-
description: "Look up which of the USER's own Chessable / PGN courses cover a position
|
|
835
|
-
"
|
|
836
|
-
"Use
|
|
837
|
-
"
|
|
880
|
+
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" +
|
|
881
|
+
"**Read multiple hits, not just the top one.** A search commonly returns 3-10 courses covering the same position. Different authors recommend different moves, weight lines differently, and disagree about which sidelines matter — that disagreement is exactly the information you want. Default assumption: read the top 3-5 hits by recency, more if the position is critical (novelty candidate, main-line trunk, sharp tactical junction). Reading only the first hit gives you one author's opinion; reading five gives you the actual state of theory as your user's library sees it.\n\n" +
|
|
882
|
+
"Use it as a reference library, not memory. Query patterns:\n" +
|
|
883
|
+
" • 'Does my chosen line have coverage?' → search from the position, read multiple hits, see whether the field agrees on the main response.\n" +
|
|
884
|
+
" • 'What do opposite-colour repertoires recommend against this move?' → search, then read every hit whose author/course maps to the other side.\n" +
|
|
885
|
+
" • 'Has anyone tried my novelty before?' → search the position, if hits exist read all of them (a novelty that appears in ONE 2019 course is still a novelty to serious opponents; a novelty covered by three 2025 courses is not).\n" +
|
|
886
|
+
" • 'What are the main disagreements between authors?' → read the top 3-5 hits, diff the recommended moves against each other; if two Chessable authors branch differently at move 8, that's a decision point worth annotating in your own file.\n\n" +
|
|
887
|
+
"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" +
|
|
888
|
+
"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 — and pass it more than once, on the top few hits, not just the first one.\n\n" +
|
|
838
889
|
"Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
|
|
839
890
|
inputSchema: {
|
|
840
891
|
type: "object",
|
|
@@ -843,6 +894,7 @@ const TOOLS = [
|
|
|
843
894
|
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
|
|
844
895
|
fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
|
|
845
896
|
moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
|
|
897
|
+
sort: { type: "string", enum: ["recency", "notes"], description: "Ranking. `recency` (default) = most-recently-updated file first. `notes` = deepest annotation first regardless of age." },
|
|
846
898
|
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
899
|
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
900
|
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 +902,28 @@ const TOOLS = [
|
|
|
850
902
|
},
|
|
851
903
|
},
|
|
852
904
|
},
|
|
905
|
+
{
|
|
906
|
+
name: "read_course_at_position",
|
|
907
|
+
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" +
|
|
908
|
+
"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" +
|
|
909
|
+
"**Called once per search is a smell.** When `find_position_in_courses` returned 5 hits and you only read the first, you have 1 author's view of the position, not a survey. Read the top 3-5 hits by default; compare their recommendations and disagreements — that comparison is the value the user's library provides over your training data.\n\n" +
|
|
910
|
+
"Usage patterns:\n" +
|
|
911
|
+
" • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
|
|
912
|
+
" • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
|
|
913
|
+
" • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
|
|
914
|
+
" • **Compare how multiple authors annotate the same position → several calls with different `course_file_id`s (this is the common case, not the exception).** If the top hits recommend different moves, that's a decision point worth annotating with the disagreement itself.",
|
|
915
|
+
inputSchema: {
|
|
916
|
+
type: "object",
|
|
917
|
+
properties: {
|
|
918
|
+
course_file_id: { type: "integer", description: "File id from a `find_position_in_courses` hit (`course_file_id` field)." },
|
|
919
|
+
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." },
|
|
920
|
+
moves: { type: "string", description: "Alternative to `fen`: SAN moves from startpos." },
|
|
921
|
+
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." },
|
|
922
|
+
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." },
|
|
923
|
+
},
|
|
924
|
+
required: ["course_file_id"],
|
|
925
|
+
},
|
|
926
|
+
},
|
|
853
927
|
{
|
|
854
928
|
name: "quote_engine_eval",
|
|
855
929
|
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 +976,9 @@ const TOOLS = [
|
|
|
902
976
|
},
|
|
903
977
|
{
|
|
904
978
|
name: "read_example_prep_files",
|
|
905
|
-
description: "
|
|
906
|
-
"
|
|
907
|
-
"Response: `{ overview: <pgn>, repertoire: <pgn> }
|
|
979
|
+
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" +
|
|
980
|
+
"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" +
|
|
981
|
+
"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
982
|
inputSchema: { type: "object", properties: {} },
|
|
909
983
|
},
|
|
910
984
|
{
|
|
@@ -1166,6 +1240,29 @@ async function autoEvaluate(args) {
|
|
|
1166
1240
|
// If the caller anchored at the root, skip evaluating the root itself
|
|
1167
1241
|
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
1168
1242
|
walk(startNode, startNode.id === ROOT_ID);
|
|
1243
|
+
// Dedup transpositions: if two candidate targets share the same
|
|
1244
|
+
// 3-field FEN key, they're the same position reached by different
|
|
1245
|
+
// move orders. Analyse ONE of them — cloud_analyse auto-propagates
|
|
1246
|
+
// the resulting ceoEval to every other node with a matching key
|
|
1247
|
+
// (see storeEvalOnNode), so the twin ends up with the same eval
|
|
1248
|
+
// without a second engine call. Keep DFS-first (mainline-preferred)
|
|
1249
|
+
// occurrence.
|
|
1250
|
+
let skippedTranspositions = 0;
|
|
1251
|
+
{
|
|
1252
|
+
const seen = new Set();
|
|
1253
|
+
const deduped = [];
|
|
1254
|
+
for (const t of targets) {
|
|
1255
|
+
const key = positionKey(t.fen);
|
|
1256
|
+
if (seen.has(key)) {
|
|
1257
|
+
skippedTranspositions++;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
seen.add(key);
|
|
1261
|
+
deduped.push(t);
|
|
1262
|
+
}
|
|
1263
|
+
targets.length = 0;
|
|
1264
|
+
targets.push(...deduped);
|
|
1265
|
+
}
|
|
1169
1266
|
// Nothing to do → return a done job synthetically so the caller doesn't
|
|
1170
1267
|
// need to special-case the empty response.
|
|
1171
1268
|
if (targets.length === 0) {
|
|
@@ -1177,6 +1274,7 @@ async function autoEvaluate(args) {
|
|
|
1177
1274
|
targetCount: 0,
|
|
1178
1275
|
evaluated: 0,
|
|
1179
1276
|
errored: 0,
|
|
1277
|
+
failedNodeIds: [],
|
|
1180
1278
|
finalVersion: g.version,
|
|
1181
1279
|
startedAt: Date.now(),
|
|
1182
1280
|
finishedAt: Date.now(),
|
|
@@ -1192,6 +1290,7 @@ async function autoEvaluate(args) {
|
|
|
1192
1290
|
targetCount: targets.length,
|
|
1193
1291
|
evaluated: 0,
|
|
1194
1292
|
errored: 0,
|
|
1293
|
+
failedNodeIds: [],
|
|
1195
1294
|
startedAt: Date.now(),
|
|
1196
1295
|
cancelled: false,
|
|
1197
1296
|
};
|
|
@@ -1207,6 +1306,9 @@ async function autoEvaluate(args) {
|
|
|
1207
1306
|
return {
|
|
1208
1307
|
job_id: jobId,
|
|
1209
1308
|
target_count: targets.length,
|
|
1309
|
+
// Transpositions inside the walk that we skipped because they'll
|
|
1310
|
+
// pick up the eval via auto-propagation. Zero when there are none.
|
|
1311
|
+
skipped_transpositions: skippedTranspositions,
|
|
1210
1312
|
status: "running",
|
|
1211
1313
|
// Rough time estimate at the current default movetime. Serialization
|
|
1212
1314
|
// on the per-combo semaphore means walltime ≈ target_count × movetime.
|
|
@@ -1235,8 +1337,15 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
|
|
|
1235
1337
|
job.finalVersion = sr.version;
|
|
1236
1338
|
pending.length = 0;
|
|
1237
1339
|
};
|
|
1340
|
+
// Consecutive-failure abort. If N cloud_analyse calls in a row error,
|
|
1341
|
+
// the engine is almost certainly dead (vanished contract, network to
|
|
1342
|
+
// VastAI down) and burning through the rest of the tree just wastes
|
|
1343
|
+
// time. Bail with an explicit reason so a targeted retry is possible.
|
|
1344
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
1345
|
+
let consecutiveFailures = 0;
|
|
1346
|
+
let aborted = false;
|
|
1238
1347
|
for (const t of targets) {
|
|
1239
|
-
if (job.cancelled)
|
|
1348
|
+
if (job.cancelled || aborted)
|
|
1240
1349
|
break;
|
|
1241
1350
|
try {
|
|
1242
1351
|
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
@@ -1244,15 +1353,25 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
|
|
|
1244
1353
|
if (ev) {
|
|
1245
1354
|
pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
|
|
1246
1355
|
job.evaluated++;
|
|
1356
|
+
consecutiveFailures = 0;
|
|
1247
1357
|
}
|
|
1248
1358
|
else {
|
|
1249
1359
|
job.errored++;
|
|
1360
|
+
job.failedNodeIds.push(t.nodeId);
|
|
1361
|
+
consecutiveFailures++;
|
|
1250
1362
|
}
|
|
1251
1363
|
}
|
|
1252
1364
|
catch {
|
|
1253
|
-
// Per-node failure — record
|
|
1254
|
-
//
|
|
1365
|
+
// Per-node failure — record the node_id so the caller can retry
|
|
1366
|
+
// just those, and count consecutive failures for the abort check.
|
|
1255
1367
|
job.errored++;
|
|
1368
|
+
job.failedNodeIds.push(t.nodeId);
|
|
1369
|
+
consecutiveFailures++;
|
|
1370
|
+
}
|
|
1371
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
1372
|
+
aborted = true;
|
|
1373
|
+
job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
|
|
1374
|
+
break;
|
|
1256
1375
|
}
|
|
1257
1376
|
if (pending.length >= SAVE_EVERY_N) {
|
|
1258
1377
|
try {
|
|
@@ -1297,6 +1416,8 @@ function autoEvaluateStatus(args) {
|
|
|
1297
1416
|
target_count: job.targetCount,
|
|
1298
1417
|
evaluated: job.evaluated,
|
|
1299
1418
|
errored: job.errored,
|
|
1419
|
+
failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
|
|
1420
|
+
aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
|
|
1300
1421
|
remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
|
|
1301
1422
|
done: job.status !== "running",
|
|
1302
1423
|
error: job.error,
|
|
@@ -1475,41 +1596,74 @@ function deepAnalyseCancel(args) {
|
|
|
1475
1596
|
// available (venv at $here/.venv/bin/python preferred, then falls back
|
|
1476
1597
|
// to system python3). DB path is resolved inside fenfind.py itself
|
|
1477
1598
|
// (FENFIND_DB env, then ~/positions.db).
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1599
|
+
// Path resolution shared by both fenfind + readpgn. FENFIND_PATH env
|
|
1600
|
+
// overrides the bundled tools/fenfind/ directory.
|
|
1601
|
+
// Path to sf_eval helper (spawns local stockfish, parses its `eval`
|
|
1602
|
+
// verbose output). Uses the same resolution pattern as FENFIND_DIR.
|
|
1603
|
+
const SF_EVAL_SCRIPT = (() => {
|
|
1604
|
+
const envPath = process.env.SF_EVAL_PATH?.trim();
|
|
1605
|
+
if (envPath && existsSync(join(envPath, "sf_eval")))
|
|
1606
|
+
return join(envPath, "sf_eval");
|
|
1484
1607
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1485
|
-
const bundled = join(here, "..", "tools", "
|
|
1608
|
+
const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
|
|
1486
1609
|
return existsSync(bundled) ? bundled : null;
|
|
1487
1610
|
})();
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
const FENFIND_TIMEOUT_MS = 15_000;
|
|
1492
|
-
async function findPositionInCourses(args) {
|
|
1493
|
-
if (!FENFIND_SCRIPT) {
|
|
1611
|
+
const SF_EVAL_TIMEOUT_MS = 12_000;
|
|
1612
|
+
async function runSfEval(fen) {
|
|
1613
|
+
if (!SF_EVAL_SCRIPT) {
|
|
1494
1614
|
return {
|
|
1495
|
-
|
|
1496
|
-
|
|
1615
|
+
found: false,
|
|
1616
|
+
error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
|
|
1497
1617
|
};
|
|
1498
1618
|
}
|
|
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
1619
|
const stdout = await new Promise((resolve, reject) => {
|
|
1510
|
-
const p = spawn(
|
|
1511
|
-
|
|
1620
|
+
const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1621
|
+
let out = "";
|
|
1622
|
+
let err = "";
|
|
1623
|
+
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
1624
|
+
p.stderr.on("data", d => { err += d.toString("utf8"); });
|
|
1625
|
+
const to = setTimeout(() => {
|
|
1626
|
+
try {
|
|
1627
|
+
p.kill("SIGTERM");
|
|
1628
|
+
}
|
|
1629
|
+
catch { /* already dead */ }
|
|
1630
|
+
reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
|
|
1631
|
+
}, SF_EVAL_TIMEOUT_MS);
|
|
1632
|
+
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
1633
|
+
p.on("close", code => {
|
|
1634
|
+
clearTimeout(to);
|
|
1635
|
+
if (code !== 0)
|
|
1636
|
+
reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
|
|
1637
|
+
else
|
|
1638
|
+
resolve(out);
|
|
1512
1639
|
});
|
|
1640
|
+
});
|
|
1641
|
+
try {
|
|
1642
|
+
return JSON.parse(stdout);
|
|
1643
|
+
}
|
|
1644
|
+
catch (e) {
|
|
1645
|
+
throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
const FENFIND_DIR = (() => {
|
|
1649
|
+
const envPath = process.env.FENFIND_PATH?.trim();
|
|
1650
|
+
if (envPath && existsSync(join(envPath, "fenfind")))
|
|
1651
|
+
return envPath;
|
|
1652
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1653
|
+
const bundled = join(here, "..", "tools", "fenfind");
|
|
1654
|
+
return existsSync(join(bundled, "fenfind")) ? bundled : null;
|
|
1655
|
+
})();
|
|
1656
|
+
// Cap on how long we let the subprocess run. SQLite hash lookup returns
|
|
1657
|
+
// sub-second; PGN read from a course file is O(chapter size) and rarely
|
|
1658
|
+
// exceeds a second. 15s is a stuck-process backstop, not a real limit.
|
|
1659
|
+
const FENFIND_TIMEOUT_MS = 15_000;
|
|
1660
|
+
async function runFenfindScript(scriptName, args) {
|
|
1661
|
+
if (!FENFIND_DIR) {
|
|
1662
|
+
throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
|
|
1663
|
+
}
|
|
1664
|
+
const script = join(FENFIND_DIR, scriptName);
|
|
1665
|
+
return new Promise((resolve, reject) => {
|
|
1666
|
+
const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1513
1667
|
let out = "";
|
|
1514
1668
|
let err = "";
|
|
1515
1669
|
p.stdout.on("data", d => { out += d.toString("utf8"); });
|
|
@@ -1519,25 +1673,71 @@ async function findPositionInCourses(args) {
|
|
|
1519
1673
|
p.kill("SIGTERM");
|
|
1520
1674
|
}
|
|
1521
1675
|
catch { /* already dead */ }
|
|
1522
|
-
reject(new Error(
|
|
1676
|
+
reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
|
|
1523
1677
|
}, FENFIND_TIMEOUT_MS);
|
|
1524
1678
|
p.on("error", e => { clearTimeout(to); reject(e); });
|
|
1525
1679
|
p.on("close", code => {
|
|
1526
1680
|
clearTimeout(to);
|
|
1527
|
-
if (code !== 0)
|
|
1528
|
-
reject(new Error(
|
|
1529
|
-
|
|
1530
|
-
else {
|
|
1681
|
+
if (code !== 0)
|
|
1682
|
+
reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
|
|
1683
|
+
else
|
|
1531
1684
|
resolve(out);
|
|
1532
|
-
}
|
|
1533
1685
|
});
|
|
1534
1686
|
});
|
|
1687
|
+
}
|
|
1688
|
+
function parseFenfindJson(scriptName, stdout) {
|
|
1535
1689
|
try {
|
|
1536
1690
|
return JSON.parse(stdout);
|
|
1537
1691
|
}
|
|
1538
1692
|
catch (e) {
|
|
1539
|
-
throw new Error(
|
|
1693
|
+
throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
async function findPositionInCourses(args) {
|
|
1697
|
+
if (!FENFIND_DIR) {
|
|
1698
|
+
return {
|
|
1699
|
+
status: "not_available",
|
|
1700
|
+
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.",
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1704
|
+
const cliArgs = [resolved.fen, "--json"];
|
|
1705
|
+
if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
|
|
1706
|
+
cliArgs.push("--sort", args.sort);
|
|
1707
|
+
}
|
|
1708
|
+
if (args.include_games)
|
|
1709
|
+
cliArgs.push("--games");
|
|
1710
|
+
if (args.chapters_mode)
|
|
1711
|
+
cliArgs.push("--chapters");
|
|
1712
|
+
if (typeof args.min_notes_chars === "number")
|
|
1713
|
+
cliArgs.push("--min", String(args.min_notes_chars));
|
|
1714
|
+
if (typeof args.limit === "number")
|
|
1715
|
+
cliArgs.push("-n", String(args.limit));
|
|
1716
|
+
const stdout = await runFenfindScript("fenfind", cliArgs);
|
|
1717
|
+
return parseFenfindJson("fenfind", stdout);
|
|
1718
|
+
}
|
|
1719
|
+
async function readCourseAtPosition(args) {
|
|
1720
|
+
if (!FENFIND_DIR) {
|
|
1721
|
+
return {
|
|
1722
|
+
status: "not_available",
|
|
1723
|
+
note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
|
|
1727
|
+
if (!Number.isFinite(fileId) || fileId <= 0) {
|
|
1728
|
+
throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
|
|
1540
1729
|
}
|
|
1730
|
+
const cliArgs = ["--file-id", String(fileId)];
|
|
1731
|
+
if (typeof args.fen === "string" && args.fen.trim() !== "")
|
|
1732
|
+
cliArgs.push("--fen", args.fen.trim());
|
|
1733
|
+
if (typeof args.moves === "string" && args.moves.trim() !== "")
|
|
1734
|
+
cliArgs.push("--moves", args.moves.trim());
|
|
1735
|
+
if (typeof args.chapter === "string" && args.chapter.trim() !== "")
|
|
1736
|
+
cliArgs.push("--chapter", args.chapter.trim());
|
|
1737
|
+
if (typeof args.max_plies_below === "number")
|
|
1738
|
+
cliArgs.push("--max-plies-below", String(args.max_plies_below));
|
|
1739
|
+
const stdout = await runFenfindScript("readpgn", cliArgs);
|
|
1740
|
+
return parseFenfindJson("readpgn", stdout);
|
|
1541
1741
|
}
|
|
1542
1742
|
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
1543
1743
|
function pathIntoTree(root, path) {
|
|
@@ -1665,6 +1865,233 @@ async function applyMutation(args, mutator) {
|
|
|
1665
1865
|
version: savedRow.version,
|
|
1666
1866
|
};
|
|
1667
1867
|
}
|
|
1868
|
+
async function loadPrepFile(id) {
|
|
1869
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
1870
|
+
const g = raw;
|
|
1871
|
+
if (typeof g.pgnContent !== "string")
|
|
1872
|
+
throw new Error("prep file missing pgnContent");
|
|
1873
|
+
return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: g.id, pgn: g.pgnContent };
|
|
1874
|
+
}
|
|
1875
|
+
// Recursively project a PrepNode into the requested view. `depthLeft`
|
|
1876
|
+
// null → unlimited; 0 → just the node without children.
|
|
1877
|
+
//
|
|
1878
|
+
// `fenIndex` (optional) enables the `transposes_to` field — for each
|
|
1879
|
+
// node whose position also appears elsewhere in the SAME file, we
|
|
1880
|
+
// annotate it with the OTHER occurrences' ids. Pass null (the default)
|
|
1881
|
+
// to skip the annotation entirely; passing the map costs one lookup
|
|
1882
|
+
// per node projected.
|
|
1883
|
+
function projectNode(node, view, depthLeft, fenIndex = null) {
|
|
1884
|
+
const base = {
|
|
1885
|
+
id: node.id,
|
|
1886
|
+
san: node.san,
|
|
1887
|
+
ply: node.ply,
|
|
1888
|
+
};
|
|
1889
|
+
if (node.nags && node.nags.length > 0)
|
|
1890
|
+
base.nags = node.nags;
|
|
1891
|
+
if (node.comment)
|
|
1892
|
+
base.comment = node.comment;
|
|
1893
|
+
if (node.ceoEval)
|
|
1894
|
+
base.ceoEval = node.ceoEval;
|
|
1895
|
+
if (view === "full") {
|
|
1896
|
+
base.fen = node.fen;
|
|
1897
|
+
if (node.annotations)
|
|
1898
|
+
base.annotations = node.annotations;
|
|
1899
|
+
}
|
|
1900
|
+
if (fenIndex && node.id !== ROOT_ID) {
|
|
1901
|
+
const group = fenIndex.get(positionKey(node.fen));
|
|
1902
|
+
if (group && group.length > 1) {
|
|
1903
|
+
const others = group.filter(n => n.id !== node.id).map(n => n.id);
|
|
1904
|
+
if (others.length > 0)
|
|
1905
|
+
base.transposes_to = others;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
// Children handling depends on view + depth budget.
|
|
1909
|
+
const showChildren = depthLeft === null || depthLeft > 0;
|
|
1910
|
+
const childDepth = depthLeft === null ? null : depthLeft - 1;
|
|
1911
|
+
if (showChildren && node.children.length > 0) {
|
|
1912
|
+
if (view === "spine") {
|
|
1913
|
+
// Only follow children[0] — collapses the tree to the mainline.
|
|
1914
|
+
base.children = [projectNode(node.children[0], view, childDepth, fenIndex)];
|
|
1915
|
+
}
|
|
1916
|
+
else {
|
|
1917
|
+
base.children = node.children.map(c => projectNode(c, view, childDepth, fenIndex));
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
base.children = [];
|
|
1922
|
+
}
|
|
1923
|
+
return base;
|
|
1924
|
+
}
|
|
1925
|
+
async function readPrepFile(args) {
|
|
1926
|
+
const id = String(args.id);
|
|
1927
|
+
const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
|
|
1928
|
+
? args.view
|
|
1929
|
+
: "compact";
|
|
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, version, fileIdEcho, pgn } = await loadPrepFile(id);
|
|
1933
|
+
const idIndex = buildIdIndex(file.root);
|
|
1934
|
+
const path = resolveNodeId(idIndex, startNodeId);
|
|
1935
|
+
const anchor = getNodeByPath(file.root, path);
|
|
1936
|
+
const fenIndex = buildFenIndex(file.root);
|
|
1937
|
+
// How many DISTINCT positions in the file appear more than once,
|
|
1938
|
+
// and how many nodes are involved. Shown in the header so the LLM
|
|
1939
|
+
// sees at a glance whether transpositions matter here before diving
|
|
1940
|
+
// into the tree.
|
|
1941
|
+
let transGroups = 0;
|
|
1942
|
+
let transNodes = 0;
|
|
1943
|
+
for (const arr of fenIndex.values()) {
|
|
1944
|
+
if (arr.length > 1) {
|
|
1945
|
+
transGroups++;
|
|
1946
|
+
transNodes += arr.length;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
const header = {
|
|
1950
|
+
id: fileIdEcho ?? id,
|
|
1951
|
+
version,
|
|
1952
|
+
tags: file.tags,
|
|
1953
|
+
view,
|
|
1954
|
+
node_id: startNodeId,
|
|
1955
|
+
max_depth: maxDepth,
|
|
1956
|
+
transposition_groups: transGroups,
|
|
1957
|
+
transposition_nodes: transNodes,
|
|
1958
|
+
};
|
|
1959
|
+
if (view === "pgn") {
|
|
1960
|
+
// For the root, just return the file's actual PGN as-is. For a
|
|
1961
|
+
// subtree, build a mini-Game from the anchor and export it. Keeps
|
|
1962
|
+
// formatting identical to what the app renders.
|
|
1963
|
+
if (startNodeId === ROOT_ID && (maxDepth === null || maxDepth >= 999)) {
|
|
1964
|
+
return { ...header, pgn };
|
|
1965
|
+
}
|
|
1966
|
+
// Truncate to a subtree with max_depth. Simple: walk the anchor's
|
|
1967
|
+
// subtree, produce a synthetic PGN starting from the anchor's FEN.
|
|
1968
|
+
const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
|
|
1969
|
+
return { ...header, pgn: subtreePgn };
|
|
1970
|
+
}
|
|
1971
|
+
return { ...header, tree: projectNode(anchor, view, maxDepth, fenIndex) };
|
|
1972
|
+
}
|
|
1973
|
+
// Produce a PGN string for a subtree rooted at `anchor`, truncated
|
|
1974
|
+
// at `maxDepth` plies below (null = unlimited). Reuses the exporter
|
|
1975
|
+
// by building a synthetic PrepFile whose root is a shallow clone of
|
|
1976
|
+
// the anchor with its children trimmed to depth.
|
|
1977
|
+
function exportSubtreePgn(file, anchor, maxDepth) {
|
|
1978
|
+
const trim = (n, depthLeft) => {
|
|
1979
|
+
if (depthLeft !== null && depthLeft <= 0)
|
|
1980
|
+
return { ...n, children: [] };
|
|
1981
|
+
const next = depthLeft === null ? null : depthLeft - 1;
|
|
1982
|
+
return { ...n, children: n.children.map(c => trim(c, next)) };
|
|
1983
|
+
};
|
|
1984
|
+
const trimmedAnchor = trim(anchor, maxDepth);
|
|
1985
|
+
// If the anchor IS the root, exporter handles it. If it's an inner
|
|
1986
|
+
// node, we set the root's FEN to the anchor's position and hang the
|
|
1987
|
+
// trimmed subtree off it. Tags carried over.
|
|
1988
|
+
if (anchor.id === ROOT_ID) {
|
|
1989
|
+
return exportPGN({ tags: file.tags, root: trimmedAnchor });
|
|
1990
|
+
}
|
|
1991
|
+
const syntheticRoot = {
|
|
1992
|
+
id: ROOT_ID,
|
|
1993
|
+
san: null,
|
|
1994
|
+
fen: anchor.fen,
|
|
1995
|
+
ply: 0,
|
|
1996
|
+
children: trimmedAnchor.children,
|
|
1997
|
+
};
|
|
1998
|
+
const tags = { ...file.tags, FEN: anchor.fen, SetUp: "1" };
|
|
1999
|
+
return exportPGN({ tags, root: syntheticRoot });
|
|
2000
|
+
}
|
|
2001
|
+
async function listNodes(args) {
|
|
2002
|
+
const id = String(args.id);
|
|
2003
|
+
const filter = String(args.filter || "");
|
|
2004
|
+
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
|
|
2005
|
+
const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
|
|
2006
|
+
const { file } = await loadPrepFile(id);
|
|
2007
|
+
const idIndex = buildIdIndex(file.root);
|
|
2008
|
+
const path = resolveNodeId(idIndex, startNodeId);
|
|
2009
|
+
const anchor = getNodeByPath(file.root, path);
|
|
2010
|
+
const fenIndex = filter === "transpositions" ? buildFenIndex(file.root) : null;
|
|
2011
|
+
const hits = [];
|
|
2012
|
+
const walk = (node, depthLeft, spineOnly) => {
|
|
2013
|
+
// Root has no san — never emit it as a match. Everything else is fair game.
|
|
2014
|
+
if (node.id !== ROOT_ID) {
|
|
2015
|
+
let include = false;
|
|
2016
|
+
let extra = {};
|
|
2017
|
+
switch (filter) {
|
|
2018
|
+
case "missing_eval":
|
|
2019
|
+
include = !node.ceoEval;
|
|
2020
|
+
break;
|
|
2021
|
+
case "has_comment":
|
|
2022
|
+
include = !!(node.comment && node.comment.length > 0);
|
|
2023
|
+
if (include)
|
|
2024
|
+
extra.comment_preview = (node.comment || "").slice(0, 80);
|
|
2025
|
+
break;
|
|
2026
|
+
case "has_annotations":
|
|
2027
|
+
include = !!(node.annotations && (node.annotations.arrows.length > 0 || node.annotations.highlights.length > 0));
|
|
2028
|
+
break;
|
|
2029
|
+
case "novelties":
|
|
2030
|
+
include = !!(node.nags && node.nags.includes("$146"));
|
|
2031
|
+
break;
|
|
2032
|
+
case "leaves":
|
|
2033
|
+
include = node.children.length === 0;
|
|
2034
|
+
break;
|
|
2035
|
+
case "mainline":
|
|
2036
|
+
include = spineOnly;
|
|
2037
|
+
break;
|
|
2038
|
+
case "transpositions": {
|
|
2039
|
+
const group = fenIndex.get(positionKey(node.fen));
|
|
2040
|
+
if (group && group.length > 1) {
|
|
2041
|
+
include = true;
|
|
2042
|
+
extra.transposes_to = group.filter(n => n.id !== node.id).map(n => n.id);
|
|
2043
|
+
}
|
|
2044
|
+
break;
|
|
2045
|
+
}
|
|
2046
|
+
case "all":
|
|
2047
|
+
include = true;
|
|
2048
|
+
break;
|
|
2049
|
+
default:
|
|
2050
|
+
throw new Error(`unknown filter: ${filter}`);
|
|
2051
|
+
}
|
|
2052
|
+
if (include) {
|
|
2053
|
+
const hit = { node_id: node.id, san: node.san, ply: node.ply };
|
|
2054
|
+
Object.assign(hit, extra);
|
|
2055
|
+
hits.push(hit);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
if (depthLeft !== null && depthLeft <= 0)
|
|
2059
|
+
return;
|
|
2060
|
+
const nextDepth = depthLeft === null ? null : depthLeft - 1;
|
|
2061
|
+
if (filter === "mainline" && spineOnly) {
|
|
2062
|
+
if (node.children.length > 0)
|
|
2063
|
+
walk(node.children[0], nextDepth, true);
|
|
2064
|
+
}
|
|
2065
|
+
else {
|
|
2066
|
+
for (const c of node.children)
|
|
2067
|
+
walk(c, nextDepth, filter === "mainline");
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
const rootIsSpineForFilter = filter === "mainline";
|
|
2071
|
+
walk(anchor, maxDepth, rootIsSpineForFilter);
|
|
2072
|
+
return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
|
|
2073
|
+
}
|
|
2074
|
+
// list_transpositions — every position that occurs 2+ times in the
|
|
2075
|
+
// file, so the LLM knows where its analysis / prose will double up.
|
|
2076
|
+
async function listTranspositions(args) {
|
|
2077
|
+
const id = String(args.id);
|
|
2078
|
+
const { file } = await loadPrepFile(id);
|
|
2079
|
+
const fenIndex = buildFenIndex(file.root);
|
|
2080
|
+
const groups = [];
|
|
2081
|
+
for (const [key, arr] of fenIndex.entries()) {
|
|
2082
|
+
if (arr.length < 2)
|
|
2083
|
+
continue;
|
|
2084
|
+
groups.push({
|
|
2085
|
+
position_key: key,
|
|
2086
|
+
size: arr.length,
|
|
2087
|
+
node_ids: arr.map(n => n.id),
|
|
2088
|
+
sans: arr.map(n => n.san),
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
groups.sort((a, b) => b.size - a.size || a.position_key.localeCompare(b.position_key));
|
|
2092
|
+
const nodeCount = groups.reduce((s, g) => s + g.size, 0);
|
|
2093
|
+
return { file_id: id, group_count: groups.length, node_count: nodeCount, groups };
|
|
2094
|
+
}
|
|
1668
2095
|
// Strip cruft the LLM doesn't need from the DB-position response.
|
|
1669
2096
|
// Called AFTER trimGamesMovetext so plyNumber survives long enough to
|
|
1670
2097
|
// slice each game's movetext. Also renames the `transpositions` field
|
|
@@ -1852,10 +2279,21 @@ function normalizeSourceForBackend(src, idx) {
|
|
|
1852
2279
|
}
|
|
1853
2280
|
return out;
|
|
1854
2281
|
}
|
|
1855
|
-
// Async resolver used by every engine / DB tool.
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
2282
|
+
// Async resolver used by every engine / DB tool. Three paths:
|
|
2283
|
+
//
|
|
2284
|
+
// 1. file_id + node_id → load file, resolve node, return FEN + handle
|
|
2285
|
+
// to persist ceoEval later. Cheapest, most explicit.
|
|
2286
|
+
//
|
|
2287
|
+
// 2. file_id + (fen | moves | line) → load file, resolve FEN
|
|
2288
|
+
// client-side, then scan the file's nodes for one matching that
|
|
2289
|
+
// FEN. If found, return the same handle as (1) so cloud_analyse
|
|
2290
|
+
// auto-stores on the matching node. Fixes the previous footgun
|
|
2291
|
+
// where `cloud_analyse({file_id, moves})` silently dropped the
|
|
2292
|
+
// eval because the server didn't try to match the resulting FEN
|
|
2293
|
+
// back to a node.
|
|
2294
|
+
//
|
|
2295
|
+
// 3. Just fen | moves | line, no file_id → scratch mode, no
|
|
2296
|
+
// persistence. Same as before.
|
|
1859
2297
|
async function resolveFromNodeOrFen(args) {
|
|
1860
2298
|
const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
|
|
1861
2299
|
const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
|
|
@@ -1870,18 +2308,51 @@ async function resolveFromNodeOrFen(args) {
|
|
|
1870
2308
|
const node = getNodeByPath(parsedFile.root, nodePath);
|
|
1871
2309
|
return {
|
|
1872
2310
|
fen: node.fen,
|
|
1873
|
-
file: {
|
|
1874
|
-
id: fileId,
|
|
1875
|
-
version: g.version ?? 0,
|
|
1876
|
-
parsedFile,
|
|
1877
|
-
idIndex,
|
|
1878
|
-
nodePath,
|
|
1879
|
-
fen: node.fen,
|
|
1880
|
-
},
|
|
2311
|
+
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath, fen: node.fen },
|
|
1881
2312
|
};
|
|
1882
2313
|
}
|
|
2314
|
+
if (fileId) {
|
|
2315
|
+
// file_id only — resolve FEN from fen/moves/line, then look it up
|
|
2316
|
+
// in the file's nodes. If a node has that FEN, treat this as if
|
|
2317
|
+
// node_id had been supplied (auto-persist on match).
|
|
2318
|
+
const fen = resolveFenFromArgs(args);
|
|
2319
|
+
try {
|
|
2320
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
|
|
2321
|
+
const g = raw;
|
|
2322
|
+
if (typeof g.pgnContent === "string") {
|
|
2323
|
+
const parsedFile = parsePGN(g.pgnContent);
|
|
2324
|
+
const match = findNodeByFen(parsedFile.root, fen);
|
|
2325
|
+
if (match) {
|
|
2326
|
+
const idIndex = buildIdIndex(parsedFile.root);
|
|
2327
|
+
return {
|
|
2328
|
+
fen,
|
|
2329
|
+
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
catch {
|
|
2335
|
+
// Best-effort: if the file load fails, fall through to scratch mode.
|
|
2336
|
+
}
|
|
2337
|
+
return { fen };
|
|
2338
|
+
}
|
|
1883
2339
|
return { fen: resolveFenFromArgs(args) };
|
|
1884
2340
|
}
|
|
2341
|
+
// Search the tree for a node whose FEN matches. Full-tree scan — trees
|
|
2342
|
+
// max out ~1000 nodes so this is fine. FEN comparison is exact string
|
|
2343
|
+
// match (both come from the same chessops normalisation).
|
|
2344
|
+
function findNodeByFen(root, targetFen) {
|
|
2345
|
+
const stack = [{ node: root, path: [] }];
|
|
2346
|
+
while (stack.length > 0) {
|
|
2347
|
+
const { node, path } = stack.pop();
|
|
2348
|
+
if (node.fen === targetFen)
|
|
2349
|
+
return { node, path };
|
|
2350
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
2351
|
+
stack.push({ node: node.children[i], path: [...path, i] });
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
return null;
|
|
2355
|
+
}
|
|
1885
2356
|
// Local wrapper — the mutation module re-exports paths.getNode so this
|
|
1886
2357
|
// import stays consistent with the rest of the file's imports.
|
|
1887
2358
|
function getNodeByPath(root, path) {
|
|
@@ -1893,22 +2364,41 @@ function getNodeByPath(root, path) {
|
|
|
1893
2364
|
}
|
|
1894
2365
|
return cur;
|
|
1895
2366
|
}
|
|
1896
|
-
// Persist a fresh ceoEval on the node referenced by the file handle
|
|
1897
|
-
//
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
2367
|
+
// Persist a fresh ceoEval on the node referenced by the file handle
|
|
2368
|
+
// AND on every other node in the same file that transposes to the
|
|
2369
|
+
// same position (matches on the frontend's 3-field FEN key: piece
|
|
2370
|
+
// placement + side to move + castling). Best-effort — if the file
|
|
2371
|
+
// version raced (another agent saved between our GET and our PUT),
|
|
2372
|
+
// we silently drop the store rather than fail the analysis the LLM
|
|
2373
|
+
// actually asked for. The eval is still returned in the response
|
|
2374
|
+
// either way.
|
|
2375
|
+
//
|
|
2376
|
+
// Return: ids of every node the eval was stamped on (empty on error).
|
|
2377
|
+
// The primary node's id is always first (if present).
|
|
1901
2378
|
async function storeEvalOnNode(handle, ev) {
|
|
1902
2379
|
try {
|
|
1903
|
-
const
|
|
1904
|
-
const
|
|
2380
|
+
const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
|
|
2381
|
+
const key = positionKey(anchor.fen);
|
|
2382
|
+
const fenIndex = buildFenIndex(handle.parsedFile.root);
|
|
2383
|
+
const group = fenIndex.get(key) ?? [anchor];
|
|
2384
|
+
// Resolve every transposed node back to its path. cloneOnPath
|
|
2385
|
+
// rebuilds the spine so we need paths, not references — the
|
|
2386
|
+
// id index was built against the original tree and every id in
|
|
2387
|
+
// `group` exists there.
|
|
2388
|
+
const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
|
|
2389
|
+
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
2390
|
+
const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
|
|
2391
|
+
const newPgn = exportPGN(newFile);
|
|
1905
2392
|
await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(handle.id)}`, {
|
|
1906
2393
|
pgn: newPgn,
|
|
1907
2394
|
expected_version: handle.version,
|
|
1908
2395
|
});
|
|
2396
|
+
// Ensure the primary node (the one the LLM addressed) comes first.
|
|
2397
|
+
const anchorId = anchor.id;
|
|
2398
|
+
return [anchorId, ...ids.filter(x => x !== anchorId)];
|
|
1909
2399
|
}
|
|
1910
2400
|
catch {
|
|
1911
|
-
|
|
2401
|
+
return [];
|
|
1912
2402
|
}
|
|
1913
2403
|
}
|
|
1914
2404
|
function stringifyForLog(v) {
|
|
@@ -1924,19 +2414,50 @@ function stringifyForLog(v) {
|
|
|
1924
2414
|
}
|
|
1925
2415
|
return s;
|
|
1926
2416
|
}
|
|
2417
|
+
// Version tag stamped on every log line so a bug report can be traced
|
|
2418
|
+
// to the exact MCP release that produced it. process.env.npm_package_version
|
|
2419
|
+
// is set by npm when the package is run via `npx` / `npm start` (and by
|
|
2420
|
+
// our systemd unit which uses npx); falls back to reading package.json
|
|
2421
|
+
// during dev when we `node dist/index.js` directly. "unknown" if all
|
|
2422
|
+
// else fails — better than pretending we know.
|
|
2423
|
+
const MCP_VERSION = (() => {
|
|
2424
|
+
const fromEnv = process.env.npm_package_version?.trim();
|
|
2425
|
+
if (fromEnv)
|
|
2426
|
+
return fromEnv;
|
|
2427
|
+
try {
|
|
2428
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2429
|
+
// dist/index.js is one level below package.json; src/index.ts is two
|
|
2430
|
+
// (src/index.ts → ../package.json). Try both.
|
|
2431
|
+
for (const p of [join(here, "..", "package.json"), join(here, "..", "..", "package.json")]) {
|
|
2432
|
+
if (existsSync(p)) {
|
|
2433
|
+
const pkg = JSON.parse(readFileSync(p, "utf8"));
|
|
2434
|
+
if (pkg.version)
|
|
2435
|
+
return pkg.version;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
catch {
|
|
2440
|
+
// fall through
|
|
2441
|
+
}
|
|
2442
|
+
return "unknown";
|
|
2443
|
+
})();
|
|
2444
|
+
const MCP_TAG = `[mcp v${MCP_VERSION}]`;
|
|
2445
|
+
// One-shot startup line so tailing the log from the beginning shows
|
|
2446
|
+
// the running version immediately, before any tool call.
|
|
2447
|
+
console.error(`${MCP_TAG} chessceo-mcp startup`);
|
|
1927
2448
|
async function callTool(name, args) {
|
|
1928
2449
|
const started = Date.now();
|
|
1929
|
-
console.error(
|
|
2450
|
+
console.error(`${MCP_TAG} IN ${name} args=${stringifyForLog(args)}`);
|
|
1930
2451
|
try {
|
|
1931
2452
|
const result = await callToolInner(name, args);
|
|
1932
2453
|
const dur = Date.now() - started;
|
|
1933
|
-
console.error(
|
|
2454
|
+
console.error(`${MCP_TAG} OUT ${name} ok ${dur}ms result=${stringifyForLog(result)}`);
|
|
1934
2455
|
return result;
|
|
1935
2456
|
}
|
|
1936
2457
|
catch (err) {
|
|
1937
2458
|
const dur = Date.now() - started;
|
|
1938
2459
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1939
|
-
console.error(
|
|
2460
|
+
console.error(`${MCP_TAG} OUT ${name} err ${dur}ms error=${JSON.stringify(msg)}`);
|
|
1940
2461
|
throw err;
|
|
1941
2462
|
}
|
|
1942
2463
|
}
|
|
@@ -2012,17 +2533,37 @@ async function callToolInner(name, args) {
|
|
|
2012
2533
|
}
|
|
2013
2534
|
case "describe_position": {
|
|
2014
2535
|
const resolved = await resolveFromNodeOrFen(args);
|
|
2015
|
-
|
|
2536
|
+
// Chess-primitive analysis (structural, ~1 ms) + Stockfish eval-
|
|
2537
|
+
// term breakdown (~50-100 ms) in parallel. Merge into one response
|
|
2538
|
+
// so the LLM sees the whole position in one call — chess-concepts,
|
|
2539
|
+
// structural weaknesses, AND engine's per-term reasoning.
|
|
2540
|
+
// If Stockfish isn't installed the eval leg returns {found: false,
|
|
2541
|
+
// error} and we drop it from the response so callers see the same
|
|
2542
|
+
// shape either way (just without `engineEvalTerms`).
|
|
2543
|
+
const [structural, evalRaw] = await Promise.all([
|
|
2544
|
+
Promise.resolve(describePosition(resolved.fen)),
|
|
2545
|
+
runSfEval(resolved.fen).catch(err => ({
|
|
2546
|
+
found: false,
|
|
2547
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2548
|
+
})),
|
|
2549
|
+
]);
|
|
2550
|
+
const merged = structural;
|
|
2551
|
+
const ev = evalRaw;
|
|
2552
|
+
if (ev && ev.found === true) {
|
|
2553
|
+
merged.engineEvalTerms = { terms: ev.terms, total: ev.total };
|
|
2554
|
+
}
|
|
2555
|
+
return merged;
|
|
2016
2556
|
}
|
|
2017
2557
|
case "predict_human_move": {
|
|
2018
2558
|
const resolved = await resolveFromNodeOrFen(args);
|
|
2019
2559
|
const fen = resolved.fen;
|
|
2560
|
+
// Rating is fixed at 2850 vs 2850. Not exposed to the LLM —
|
|
2561
|
+
// cross-position comparisons only mean something at a constant
|
|
2562
|
+
// rating, and top-level is the useful reference point for prep.
|
|
2020
2563
|
const qs = new URLSearchParams();
|
|
2021
2564
|
qs.set("fen", fen);
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
if (typeof args.black_elo === "number")
|
|
2025
|
-
qs.set("black_elo", String(args.black_elo));
|
|
2565
|
+
qs.set("white_elo", "2850");
|
|
2566
|
+
qs.set("black_elo", "2850");
|
|
2026
2567
|
if (typeof args.top === "number")
|
|
2027
2568
|
qs.set("top", String(args.top));
|
|
2028
2569
|
if (Array.isArray(args.prev_fens)) {
|
|
@@ -2031,7 +2572,20 @@ async function callToolInner(name, args) {
|
|
|
2031
2572
|
qs.append("prev_fen", p);
|
|
2032
2573
|
}
|
|
2033
2574
|
}
|
|
2034
|
-
|
|
2575
|
+
const raw = await authedRequest("GET", `/api/agent/predict-move?${qs.toString()}`);
|
|
2576
|
+
// Strip uci from each move — san is enough for the LLM and the
|
|
2577
|
+
// duplicate field is context bloat. Also drop whiteElo/blackElo
|
|
2578
|
+
// from the response (always 2850 now — echoing them adds nothing).
|
|
2579
|
+
if (raw && typeof raw === "object") {
|
|
2580
|
+
const r = raw;
|
|
2581
|
+
if (Array.isArray(r.moves)) {
|
|
2582
|
+
for (const m of r.moves)
|
|
2583
|
+
delete m.uci;
|
|
2584
|
+
}
|
|
2585
|
+
delete r.whiteElo;
|
|
2586
|
+
delete r.blackElo;
|
|
2587
|
+
}
|
|
2588
|
+
return raw;
|
|
2035
2589
|
}
|
|
2036
2590
|
case "get_head_to_head":
|
|
2037
2591
|
return get("/api/chess/players/h2h", {
|
|
@@ -2081,8 +2635,15 @@ async function callToolInner(name, args) {
|
|
|
2081
2635
|
// was supplied and the eval survives via the [%ceo-eval] escape.
|
|
2082
2636
|
if (resolved.file) {
|
|
2083
2637
|
const ev = analysisToStoredEval(converted);
|
|
2084
|
-
if (ev)
|
|
2085
|
-
await storeEvalOnNode(resolved.file, ev);
|
|
2638
|
+
if (ev) {
|
|
2639
|
+
const stamped = await storeEvalOnNode(resolved.file, ev);
|
|
2640
|
+
if (stamped.length > 1) {
|
|
2641
|
+
// Surface the propagation so the LLM sees exactly which
|
|
2642
|
+
// other nodes now carry this eval (and can skip them for
|
|
2643
|
+
// re-analysis).
|
|
2644
|
+
converted.also_stored_on = stamped.slice(1);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2086
2647
|
}
|
|
2087
2648
|
return converted;
|
|
2088
2649
|
}
|
|
@@ -2094,23 +2655,18 @@ async function callToolInner(name, args) {
|
|
|
2094
2655
|
return deepAnalyseCancel(args);
|
|
2095
2656
|
case "find_position_in_courses":
|
|
2096
2657
|
return findPositionInCourses(args);
|
|
2658
|
+
case "read_course_at_position":
|
|
2659
|
+
return readCourseAtPosition(args);
|
|
2097
2660
|
case "list_prep_files":
|
|
2098
2661
|
return authedRequest("GET", "/api/agent/prep-files");
|
|
2099
2662
|
case "search_prep_files":
|
|
2100
2663
|
return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
|
|
2101
|
-
case "read_prep_file":
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
return {
|
|
2108
|
-
id: g.id,
|
|
2109
|
-
version: g.version,
|
|
2110
|
-
tags: file.tags,
|
|
2111
|
-
tree: file.root,
|
|
2112
|
-
};
|
|
2113
|
-
}
|
|
2664
|
+
case "read_prep_file":
|
|
2665
|
+
return readPrepFile(args);
|
|
2666
|
+
case "list_nodes":
|
|
2667
|
+
return listNodes(args);
|
|
2668
|
+
case "list_transpositions":
|
|
2669
|
+
return listTranspositions(args);
|
|
2114
2670
|
case "create_prep_file":
|
|
2115
2671
|
return authedRequest("POST", "/api/agent/prep-files", {
|
|
2116
2672
|
name: String(args.name),
|