@chessceo/mcp 0.30.2 → 0.31.2
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 +322 -149
- package/dist/pgn/mutations.js +80 -27
- package/dist/pgn/parser.js +9 -2
- package/dist/pgn/paths.js +77 -10
- package/dist/pgn/types.js +17 -6
- package/docs/engine-usage.md +10 -0
- package/docs/examples/italian-fried-liver.pgn +12 -0
- package/docs/examples/najdorf-6-f4-white.pgn +18 -0
- package/docs/pgn-authoring.md +102 -30
- package/docs/prep-files-guide.md +20 -15
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,8 +15,8 @@ import { Chess } from "chess.js";
|
|
|
15
15
|
import { parsePGN } from "./pgn/parser.js";
|
|
16
16
|
import { exportPGN } from "./pgn/exporter.js";
|
|
17
17
|
import { describePosition } from "./pgn/describe.js";
|
|
18
|
-
import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
19
|
-
import { PathError } from "./pgn/paths.js";
|
|
18
|
+
import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
19
|
+
import { buildIdIndex, NodeIdError, PathError, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
|
|
20
20
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
21
21
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
22
22
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -42,6 +42,13 @@ const ENGINE_USAGE_DOC = loadBundledDoc("engine-usage.md", "Engine usage guide")
|
|
|
42
42
|
const PREP_STRATEGY_DOC = loadBundledDoc("prep-strategy.md", "Prep strategy guide");
|
|
43
43
|
const PREP_FILES_DOC = loadBundledDoc("prep-files-guide.md", "Prep files guide");
|
|
44
44
|
const PGN_AUTHORING_DOC = loadBundledDoc("pgn-authoring.md", "PGN authoring guide");
|
|
45
|
+
// Reference PGNs authored by a strong human coach. LLM pulls these when
|
|
46
|
+
// it wants to see the commentary style, NAG discipline, and annotation
|
|
47
|
+
// density we want it to hit. Kept as raw PGN so the LLM can parse them
|
|
48
|
+
// against its own understanding of the game (comments, arrows, NAGs
|
|
49
|
+
// all intact) — not summarised into English.
|
|
50
|
+
const EXAMPLE_OVERVIEW_PGN = loadBundledDoc("examples/italian-fried-liver.pgn", "Italian Fried Liver overview example");
|
|
51
|
+
const EXAMPLE_REPERTOIRE_PGN = loadBundledDoc("examples/najdorf-6-f4-white.pgn", "Najdorf 6.f4 White repertoire example");
|
|
45
52
|
// ── HTTP ────────────────────────────────────────────────────────────
|
|
46
53
|
//
|
|
47
54
|
// Two auth flavours coexist:
|
|
@@ -71,6 +78,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
71
78
|
"create_prep_file",
|
|
72
79
|
"delete_prep_file",
|
|
73
80
|
"add_move",
|
|
81
|
+
"add_line",
|
|
74
82
|
"set_comment",
|
|
75
83
|
"set_nags",
|
|
76
84
|
"set_annotations",
|
|
@@ -79,6 +87,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
79
87
|
"set_tag",
|
|
80
88
|
"apply_mutations",
|
|
81
89
|
"auto_evaluate",
|
|
90
|
+
"quote_engine_eval",
|
|
82
91
|
"predict_human_move",
|
|
83
92
|
]);
|
|
84
93
|
function isAuthedToolCall(body) {
|
|
@@ -206,17 +215,25 @@ const TOOLS = [
|
|
|
206
215
|
enum: ["white", "black"],
|
|
207
216
|
description: "Which colour the player is analysed with.",
|
|
208
217
|
},
|
|
218
|
+
file_id: {
|
|
219
|
+
type: "string",
|
|
220
|
+
description: "Prep file id to address the position from. Combine with `node_id` — the server derives the FEN from the tree, so you don't paste a FEN string. **Prefer this over `fen`/`line`/`moves` when a prep file is open.**",
|
|
221
|
+
},
|
|
222
|
+
node_id: {
|
|
223
|
+
type: "string",
|
|
224
|
+
description: "Node id inside `file_id` whose position to query. Root is 'r'. When set, overrides `fen`/`line`/`moves`.",
|
|
225
|
+
},
|
|
209
226
|
line: {
|
|
210
227
|
type: "string",
|
|
211
|
-
description: "Move sequence in SAN from the starting position, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for startpos. Alias: `moves` (same thing).",
|
|
228
|
+
description: "Move sequence in SAN from the starting position, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for startpos. Alias: `moves` (same thing). Only used if `node_id` is not set.",
|
|
212
229
|
},
|
|
213
230
|
moves: {
|
|
214
231
|
type: "string",
|
|
215
|
-
description: "SAN moves to apply on top of `fen` (or on top of startpos if no fen). Same shape as `line`.
|
|
232
|
+
description: "SAN moves to apply on top of `fen` (or on top of startpos if no fen). Same shape as `line`. Wins over `line` if both are set. Only used if `node_id` is not set.",
|
|
216
233
|
},
|
|
217
234
|
fen: {
|
|
218
235
|
type: "string",
|
|
219
|
-
description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone.",
|
|
236
|
+
description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone. Only used if `node_id` is not set.",
|
|
220
237
|
},
|
|
221
238
|
limit: {
|
|
222
239
|
type: "integer",
|
|
@@ -236,21 +253,29 @@ const TOOLS = [
|
|
|
236
253
|
"- `gm-classical` — GM classical games (both players ≥2500, real thinking-time). BEST for opening prep — every move is signal, avgElo ~2600 across all listed moves.\n" +
|
|
237
254
|
"- `main` — the whole 11.7M-game DB. Widest coverage but noisiest (includes 1000-Elo blunder-fests in the move stats). Use as fallback when gm-classical's totalCount is too small to be informative.\n\n" +
|
|
238
255
|
"Game movetext is trimmed to the moves AFTER the queried position (using each game's plyNumber). Saves ~70% of the bytes vs full movetext.\n\n" +
|
|
239
|
-
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN.",
|
|
256
|
+
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN. When called with `file_id`+`node_id`, the eval is also auto-stored on that node's `ceoEval` — later readable via quote_engine_eval.",
|
|
240
257
|
inputSchema: {
|
|
241
258
|
type: "object",
|
|
242
259
|
properties: {
|
|
260
|
+
file_id: {
|
|
261
|
+
type: "string",
|
|
262
|
+
description: "Prep file id. **Prefer file_id+node_id over `fen`** when a prep file is open — the server derives the FEN from the tree.",
|
|
263
|
+
},
|
|
264
|
+
node_id: {
|
|
265
|
+
type: "string",
|
|
266
|
+
description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`/`line`.",
|
|
267
|
+
},
|
|
243
268
|
fen: {
|
|
244
269
|
type: "string",
|
|
245
|
-
description: "Starting position as FEN. Combine with `moves`
|
|
270
|
+
description: "Starting position as FEN. Combine with `moves`. Only used if `node_id` is not set.",
|
|
246
271
|
},
|
|
247
272
|
moves: {
|
|
248
273
|
type: "string",
|
|
249
|
-
description: "Optional SAN moves
|
|
274
|
+
description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
250
275
|
},
|
|
251
276
|
line: {
|
|
252
277
|
type: "string",
|
|
253
|
-
description: "Synonym for `moves` from
|
|
278
|
+
description: "Synonym for `moves` from startpos; kept for compatibility.",
|
|
254
279
|
},
|
|
255
280
|
limit: {
|
|
256
281
|
type: "integer",
|
|
@@ -271,12 +296,14 @@ const TOOLS = [
|
|
|
271
296
|
description: "Structured facts about a chess position: piece placements per colour (SAN-style letters), material balance in pawn units, list of every contested piece (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant square, side to move, and the full list of LEGAL MOVES for the side to move. Pure computation — no engine, ~1 ms per call.\n\n" +
|
|
272
297
|
"USE THIS BEFORE COMMENTING ON A POSITION. LLMs are not reliable at reading FEN strings — you'll misplace pieces or invent captures. This tool gives you the same board state a human sees.\n\n" +
|
|
273
298
|
"ALSO USE THIS if `add_move` rejects an illegal SAN — the `.legalMoves` array shows exactly what's playable from that position.\n\n" +
|
|
274
|
-
"Position input
|
|
299
|
+
"Position input: prefer `file_id`+`node_id` if you're inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
|
|
275
300
|
inputSchema: {
|
|
276
301
|
type: "object",
|
|
277
302
|
properties: {
|
|
278
|
-
|
|
279
|
-
|
|
303
|
+
file_id: { type: "string", description: "Prep file id. When combined with `node_id`, describes that node's position." },
|
|
304
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
|
|
305
|
+
fen: { type: "string", description: "Starting position as FEN (defaults to startpos). Only used if `node_id` is not set." },
|
|
306
|
+
moves: { type: "string", description: "Optional SAN moves to apply on top of `fen`. Only used if `node_id` is not set." },
|
|
280
307
|
},
|
|
281
308
|
},
|
|
282
309
|
},
|
|
@@ -290,14 +317,16 @@ const TOOLS = [
|
|
|
290
317
|
"• 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" +
|
|
291
318
|
"• 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" +
|
|
292
319
|
"• Pass `prev_fen` (most recent first) when analysing mid-trade positions — without history the model treats the position as quiet.\n\n" +
|
|
293
|
-
"Position input
|
|
320
|
+
"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.",
|
|
294
321
|
inputSchema: {
|
|
295
322
|
type: "object",
|
|
296
323
|
properties: {
|
|
297
|
-
|
|
324
|
+
file_id: { type: "string", description: "Prep file id. Combine with `node_id` to point at a tree node's position." },
|
|
325
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
|
|
326
|
+
fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
|
|
298
327
|
moves: {
|
|
299
328
|
type: "string",
|
|
300
|
-
description: "Optional SAN moves to apply on top of `fen` (or startpos).",
|
|
329
|
+
description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
301
330
|
},
|
|
302
331
|
white_elo: {
|
|
303
332
|
type: "integer",
|
|
@@ -418,14 +447,17 @@ const TOOLS = [
|
|
|
418
447
|
"Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Practical range -20..+20. Use it to find non-objective 'practical' ideas or when the user needs to steer toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
|
|
419
448
|
"Also useful: pass `moves` on top of `fen` to explore a variation without computing FENs yourself (e.g. fen='<tabiya>', moves='b4 a5 c3'). And the flip-side-to-move threat check documented in the guide is a great free trick.\n\n" +
|
|
420
449
|
"For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
|
|
421
|
-
"Not for casual questions — this costs real money per second. Use
|
|
450
|
+
"Not for casual questions — this costs real money per second. Use `get_position_stats` for anything that doesn't require deep prep.\n\n" +
|
|
451
|
+
"**When called with `file_id`+`node_id` (preferred inside a prep file), the resulting eval is auto-stored on that node's `ceoEval` — you can then quote it with quote_engine_eval on any later call.** This is what makes engine attribution trustworthy: prose that says 'engines say X on node Y' can only be true if a call was actually made against node_id=Y.",
|
|
422
452
|
inputSchema: {
|
|
423
453
|
type: "object",
|
|
424
454
|
properties: {
|
|
425
|
-
|
|
455
|
+
file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id over fen** when inside a prep file — the FEN comes from the tree AND the result is stored on the node." },
|
|
456
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
|
|
457
|
+
fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
|
|
426
458
|
moves: {
|
|
427
459
|
type: "string",
|
|
428
|
-
description: "Optional SAN moves to apply on top of `fen` (or
|
|
460
|
+
description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
429
461
|
},
|
|
430
462
|
movetime_ms: {
|
|
431
463
|
type: "integer",
|
|
@@ -446,7 +478,6 @@ const TOOLS = [
|
|
|
446
478
|
description: "Lc0 contempt bias. 0 = objective (default). Positive favours White, negative favours Black; stay within -20..+20 in practice. Not applied to Stockfish. See engine_usage_primer prompt for when to use.",
|
|
447
479
|
},
|
|
448
480
|
},
|
|
449
|
-
required: ["fen"],
|
|
450
481
|
},
|
|
451
482
|
},
|
|
452
483
|
{
|
|
@@ -467,8 +498,9 @@ const TOOLS = [
|
|
|
467
498
|
},
|
|
468
499
|
{
|
|
469
500
|
name: "read_prep_file",
|
|
470
|
-
description: "Read one prep file. Returns a compact tree (
|
|
471
|
-
"
|
|
501
|
+
description: "Read one prep file. Returns a compact tree (each node carries a stable `id`, `san`, `fen`, `ply`, optional `comment`/`nags`/`annotations`/`ceoEval`, and `children`) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN — all edits go through the mutation tools (add_move / add_line / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
|
|
502
|
+
"**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash derived from its parent's id + its SAN. Sibling insertions, deletions and variation promotions do NOT change any other node's id. Pass this id as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
|
|
503
|
+
"**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_player_preparation, 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.",
|
|
472
504
|
inputSchema: {
|
|
473
505
|
type: "object",
|
|
474
506
|
properties: {
|
|
@@ -505,56 +537,71 @@ const TOOLS = [
|
|
|
505
537
|
},
|
|
506
538
|
{
|
|
507
539
|
name: "add_move",
|
|
508
|
-
description: "Append a move as a new child of the node
|
|
509
|
-
"Auto-saves. Returns `{
|
|
540
|
+
description: "Append a move as a new child of the node identified by `parent_id`. If the parent already has children, the new move becomes a variation (appended at the end); use promote_variation afterwards to make it the mainline. SAN is validated against the position — illegal moves are rejected with a clear error.\n\n" +
|
|
541
|
+
"Auto-saves. Returns `{node_id, version}` — the id of the new node (pass this to follow-up set_comment / set_nags / etc.) and the new file version for optimistic locking. **Node ids are content-derived and stable** — sibling insertions, deletions, and promotions do NOT change any other node's id.",
|
|
510
542
|
inputSchema: {
|
|
511
543
|
type: "object",
|
|
512
544
|
properties: {
|
|
513
545
|
id: { type: "string", description: "Prep file id." },
|
|
514
|
-
|
|
546
|
+
parent_id: { type: "string", description: "Node id of the parent (the position the move is played FROM). Root id is 'r'." },
|
|
515
547
|
san: { type: "string", description: "The move in SAN notation (e.g. 'Nf3', 'exd5', 'O-O', 'Qxf7+')." },
|
|
516
548
|
expected_version: { type: "integer", description: "Optimistic-lock check; pass the `version` from your last read." },
|
|
517
549
|
},
|
|
518
|
-
required: ["id", "
|
|
550
|
+
required: ["id", "parent_id", "san"],
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
name: "add_line",
|
|
555
|
+
description: "Append a linear sequence of moves under `parent_id`. Each SAN in the list becomes the mainline child of the previous — one call instead of N add_move calls for a straight variation. If the parent already has other children, this whole line is appended as a variation (promote_variation the first move if you want it as the mainline).\n\n" +
|
|
556
|
+
"Auto-saves. Returns `{node_id, line: [{node_id, san}, ...], version}` — `node_id` is the last (leaf) node's id, `line` is every node created in order so you can address any of them next.",
|
|
557
|
+
inputSchema: {
|
|
558
|
+
type: "object",
|
|
559
|
+
properties: {
|
|
560
|
+
id: { type: "string", description: "Prep file id." },
|
|
561
|
+
parent_id: { type: "string", description: "Node id of the parent to build from. Root is 'r'." },
|
|
562
|
+
sans: { type: "array", items: { type: "string" }, minItems: 1, description: "SAN moves in order, e.g. ['e4','e5','Nf3','Nc6','Bb5']." },
|
|
563
|
+
expected_version: { type: "integer" },
|
|
564
|
+
},
|
|
565
|
+
required: ["id", "parent_id", "sans"],
|
|
519
566
|
},
|
|
520
567
|
},
|
|
521
568
|
{
|
|
522
569
|
name: "set_comment",
|
|
523
|
-
description: "Set (or clear, with empty string) the text comment on the node
|
|
570
|
+
description: "Set (or clear, with empty string) the text comment on the node identified by `node_id`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.",
|
|
524
571
|
inputSchema: {
|
|
525
572
|
type: "object",
|
|
526
573
|
properties: {
|
|
527
574
|
id: { type: "string", description: "Prep file id." },
|
|
528
|
-
|
|
575
|
+
node_id: { type: "string", description: "Node id from read_prep_file / add_move." },
|
|
529
576
|
comment: { type: "string", description: "New comment text. Empty string clears." },
|
|
530
577
|
expected_version: { type: "integer" },
|
|
531
578
|
},
|
|
532
|
-
required: ["id", "
|
|
579
|
+
required: ["id", "node_id", "comment"],
|
|
533
580
|
},
|
|
534
581
|
},
|
|
535
582
|
{
|
|
536
583
|
name: "set_nags",
|
|
537
|
-
description: "Replace the list of NAGs on the node
|
|
584
|
+
description: "Replace the list of NAGs on the node identified by `node_id`. Empty array clears them. NAGs are your EDITORIAL call — see read_pgn_authoring_guide for the discipline (novelty $146, sharp choice $5, decisive $18/$19, etc.). Do NOT set $10 '=' on every equal position; that's board noise. Auto-saves.",
|
|
538
585
|
inputSchema: {
|
|
539
586
|
type: "object",
|
|
540
587
|
properties: {
|
|
541
588
|
id: { type: "string" },
|
|
542
|
-
|
|
589
|
+
node_id: { type: "string" },
|
|
543
590
|
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" }, description: "NAG list, e.g. ['$14'] or ['$146', '$44']." },
|
|
544
591
|
expected_version: { type: "integer" },
|
|
545
592
|
},
|
|
546
|
-
required: ["id", "
|
|
593
|
+
required: ["id", "node_id", "nags"],
|
|
547
594
|
},
|
|
548
595
|
},
|
|
549
596
|
{
|
|
550
597
|
name: "set_annotations",
|
|
551
|
-
description: "Replace the visual annotations (arrows + coloured squares) on the node
|
|
598
|
+
description: "Replace the visual annotations (arrows + coloured squares) on the node identified by `node_id`. Passing empty arrays clears them.\n\n" +
|
|
552
599
|
"Colours: green, red, yellow, light-blue, dark-blue, orange. Keep it LIGHT: 1-3 arrows and 2-3 squares per move maximum. Twenty arrows is noise, not signal. Auto-saves.",
|
|
553
600
|
inputSchema: {
|
|
554
601
|
type: "object",
|
|
555
602
|
properties: {
|
|
556
603
|
id: { type: "string" },
|
|
557
|
-
|
|
604
|
+
node_id: { type: "string" },
|
|
558
605
|
arrows: {
|
|
559
606
|
type: "array",
|
|
560
607
|
items: {
|
|
@@ -580,40 +627,40 @@ const TOOLS = [
|
|
|
580
627
|
},
|
|
581
628
|
expected_version: { type: "integer" },
|
|
582
629
|
},
|
|
583
|
-
required: ["id", "
|
|
630
|
+
required: ["id", "node_id"],
|
|
584
631
|
},
|
|
585
632
|
},
|
|
586
633
|
{
|
|
587
634
|
name: "delete_subtree",
|
|
588
|
-
description: "Delete the node
|
|
635
|
+
description: "Delete the node identified by `node_id` and all its descendants. Refuses to delete the root. Auto-saves.",
|
|
589
636
|
inputSchema: {
|
|
590
637
|
type: "object",
|
|
591
638
|
properties: {
|
|
592
639
|
id: { type: "string" },
|
|
593
|
-
|
|
640
|
+
node_id: { type: "string" },
|
|
594
641
|
expected_version: { type: "integer" },
|
|
595
642
|
},
|
|
596
|
-
required: ["id", "
|
|
643
|
+
required: ["id", "node_id"],
|
|
597
644
|
},
|
|
598
645
|
},
|
|
599
646
|
{
|
|
600
647
|
name: "promote_variation",
|
|
601
|
-
description: "Make the node
|
|
648
|
+
description: "Make the node identified by `node_id` its parent's mainline (children[0]), demoting the current mainline (and any other siblings) into variation order. Silently no-op if already the mainline. Auto-saves.",
|
|
602
649
|
inputSchema: {
|
|
603
650
|
type: "object",
|
|
604
651
|
properties: {
|
|
605
652
|
id: { type: "string" },
|
|
606
|
-
|
|
653
|
+
node_id: { type: "string", description: "Node id of the variation to promote. Cannot be root." },
|
|
607
654
|
expected_version: { type: "integer" },
|
|
608
655
|
},
|
|
609
|
-
required: ["id", "
|
|
656
|
+
required: ["id", "node_id"],
|
|
610
657
|
},
|
|
611
658
|
},
|
|
612
659
|
{
|
|
613
660
|
name: "apply_mutations",
|
|
614
661
|
description: "Batch: apply a list of mutations in one call. One load-parse-mutate-export-save cycle for N ops, so building a 100-move repertoire costs one HTTP round-trip and one save instead of 100. This is the RIGHT way to build a file — use single mutations only for surgical follow-up edits.\n\n" +
|
|
615
|
-
"Each mutation is `{op,
|
|
616
|
-
"Any op error aborts the batch (nothing saved). Response is `{ok, results: [{
|
|
662
|
+
"Each mutation is `{op, node_id | parent_id, ...args}` where `op` is one of: add_move, add_line, set_comment, set_nags, set_annotations, delete_subtree, promote_variation, set_tag. Same arg shape as the individual tools. Ops apply in order; because node ids are content-derived (hash of parent_id + san), a node created by an early op has a deterministic id you can reference in later ops in the same batch.\n\n" +
|
|
663
|
+
"Any op error aborts the batch (nothing saved). Response is `{ok, results: [{node_id, line?}], version}` — one entry per op with the id it landed on (add_line also returns the full line array).",
|
|
617
664
|
inputSchema: {
|
|
618
665
|
type: "object",
|
|
619
666
|
properties: {
|
|
@@ -625,9 +672,11 @@ const TOOLS = [
|
|
|
625
672
|
items: {
|
|
626
673
|
type: "object",
|
|
627
674
|
properties: {
|
|
628
|
-
op: { type: "string", enum: ["add_move", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
|
|
629
|
-
|
|
675
|
+
op: { type: "string", enum: ["add_move", "add_line", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
|
|
676
|
+
node_id: { type: "string" },
|
|
677
|
+
parent_id: { type: "string" },
|
|
630
678
|
san: { type: "string" },
|
|
679
|
+
sans: { type: "array", items: { type: "string" } },
|
|
631
680
|
comment: { type: "string" },
|
|
632
681
|
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" } },
|
|
633
682
|
arrows: { type: "array" },
|
|
@@ -644,15 +693,15 @@ const TOOLS = [
|
|
|
644
693
|
},
|
|
645
694
|
{
|
|
646
695
|
name: "auto_evaluate",
|
|
647
|
-
description: "Walk the tree from `
|
|
648
|
-
"**Does NOT set visible NAGs.** NAG placement is your call, not the engine's — an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move (that's just noise on the board), and a `!` on a novelty or a `?!` on a risky committal is a judgment call the engine can't make. Use this tool to persist the raw numbers on every node, then re-read the file and hand-pick NAGs on the moves where a glyph carries real signal
|
|
649
|
-
"On re-read every node
|
|
696
|
+
description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. Requires a running cloud combo instance.\n\n" +
|
|
697
|
+
"**Does NOT set visible NAGs.** NAG placement is your call, not the engine's — an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move (that's just noise on the board), and a `!` on a novelty or a `?!` on a risky committal is a judgment call the engine can't make. Use this tool to persist the raw numbers on every node, then re-read the file and hand-pick NAGs on the moves where a glyph carries real signal.\n\n" +
|
|
698
|
+
"On re-read every evaluated node carries `ceoEval: { sf: {cp, depth}, lc0: {cp, depth} }` — the numbers travel with the file. Read those back with quote_engine_eval before writing prose that references engine numbers. Use `only_missing=true` (default) to skip nodes already evaluated on repeat runs.\n\n" +
|
|
650
699
|
"Costs real money — hits cloud_analyse per node. A 200-node tree at 1.5s/node = ~5 min of engine time. Runs 4 evaluations in parallel to save wall time.",
|
|
651
700
|
inputSchema: {
|
|
652
701
|
type: "object",
|
|
653
702
|
properties: {
|
|
654
703
|
id: { type: "string" },
|
|
655
|
-
|
|
704
|
+
node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
|
|
656
705
|
only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
|
|
657
706
|
movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
|
|
658
707
|
expected_version: { type: "integer" },
|
|
@@ -660,6 +709,19 @@ const TOOLS = [
|
|
|
660
709
|
required: ["id"],
|
|
661
710
|
},
|
|
662
711
|
},
|
|
712
|
+
{
|
|
713
|
+
name: "quote_engine_eval",
|
|
714
|
+
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" +
|
|
715
|
+
"Response: `{ ceoEval: { sf: {cp, depth}, lc0: {cp, depth}, nag } | null }`. `cp` is White-POV centipawns as an integer (+20 = +0.20). `nag` is the threshold-derived glyph as a SUGGESTION — promote to a visible NAG via set_nags only when a glyph on that move carries editorial signal.",
|
|
716
|
+
inputSchema: {
|
|
717
|
+
type: "object",
|
|
718
|
+
properties: {
|
|
719
|
+
id: { type: "string", description: "Prep file id." },
|
|
720
|
+
node_id: { type: "string", description: "Node id whose stored eval you want to quote." },
|
|
721
|
+
},
|
|
722
|
+
required: ["id", "node_id"],
|
|
723
|
+
},
|
|
724
|
+
},
|
|
663
725
|
{
|
|
664
726
|
name: "set_tag",
|
|
665
727
|
description: "Set or clear a game-level PGN tag (Event, Site, Date, White, Black, Result, or any custom tag). Passing empty string removes the tag. Auto-saves.",
|
|
@@ -694,6 +756,13 @@ const TOOLS = [
|
|
|
694
756
|
description: "Returns the guide on how to write correct, useful PGN — mainline discipline, variations as moves (never prose describing moves), NAG symbols including novelty ($146), unclear ($13), compensation ($44) and the standard set, ChessBase arrow/coloured-square syntax ([%cal] / [%csl]), and common pitfalls the parser will reject. Call this ONCE per session before any save_prep_file call, or any time you're producing PGN output for the user.",
|
|
695
757
|
inputSchema: { type: "object", properties: {} },
|
|
696
758
|
},
|
|
759
|
+
{
|
|
760
|
+
name: "read_example_prep_files",
|
|
761
|
+
description: "Returns two full reference PGNs authored by a strong human coach — one a general opening overview (Italian Fried Liver, both sides, 1600+ audience) and one a one-sided repertoire (Najdorf 6.f4 for White, 2200+ audience). Bundled with the MCP; not the user's files.\n\n" +
|
|
762
|
+
"Read these ONCE per session before writing your first substantial prep file. The authoring guide (read_pgn_authoring_guide) tells you the rules; these examples show you what the rules look like when applied by someone who knows what they're doing — comment density, when to cite games by player name, how to use `$146` / `$3` / `$44` sparingly and correctly, when a bare `[%csl Rf7]` says everything, how to acknowledge transpositions, how to phrase practical guidance vs objective evaluation. Study the rhythm before writing your own.\n\n" +
|
|
763
|
+
"Response: `{ overview: <pgn>, repertoire: <pgn> }`. Raw PGN — comments, arrows, NAGs, and stored evals all intact.",
|
|
764
|
+
inputSchema: { type: "object", properties: {} },
|
|
765
|
+
},
|
|
697
766
|
{
|
|
698
767
|
name: "prep_snapshot",
|
|
699
768
|
description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).\n\n" +
|
|
@@ -704,13 +773,15 @@ const TOOLS = [
|
|
|
704
773
|
fide_id_me: { type: "integer", description: "Your FIDE ID." },
|
|
705
774
|
fide_id_opponent: { type: "integer", description: "Opponent's FIDE ID." },
|
|
706
775
|
my_color: { type: "string", enum: ["white", "black"], description: "The colour YOU will play." },
|
|
776
|
+
file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id** when inside a prep file." },
|
|
777
|
+
node_id: { type: "string", description: "Node id inside `file_id`. When set, overrides `line`/`fen`." },
|
|
707
778
|
line: {
|
|
708
779
|
type: "string",
|
|
709
|
-
description: "Move sequence in SAN, space-separated. Empty = starting position.
|
|
780
|
+
description: "Move sequence in SAN, space-separated. Empty = starting position. Only used if `node_id` is not set.",
|
|
710
781
|
},
|
|
711
782
|
fen: {
|
|
712
783
|
type: "string",
|
|
713
|
-
description: "Alternative to line — raw FEN of the target position.",
|
|
784
|
+
description: "Alternative to line — raw FEN of the target position. Only used if `node_id` is not set.",
|
|
714
785
|
},
|
|
715
786
|
},
|
|
716
787
|
required: ["fide_id_me", "fide_id_opponent", "my_color"],
|
|
@@ -799,56 +870,70 @@ function convertCloudSnapshotResponse(raw, startFen) {
|
|
|
799
870
|
}
|
|
800
871
|
return raw;
|
|
801
872
|
}
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
for (let i = 0; i < raw.length; i++) {
|
|
810
|
-
const n = raw[i];
|
|
811
|
-
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
812
|
-
throw new Error(`path[${i}] must be a non-negative integer (got ${JSON.stringify(n)})`);
|
|
813
|
-
}
|
|
814
|
-
out.push(n);
|
|
873
|
+
// Extract a node id from the args. Accepts either `node_id` or a
|
|
874
|
+
// `parent_id` alias for the add-style tools. Throws with a helpful
|
|
875
|
+
// message if malformed.
|
|
876
|
+
function argNodeId(args, key = "node_id") {
|
|
877
|
+
const raw = args[key];
|
|
878
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
879
|
+
throw new Error(`\`${key}\` is required (call read_prep_file to get valid node ids)`);
|
|
815
880
|
}
|
|
816
|
-
return
|
|
881
|
+
return raw.trim();
|
|
817
882
|
}
|
|
818
|
-
// Dispatch table for the batch tool: name → mutator that returns
|
|
819
|
-
//
|
|
820
|
-
|
|
883
|
+
// Dispatch table for the batch tool: name → mutator that returns
|
|
884
|
+
// { file, id } where id is the node the mutation touched. The batch
|
|
885
|
+
// caller rebuilds the id → path index between ops so newly-created
|
|
886
|
+
// nodes are addressable within the same batch.
|
|
887
|
+
function dispatchMutation(file, idIndex, op) {
|
|
821
888
|
const kind = String(op.op);
|
|
822
|
-
|
|
889
|
+
// Small local helper — resolves a node_id (or parent_id) op field to
|
|
890
|
+
// a path against the CURRENT tree state.
|
|
891
|
+
const nodeIdField = (key) => {
|
|
892
|
+
const raw = op[key];
|
|
893
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
894
|
+
throw new Error(`\`${key}\` required on op ${kind}`);
|
|
895
|
+
}
|
|
896
|
+
return raw.trim();
|
|
897
|
+
};
|
|
898
|
+
const resolve = (id) => resolveNodeId(idIndex, id);
|
|
823
899
|
switch (kind) {
|
|
824
900
|
case "add_move":
|
|
825
|
-
return addMove(file,
|
|
901
|
+
return addMove(file, resolve(nodeIdField("parent_id")), String(op.san));
|
|
902
|
+
case "add_line": {
|
|
903
|
+
const sans = Array.isArray(op.sans) ? op.sans.map(String) : [];
|
|
904
|
+
const parentPath = resolve(nodeIdField("parent_id"));
|
|
905
|
+
const step = addLine(file, parentPath, sans);
|
|
906
|
+
const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : nodeIdField("parent_id");
|
|
907
|
+
return { file: step.file, id: lastId, results: step.line };
|
|
908
|
+
}
|
|
826
909
|
case "set_comment":
|
|
827
|
-
return setComment(file,
|
|
910
|
+
return setComment(file, resolve(nodeIdField("node_id")), typeof op.comment === "string" ? op.comment : "");
|
|
828
911
|
case "set_nags":
|
|
829
|
-
return setNags(file,
|
|
912
|
+
return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
|
|
830
913
|
case "set_annotations": {
|
|
831
914
|
const arrows = Array.isArray(op.arrows) ? op.arrows : [];
|
|
832
915
|
const highlights = Array.isArray(op.highlights) ? op.highlights : [];
|
|
833
916
|
const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
|
|
834
|
-
return setAnnotations(file,
|
|
917
|
+
return setAnnotations(file, resolve(nodeIdField("node_id")), ann);
|
|
835
918
|
}
|
|
836
919
|
case "set_ceo_eval": {
|
|
837
920
|
const ev = op.ceoEval;
|
|
838
|
-
return setCeoEval(file,
|
|
921
|
+
return setCeoEval(file, resolve(nodeIdField("node_id")), ev ?? null);
|
|
839
922
|
}
|
|
840
923
|
case "delete_subtree":
|
|
841
|
-
return deleteSubtree(file,
|
|
924
|
+
return deleteSubtree(file, resolve(nodeIdField("node_id")));
|
|
842
925
|
case "promote_variation":
|
|
843
|
-
return promoteVariation(file,
|
|
926
|
+
return promoteVariation(file, resolve(nodeIdField("node_id")));
|
|
844
927
|
case "set_tag":
|
|
845
|
-
return { file: setTag(file, String(op.key), String(op.value ?? "")),
|
|
928
|
+
return { file: setTag(file, String(op.key), String(op.value ?? "")), id: ROOT_ID };
|
|
846
929
|
default:
|
|
847
930
|
throw new Error(`unknown mutation op: ${kind}`);
|
|
848
931
|
}
|
|
849
932
|
}
|
|
850
|
-
// Batch: load, parse, apply N mutations in order, export, save.
|
|
851
|
-
// any error aborts and nothing is saved.
|
|
933
|
+
// Batch: load, parse, apply N mutations in order, export, save.
|
|
934
|
+
// All-or-nothing — any error aborts and nothing is saved. The id index
|
|
935
|
+
// is rebuilt after each op so nodes created earlier in the batch can be
|
|
936
|
+
// addressed by later ops via their newly-derived node_id.
|
|
852
937
|
async function applyBatchMutations(args) {
|
|
853
938
|
const id = String(args.id);
|
|
854
939
|
const mutations = Array.isArray(args.mutations) ? args.mutations : [];
|
|
@@ -859,13 +944,15 @@ async function applyBatchMutations(args) {
|
|
|
859
944
|
if (typeof g.pgnContent !== "string")
|
|
860
945
|
throw new Error("prep file missing pgnContent");
|
|
861
946
|
let file = parsePGN(g.pgnContent);
|
|
947
|
+
let idIndex = buildIdIndex(file.root);
|
|
862
948
|
const results = [];
|
|
863
949
|
for (let i = 0; i < mutations.length; i++) {
|
|
864
950
|
const op = mutations[i];
|
|
865
951
|
try {
|
|
866
|
-
const step = dispatchMutation(file, op);
|
|
952
|
+
const step = dispatchMutation(file, idIndex, op);
|
|
867
953
|
file = step.file;
|
|
868
|
-
|
|
954
|
+
idIndex = buildIdIndex(file.root);
|
|
955
|
+
results.push({ node_id: step.id, ...(step.results !== undefined ? { line: step.results } : {}) });
|
|
869
956
|
}
|
|
870
957
|
catch (err) {
|
|
871
958
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -888,7 +975,9 @@ async function applyBatchMutations(args) {
|
|
|
888
975
|
// mutation at the end so a 200-node evaluate is one save.
|
|
889
976
|
async function autoEvaluate(args) {
|
|
890
977
|
const id = String(args.id);
|
|
891
|
-
const
|
|
978
|
+
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
|
|
979
|
+
? String(args.node_id)
|
|
980
|
+
: ROOT_ID;
|
|
892
981
|
const onlyMissing = args.only_missing !== false; // default true
|
|
893
982
|
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
|
|
894
983
|
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
@@ -896,18 +985,22 @@ async function autoEvaluate(args) {
|
|
|
896
985
|
if (typeof g.pgnContent !== "string")
|
|
897
986
|
throw new Error("prep file missing pgnContent");
|
|
898
987
|
const file = parsePGN(g.pgnContent);
|
|
988
|
+
const idIndex = buildIdIndex(file.root);
|
|
989
|
+
const startPath = resolveNodeId(idIndex, startNodeId);
|
|
899
990
|
const targets = [];
|
|
900
|
-
const walk = (node,
|
|
901
|
-
if (
|
|
991
|
+
const walk = (node, isStartAndRoot) => {
|
|
992
|
+
if (!isStartAndRoot) {
|
|
902
993
|
if (!onlyMissing || !node.ceoEval) {
|
|
903
|
-
targets.push({
|
|
994
|
+
targets.push({ nodeId: node.id, fen: node.fen });
|
|
904
995
|
}
|
|
905
996
|
}
|
|
906
|
-
for (
|
|
907
|
-
walk(
|
|
997
|
+
for (const child of node.children)
|
|
998
|
+
walk(child, false);
|
|
908
999
|
};
|
|
909
|
-
const startNode =
|
|
910
|
-
|
|
1000
|
+
const startNode = getNodeByPath(file.root, startPath);
|
|
1001
|
+
// If the caller anchored at the root, skip evaluating the root itself
|
|
1002
|
+
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
1003
|
+
walk(startNode, startNode.id === ROOT_ID);
|
|
911
1004
|
if (targets.length === 0)
|
|
912
1005
|
return { ok: true, evaluated: 0, skipped: 0, version: g.version };
|
|
913
1006
|
// Dispatch cloud_analyse in parallel with a 4-way cap so we don't
|
|
@@ -923,7 +1016,7 @@ async function autoEvaluate(args) {
|
|
|
923
1016
|
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
924
1017
|
const ev = analysisToStoredEval(analysis, t.fen);
|
|
925
1018
|
if (ev)
|
|
926
|
-
stored.push({
|
|
1019
|
+
stored.push({ nodeId: t.nodeId, ev });
|
|
927
1020
|
}
|
|
928
1021
|
catch {
|
|
929
1022
|
// best-effort — a single failed node shouldn't kill the batch
|
|
@@ -933,9 +1026,16 @@ async function autoEvaluate(args) {
|
|
|
933
1026
|
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
934
1027
|
if (stored.length === 0)
|
|
935
1028
|
return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
|
|
1029
|
+
// Persist the raw numbers only — never touch visible NAGs. NAG glyphs
|
|
1030
|
+
// are the LLM's editorial call (novelty, sharp choice, real mistake),
|
|
1031
|
+
// not an automatic mapping from the engine number. Stamping `$10` on
|
|
1032
|
+
// every 0.00 position in an opening tree just clutters the board.
|
|
1033
|
+
// The threshold-derived NAG still lives INSIDE `ceoEval.nag` so the
|
|
1034
|
+
// LLM can read it back and decide whether to promote it to a visible
|
|
1035
|
+
// NAG on a case-by-case basis.
|
|
936
1036
|
const batchMutations = [];
|
|
937
1037
|
for (const s of stored) {
|
|
938
|
-
batchMutations.push({ op: "set_ceo_eval",
|
|
1038
|
+
batchMutations.push({ op: "set_ceo_eval", node_id: s.nodeId, ceoEval: s.ev });
|
|
939
1039
|
}
|
|
940
1040
|
const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
|
|
941
1041
|
const sr = saveResult;
|
|
@@ -1030,7 +1130,9 @@ function storedEvalToCompact(ev, analysis) {
|
|
|
1030
1130
|
}
|
|
1031
1131
|
// Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
|
|
1032
1132
|
// save with optimistic lock. Auto-saves so every tool call is atomic;
|
|
1033
|
-
// the LLM never sees intermediate state.
|
|
1133
|
+
// the LLM never sees intermediate state. The mutator is called with
|
|
1134
|
+
// both the parsed file and its id → path index, so the mutation can
|
|
1135
|
+
// resolve node_ids without rebuilding the index itself.
|
|
1034
1136
|
async function applyMutation(args, mutator) {
|
|
1035
1137
|
const id = String(args.id);
|
|
1036
1138
|
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
@@ -1038,12 +1140,13 @@ async function applyMutation(args, mutator) {
|
|
|
1038
1140
|
if (typeof g.pgnContent !== "string")
|
|
1039
1141
|
throw new Error("prep file missing pgnContent");
|
|
1040
1142
|
const file = parsePGN(g.pgnContent);
|
|
1143
|
+
const idIndex = buildIdIndex(file.root);
|
|
1041
1144
|
let result;
|
|
1042
1145
|
try {
|
|
1043
|
-
result = mutator(file);
|
|
1146
|
+
result = mutator(file, idIndex);
|
|
1044
1147
|
}
|
|
1045
1148
|
catch (err) {
|
|
1046
|
-
if (err instanceof MutationError || err instanceof PathError) {
|
|
1149
|
+
if (err instanceof MutationError || err instanceof PathError || err instanceof NodeIdError) {
|
|
1047
1150
|
throw new Error(`mutation rejected: ${err.message}`);
|
|
1048
1151
|
}
|
|
1049
1152
|
throw err;
|
|
@@ -1057,7 +1160,8 @@ async function applyMutation(args, mutator) {
|
|
|
1057
1160
|
const savedRow = saved;
|
|
1058
1161
|
return {
|
|
1059
1162
|
ok: true,
|
|
1060
|
-
|
|
1163
|
+
node_id: result.id,
|
|
1164
|
+
...(result.results !== undefined ? { line: result.results } : {}),
|
|
1061
1165
|
version: savedRow.version,
|
|
1062
1166
|
};
|
|
1063
1167
|
}
|
|
@@ -1208,6 +1312,65 @@ function resolveFenFromArgs(args) {
|
|
|
1208
1312
|
}
|
|
1209
1313
|
return board.fen();
|
|
1210
1314
|
}
|
|
1315
|
+
// Async resolver used by every engine / DB tool. If the caller supplied
|
|
1316
|
+
// file_id + node_id (preferred), load the file, resolve the node, and
|
|
1317
|
+
// return both the FEN and a handle we can use to persist ceoEval later.
|
|
1318
|
+
// Otherwise fall back to the raw fen/moves/line inputs.
|
|
1319
|
+
async function resolveFromNodeOrFen(args) {
|
|
1320
|
+
const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
|
|
1321
|
+
const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
|
|
1322
|
+
if (fileId && nodeId) {
|
|
1323
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
|
|
1324
|
+
const g = raw;
|
|
1325
|
+
if (typeof g.pgnContent !== "string")
|
|
1326
|
+
throw new Error("prep file missing pgnContent");
|
|
1327
|
+
const parsedFile = parsePGN(g.pgnContent);
|
|
1328
|
+
const idIndex = buildIdIndex(parsedFile.root);
|
|
1329
|
+
const nodePath = resolveNodeId(idIndex, nodeId);
|
|
1330
|
+
const node = getNodeByPath(parsedFile.root, nodePath);
|
|
1331
|
+
return {
|
|
1332
|
+
fen: node.fen,
|
|
1333
|
+
file: {
|
|
1334
|
+
id: fileId,
|
|
1335
|
+
version: g.version ?? 0,
|
|
1336
|
+
parsedFile,
|
|
1337
|
+
idIndex,
|
|
1338
|
+
nodePath,
|
|
1339
|
+
fen: node.fen,
|
|
1340
|
+
},
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
return { fen: resolveFenFromArgs(args) };
|
|
1344
|
+
}
|
|
1345
|
+
// Local wrapper — the mutation module re-exports paths.getNode so this
|
|
1346
|
+
// import stays consistent with the rest of the file's imports.
|
|
1347
|
+
function getNodeByPath(root, path) {
|
|
1348
|
+
let cur = root;
|
|
1349
|
+
for (const idx of path) {
|
|
1350
|
+
if (idx < 0 || idx >= cur.children.length)
|
|
1351
|
+
throw new Error(`invalid node path segment ${idx}`);
|
|
1352
|
+
cur = cur.children[idx];
|
|
1353
|
+
}
|
|
1354
|
+
return cur;
|
|
1355
|
+
}
|
|
1356
|
+
// Persist a fresh ceoEval on the node referenced by the file handle.
|
|
1357
|
+
// Best-effort — if the file version raced (another agent saved
|
|
1358
|
+
// between our GET and our PUT), we silently drop the store rather
|
|
1359
|
+
// than fail the analysis the LLM actually asked for. The eval is
|
|
1360
|
+
// still returned in the response either way.
|
|
1361
|
+
async function storeEvalOnNode(handle, ev) {
|
|
1362
|
+
try {
|
|
1363
|
+
const step = setCeoEval(handle.parsedFile, handle.nodePath, ev);
|
|
1364
|
+
const newPgn = exportPGN(step.file);
|
|
1365
|
+
await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(handle.id)}`, {
|
|
1366
|
+
pgn: newPgn,
|
|
1367
|
+
expected_version: handle.version,
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
catch {
|
|
1371
|
+
// Best-effort — the analysis result is what the LLM asked for.
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1211
1374
|
function stringifyForLog(v) {
|
|
1212
1375
|
let s;
|
|
1213
1376
|
try {
|
|
@@ -1244,31 +1407,21 @@ async function callToolInner(name, args) {
|
|
|
1244
1407
|
case "get_player_profile":
|
|
1245
1408
|
return get("/api/chess/players/profile", { fideId: Number(args.fide_id) });
|
|
1246
1409
|
case "get_player_preparation": {
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
// history it didn't see anyway.
|
|
1253
|
-
const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
|
|
1254
|
-
const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
|
|
1255
|
-
const lineArg = typeof args.line === "string" ? args.line.trim() : "";
|
|
1410
|
+
// Node-first: if file_id+node_id was given, derive the FEN from the
|
|
1411
|
+
// tree (never trust an LLM-supplied FEN when a node reference is
|
|
1412
|
+
// available). Otherwise fall back to fen/moves/line.
|
|
1413
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1414
|
+
const effectiveFen = resolved.fen;
|
|
1256
1415
|
const params = {
|
|
1257
1416
|
fideId: Number(args.fide_id),
|
|
1258
1417
|
color: String(args.color),
|
|
1259
1418
|
compact: "true",
|
|
1419
|
+
fen: effectiveFen,
|
|
1260
1420
|
};
|
|
1261
|
-
if (fenArg || movesArg) {
|
|
1262
|
-
params.fen = resolveFenFromArgs(args);
|
|
1263
|
-
}
|
|
1264
|
-
else if (lineArg) {
|
|
1265
|
-
params.line = lineArg;
|
|
1266
|
-
}
|
|
1267
1421
|
if (typeof args.limit === "number")
|
|
1268
1422
|
params.limit = args.limit;
|
|
1269
1423
|
if (typeof args.offset === "number")
|
|
1270
1424
|
params.offset = args.offset;
|
|
1271
|
-
const effectiveFen = resolveFenFromArgs(args);
|
|
1272
1425
|
const [raw, ev] = await Promise.all([
|
|
1273
1426
|
get("/api/chess/prep/by-player", params),
|
|
1274
1427
|
fetchCompactEval(effectiveFen),
|
|
@@ -1283,7 +1436,8 @@ async function callToolInner(name, args) {
|
|
|
1283
1436
|
return converted;
|
|
1284
1437
|
}
|
|
1285
1438
|
case "get_position_stats": {
|
|
1286
|
-
const
|
|
1439
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1440
|
+
const fen = resolved.fen;
|
|
1287
1441
|
const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
|
|
1288
1442
|
const source = rawSource === "main" ? "main" : "gm-classical";
|
|
1289
1443
|
const [raw, ev] = await Promise.all([
|
|
@@ -1304,10 +1458,13 @@ async function callToolInner(name, args) {
|
|
|
1304
1458
|
}
|
|
1305
1459
|
return converted;
|
|
1306
1460
|
}
|
|
1307
|
-
case "describe_position":
|
|
1308
|
-
|
|
1461
|
+
case "describe_position": {
|
|
1462
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1463
|
+
return describePosition(resolved.fen);
|
|
1464
|
+
}
|
|
1309
1465
|
case "predict_human_move": {
|
|
1310
|
-
const
|
|
1466
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1467
|
+
const fen = resolved.fen;
|
|
1311
1468
|
const qs = new URLSearchParams();
|
|
1312
1469
|
qs.set("fen", fen);
|
|
1313
1470
|
if (typeof args.white_elo === "number")
|
|
@@ -1349,7 +1506,8 @@ async function callToolInner(name, args) {
|
|
|
1349
1506
|
case "stop_cloud_engine":
|
|
1350
1507
|
return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
|
|
1351
1508
|
case "cloud_analyse": {
|
|
1352
|
-
const
|
|
1509
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1510
|
+
const fen = resolved.fen;
|
|
1353
1511
|
const body = { fen };
|
|
1354
1512
|
if (typeof args.movetime_ms === "number")
|
|
1355
1513
|
body.movetime_ms = args.movetime_ms;
|
|
@@ -1358,7 +1516,19 @@ async function callToolInner(name, args) {
|
|
|
1358
1516
|
if (typeof args.contempt === "number")
|
|
1359
1517
|
body.contempt = args.contempt;
|
|
1360
1518
|
const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
1361
|
-
|
|
1519
|
+
const converted = convertCloudSnapshotResponse(raw, fen);
|
|
1520
|
+
// Node-addressed calls: persist the result on the node's ceoEval
|
|
1521
|
+
// so a later quote_engine_eval can cite this measurement. This is
|
|
1522
|
+
// the anti-hallucination hinge — prose that says "engines say X
|
|
1523
|
+
// on node Y" can only trace back to a call actually made against
|
|
1524
|
+
// node_id=Y, because the store only fires when file_id+node_id
|
|
1525
|
+
// was supplied and the eval survives via the [%ceo-eval] escape.
|
|
1526
|
+
if (resolved.file) {
|
|
1527
|
+
const ev = analysisToStoredEval(converted, fen);
|
|
1528
|
+
if (ev)
|
|
1529
|
+
await storeEvalOnNode(resolved.file, ev);
|
|
1530
|
+
}
|
|
1531
|
+
return converted;
|
|
1362
1532
|
}
|
|
1363
1533
|
case "list_prep_files":
|
|
1364
1534
|
return authedRequest("GET", "/api/agent/prep-files");
|
|
@@ -1384,32 +1554,53 @@ async function callToolInner(name, args) {
|
|
|
1384
1554
|
case "delete_prep_file":
|
|
1385
1555
|
return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
|
|
1386
1556
|
case "add_move":
|
|
1387
|
-
return applyMutation(args, file => addMove(file,
|
|
1557
|
+
return applyMutation(args, (file, idIndex) => addMove(file, resolveNodeId(idIndex, argNodeId(args, "parent_id")), String(args.san)));
|
|
1558
|
+
case "add_line":
|
|
1559
|
+
return applyMutation(args, (file, idIndex) => {
|
|
1560
|
+
const sans = Array.isArray(args.sans) ? args.sans.map(String) : [];
|
|
1561
|
+
const parentPath = resolveNodeId(idIndex, argNodeId(args, "parent_id"));
|
|
1562
|
+
const step = addLine(file, parentPath, sans);
|
|
1563
|
+
const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : argNodeId(args, "parent_id");
|
|
1564
|
+
return { file: step.file, id: lastId, results: step.line };
|
|
1565
|
+
});
|
|
1388
1566
|
case "set_comment":
|
|
1389
|
-
return applyMutation(args, file => setComment(file,
|
|
1567
|
+
return applyMutation(args, (file, idIndex) => setComment(file, resolveNodeId(idIndex, argNodeId(args)), typeof args.comment === "string" ? args.comment : ""));
|
|
1390
1568
|
case "set_nags":
|
|
1391
|
-
return applyMutation(args, file => setNags(file,
|
|
1569
|
+
return applyMutation(args, (file, idIndex) => setNags(file, resolveNodeId(idIndex, argNodeId(args)), Array.isArray(args.nags) ? args.nags.map(String) : []));
|
|
1392
1570
|
case "set_annotations": {
|
|
1393
1571
|
const arrowsRaw = Array.isArray(args.arrows) ? args.arrows : [];
|
|
1394
1572
|
const highlightsRaw = Array.isArray(args.highlights) ? args.highlights : [];
|
|
1395
1573
|
const ann = (arrowsRaw.length === 0 && highlightsRaw.length === 0)
|
|
1396
1574
|
? null
|
|
1397
1575
|
: { arrows: arrowsRaw, highlights: highlightsRaw };
|
|
1398
|
-
return applyMutation(args, file => setAnnotations(file,
|
|
1576
|
+
return applyMutation(args, (file, idIndex) => setAnnotations(file, resolveNodeId(idIndex, argNodeId(args)), ann));
|
|
1399
1577
|
}
|
|
1400
1578
|
case "delete_subtree":
|
|
1401
|
-
return applyMutation(args, file => deleteSubtree(file,
|
|
1579
|
+
return applyMutation(args, (file, idIndex) => deleteSubtree(file, resolveNodeId(idIndex, argNodeId(args))));
|
|
1402
1580
|
case "promote_variation":
|
|
1403
|
-
return applyMutation(args, file => promoteVariation(file,
|
|
1581
|
+
return applyMutation(args, (file, idIndex) => promoteVariation(file, resolveNodeId(idIndex, argNodeId(args))));
|
|
1404
1582
|
case "set_tag":
|
|
1405
1583
|
return applyMutation(args, file => ({
|
|
1406
1584
|
file: setTag(file, String(args.key), String(args.value ?? "")),
|
|
1407
|
-
|
|
1585
|
+
id: ROOT_ID,
|
|
1408
1586
|
}));
|
|
1409
1587
|
case "apply_mutations":
|
|
1410
1588
|
return applyBatchMutations(args);
|
|
1411
1589
|
case "auto_evaluate":
|
|
1412
1590
|
return autoEvaluate(args);
|
|
1591
|
+
case "quote_engine_eval": {
|
|
1592
|
+
const fileId = String(args.id);
|
|
1593
|
+
const nodeId = argNodeId(args);
|
|
1594
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
|
|
1595
|
+
const g = raw;
|
|
1596
|
+
if (typeof g.pgnContent !== "string")
|
|
1597
|
+
throw new Error("prep file missing pgnContent");
|
|
1598
|
+
const file = parsePGN(g.pgnContent);
|
|
1599
|
+
const idIndex = buildIdIndex(file.root);
|
|
1600
|
+
const path = resolveNodeId(idIndex, nodeId);
|
|
1601
|
+
const node = getNodeByPath(file.root, path);
|
|
1602
|
+
return { ceoEval: node.ceoEval ?? null };
|
|
1603
|
+
}
|
|
1413
1604
|
case "read_engine_usage_guide":
|
|
1414
1605
|
return { guide: ENGINE_USAGE_DOC };
|
|
1415
1606
|
case "read_prep_strategy_guide":
|
|
@@ -1418,40 +1609,22 @@ async function callToolInner(name, args) {
|
|
|
1418
1609
|
return { guide: PREP_FILES_DOC };
|
|
1419
1610
|
case "read_pgn_authoring_guide":
|
|
1420
1611
|
return { guide: PGN_AUTHORING_DOC };
|
|
1612
|
+
case "read_example_prep_files":
|
|
1613
|
+
return { overview: EXAMPLE_OVERVIEW_PGN, repertoire: EXAMPLE_REPERTOIRE_PGN };
|
|
1421
1614
|
case "prep_snapshot": {
|
|
1422
1615
|
const me = Number(args.fide_id_me);
|
|
1423
1616
|
const opp = Number(args.fide_id_opponent);
|
|
1424
1617
|
const myColor = String(args.my_color);
|
|
1425
1618
|
const oppColor = myColor === "white" ? "black" : "white";
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
// parallel instead of doing a preliminary round-trip.
|
|
1431
|
-
if (!fen) {
|
|
1432
|
-
const board = new Chess();
|
|
1433
|
-
if (line.length > 0) {
|
|
1434
|
-
for (const raw of line.split(/\s+/)) {
|
|
1435
|
-
// Tolerant of move-number tokens like "1." / "12..." that some
|
|
1436
|
-
// clients include; chess.js rejects those outright.
|
|
1437
|
-
const san = raw.replace(/^\d+\.+/, "");
|
|
1438
|
-
if (!san)
|
|
1439
|
-
continue;
|
|
1440
|
-
try {
|
|
1441
|
-
board.move(san);
|
|
1442
|
-
}
|
|
1443
|
-
catch {
|
|
1444
|
-
throw new Error(`bad SAN token '${raw}' in line`);
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
fen = board.fen();
|
|
1449
|
-
}
|
|
1619
|
+
// Prefer file_id+node_id — derives FEN from the tree, no LLM-typed
|
|
1620
|
+
// FEN in the loop. Falls back to line/fen for scratch positions.
|
|
1621
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1622
|
+
const fen = resolved.fen;
|
|
1450
1623
|
const prepParams = (fideId, color) => ({
|
|
1451
1624
|
fideId,
|
|
1452
1625
|
color,
|
|
1453
1626
|
compact: "true",
|
|
1454
|
-
|
|
1627
|
+
fen,
|
|
1455
1628
|
});
|
|
1456
1629
|
const [opponent, you, general, ev] = await Promise.all([
|
|
1457
1630
|
get("/api/chess/prep/by-player", prepParams(opp, oppColor)),
|
|
@@ -1460,7 +1633,7 @@ async function callToolInner(name, args) {
|
|
|
1460
1633
|
fetchCompactEval(fen),
|
|
1461
1634
|
]);
|
|
1462
1635
|
return {
|
|
1463
|
-
position: {
|
|
1636
|
+
position: { fen, my_color: myColor, ...(resolved.file ? { node_id: typeof args.node_id === "string" ? args.node_id : undefined } : {}) },
|
|
1464
1637
|
eval: ev,
|
|
1465
1638
|
opponent: convertAvailableMovesToSAN(opponent, fen),
|
|
1466
1639
|
you: convertAvailableMovesToSAN(you, fen),
|