@chessceo/mcp 0.19.0 → 0.24.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 +436 -108
- package/dist/pgn/exporter.js +128 -0
- package/dist/pgn/mutations.js +155 -0
- package/dist/pgn/parser.js +117 -0
- package/dist/pgn/paths.js +55 -0
- package/dist/pgn/types.js +21 -0
- package/docs/pgn-authoring.md +194 -0
- package/docs/prep-files-guide.md +11 -33
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -12,6 +12,10 @@ import { readFileSync } from "node:fs";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { Chess } from "chess.js";
|
|
15
|
+
import { parsePGN } from "./pgn/parser.js";
|
|
16
|
+
import { exportPGN } from "./pgn/exporter.js";
|
|
17
|
+
import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
18
|
+
import { PathError } from "./pgn/paths.js";
|
|
15
19
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
21
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -36,6 +40,7 @@ function loadBundledDoc(filename, fallbackLabel) {
|
|
|
36
40
|
const ENGINE_USAGE_DOC = loadBundledDoc("engine-usage.md", "Engine usage guide");
|
|
37
41
|
const PREP_STRATEGY_DOC = loadBundledDoc("prep-strategy.md", "Prep strategy guide");
|
|
38
42
|
const PREP_FILES_DOC = loadBundledDoc("prep-files-guide.md", "Prep files guide");
|
|
43
|
+
const PGN_AUTHORING_DOC = loadBundledDoc("pgn-authoring.md", "PGN authoring guide");
|
|
39
44
|
// ── HTTP ────────────────────────────────────────────────────────────
|
|
40
45
|
//
|
|
41
46
|
// Two auth flavours coexist:
|
|
@@ -63,8 +68,16 @@ const AUTHED_TOOLS = new Set([
|
|
|
63
68
|
"search_prep_files",
|
|
64
69
|
"read_prep_file",
|
|
65
70
|
"create_prep_file",
|
|
66
|
-
"save_prep_file",
|
|
67
71
|
"delete_prep_file",
|
|
72
|
+
"add_move",
|
|
73
|
+
"set_comment",
|
|
74
|
+
"set_nags",
|
|
75
|
+
"set_annotations",
|
|
76
|
+
"delete_subtree",
|
|
77
|
+
"promote_variation",
|
|
78
|
+
"set_tag",
|
|
79
|
+
"apply_mutations",
|
|
80
|
+
"auto_evaluate",
|
|
68
81
|
"predict_human_move",
|
|
69
82
|
]);
|
|
70
83
|
function isAuthedToolCall(body) {
|
|
@@ -241,35 +254,6 @@ const TOOLS = [
|
|
|
241
254
|
},
|
|
242
255
|
},
|
|
243
256
|
},
|
|
244
|
-
{
|
|
245
|
-
name: "analyse",
|
|
246
|
-
description: "Short Stockfish evaluation at a position. Returns the top-N candidate moves with score (centipawns from side-to-move POV, positive = advantage; or mate distance) and the principal variation for each. Defaults: 2s think time, top-3 lines. PV moves come back in SAN (e4, Nf3, Bxc4 — not UCI). Free (no cloud instance needed) — use liberally.\n\n" +
|
|
247
|
-
"GROUNDING: cite this tool's actual output when you claim things about positions. Don't invent evaluations from general principles or training data — if you don't have engine output for a FEN, call this. Compute is cheap.\n\n" +
|
|
248
|
-
"Position input is flexible — pass `fen`, or `moves` from startpos, or `fen + moves` to walk from an arbitrary position. Also see the flip-side-to-move threat check in read_engine_usage_guide.\n\n" +
|
|
249
|
-
"Use this to sanity-check candidate lines from get_position_stats or get_player_preparation — human game frequency tells you what people play, engine evaluation tells you what's actually good.",
|
|
250
|
-
inputSchema: {
|
|
251
|
-
type: "object",
|
|
252
|
-
properties: {
|
|
253
|
-
fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
|
|
254
|
-
moves: {
|
|
255
|
-
type: "string",
|
|
256
|
-
description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
|
|
257
|
-
},
|
|
258
|
-
movetime_ms: {
|
|
259
|
-
type: "integer",
|
|
260
|
-
minimum: 100,
|
|
261
|
-
maximum: 10000,
|
|
262
|
-
description: "Think time in milliseconds (default 2000).",
|
|
263
|
-
},
|
|
264
|
-
multipv: {
|
|
265
|
-
type: "integer",
|
|
266
|
-
minimum: 1,
|
|
267
|
-
maximum: 10,
|
|
268
|
-
description: "Number of candidate lines to return (default 3).",
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
257
|
{
|
|
274
258
|
name: "predict_human_move",
|
|
275
259
|
description: "Predicts what a HUMAN of the given rating will most likely play from a position. Rating-conditioned neural net (ResNet-20x256) — you get the top-N most likely moves with their probabilities and a WDL value head (White POV).\n\n" +
|
|
@@ -457,7 +441,8 @@ const TOOLS = [
|
|
|
457
441
|
},
|
|
458
442
|
{
|
|
459
443
|
name: "read_prep_file",
|
|
460
|
-
description: "Read one prep file. Returns
|
|
444
|
+
description: "Read one prep file. Returns a compact tree (path-addressable, one node per move) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN — all edits go through the mutation tools (add_move / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
|
|
445
|
+
"Path addressing: paths are arrays of child indices. `[]` = root position. `[0]` = the first mainline move. `[0, 1]` = the second variation branching after the first move. `[0, 0, 0]` = three plies deep on the mainline. Every mutation returns the effective path it landed at.",
|
|
461
446
|
inputSchema: {
|
|
462
447
|
type: "object",
|
|
463
448
|
properties: {
|
|
@@ -468,55 +453,200 @@ const TOOLS = [
|
|
|
468
453
|
},
|
|
469
454
|
{
|
|
470
455
|
name: "create_prep_file",
|
|
471
|
-
description: "Create a new prep file. `name` becomes the Event PGN tag
|
|
472
|
-
"ALWAYS
|
|
456
|
+
description: "Create a new (empty) prep file. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
|
|
457
|
+
"ALWAYS call list_prep_files (or search_prep_files with the opponent / opening keyword) FIRST — creating a duplicate 'Prep vs Firouzja' when one exists is the #1 LLM failure mode. If a file already covers the topic, add moves to that one instead.",
|
|
473
458
|
inputSchema: {
|
|
474
459
|
type: "object",
|
|
475
460
|
properties: {
|
|
476
461
|
name: {
|
|
477
462
|
type: "string",
|
|
478
|
-
description: "User-facing name
|
|
479
|
-
},
|
|
480
|
-
pgn: {
|
|
481
|
-
type: "string",
|
|
482
|
-
description: "Optional initial PGN content. Supports full PGN with headers and parenthesised variations. If omitted, an empty PGN is created.",
|
|
463
|
+
description: "User-facing name — becomes the [Event] tag. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
|
|
483
464
|
},
|
|
484
465
|
},
|
|
485
466
|
required: ["name"],
|
|
486
467
|
},
|
|
487
468
|
},
|
|
488
469
|
{
|
|
489
|
-
name: "
|
|
490
|
-
description: "
|
|
491
|
-
"PGN structure guidance:\n" +
|
|
492
|
-
"- Keep the mainline clean (your top recommendation).\n" +
|
|
493
|
-
"- Alternative candidates go in parenthesised variations at the branching move.\n" +
|
|
494
|
-
"- Attach one-sentence commentary at key branch points using PGN {curly-brace comments}. Cite the actual tool output ('Lc0 gives +0.15 in this line') — don't dress the file up with unsourced chess prose.\n" +
|
|
495
|
-
"- Use PGN NAGs for evaluations: $1 !, $2 ?, $3 !!, $4 ??, $6 =, $10 = (equal), $14 +/= (slight edge), etc. Optional but nice.",
|
|
470
|
+
name: "delete_prep_file",
|
|
471
|
+
description: "Soft-delete a prep file. User can restore from the app's recycle bin. Rare — usually you extend or edit instead.",
|
|
496
472
|
inputSchema: {
|
|
497
473
|
type: "object",
|
|
498
474
|
properties: {
|
|
499
475
|
id: { type: "string", description: "Prep file id." },
|
|
500
|
-
pgn: { type: "string", description: "Full replacement PGN (headers + moves)." },
|
|
501
|
-
expected_version: {
|
|
502
|
-
type: "integer",
|
|
503
|
-
description: "The `version` you got from read_prep_file. If omitted, no optimistic check runs (last-write-wins — riskier).",
|
|
504
|
-
},
|
|
505
476
|
},
|
|
506
|
-
required: ["id"
|
|
477
|
+
required: ["id"],
|
|
507
478
|
},
|
|
508
479
|
},
|
|
509
480
|
{
|
|
510
|
-
name: "
|
|
511
|
-
description: "
|
|
481
|
+
name: "add_move",
|
|
482
|
+
description: "Append a move as a new child of the node at `path`. If the node already has children, the new move becomes a variation (appended at the end). Use promote_variation afterwards if you want it to be the mainline. SAN is validated against the position — illegal moves are rejected with a clear error.\n\n" +
|
|
483
|
+
"Auto-saves. Returns `{path, version}` — the effective path the new node landed at (which you pass to follow-up set_comment / set_nags / etc.) and the new file version for optimistic locking on your next mutation.",
|
|
484
|
+
inputSchema: {
|
|
485
|
+
type: "object",
|
|
486
|
+
properties: {
|
|
487
|
+
id: { type: "string", description: "Prep file id." },
|
|
488
|
+
path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the parent node the move is played FROM." },
|
|
489
|
+
san: { type: "string", description: "The move in SAN notation (e.g. 'Nf3', 'exd5', 'O-O', 'Qxf7+')." },
|
|
490
|
+
expected_version: { type: "integer", description: "Optimistic-lock check; pass the `version` from your last read." },
|
|
491
|
+
},
|
|
492
|
+
required: ["id", "path", "san"],
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: "set_comment",
|
|
497
|
+
description: "Set (or clear, with empty string) the text comment on the node at `path`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.",
|
|
512
498
|
inputSchema: {
|
|
513
499
|
type: "object",
|
|
514
500
|
properties: {
|
|
515
501
|
id: { type: "string", description: "Prep file id." },
|
|
502
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
503
|
+
comment: { type: "string", description: "New comment text. Empty string clears." },
|
|
504
|
+
expected_version: { type: "integer" },
|
|
505
|
+
},
|
|
506
|
+
required: ["id", "path", "comment"],
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: "set_nags",
|
|
511
|
+
description: "Replace the list of NAGs on the node at `path`. Empty array clears them. See read_pgn_authoring_guide for the full NAG table and eval→NAG thresholds (|eval|<0.25 → $10; <0.6 → $14/$15; <1.3 → $16/$17; ≥1.3 → $18/$19). Auto-saves.",
|
|
512
|
+
inputSchema: {
|
|
513
|
+
type: "object",
|
|
514
|
+
properties: {
|
|
515
|
+
id: { type: "string" },
|
|
516
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
517
|
+
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" }, description: "NAG list, e.g. ['$14'] or ['$146', '$44']." },
|
|
518
|
+
expected_version: { type: "integer" },
|
|
519
|
+
},
|
|
520
|
+
required: ["id", "path", "nags"],
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
name: "set_annotations",
|
|
525
|
+
description: "Replace the visual annotations (arrows + coloured squares) on the node at `path`. Passing empty arrays clears them.\n\n" +
|
|
526
|
+
"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.",
|
|
527
|
+
inputSchema: {
|
|
528
|
+
type: "object",
|
|
529
|
+
properties: {
|
|
530
|
+
id: { type: "string" },
|
|
531
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
532
|
+
arrows: {
|
|
533
|
+
type: "array",
|
|
534
|
+
items: {
|
|
535
|
+
type: "object",
|
|
536
|
+
properties: {
|
|
537
|
+
color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
|
|
538
|
+
from: { type: "string", pattern: "^[a-h][1-8]$" },
|
|
539
|
+
to: { type: "string", pattern: "^[a-h][1-8]$" },
|
|
540
|
+
},
|
|
541
|
+
required: ["color", "from", "to"],
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
highlights: {
|
|
545
|
+
type: "array",
|
|
546
|
+
items: {
|
|
547
|
+
type: "object",
|
|
548
|
+
properties: {
|
|
549
|
+
color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
|
|
550
|
+
square: { type: "string", pattern: "^[a-h][1-8]$" },
|
|
551
|
+
},
|
|
552
|
+
required: ["color", "square"],
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
expected_version: { type: "integer" },
|
|
556
|
+
},
|
|
557
|
+
required: ["id", "path"],
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: "delete_subtree",
|
|
562
|
+
description: "Delete the node at `path` and all its descendants. Refuses to delete the root. Auto-saves.",
|
|
563
|
+
inputSchema: {
|
|
564
|
+
type: "object",
|
|
565
|
+
properties: {
|
|
566
|
+
id: { type: "string" },
|
|
567
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
568
|
+
expected_version: { type: "integer" },
|
|
569
|
+
},
|
|
570
|
+
required: ["id", "path"],
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: "promote_variation",
|
|
575
|
+
description: "Make the node at `path` 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.",
|
|
576
|
+
inputSchema: {
|
|
577
|
+
type: "object",
|
|
578
|
+
properties: {
|
|
579
|
+
id: { type: "string" },
|
|
580
|
+
path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the node to promote. Length ≥ 1." },
|
|
581
|
+
expected_version: { type: "integer" },
|
|
582
|
+
},
|
|
583
|
+
required: ["id", "path"],
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
name: "apply_mutations",
|
|
588
|
+
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" +
|
|
589
|
+
"Each mutation is `{op, path, ...args}` where `op` is one of: add_move, set_comment, set_nags, set_annotations, delete_subtree, promote_variation, set_tag. Same arg shape as the individual tools. Ops apply in order; `path` is resolved against the tree AFTER preceding ops in the batch — so after `{op: 'add_move', path: [0], san: 'Nc3'}` the new node's path is `[0, N]` where N is the parent's previous children count (or `[0, 0]` if it was empty).\n\n" +
|
|
590
|
+
"Any op error aborts the batch (nothing saved). Response is `{ok, results: [{path}], version}` giving the effective path each op landed at plus the new file version.",
|
|
591
|
+
inputSchema: {
|
|
592
|
+
type: "object",
|
|
593
|
+
properties: {
|
|
594
|
+
id: { type: "string" },
|
|
595
|
+
expected_version: { type: "integer" },
|
|
596
|
+
mutations: {
|
|
597
|
+
type: "array",
|
|
598
|
+
minItems: 1,
|
|
599
|
+
items: {
|
|
600
|
+
type: "object",
|
|
601
|
+
properties: {
|
|
602
|
+
op: { type: "string", enum: ["add_move", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
|
|
603
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
604
|
+
san: { type: "string" },
|
|
605
|
+
comment: { type: "string" },
|
|
606
|
+
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" } },
|
|
607
|
+
arrows: { type: "array" },
|
|
608
|
+
highlights: { type: "array" },
|
|
609
|
+
key: { type: "string" },
|
|
610
|
+
value: { type: "string" },
|
|
611
|
+
},
|
|
612
|
+
required: ["op"],
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
required: ["id", "mutations"],
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
name: "auto_evaluate",
|
|
621
|
+
description: "Walk the tree from `path` (default `[]` = whole file) and auto-assign NAGs to every node based on the Stockfish eval from cloud_analyse. Uses the standard eval → NAG thresholds (|eval|<0.25 → $10, <0.6 → $14/$15, <1.3 → $16/$17, ≥1.3 → $18/$19). Requires a running cloud combo instance.\n\n" +
|
|
622
|
+
"This is the automate-the-boring-part tool. Instead of hand-annotating each move with a NAG, you build the tree via apply_mutations (no NAGs needed), then call auto_evaluate once and every node gets the right glyph.\n\n" +
|
|
623
|
+
"Costs real money — hits cloud_analyse per node. A 200-node tree at 1.5s/node = ~5 min of engine time. Use `only_missing=true` (default) to skip nodes that already have a NAG on subsequent runs. Runs 4 evaluations in parallel to save wall time.",
|
|
624
|
+
inputSchema: {
|
|
625
|
+
type: "object",
|
|
626
|
+
properties: {
|
|
627
|
+
id: { type: "string" },
|
|
628
|
+
path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Subtree root (default = whole file)." },
|
|
629
|
+
only_missing: { type: "boolean", description: "Skip nodes that already carry a NAG (default true)." },
|
|
630
|
+
movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
|
|
631
|
+
expected_version: { type: "integer" },
|
|
516
632
|
},
|
|
517
633
|
required: ["id"],
|
|
518
634
|
},
|
|
519
635
|
},
|
|
636
|
+
{
|
|
637
|
+
name: "set_tag",
|
|
638
|
+
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.",
|
|
639
|
+
inputSchema: {
|
|
640
|
+
type: "object",
|
|
641
|
+
properties: {
|
|
642
|
+
id: { type: "string" },
|
|
643
|
+
key: { type: "string", description: "Tag key, e.g. 'Event', 'White', 'Date'." },
|
|
644
|
+
value: { type: "string", description: "Tag value. Empty string removes the tag." },
|
|
645
|
+
expected_version: { type: "integer" },
|
|
646
|
+
},
|
|
647
|
+
required: ["id", "key", "value"],
|
|
648
|
+
},
|
|
649
|
+
},
|
|
520
650
|
{
|
|
521
651
|
name: "read_engine_usage_guide",
|
|
522
652
|
description: "Returns the full chess.ceo engine-usage guide: when to trust Stockfish (objective truth) vs Lc0 (practical eval), how to read disagreements between them, and how to use Lc0 contempt to find non-objective 'practical' ideas. Call this ONCE per session before running expensive `cloud_analyse` calls or when the user asks WHY the engines gave certain scores. Same content is also available as the `engine_usage_primer` prompt (for clients that surface prompts as slash commands), but many clients do not expose prompts to the model — this tool works everywhere.",
|
|
@@ -529,7 +659,12 @@ const TOOLS = [
|
|
|
529
659
|
},
|
|
530
660
|
{
|
|
531
661
|
name: "read_prep_files_guide",
|
|
532
|
-
description: "Returns the
|
|
662
|
+
description: "Returns the guide to the prep-files FEATURE — how the storage works, when to list vs search vs create, optimistic locking with `version`, and naming conventions for the [Event] tag. Call this ONCE per session before your first create_prep_file. For how to actually WRITE PGN (mainline, variations, NAGs, arrows, comments) call read_pgn_authoring_guide instead — separate concern.",
|
|
663
|
+
inputSchema: { type: "object", properties: {} },
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: "read_pgn_authoring_guide",
|
|
667
|
+
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.",
|
|
533
668
|
inputSchema: { type: "object", properties: {} },
|
|
534
669
|
},
|
|
535
670
|
{
|
|
@@ -605,20 +740,6 @@ function uciMoveToSAN(startFen, uci) {
|
|
|
605
740
|
return uci;
|
|
606
741
|
}
|
|
607
742
|
}
|
|
608
|
-
// Rewrite the local /chess/database/analyse response (single engine)
|
|
609
|
-
// so PVs come back in SAN.
|
|
610
|
-
function convertAnalyseResponse(raw, startFen) {
|
|
611
|
-
if (!raw || typeof raw !== "object")
|
|
612
|
-
return raw;
|
|
613
|
-
const r = raw;
|
|
614
|
-
if (Array.isArray(r.lines)) {
|
|
615
|
-
for (const line of r.lines) {
|
|
616
|
-
if (Array.isArray(line.pv))
|
|
617
|
-
line.pv = uciLineToSAN(startFen, line.pv);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
return raw;
|
|
621
|
-
}
|
|
622
743
|
// Rewrite the /api/agent/cloud-engines/analyse response (two engines,
|
|
623
744
|
// each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
|
|
624
745
|
function convertCloudSnapshotResponse(raw, startFen) {
|
|
@@ -639,23 +760,217 @@ function convertCloudSnapshotResponse(raw, startFen) {
|
|
|
639
760
|
}
|
|
640
761
|
return raw;
|
|
641
762
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
763
|
+
// Path arg parsing for the mutation tools. Rejects malformed input
|
|
764
|
+
// with a clear message the LLM can act on.
|
|
765
|
+
function argPath(args) {
|
|
766
|
+
const raw = args.path;
|
|
767
|
+
if (!Array.isArray(raw))
|
|
768
|
+
throw new Error("`path` must be an array of non-negative integers");
|
|
769
|
+
const out = [];
|
|
770
|
+
for (let i = 0; i < raw.length; i++) {
|
|
771
|
+
const n = raw[i];
|
|
772
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
773
|
+
throw new Error(`path[${i}] must be a non-negative integer (got ${JSON.stringify(n)})`);
|
|
774
|
+
}
|
|
775
|
+
out.push(n);
|
|
655
776
|
}
|
|
656
|
-
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
779
|
+
// Dispatch table for the batch tool: name → mutator that returns the
|
|
780
|
+
// standard { file, path } shape. Reused for individual and batch calls.
|
|
781
|
+
function dispatchMutation(file, op) {
|
|
782
|
+
const kind = String(op.op);
|
|
783
|
+
const path = Array.isArray(op.path) ? op.path : [];
|
|
784
|
+
switch (kind) {
|
|
785
|
+
case "add_move":
|
|
786
|
+
return addMove(file, path, String(op.san));
|
|
787
|
+
case "set_comment":
|
|
788
|
+
return setComment(file, path, typeof op.comment === "string" ? op.comment : "");
|
|
789
|
+
case "set_nags":
|
|
790
|
+
return setNags(file, path, Array.isArray(op.nags) ? op.nags.map(String) : []);
|
|
791
|
+
case "set_annotations": {
|
|
792
|
+
const arrows = Array.isArray(op.arrows) ? op.arrows : [];
|
|
793
|
+
const highlights = Array.isArray(op.highlights) ? op.highlights : [];
|
|
794
|
+
const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
|
|
795
|
+
return setAnnotations(file, path, ann);
|
|
796
|
+
}
|
|
797
|
+
case "delete_subtree":
|
|
798
|
+
return deleteSubtree(file, path);
|
|
799
|
+
case "promote_variation":
|
|
800
|
+
return promoteVariation(file, path);
|
|
801
|
+
case "set_tag":
|
|
802
|
+
return { file: setTag(file, String(op.key), String(op.value ?? "")), path: [] };
|
|
803
|
+
default:
|
|
804
|
+
throw new Error(`unknown mutation op: ${kind}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// Batch: load, parse, apply N mutations in order, export, save. All-or-nothing —
|
|
808
|
+
// any error aborts and nothing is saved.
|
|
809
|
+
async function applyBatchMutations(args) {
|
|
810
|
+
const id = String(args.id);
|
|
811
|
+
const mutations = Array.isArray(args.mutations) ? args.mutations : [];
|
|
812
|
+
if (mutations.length === 0)
|
|
813
|
+
throw new Error("mutations array required");
|
|
814
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
815
|
+
const g = raw;
|
|
816
|
+
if (typeof g.pgnContent !== "string")
|
|
817
|
+
throw new Error("prep file missing pgnContent");
|
|
818
|
+
let file = parsePGN(g.pgnContent);
|
|
819
|
+
const results = [];
|
|
820
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
821
|
+
const op = mutations[i];
|
|
822
|
+
try {
|
|
823
|
+
const step = dispatchMutation(file, op);
|
|
824
|
+
file = step.file;
|
|
825
|
+
results.push({ path: step.path });
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
829
|
+
throw new Error(`mutation #${i} (${String(op.op)}) failed: ${msg}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const newPgn = exportPGN(file);
|
|
833
|
+
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
834
|
+
const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
|
|
835
|
+
pgn: newPgn,
|
|
836
|
+
expected_version: expected,
|
|
837
|
+
});
|
|
838
|
+
const savedRow = saved;
|
|
839
|
+
return { ok: true, results, version: savedRow.version };
|
|
840
|
+
}
|
|
841
|
+
// Auto-evaluate: walk the tree from `path`, run cloud_analyse on each node,
|
|
842
|
+
// convert the SF score to a NAG per the standard thresholds, and apply
|
|
843
|
+
// everything in one batch mutation. Uses batching to avoid N saves.
|
|
844
|
+
async function autoEvaluate(args) {
|
|
845
|
+
const id = String(args.id);
|
|
846
|
+
const startPath = Array.isArray(args.path) ? args.path : [];
|
|
847
|
+
const onlyMissing = args.only_missing !== false; // default true
|
|
848
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
|
|
849
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
850
|
+
const g = raw;
|
|
851
|
+
if (typeof g.pgnContent !== "string")
|
|
852
|
+
throw new Error("prep file missing pgnContent");
|
|
853
|
+
const file = parsePGN(g.pgnContent);
|
|
854
|
+
const targets = [];
|
|
855
|
+
const walk = (node, path) => {
|
|
856
|
+
if (path.length > 0) {
|
|
857
|
+
if (!onlyMissing || !(node.nags && node.nags.length > 0)) {
|
|
858
|
+
targets.push({ path, fen: node.fen });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
for (let i = 0; i < node.children.length; i++)
|
|
862
|
+
walk(node.children[i], [...path, i]);
|
|
863
|
+
};
|
|
864
|
+
const startNode = pathIntoTree(file.root, startPath);
|
|
865
|
+
walk(startNode, startPath);
|
|
866
|
+
if (targets.length === 0)
|
|
867
|
+
return { ok: true, evaluated: 0, skipped: 0, version: g.version };
|
|
868
|
+
// Dispatch cloud_analyse in parallel with a 4-way cap so we don't
|
|
869
|
+
// over-saturate the engine's WS connection cap (single-client per port).
|
|
870
|
+
const nags = [];
|
|
871
|
+
const CONCURRENCY = 4;
|
|
872
|
+
let cursor = 0;
|
|
873
|
+
async function worker() {
|
|
874
|
+
while (cursor < targets.length) {
|
|
875
|
+
const idx = cursor++;
|
|
876
|
+
const t = targets[idx];
|
|
877
|
+
try {
|
|
878
|
+
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
879
|
+
const nag = analysisToNag(analysis);
|
|
880
|
+
if (nag)
|
|
881
|
+
nags.push({ path: t.path, nag });
|
|
882
|
+
}
|
|
883
|
+
catch {
|
|
884
|
+
// ignore per-node failures — best-effort auto-annotation
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
889
|
+
if (nags.length === 0)
|
|
890
|
+
return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
|
|
891
|
+
// Apply all NAGs as a single batch.
|
|
892
|
+
const batchMutations = nags.map(n => ({ op: "set_nags", path: n.path, nags: [n.nag] }));
|
|
893
|
+
const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
|
|
894
|
+
const sr = saveResult;
|
|
895
|
+
return { ok: true, evaluated: nags.length, skipped: targets.length - nags.length, version: sr.version };
|
|
896
|
+
}
|
|
897
|
+
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
898
|
+
function pathIntoTree(root, path) {
|
|
899
|
+
let cur = root;
|
|
900
|
+
for (let i = 0; i < path.length; i++) {
|
|
901
|
+
if (path[i] < 0 || path[i] >= cur.children.length) {
|
|
902
|
+
throw new Error(`path segment ${i}=${path[i]} out of bounds`);
|
|
903
|
+
}
|
|
904
|
+
cur = cur.children[path[i]];
|
|
905
|
+
}
|
|
906
|
+
return cur;
|
|
907
|
+
}
|
|
908
|
+
// Read Stockfish's rank-1 line, convert to White-POV centipawns, and
|
|
909
|
+
// bucket into a NAG per the standard threshold table. Returns null if
|
|
910
|
+
// the response shape doesn't carry a usable score.
|
|
911
|
+
function analysisToNag(analysis) {
|
|
912
|
+
if (!analysis || typeof analysis !== "object")
|
|
657
913
|
return null;
|
|
914
|
+
const r = analysis;
|
|
915
|
+
const line = r.stockfish?.lines?.[0];
|
|
916
|
+
if (!line)
|
|
917
|
+
return null;
|
|
918
|
+
// Determine side to move from the FEN so we can convert side-to-move
|
|
919
|
+
// POV score to White POV.
|
|
920
|
+
const whiteToMove = typeof r.fen === "string" && / w /.test(r.fen);
|
|
921
|
+
let cp;
|
|
922
|
+
if (typeof line.mate === "number") {
|
|
923
|
+
cp = line.mate > 0 ? 10000 : -10000;
|
|
924
|
+
}
|
|
925
|
+
else if (typeof line.scoreCp === "number") {
|
|
926
|
+
cp = line.scoreCp;
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
if (!whiteToMove)
|
|
932
|
+
cp = -cp;
|
|
933
|
+
const abs = Math.abs(cp);
|
|
934
|
+
if (abs < 25)
|
|
935
|
+
return "$10";
|
|
936
|
+
if (abs < 60)
|
|
937
|
+
return cp > 0 ? "$14" : "$15";
|
|
938
|
+
if (abs < 130)
|
|
939
|
+
return cp > 0 ? "$16" : "$17";
|
|
940
|
+
return cp > 0 ? "$18" : "$19";
|
|
941
|
+
}
|
|
942
|
+
// Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
|
|
943
|
+
// save with optimistic lock. Auto-saves so every tool call is atomic;
|
|
944
|
+
// the LLM never sees intermediate state.
|
|
945
|
+
async function applyMutation(args, mutator) {
|
|
946
|
+
const id = String(args.id);
|
|
947
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
948
|
+
const g = raw;
|
|
949
|
+
if (typeof g.pgnContent !== "string")
|
|
950
|
+
throw new Error("prep file missing pgnContent");
|
|
951
|
+
const file = parsePGN(g.pgnContent);
|
|
952
|
+
let result;
|
|
953
|
+
try {
|
|
954
|
+
result = mutator(file);
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
if (err instanceof MutationError || err instanceof PathError) {
|
|
958
|
+
throw new Error(`mutation rejected: ${err.message}`);
|
|
959
|
+
}
|
|
960
|
+
throw err;
|
|
658
961
|
}
|
|
962
|
+
const newPgn = exportPGN(result.file);
|
|
963
|
+
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
964
|
+
const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
|
|
965
|
+
pgn: newPgn,
|
|
966
|
+
expected_version: expected,
|
|
967
|
+
});
|
|
968
|
+
const savedRow = saved;
|
|
969
|
+
return {
|
|
970
|
+
ok: true,
|
|
971
|
+
path: result.path,
|
|
972
|
+
version: savedRow.version,
|
|
973
|
+
};
|
|
659
974
|
}
|
|
660
975
|
// Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
|
|
661
976
|
// endpoints return moves in UCI on the wire — same LLM-readability
|
|
@@ -779,16 +1094,6 @@ async function callToolInner(name, args) {
|
|
|
779
1094
|
});
|
|
780
1095
|
return convertAvailableMovesToSAN(raw, fen);
|
|
781
1096
|
}
|
|
782
|
-
case "analyse": {
|
|
783
|
-
const fen = resolveFenFromArgs(args);
|
|
784
|
-
const params = { fen };
|
|
785
|
-
if (typeof args.movetime_ms === "number")
|
|
786
|
-
params.movetime_ms = args.movetime_ms;
|
|
787
|
-
if (typeof args.multipv === "number")
|
|
788
|
-
params.multipv = args.multipv;
|
|
789
|
-
const raw = await get("/api/chess/database/analyse", params);
|
|
790
|
-
return convertAnalyseResponse(raw, fen);
|
|
791
|
-
}
|
|
792
1097
|
case "predict_human_move": {
|
|
793
1098
|
const fen = resolveFenFromArgs(args);
|
|
794
1099
|
const qs = new URLSearchParams();
|
|
@@ -849,35 +1154,58 @@ async function callToolInner(name, args) {
|
|
|
849
1154
|
return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
|
|
850
1155
|
case "read_prep_file": {
|
|
851
1156
|
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1157
|
+
const g = raw;
|
|
1158
|
+
if (typeof g.pgnContent !== "string")
|
|
1159
|
+
throw new Error("prep file missing pgnContent");
|
|
1160
|
+
const file = parsePGN(g.pgnContent);
|
|
1161
|
+
return {
|
|
1162
|
+
id: g.id,
|
|
1163
|
+
version: g.version,
|
|
1164
|
+
tags: file.tags,
|
|
1165
|
+
tree: file.root,
|
|
1166
|
+
};
|
|
862
1167
|
}
|
|
863
1168
|
case "create_prep_file":
|
|
864
1169
|
return authedRequest("POST", "/api/agent/prep-files", {
|
|
865
1170
|
name: String(args.name),
|
|
866
|
-
pgn: typeof args.pgn === "string" ? args.pgn : undefined,
|
|
867
|
-
});
|
|
868
|
-
case "save_prep_file":
|
|
869
|
-
return authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`, {
|
|
870
|
-
pgn: String(args.pgn),
|
|
871
|
-
expected_version: typeof args.expected_version === "number" ? args.expected_version : undefined,
|
|
872
1171
|
});
|
|
873
1172
|
case "delete_prep_file":
|
|
874
1173
|
return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
|
|
1174
|
+
case "add_move":
|
|
1175
|
+
return applyMutation(args, file => addMove(file, argPath(args), String(args.san)));
|
|
1176
|
+
case "set_comment":
|
|
1177
|
+
return applyMutation(args, file => setComment(file, argPath(args), typeof args.comment === "string" ? args.comment : ""));
|
|
1178
|
+
case "set_nags":
|
|
1179
|
+
return applyMutation(args, file => setNags(file, argPath(args), Array.isArray(args.nags) ? args.nags.map(String) : []));
|
|
1180
|
+
case "set_annotations": {
|
|
1181
|
+
const arrowsRaw = Array.isArray(args.arrows) ? args.arrows : [];
|
|
1182
|
+
const highlightsRaw = Array.isArray(args.highlights) ? args.highlights : [];
|
|
1183
|
+
const ann = (arrowsRaw.length === 0 && highlightsRaw.length === 0)
|
|
1184
|
+
? null
|
|
1185
|
+
: { arrows: arrowsRaw, highlights: highlightsRaw };
|
|
1186
|
+
return applyMutation(args, file => setAnnotations(file, argPath(args), ann));
|
|
1187
|
+
}
|
|
1188
|
+
case "delete_subtree":
|
|
1189
|
+
return applyMutation(args, file => deleteSubtree(file, argPath(args)));
|
|
1190
|
+
case "promote_variation":
|
|
1191
|
+
return applyMutation(args, file => promoteVariation(file, argPath(args)));
|
|
1192
|
+
case "set_tag":
|
|
1193
|
+
return applyMutation(args, file => ({
|
|
1194
|
+
file: setTag(file, String(args.key), String(args.value ?? "")),
|
|
1195
|
+
path: [],
|
|
1196
|
+
}));
|
|
1197
|
+
case "apply_mutations":
|
|
1198
|
+
return applyBatchMutations(args);
|
|
1199
|
+
case "auto_evaluate":
|
|
1200
|
+
return autoEvaluate(args);
|
|
875
1201
|
case "read_engine_usage_guide":
|
|
876
1202
|
return { guide: ENGINE_USAGE_DOC };
|
|
877
1203
|
case "read_prep_strategy_guide":
|
|
878
1204
|
return { guide: PREP_STRATEGY_DOC };
|
|
879
1205
|
case "read_prep_files_guide":
|
|
880
1206
|
return { guide: PREP_FILES_DOC };
|
|
1207
|
+
case "read_pgn_authoring_guide":
|
|
1208
|
+
return { guide: PGN_AUTHORING_DOC };
|
|
881
1209
|
case "prep_snapshot": {
|
|
882
1210
|
const me = Number(args.fide_id_me);
|
|
883
1211
|
const opp = Number(args.fide_id_opponent);
|