@chessceo/mcp 0.17.0 → 0.23.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 +282 -56
- 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 +163 -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,15 @@ 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
|
+
"predict_human_move",
|
|
68
80
|
]);
|
|
69
81
|
function isAuthedToolCall(body) {
|
|
70
82
|
if (!body || typeof body !== "object")
|
|
@@ -269,6 +281,51 @@ const TOOLS = [
|
|
|
269
281
|
},
|
|
270
282
|
},
|
|
271
283
|
},
|
|
284
|
+
{
|
|
285
|
+
name: "predict_human_move",
|
|
286
|
+
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" +
|
|
287
|
+
"This is a completely different question from the engine tools (analyse / cloud_analyse):\n" +
|
|
288
|
+
"• Engines answer: 'what is objectively best?'\n" +
|
|
289
|
+
"• predict_human_move answers: 'what will my 2200-rated opponent actually play here?'\n\n" +
|
|
290
|
+
"Prime use cases in prep:\n" +
|
|
291
|
+
"• 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" +
|
|
292
|
+
"• 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" +
|
|
293
|
+
"• Pass `prev_fen` (most recent first) when analysing mid-trade positions — without history the model treats the position as quiet.\n\n" +
|
|
294
|
+
"Position input is flexible — pass `fen`, or `moves` from startpos, or `fen + moves`. Default rating 2400 both sides. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: {
|
|
298
|
+
fen: { type: "string", description: "Starting position as FEN." },
|
|
299
|
+
moves: {
|
|
300
|
+
type: "string",
|
|
301
|
+
description: "Optional SAN moves to apply on top of `fen` (or startpos).",
|
|
302
|
+
},
|
|
303
|
+
white_elo: {
|
|
304
|
+
type: "integer",
|
|
305
|
+
minimum: 100,
|
|
306
|
+
maximum: 3400,
|
|
307
|
+
description: "White's rating (default 2400).",
|
|
308
|
+
},
|
|
309
|
+
black_elo: {
|
|
310
|
+
type: "integer",
|
|
311
|
+
minimum: 100,
|
|
312
|
+
maximum: 3400,
|
|
313
|
+
description: "Black's rating (default 2400).",
|
|
314
|
+
},
|
|
315
|
+
top: {
|
|
316
|
+
type: "integer",
|
|
317
|
+
minimum: 1,
|
|
318
|
+
maximum: 20,
|
|
319
|
+
description: "Number of top predicted moves to return (default 5).",
|
|
320
|
+
},
|
|
321
|
+
prev_fens: {
|
|
322
|
+
type: "array",
|
|
323
|
+
items: { type: "string" },
|
|
324
|
+
description: "Previous FEN(s), most recent first. Optional — omit for quiet-position analysis. Useful mid-trade so the model doesn't assume the position is stable.",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
272
329
|
{
|
|
273
330
|
name: "get_head_to_head",
|
|
274
331
|
description: "Complete head-to-head record between two players. Includes overall and per-colour W/D/L (from player A's perspective), splits by time control, most-played openings between them, first / last meeting, average game length, and the game list.",
|
|
@@ -411,7 +468,8 @@ const TOOLS = [
|
|
|
411
468
|
},
|
|
412
469
|
{
|
|
413
470
|
name: "read_prep_file",
|
|
414
|
-
description: "Read one prep file. Returns
|
|
471
|
+
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" +
|
|
472
|
+
"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.",
|
|
415
473
|
inputSchema: {
|
|
416
474
|
type: "object",
|
|
417
475
|
properties: {
|
|
@@ -422,53 +480,148 @@ const TOOLS = [
|
|
|
422
480
|
},
|
|
423
481
|
{
|
|
424
482
|
name: "create_prep_file",
|
|
425
|
-
description: "Create a new prep file. `name` becomes the Event PGN tag
|
|
426
|
-
"ALWAYS
|
|
483
|
+
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" +
|
|
484
|
+
"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.",
|
|
427
485
|
inputSchema: {
|
|
428
486
|
type: "object",
|
|
429
487
|
properties: {
|
|
430
488
|
name: {
|
|
431
489
|
type: "string",
|
|
432
|
-
description: "User-facing name
|
|
433
|
-
},
|
|
434
|
-
pgn: {
|
|
435
|
-
type: "string",
|
|
436
|
-
description: "Optional initial PGN content. Supports full PGN with headers and parenthesised variations. If omitted, an empty PGN is created.",
|
|
490
|
+
description: "User-facing name — becomes the [Event] tag. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
|
|
437
491
|
},
|
|
438
492
|
},
|
|
439
493
|
required: ["name"],
|
|
440
494
|
},
|
|
441
495
|
},
|
|
442
496
|
{
|
|
443
|
-
name: "
|
|
444
|
-
description: "
|
|
445
|
-
"PGN structure guidance:\n" +
|
|
446
|
-
"- Keep the mainline clean (your top recommendation).\n" +
|
|
447
|
-
"- Alternative candidates go in parenthesised variations at the branching move.\n" +
|
|
448
|
-
"- 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" +
|
|
449
|
-
"- Use PGN NAGs for evaluations: $1 !, $2 ?, $3 !!, $4 ??, $6 =, $10 = (equal), $14 +/= (slight edge), etc. Optional but nice.",
|
|
497
|
+
name: "delete_prep_file",
|
|
498
|
+
description: "Soft-delete a prep file. User can restore from the app's recycle bin. Rare — usually you extend or edit instead.",
|
|
450
499
|
inputSchema: {
|
|
451
500
|
type: "object",
|
|
452
501
|
properties: {
|
|
453
502
|
id: { type: "string", description: "Prep file id." },
|
|
454
|
-
pgn: { type: "string", description: "Full replacement PGN (headers + moves)." },
|
|
455
|
-
expected_version: {
|
|
456
|
-
type: "integer",
|
|
457
|
-
description: "The `version` you got from read_prep_file. If omitted, no optimistic check runs (last-write-wins — riskier).",
|
|
458
|
-
},
|
|
459
503
|
},
|
|
460
|
-
required: ["id"
|
|
504
|
+
required: ["id"],
|
|
461
505
|
},
|
|
462
506
|
},
|
|
463
507
|
{
|
|
464
|
-
name: "
|
|
465
|
-
description: "
|
|
508
|
+
name: "add_move",
|
|
509
|
+
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" +
|
|
510
|
+
"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.",
|
|
466
511
|
inputSchema: {
|
|
467
512
|
type: "object",
|
|
468
513
|
properties: {
|
|
469
514
|
id: { type: "string", description: "Prep file id." },
|
|
515
|
+
path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the parent node the move is played FROM." },
|
|
516
|
+
san: { type: "string", description: "The move in SAN notation (e.g. 'Nf3', 'exd5', 'O-O', 'Qxf7+')." },
|
|
517
|
+
expected_version: { type: "integer", description: "Optimistic-lock check; pass the `version` from your last read." },
|
|
470
518
|
},
|
|
471
|
-
required: ["id"],
|
|
519
|
+
required: ["id", "path", "san"],
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: "set_comment",
|
|
524
|
+
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.",
|
|
525
|
+
inputSchema: {
|
|
526
|
+
type: "object",
|
|
527
|
+
properties: {
|
|
528
|
+
id: { type: "string", description: "Prep file id." },
|
|
529
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
530
|
+
comment: { type: "string", description: "New comment text. Empty string clears." },
|
|
531
|
+
expected_version: { type: "integer" },
|
|
532
|
+
},
|
|
533
|
+
required: ["id", "path", "comment"],
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
name: "set_nags",
|
|
538
|
+
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.",
|
|
539
|
+
inputSchema: {
|
|
540
|
+
type: "object",
|
|
541
|
+
properties: {
|
|
542
|
+
id: { type: "string" },
|
|
543
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
544
|
+
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" }, description: "NAG list, e.g. ['$14'] or ['$146', '$44']." },
|
|
545
|
+
expected_version: { type: "integer" },
|
|
546
|
+
},
|
|
547
|
+
required: ["id", "path", "nags"],
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "set_annotations",
|
|
552
|
+
description: "Replace the visual annotations (arrows + coloured squares) on the node at `path`. Passing empty arrays clears them.\n\n" +
|
|
553
|
+
"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.",
|
|
554
|
+
inputSchema: {
|
|
555
|
+
type: "object",
|
|
556
|
+
properties: {
|
|
557
|
+
id: { type: "string" },
|
|
558
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
559
|
+
arrows: {
|
|
560
|
+
type: "array",
|
|
561
|
+
items: {
|
|
562
|
+
type: "object",
|
|
563
|
+
properties: {
|
|
564
|
+
color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
|
|
565
|
+
from: { type: "string", pattern: "^[a-h][1-8]$" },
|
|
566
|
+
to: { type: "string", pattern: "^[a-h][1-8]$" },
|
|
567
|
+
},
|
|
568
|
+
required: ["color", "from", "to"],
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
highlights: {
|
|
572
|
+
type: "array",
|
|
573
|
+
items: {
|
|
574
|
+
type: "object",
|
|
575
|
+
properties: {
|
|
576
|
+
color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
|
|
577
|
+
square: { type: "string", pattern: "^[a-h][1-8]$" },
|
|
578
|
+
},
|
|
579
|
+
required: ["color", "square"],
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
expected_version: { type: "integer" },
|
|
583
|
+
},
|
|
584
|
+
required: ["id", "path"],
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: "delete_subtree",
|
|
589
|
+
description: "Delete the node at `path` and all its descendants. Refuses to delete the root. Auto-saves.",
|
|
590
|
+
inputSchema: {
|
|
591
|
+
type: "object",
|
|
592
|
+
properties: {
|
|
593
|
+
id: { type: "string" },
|
|
594
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
595
|
+
expected_version: { type: "integer" },
|
|
596
|
+
},
|
|
597
|
+
required: ["id", "path"],
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
name: "promote_variation",
|
|
602
|
+
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.",
|
|
603
|
+
inputSchema: {
|
|
604
|
+
type: "object",
|
|
605
|
+
properties: {
|
|
606
|
+
id: { type: "string" },
|
|
607
|
+
path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the node to promote. Length ≥ 1." },
|
|
608
|
+
expected_version: { type: "integer" },
|
|
609
|
+
},
|
|
610
|
+
required: ["id", "path"],
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: "set_tag",
|
|
615
|
+
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.",
|
|
616
|
+
inputSchema: {
|
|
617
|
+
type: "object",
|
|
618
|
+
properties: {
|
|
619
|
+
id: { type: "string" },
|
|
620
|
+
key: { type: "string", description: "Tag key, e.g. 'Event', 'White', 'Date'." },
|
|
621
|
+
value: { type: "string", description: "Tag value. Empty string removes the tag." },
|
|
622
|
+
expected_version: { type: "integer" },
|
|
623
|
+
},
|
|
624
|
+
required: ["id", "key", "value"],
|
|
472
625
|
},
|
|
473
626
|
},
|
|
474
627
|
{
|
|
@@ -483,7 +636,12 @@ const TOOLS = [
|
|
|
483
636
|
},
|
|
484
637
|
{
|
|
485
638
|
name: "read_prep_files_guide",
|
|
486
|
-
description: "Returns the
|
|
639
|
+
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.",
|
|
640
|
+
inputSchema: { type: "object", properties: {} },
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "read_pgn_authoring_guide",
|
|
644
|
+
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.",
|
|
487
645
|
inputSchema: { type: "object", properties: {} },
|
|
488
646
|
},
|
|
489
647
|
{
|
|
@@ -593,23 +751,54 @@ function convertCloudSnapshotResponse(raw, startFen) {
|
|
|
593
751
|
}
|
|
594
752
|
return raw;
|
|
595
753
|
}
|
|
596
|
-
|
|
754
|
+
// Path arg parsing for the mutation tools. Rejects malformed input
|
|
755
|
+
// with a clear message the LLM can act on.
|
|
756
|
+
function argPath(args) {
|
|
757
|
+
const raw = args.path;
|
|
758
|
+
if (!Array.isArray(raw))
|
|
759
|
+
throw new Error("`path` must be an array of non-negative integers");
|
|
760
|
+
const out = [];
|
|
761
|
+
for (let i = 0; i < raw.length; i++) {
|
|
762
|
+
const n = raw[i];
|
|
763
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
764
|
+
throw new Error(`path[${i}] must be a non-negative integer (got ${JSON.stringify(n)})`);
|
|
765
|
+
}
|
|
766
|
+
out.push(n);
|
|
767
|
+
}
|
|
768
|
+
return out;
|
|
769
|
+
}
|
|
770
|
+
// Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
|
|
771
|
+
// save with optimistic lock. Auto-saves so every tool call is atomic;
|
|
772
|
+
// the LLM never sees intermediate state.
|
|
773
|
+
async function applyMutation(args, mutator) {
|
|
774
|
+
const id = String(args.id);
|
|
775
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
776
|
+
const g = raw;
|
|
777
|
+
if (typeof g.pgnContent !== "string")
|
|
778
|
+
throw new Error("prep file missing pgnContent");
|
|
779
|
+
const file = parsePGN(g.pgnContent);
|
|
780
|
+
let result;
|
|
597
781
|
try {
|
|
598
|
-
|
|
599
|
-
// chess.js loadPgn accepts full PGN incl. tags; loadPgn returns true on
|
|
600
|
-
// success or throws on newer versions. Wrap defensively.
|
|
601
|
-
board.loadPgn(pgn);
|
|
602
|
-
// history({verbose:true}) walks the mainline only; variations aren't
|
|
603
|
-
// exposed by chess.js's built-in PGN loader at time of writing. If the
|
|
604
|
-
// PGN carries parenthesised variations they get flattened.
|
|
605
|
-
const hist = board.history({ verbose: true });
|
|
606
|
-
if (hist.length === 0)
|
|
607
|
-
return [];
|
|
608
|
-
return hist.map(h => ({ san: h.san, fen: h.after }));
|
|
782
|
+
result = mutator(file);
|
|
609
783
|
}
|
|
610
|
-
catch {
|
|
611
|
-
|
|
784
|
+
catch (err) {
|
|
785
|
+
if (err instanceof MutationError || err instanceof PathError) {
|
|
786
|
+
throw new Error(`mutation rejected: ${err.message}`);
|
|
787
|
+
}
|
|
788
|
+
throw err;
|
|
612
789
|
}
|
|
790
|
+
const newPgn = exportPGN(result.file);
|
|
791
|
+
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
792
|
+
const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
|
|
793
|
+
pgn: newPgn,
|
|
794
|
+
expected_version: expected,
|
|
795
|
+
});
|
|
796
|
+
const savedRow = saved;
|
|
797
|
+
return {
|
|
798
|
+
ok: true,
|
|
799
|
+
path: result.path,
|
|
800
|
+
version: savedRow.version,
|
|
801
|
+
};
|
|
613
802
|
}
|
|
614
803
|
// Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
|
|
615
804
|
// endpoints return moves in UCI on the wire — same LLM-readability
|
|
@@ -743,6 +932,24 @@ async function callToolInner(name, args) {
|
|
|
743
932
|
const raw = await get("/api/chess/database/analyse", params);
|
|
744
933
|
return convertAnalyseResponse(raw, fen);
|
|
745
934
|
}
|
|
935
|
+
case "predict_human_move": {
|
|
936
|
+
const fen = resolveFenFromArgs(args);
|
|
937
|
+
const qs = new URLSearchParams();
|
|
938
|
+
qs.set("fen", fen);
|
|
939
|
+
if (typeof args.white_elo === "number")
|
|
940
|
+
qs.set("white_elo", String(args.white_elo));
|
|
941
|
+
if (typeof args.black_elo === "number")
|
|
942
|
+
qs.set("black_elo", String(args.black_elo));
|
|
943
|
+
if (typeof args.top === "number")
|
|
944
|
+
qs.set("top", String(args.top));
|
|
945
|
+
if (Array.isArray(args.prev_fens)) {
|
|
946
|
+
for (const p of args.prev_fens) {
|
|
947
|
+
if (typeof p === "string" && p.length > 0)
|
|
948
|
+
qs.append("prev_fen", p);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return authedRequest("GET", `/api/agent/predict-move?${qs.toString()}`);
|
|
952
|
+
}
|
|
746
953
|
case "get_head_to_head":
|
|
747
954
|
return get("/api/chess/players/h2h", {
|
|
748
955
|
a: Number(args.fide_id_a),
|
|
@@ -785,35 +992,54 @@ async function callToolInner(name, args) {
|
|
|
785
992
|
return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
|
|
786
993
|
case "read_prep_file": {
|
|
787
994
|
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
995
|
+
const g = raw;
|
|
996
|
+
if (typeof g.pgnContent !== "string")
|
|
997
|
+
throw new Error("prep file missing pgnContent");
|
|
998
|
+
const file = parsePGN(g.pgnContent);
|
|
999
|
+
return {
|
|
1000
|
+
id: g.id,
|
|
1001
|
+
version: g.version,
|
|
1002
|
+
tags: file.tags,
|
|
1003
|
+
tree: file.root,
|
|
1004
|
+
};
|
|
798
1005
|
}
|
|
799
1006
|
case "create_prep_file":
|
|
800
1007
|
return authedRequest("POST", "/api/agent/prep-files", {
|
|
801
1008
|
name: String(args.name),
|
|
802
|
-
pgn: typeof args.pgn === "string" ? args.pgn : undefined,
|
|
803
|
-
});
|
|
804
|
-
case "save_prep_file":
|
|
805
|
-
return authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`, {
|
|
806
|
-
pgn: String(args.pgn),
|
|
807
|
-
expected_version: typeof args.expected_version === "number" ? args.expected_version : undefined,
|
|
808
1009
|
});
|
|
809
1010
|
case "delete_prep_file":
|
|
810
1011
|
return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
|
|
1012
|
+
case "add_move":
|
|
1013
|
+
return applyMutation(args, file => addMove(file, argPath(args), String(args.san)));
|
|
1014
|
+
case "set_comment":
|
|
1015
|
+
return applyMutation(args, file => setComment(file, argPath(args), typeof args.comment === "string" ? args.comment : ""));
|
|
1016
|
+
case "set_nags":
|
|
1017
|
+
return applyMutation(args, file => setNags(file, argPath(args), Array.isArray(args.nags) ? args.nags.map(String) : []));
|
|
1018
|
+
case "set_annotations": {
|
|
1019
|
+
const arrowsRaw = Array.isArray(args.arrows) ? args.arrows : [];
|
|
1020
|
+
const highlightsRaw = Array.isArray(args.highlights) ? args.highlights : [];
|
|
1021
|
+
const ann = (arrowsRaw.length === 0 && highlightsRaw.length === 0)
|
|
1022
|
+
? null
|
|
1023
|
+
: { arrows: arrowsRaw, highlights: highlightsRaw };
|
|
1024
|
+
return applyMutation(args, file => setAnnotations(file, argPath(args), ann));
|
|
1025
|
+
}
|
|
1026
|
+
case "delete_subtree":
|
|
1027
|
+
return applyMutation(args, file => deleteSubtree(file, argPath(args)));
|
|
1028
|
+
case "promote_variation":
|
|
1029
|
+
return applyMutation(args, file => promoteVariation(file, argPath(args)));
|
|
1030
|
+
case "set_tag":
|
|
1031
|
+
return applyMutation(args, file => ({
|
|
1032
|
+
file: setTag(file, String(args.key), String(args.value ?? "")),
|
|
1033
|
+
path: [],
|
|
1034
|
+
}));
|
|
811
1035
|
case "read_engine_usage_guide":
|
|
812
1036
|
return { guide: ENGINE_USAGE_DOC };
|
|
813
1037
|
case "read_prep_strategy_guide":
|
|
814
1038
|
return { guide: PREP_STRATEGY_DOC };
|
|
815
1039
|
case "read_prep_files_guide":
|
|
816
1040
|
return { guide: PREP_FILES_DOC };
|
|
1041
|
+
case "read_pgn_authoring_guide":
|
|
1042
|
+
return { guide: PGN_AUTHORING_DOC };
|
|
817
1043
|
case "prep_snapshot": {
|
|
818
1044
|
const me = Number(args.fide_id_me);
|
|
819
1045
|
const opp = Number(args.fide_id_opponent);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// PrepFile → PGN text. Deterministic — same tree always exports to the
|
|
2
|
+
// same PGN modulo whitespace, so paths stay stable across
|
|
3
|
+
// export→reparse cycles.
|
|
4
|
+
import { codeFromColor } from "./types.js";
|
|
5
|
+
// Standard PGN "seven tag roster" order. We emit these first (in the
|
|
6
|
+
// order they appear in tags), then any extra tags in insertion order.
|
|
7
|
+
const STR_ORDER = ["Event", "Site", "Date", "Round", "White", "Black", "Result"];
|
|
8
|
+
export function exportPGN(file) {
|
|
9
|
+
const parts = [];
|
|
10
|
+
// Tag pairs. Ensure the seven-tag roster is present with sane
|
|
11
|
+
// defaults; other tags come after in whatever order the map yields.
|
|
12
|
+
const emittedTags = new Set();
|
|
13
|
+
const withDefaults = STR_ORDER.map(k => {
|
|
14
|
+
const value = file.tags[k];
|
|
15
|
+
emittedTags.add(k);
|
|
16
|
+
return [k, value ?? defaultTagValue(k)];
|
|
17
|
+
});
|
|
18
|
+
for (const [k, v] of Object.entries(file.tags)) {
|
|
19
|
+
if (emittedTags.has(k))
|
|
20
|
+
continue;
|
|
21
|
+
withDefaults.push([k, v]);
|
|
22
|
+
emittedTags.add(k);
|
|
23
|
+
}
|
|
24
|
+
for (const [k, v] of withDefaults) {
|
|
25
|
+
parts.push(`[${k} "${escapeTagValue(v)}"]`);
|
|
26
|
+
}
|
|
27
|
+
parts.push(""); // blank line between headers and movetext
|
|
28
|
+
// Movetext.
|
|
29
|
+
const rootAnnotation = renderNodeAnnotation(file.root);
|
|
30
|
+
const rootPrelude = rootAnnotation ? `${rootAnnotation} ` : "";
|
|
31
|
+
const movetext = rootPrelude + renderChildren(file.root.children, file.root.ply + 1, /* forceMoveNumber */ true);
|
|
32
|
+
const result = file.tags.Result ?? "*";
|
|
33
|
+
parts.push(`${movetext.trim()} ${result}`);
|
|
34
|
+
return parts.join("\n") + "\n";
|
|
35
|
+
}
|
|
36
|
+
function defaultTagValue(k) {
|
|
37
|
+
switch (k) {
|
|
38
|
+
case "Result": return "*";
|
|
39
|
+
case "Date": return "????.??.??";
|
|
40
|
+
default: return "?";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function escapeTagValue(v) {
|
|
44
|
+
return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
45
|
+
}
|
|
46
|
+
// Render a variation (a sequence of children starting at children[0] as
|
|
47
|
+
// mainline and children[1..N] as parenthesised alternatives).
|
|
48
|
+
function renderChildren(children, ply, forceMoveNumber) {
|
|
49
|
+
if (children.length === 0)
|
|
50
|
+
return "";
|
|
51
|
+
const mainline = children[0];
|
|
52
|
+
const alts = children.slice(1);
|
|
53
|
+
const bits = [];
|
|
54
|
+
// Emit the mainline node with move number as needed.
|
|
55
|
+
bits.push(renderMoveWithNumber(mainline, forceMoveNumber));
|
|
56
|
+
// Then, alternative variations that branch AT THIS POSITION —
|
|
57
|
+
// i.e. all siblings of the mainline child. They must fork from the
|
|
58
|
+
// same position (parent), so their move number matches the mainline's.
|
|
59
|
+
for (const alt of alts) {
|
|
60
|
+
bits.push(`(${renderMoveWithNumber(alt, /* forceMoveNumber */ true)}${renderVariationTail(alt)})`);
|
|
61
|
+
}
|
|
62
|
+
// Continue down the mainline. After emitting a variation, the next
|
|
63
|
+
// mainline move needs its own number rendered (Black's move after a
|
|
64
|
+
// variation should show "N... Nf6", etc.).
|
|
65
|
+
const forceNext = alts.length > 0;
|
|
66
|
+
bits.push(renderChildren(mainline.children, ply + 1, forceNext));
|
|
67
|
+
return bits.filter(Boolean).join(" ");
|
|
68
|
+
}
|
|
69
|
+
// Render the tail of a variation — everything after the variation's
|
|
70
|
+
// first move (which is rendered by the parent). The recursion here
|
|
71
|
+
// mirrors renderChildren but doesn't force a move number on the very
|
|
72
|
+
// next move (unless a nested variation intervenes).
|
|
73
|
+
function renderVariationTail(startNode) {
|
|
74
|
+
const suffix = renderChildren(startNode.children, startNode.ply + 1, /* forceMoveNumber */ false);
|
|
75
|
+
return suffix ? " " + suffix : "";
|
|
76
|
+
}
|
|
77
|
+
// Render a single move — number prefix + SAN + NAGs + comment.
|
|
78
|
+
function renderMoveWithNumber(node, forceMoveNumber) {
|
|
79
|
+
if (!node.san)
|
|
80
|
+
return "";
|
|
81
|
+
const whiteToMove = (node.ply % 2) === 1;
|
|
82
|
+
let prefix = "";
|
|
83
|
+
if (whiteToMove) {
|
|
84
|
+
prefix = `${Math.ceil(node.ply / 2)}. `;
|
|
85
|
+
}
|
|
86
|
+
else if (forceMoveNumber) {
|
|
87
|
+
prefix = `${Math.ceil(node.ply / 2)}... `;
|
|
88
|
+
}
|
|
89
|
+
const suffix = renderNagsAndComment(node);
|
|
90
|
+
return `${prefix}${node.san}${suffix ? " " + suffix : ""}`.trimEnd();
|
|
91
|
+
}
|
|
92
|
+
function renderNagsAndComment(node) {
|
|
93
|
+
const bits = [];
|
|
94
|
+
if (node.nags && node.nags.length > 0) {
|
|
95
|
+
bits.push(node.nags.join(" "));
|
|
96
|
+
}
|
|
97
|
+
const commentBits = [];
|
|
98
|
+
if (node.comment && node.comment.trim().length > 0) {
|
|
99
|
+
commentBits.push(node.comment.trim());
|
|
100
|
+
}
|
|
101
|
+
const annotationCmd = renderNodeAnnotation(node);
|
|
102
|
+
if (annotationCmd)
|
|
103
|
+
commentBits.push(annotationCmd);
|
|
104
|
+
if (commentBits.length > 0) {
|
|
105
|
+
bits.push(`{${commentBits.join(" ")}}`);
|
|
106
|
+
}
|
|
107
|
+
return bits.join(" ");
|
|
108
|
+
}
|
|
109
|
+
// Build the [%cal ...] / [%csl ...] fragments if this node has arrows
|
|
110
|
+
// or highlighted squares. Returns "" if none.
|
|
111
|
+
function renderNodeAnnotation(node) {
|
|
112
|
+
if (!node.annotations)
|
|
113
|
+
return "";
|
|
114
|
+
const bits = [];
|
|
115
|
+
if (node.annotations.arrows.length > 0) {
|
|
116
|
+
const entries = node.annotations.arrows
|
|
117
|
+
.map(a => `${codeFromColor(a.color)}${a.from}${a.to}`)
|
|
118
|
+
.join(",");
|
|
119
|
+
bits.push(`[%cal ${entries}]`);
|
|
120
|
+
}
|
|
121
|
+
if (node.annotations.highlights.length > 0) {
|
|
122
|
+
const entries = node.annotations.highlights
|
|
123
|
+
.map(h => `${codeFromColor(h.color)}${h.square}`)
|
|
124
|
+
.join(",");
|
|
125
|
+
bits.push(`[%csl ${entries}]`);
|
|
126
|
+
}
|
|
127
|
+
return bits.join(" ");
|
|
128
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Pure mutation functions on a PrepFile tree. Each returns a new
|
|
2
|
+
// PrepFile (structural sharing where safe) plus the effective path of
|
|
3
|
+
// the touched node, so tools can report where the LLM's request
|
|
4
|
+
// actually landed after any rebalancing.
|
|
5
|
+
//
|
|
6
|
+
// All move-making mutations validate SAN against the current FEN via
|
|
7
|
+
// chessops before touching the tree — a bad move surfaces as a thrown
|
|
8
|
+
// error containing the FEN and rejected SAN, which the LLM can act on
|
|
9
|
+
// (usually by re-checking the position it thought it was at).
|
|
10
|
+
import { parseFen } from "chessops/fen";
|
|
11
|
+
import { Chess } from "chessops/chess";
|
|
12
|
+
import { parseSan } from "chessops/san";
|
|
13
|
+
import { makeFen } from "chessops/fen";
|
|
14
|
+
import { cloneOnPath, getNode, PathError } from "./paths.js";
|
|
15
|
+
export class MutationError extends Error {
|
|
16
|
+
}
|
|
17
|
+
// Apply a SAN move as a new child at `path`. If the target already has
|
|
18
|
+
// children, the new node is appended (becomes a variation, since
|
|
19
|
+
// mainline is children[0]). Use promoteVariation to make the new node
|
|
20
|
+
// the mainline afterwards if that's what you wanted.
|
|
21
|
+
export function addMove(file, path, san) {
|
|
22
|
+
const parent = getNode(file.root, path);
|
|
23
|
+
const move = validateSan(parent.fen, san);
|
|
24
|
+
const posSetup = parseFen(parent.fen);
|
|
25
|
+
if (posSetup.isErr)
|
|
26
|
+
throw new MutationError(`bad parent FEN in tree: ${posSetup.error}`);
|
|
27
|
+
const pos = Chess.fromSetup(posSetup.value);
|
|
28
|
+
if (pos.isErr)
|
|
29
|
+
throw new MutationError(`bad parent position: ${pos.error}`);
|
|
30
|
+
const board = pos.value;
|
|
31
|
+
board.play(move);
|
|
32
|
+
const childFen = makeFen(board.toSetup());
|
|
33
|
+
const newNode = {
|
|
34
|
+
san,
|
|
35
|
+
fen: childFen,
|
|
36
|
+
ply: parent.ply + 1,
|
|
37
|
+
children: [],
|
|
38
|
+
};
|
|
39
|
+
const { root: newRoot, target } = cloneOnPath(file.root, path);
|
|
40
|
+
target.children = [...target.children, newNode];
|
|
41
|
+
return {
|
|
42
|
+
file: { tags: file.tags, root: newRoot },
|
|
43
|
+
path: [...path, target.children.length - 1],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Replace the comment on the node at `path`. Passing empty string /
|
|
47
|
+
// null clears it.
|
|
48
|
+
export function setComment(file, path, comment) {
|
|
49
|
+
const { root: newRoot, target } = cloneOnPath(file.root, path);
|
|
50
|
+
const trimmed = (comment ?? "").trim();
|
|
51
|
+
if (trimmed)
|
|
52
|
+
target.comment = trimmed;
|
|
53
|
+
else
|
|
54
|
+
delete target.comment;
|
|
55
|
+
return { file: { tags: file.tags, root: newRoot }, path };
|
|
56
|
+
}
|
|
57
|
+
// Replace the NAG list. Empty array clears.
|
|
58
|
+
export function setNags(file, path, nags) {
|
|
59
|
+
const cleaned = nags
|
|
60
|
+
.map(s => s.trim())
|
|
61
|
+
.filter(s => /^\$\d+$/.test(s));
|
|
62
|
+
const { root: newRoot, target } = cloneOnPath(file.root, path);
|
|
63
|
+
if (cleaned.length > 0)
|
|
64
|
+
target.nags = cleaned;
|
|
65
|
+
else
|
|
66
|
+
delete target.nags;
|
|
67
|
+
return { file: { tags: file.tags, root: newRoot }, path };
|
|
68
|
+
}
|
|
69
|
+
// Replace visual annotations (arrows + highlighted squares). Empty
|
|
70
|
+
// arrows and highlights arrays clear the annotations entirely.
|
|
71
|
+
export function setAnnotations(file, path, ann) {
|
|
72
|
+
const { root: newRoot, target } = cloneOnPath(file.root, path);
|
|
73
|
+
const isEmpty = !ann || (ann.arrows.length === 0 && ann.highlights.length === 0);
|
|
74
|
+
if (isEmpty)
|
|
75
|
+
delete target.annotations;
|
|
76
|
+
else
|
|
77
|
+
target.annotations = { arrows: ann.arrows, highlights: ann.highlights };
|
|
78
|
+
return { file: { tags: file.tags, root: newRoot }, path };
|
|
79
|
+
}
|
|
80
|
+
// Delete the node at `path` and all its descendants. Refuses to delete
|
|
81
|
+
// the root. Returns the path of the deleted node's parent for the
|
|
82
|
+
// caller's convenience.
|
|
83
|
+
export function deleteSubtree(file, path) {
|
|
84
|
+
if (path.length === 0)
|
|
85
|
+
throw new MutationError("cannot delete root");
|
|
86
|
+
const parentPath = path.slice(0, -1);
|
|
87
|
+
const removeIdx = path[path.length - 1];
|
|
88
|
+
const { root: newRoot, target: parent } = cloneOnPath(file.root, parentPath);
|
|
89
|
+
if (removeIdx < 0 || removeIdx >= parent.children.length) {
|
|
90
|
+
throw new PathError(`delete index ${removeIdx} out of bounds`, path);
|
|
91
|
+
}
|
|
92
|
+
parent.children = parent.children.filter((_, i) => i !== removeIdx);
|
|
93
|
+
return { file: { tags: file.tags, root: newRoot }, path: parentPath };
|
|
94
|
+
}
|
|
95
|
+
// Promote the node at `path` to be its parent's first child (the
|
|
96
|
+
// mainline). Silently no-ops if it's already the mainline. Refuses on
|
|
97
|
+
// root (root has no parent to promote against).
|
|
98
|
+
export function promoteVariation(file, path) {
|
|
99
|
+
if (path.length === 0)
|
|
100
|
+
throw new MutationError("cannot promote root");
|
|
101
|
+
const parentPath = path.slice(0, -1);
|
|
102
|
+
const idx = path[path.length - 1];
|
|
103
|
+
if (idx === 0)
|
|
104
|
+
return { file, path }; // already mainline
|
|
105
|
+
const { root: newRoot, target: parent } = cloneOnPath(file.root, parentPath);
|
|
106
|
+
const promoted = parent.children[idx];
|
|
107
|
+
const rest = parent.children.filter((_, i) => i !== idx);
|
|
108
|
+
parent.children = [promoted, ...rest];
|
|
109
|
+
return { file: { tags: file.tags, root: newRoot }, path: [...parentPath, 0] };
|
|
110
|
+
}
|
|
111
|
+
// Set or clear a tag. Passing null / empty removes.
|
|
112
|
+
export function setTag(file, key, value) {
|
|
113
|
+
const cleanedKey = key.trim();
|
|
114
|
+
if (!cleanedKey)
|
|
115
|
+
throw new MutationError("tag key required");
|
|
116
|
+
const newTags = { ...file.tags };
|
|
117
|
+
if (value === null || value === "")
|
|
118
|
+
delete newTags[cleanedKey];
|
|
119
|
+
else
|
|
120
|
+
newTags[cleanedKey] = value;
|
|
121
|
+
return { tags: newTags, root: file.root };
|
|
122
|
+
}
|
|
123
|
+
// SAN validation: parses via chessops from the parent FEN. Throws a
|
|
124
|
+
// MutationError with the FEN + rejected SAN so the LLM sees what
|
|
125
|
+
// position it was actually attacking.
|
|
126
|
+
function validateSan(fen, san) {
|
|
127
|
+
const setup = parseFen(fen);
|
|
128
|
+
if (setup.isErr)
|
|
129
|
+
throw new MutationError(`bad parent FEN: ${setup.error}`);
|
|
130
|
+
const pos = Chess.fromSetup(setup.value);
|
|
131
|
+
if (pos.isErr)
|
|
132
|
+
throw new MutationError(`bad parent position: ${pos.error}`);
|
|
133
|
+
const board = pos.value;
|
|
134
|
+
const move = parseSan(board, san);
|
|
135
|
+
if (!move) {
|
|
136
|
+
throw new MutationError(`illegal SAN "${san}" at fen "${fen}" — check the position (side to move, piece disambiguation, promotion piece) and re-check the path.`);
|
|
137
|
+
}
|
|
138
|
+
return move;
|
|
139
|
+
}
|
|
140
|
+
// Utility: report the path of a node reference. Used when a tool wants
|
|
141
|
+
// to tell the LLM where the mutation landed after any rebalancing.
|
|
142
|
+
export function pathOf(root, target) {
|
|
143
|
+
const stack = [{ node: root, path: [] }];
|
|
144
|
+
while (stack.length > 0) {
|
|
145
|
+
const { node, path } = stack.pop();
|
|
146
|
+
if (node === target)
|
|
147
|
+
return path;
|
|
148
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
149
|
+
stack.push({ node: node.children[i], path: [...path, i] });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
// Re-exports so index.ts only imports from mutations.
|
|
155
|
+
export { getNode, getParent } from "./paths.js";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// PGN → PrepFile. Uses chessops for the heavy lifting (SAN parsing,
|
|
2
|
+
// legality, FEN generation) and walks the resulting parse tree into
|
|
3
|
+
// our compact PrepNode shape.
|
|
4
|
+
//
|
|
5
|
+
// ChessBase annotations ([%cal ...], [%csl ...]) get stripped out of
|
|
6
|
+
// the comment text and reified into the node's `annotations` field.
|
|
7
|
+
// Unknown [%foo bar] blocks are dropped silently — the LLM won't be
|
|
8
|
+
// writing arbitrary commands, and preserving unknown ones adds noise.
|
|
9
|
+
import { parsePgn, startingPosition } from "chessops/pgn";
|
|
10
|
+
import { parseSan } from "chessops/san";
|
|
11
|
+
import { makeFen } from "chessops/fen";
|
|
12
|
+
import { opposite } from "chessops/util";
|
|
13
|
+
import { colorFromCode, } from "./types.js";
|
|
14
|
+
const CAL_RE = /\[%cal\s+([^\]]+)\]/g;
|
|
15
|
+
const CSL_RE = /\[%csl\s+([^\]]+)\]/g;
|
|
16
|
+
const ANY_CMD_RE = /\[%[a-z]+\s+[^\]]+\]/g;
|
|
17
|
+
// Parse a comment string, splitting out visual annotations from prose.
|
|
18
|
+
export function parseCommentAnnotations(comment) {
|
|
19
|
+
const arrows = [];
|
|
20
|
+
const highlights = [];
|
|
21
|
+
for (const m of comment.matchAll(CAL_RE)) {
|
|
22
|
+
for (const entry of m[1].split(",")) {
|
|
23
|
+
const e = entry.trim().match(/^([GCRBOY])([a-h][1-8])([a-h][1-8])$/i);
|
|
24
|
+
if (e)
|
|
25
|
+
arrows.push({ color: colorFromCode(e[1]), from: e[2].toLowerCase(), to: e[3].toLowerCase() });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const m of comment.matchAll(CSL_RE)) {
|
|
29
|
+
for (const entry of m[1].split(",")) {
|
|
30
|
+
const e = entry.trim().match(/^([GCRBOY])([a-h][1-8])$/i);
|
|
31
|
+
if (e)
|
|
32
|
+
highlights.push({ color: colorFromCode(e[1]), square: e[2].toLowerCase() });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const text = comment.replace(ANY_CMD_RE, "").replace(/\s+/g, " ").trim();
|
|
36
|
+
const annotations = arrows.length || highlights.length ? { arrows, highlights } : undefined;
|
|
37
|
+
return { text, annotations };
|
|
38
|
+
}
|
|
39
|
+
// Parse full PGN → PrepFile. Throws on unrecoverable errors (no games,
|
|
40
|
+
// invalid starting FEN). Illegal SAN moves in the movetext are skipped
|
|
41
|
+
// silently — matches the frontend's tolerant behaviour so a game with
|
|
42
|
+
// one typo doesn't nuke the whole tree.
|
|
43
|
+
export function parsePGN(pgn) {
|
|
44
|
+
const games = parsePgn(pgn);
|
|
45
|
+
if (games.length === 0)
|
|
46
|
+
throw new Error("no games in PGN");
|
|
47
|
+
const game = games[0];
|
|
48
|
+
const tags = {};
|
|
49
|
+
game.headers.forEach((value, key) => { tags[key] = value; });
|
|
50
|
+
const startResult = startingPosition(game.headers);
|
|
51
|
+
if (startResult.isErr) {
|
|
52
|
+
throw new Error(`invalid starting position: ${startResult.error}`);
|
|
53
|
+
}
|
|
54
|
+
const startPos = startResult.value;
|
|
55
|
+
const rootFen = makeFen(startPos.toSetup());
|
|
56
|
+
const root = { san: null, fen: rootFen, ply: 0, children: [] };
|
|
57
|
+
// Root-level comment on the game (rare, but supported).
|
|
58
|
+
const gameComments = game.moves.comments;
|
|
59
|
+
if (gameComments && gameComments.length > 0) {
|
|
60
|
+
const parsed = parseCommentAnnotations(gameComments.join(" "));
|
|
61
|
+
if (parsed.text)
|
|
62
|
+
root.comment = parsed.text;
|
|
63
|
+
if (parsed.annotations)
|
|
64
|
+
root.annotations = parsed.annotations;
|
|
65
|
+
}
|
|
66
|
+
const build = (childNode, treeParent, pos, ply) => {
|
|
67
|
+
if (!childNode.data || typeof childNode.data.san !== "string")
|
|
68
|
+
return;
|
|
69
|
+
const san = childNode.data.san;
|
|
70
|
+
let nodeFen;
|
|
71
|
+
if (san === "--") {
|
|
72
|
+
// Null move — flip side, clear en-passant.
|
|
73
|
+
pos.turn = opposite(pos.turn);
|
|
74
|
+
pos.epSquare = undefined;
|
|
75
|
+
nodeFen = makeFen(pos.toSetup());
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const move = parseSan(pos, san);
|
|
79
|
+
if (!move)
|
|
80
|
+
return; // skip illegal — chessops tolerates the rest of the tree
|
|
81
|
+
pos.play(move);
|
|
82
|
+
nodeFen = makeFen(pos.toSetup());
|
|
83
|
+
}
|
|
84
|
+
const node = { san, fen: nodeFen, ply, children: [] };
|
|
85
|
+
// Comment on this move (both "starting comments" attached to
|
|
86
|
+
// variations and "after-move comments" — we fold both into the
|
|
87
|
+
// node's own comment; the LLM doesn't need the PGN's positional
|
|
88
|
+
// subtlety here).
|
|
89
|
+
const combined = [];
|
|
90
|
+
if (Array.isArray(childNode.data.startingComments))
|
|
91
|
+
combined.push(...childNode.data.startingComments);
|
|
92
|
+
if (Array.isArray(childNode.data.comments))
|
|
93
|
+
combined.push(...childNode.data.comments);
|
|
94
|
+
if (combined.length > 0) {
|
|
95
|
+
const parsed = parseCommentAnnotations(combined.join(" "));
|
|
96
|
+
if (parsed.text)
|
|
97
|
+
node.comment = parsed.text;
|
|
98
|
+
if (parsed.annotations)
|
|
99
|
+
node.annotations = parsed.annotations;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(childNode.data.nags) && childNode.data.nags.length > 0) {
|
|
102
|
+
node.nags = childNode.data.nags.map((n) => `$${n}`);
|
|
103
|
+
}
|
|
104
|
+
treeParent.children.push(node);
|
|
105
|
+
for (const grandchild of childNode.children) {
|
|
106
|
+
// chessops shares position state across siblings; we need a fresh
|
|
107
|
+
// clone per variation branch.
|
|
108
|
+
const forked = pos.clone();
|
|
109
|
+
build(grandchild, node, forked, ply + 1);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
for (const child of game.moves.children) {
|
|
113
|
+
const forked = startPos.clone();
|
|
114
|
+
build(child, root, forked, 1);
|
|
115
|
+
}
|
|
116
|
+
return { tags, root };
|
|
117
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Path-based addressing for the LLM. The frontend uses UUIDs but they
|
|
2
|
+
// regenerate every parse, so we can't use them across sessions. Paths
|
|
3
|
+
// are child-index arrays — deterministic across parse/export cycles as
|
|
4
|
+
// long as we don't reorder children on export.
|
|
5
|
+
export class PathError extends Error {
|
|
6
|
+
path;
|
|
7
|
+
constructor(msg, path) {
|
|
8
|
+
super(`${msg} (path=${JSON.stringify(path)})`);
|
|
9
|
+
this.path = path;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// Resolve a path to a node reference. Throws PathError if the path
|
|
13
|
+
// escapes the tree — that's ALWAYS the LLM's fault, and the message
|
|
14
|
+
// tells it exactly where things fell apart.
|
|
15
|
+
export function getNode(root, path) {
|
|
16
|
+
let cur = root;
|
|
17
|
+
for (let i = 0; i < path.length; i++) {
|
|
18
|
+
const idx = path[i];
|
|
19
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.children.length) {
|
|
20
|
+
throw new PathError(`path segment ${i} = ${idx} is out of bounds; parent has ${cur.children.length} children`, path);
|
|
21
|
+
}
|
|
22
|
+
cur = cur.children[idx];
|
|
23
|
+
}
|
|
24
|
+
return cur;
|
|
25
|
+
}
|
|
26
|
+
// Get the parent of the node at `path` plus this node's index in its
|
|
27
|
+
// parent's children array. Returns null if path refers to the root.
|
|
28
|
+
export function getParent(root, path) {
|
|
29
|
+
if (path.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const parentPath = path.slice(0, -1);
|
|
32
|
+
const parent = getNode(root, parentPath);
|
|
33
|
+
const index = path[path.length - 1];
|
|
34
|
+
return { parent, index };
|
|
35
|
+
}
|
|
36
|
+
// Read-only shallow clone helper the mutation functions use to avoid
|
|
37
|
+
// hidden aliasing. Deep-clones nodes on the path from root to the
|
|
38
|
+
// mutation target, leaving unrelated subtrees shared (they're
|
|
39
|
+
// immutable-by-convention downstream, so this is safe and cheap).
|
|
40
|
+
export function cloneOnPath(root, path) {
|
|
41
|
+
const newRoot = { ...root, children: [...root.children] };
|
|
42
|
+
const parentChain = [newRoot];
|
|
43
|
+
let cur = newRoot;
|
|
44
|
+
for (let i = 0; i < path.length; i++) {
|
|
45
|
+
const idx = path[i];
|
|
46
|
+
if (idx < 0 || idx >= cur.children.length) {
|
|
47
|
+
throw new PathError(`path segment ${i} = ${idx} out of bounds`, path);
|
|
48
|
+
}
|
|
49
|
+
const cloned = { ...cur.children[idx], children: [...cur.children[idx].children] };
|
|
50
|
+
cur.children[idx] = cloned;
|
|
51
|
+
cur = cloned;
|
|
52
|
+
parentChain.push(cur);
|
|
53
|
+
}
|
|
54
|
+
return { root: newRoot, target: cur, parentChain };
|
|
55
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Compact tree types the LLM works with. No stable ids — chessops
|
|
2
|
+
// regenerates them per parse — so we address nodes by PATH: the array
|
|
3
|
+
// of child indices from the root. [] is the root, [0] is root's first
|
|
4
|
+
// child, [0, 1] is the second child of the first child, etc.
|
|
5
|
+
//
|
|
6
|
+
// Paths ARE stable across parse/export/reparse cycles because move
|
|
7
|
+
// order is deterministic and we don't rearrange children on export.
|
|
8
|
+
// Named colours as they appear in the parsed tree. The wire format uses
|
|
9
|
+
// single-letter codes (G, R, Y, C, B, O); the tree uses these longer
|
|
10
|
+
// names to match how the frontend represents them.
|
|
11
|
+
export const COLOR_NAMES = ["green", "red", "yellow", "light-blue", "dark-blue", "orange"];
|
|
12
|
+
const COLOR_CODE_TO_NAME = {
|
|
13
|
+
G: "green", R: "red", Y: "yellow", C: "light-blue", B: "dark-blue", O: "orange",
|
|
14
|
+
};
|
|
15
|
+
const COLOR_NAME_TO_CODE = Object.fromEntries(Object.entries(COLOR_CODE_TO_NAME).map(([code, name]) => [name, code]));
|
|
16
|
+
export function colorFromCode(code) {
|
|
17
|
+
return COLOR_CODE_TO_NAME[code.toUpperCase()] ?? "green";
|
|
18
|
+
}
|
|
19
|
+
export function codeFromColor(color) {
|
|
20
|
+
return COLOR_NAME_TO_CODE[color] ?? "G";
|
|
21
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# PGN authoring guide
|
|
2
|
+
|
|
3
|
+
You author prep files by mutating a tree, not by writing PGN text. The MCP layer holds a parser+exporter (chessops-backed) so every mutation call load-mutates-saves atomically — you only ever address nodes, never raw text. No paren-counting, no move-numbering, no SAN typos surviving your edit.
|
|
4
|
+
|
|
5
|
+
## Path addressing
|
|
6
|
+
|
|
7
|
+
Nodes are addressed by **path**: an array of child indices from the root.
|
|
8
|
+
|
|
9
|
+
- `[]` — the root position (empty board state, before any move).
|
|
10
|
+
- `[0]` — the first mainline move (root's first child).
|
|
11
|
+
- `[0, 0]` — the second ply on the mainline.
|
|
12
|
+
- `[0, 1]` — a variation branching after the first move (sibling of `[0, 0]`).
|
|
13
|
+
- `[0, 0, 0, 1]` — a variation branching at the third ply.
|
|
14
|
+
|
|
15
|
+
`children[0]` is always the mainline; `children[1..N]` are alternative variations in declaration order. `promote_variation` swaps this order.
|
|
16
|
+
|
|
17
|
+
## Workflow
|
|
18
|
+
|
|
19
|
+
The mutation tool set is:
|
|
20
|
+
|
|
21
|
+
- `read_prep_file(id)` → returns `{version, tags, tree}`. Every node in `tree` has `san`, `fen`, `ply`, optional `comment`, `nags`, `annotations`, and `children`.
|
|
22
|
+
- `add_move(id, path, san)` — appends a new child of `path`. If `path` already has children, the new node becomes a variation. Returns the effective path of the new node.
|
|
23
|
+
- `set_comment(id, path, comment)` — replace comment. Empty string clears.
|
|
24
|
+
- `set_nags(id, path, nags)` — replace NAG list. Empty array clears.
|
|
25
|
+
- `set_annotations(id, path, {arrows, highlights})` — replace visual annotations. Empty arrays clear.
|
|
26
|
+
- `delete_subtree(id, path)` — remove node + descendants. Refuses to delete the root.
|
|
27
|
+
- `promote_variation(id, path)` — make the node at `path` its parent's mainline.
|
|
28
|
+
- `set_tag(id, key, value)` — set/clear a game-level tag.
|
|
29
|
+
|
|
30
|
+
Every mutation **auto-saves** with optimistic locking. Response is `{ok: true, path, version}`. Pass the returned `version` as `expected_version` on your next mutation to catch concurrent edits.
|
|
31
|
+
|
|
32
|
+
## Typical build order
|
|
33
|
+
|
|
34
|
+
1. `read_prep_file` — see what's there.
|
|
35
|
+
2. `add_move` to extend the mainline: `add_move(id, [], "e4")` then `add_move(id, [0], "c5")` etc.
|
|
36
|
+
3. `add_move` for a variation at some node: `add_move(id, [0, 0], "Nc3")` adds Nc3 as a sibling of the current mainline's move 2.
|
|
37
|
+
4. `set_comment` for plans / prep-signal, `set_nags` for evaluations, `set_annotations` for arrows and squares — always on the node the annotation belongs to.
|
|
38
|
+
5. `promote_variation` if a variation is more important than the current mainline.
|
|
39
|
+
6. `delete_subtree` to prune.
|
|
40
|
+
|
|
41
|
+
Every step returns the new `version`; carry it forward.
|
|
42
|
+
|
|
43
|
+
## Variations vs prose
|
|
44
|
+
|
|
45
|
+
**Variations are moves, not sentences.** This was the biggest failure mode of raw-PGN authoring — LLMs writing "if Black plays Be6 White responds with f3" as prose. In the tree model there's no such temptation: variations are `add_move` calls at the branching node.
|
|
46
|
+
|
|
47
|
+
Prose comments are for what MOVES cannot say:
|
|
48
|
+
|
|
49
|
+
- **Plans**: `{Plan: exchange dark-square bishops, then break with f5.}`
|
|
50
|
+
- **Prep-signal**: `{Firouzja plays this in 32 games since 2023, scores 41%.}`
|
|
51
|
+
- **Interpretation the app can't derive**: `{IQP structure; Black's plan is …Nb4.}`
|
|
52
|
+
- **Practical layer beyond the objective eval**: `{Objectively equal, but Black must remember 8 precise moves; White plays this blindfolded.}`
|
|
53
|
+
|
|
54
|
+
Prose is NEVER for:
|
|
55
|
+
|
|
56
|
+
- Move sequences ("then Nf3, then Bg5, ...") — use variations.
|
|
57
|
+
- Move recommendations ("here White should play h4") — add_move it.
|
|
58
|
+
- Restating the eval a NAG already conveys.
|
|
59
|
+
|
|
60
|
+
## Move-judgment symbols (NAGs)
|
|
61
|
+
|
|
62
|
+
NAGs are the compact way to attach an evaluation to a move. Pass as `$N` strings to `set_nags`.
|
|
63
|
+
|
|
64
|
+
| NAG | Symbol | Meaning |
|
|
65
|
+
|-------|---------|----------------------------------------------------------------|
|
|
66
|
+
| `$1` | `!` | Good move |
|
|
67
|
+
| `$2` | `?` | Mistake |
|
|
68
|
+
| `$3` | `!!` | Brilliant move |
|
|
69
|
+
| `$4` | `??` | Blunder |
|
|
70
|
+
| `$5` | `!?` | Interesting / speculative |
|
|
71
|
+
| `$6` | `?!` | Dubious |
|
|
72
|
+
| `$10` | `=` | Equal |
|
|
73
|
+
| `$13` | `∞` | **Unclear** — position genuinely hard to evaluate |
|
|
74
|
+
| `$14` | `⩲` | White slightly better |
|
|
75
|
+
| `$15` | `⩱` | Black slightly better |
|
|
76
|
+
| `$16` | `±` | White clearly better |
|
|
77
|
+
| `$17` | `∓` | Black clearly better |
|
|
78
|
+
| `$18` | `+−` | Winning for White |
|
|
79
|
+
| `$19` | `−+` | Winning for Black |
|
|
80
|
+
| `$36` | `↑` | With initiative |
|
|
81
|
+
| `$40` | `→` | With attack |
|
|
82
|
+
| `$44` | `=/∞` | **Compensation** for the material |
|
|
83
|
+
| `$132`| `⇆` | With counterplay |
|
|
84
|
+
| `$140`| `∆` | With the idea … |
|
|
85
|
+
| `$146`| `N` | **Novelty** — this move has not been played before at this level |
|
|
86
|
+
|
|
87
|
+
### Eval → NAG thresholds (the rule)
|
|
88
|
+
|
|
89
|
+
Engine numbers go into the NAG, not into prose. Convert the eval to the correct NAG once and be done.
|
|
90
|
+
|
|
91
|
+
| Engine eval (White POV) | NAG (White ahead) | NAG (Black ahead) |
|
|
92
|
+
|-------------------------|-------------------------|-------------------------|
|
|
93
|
+
| `\|eval\| < 0.25` | `$10` (=) | `$10` (=) |
|
|
94
|
+
| `0.25 ≤ \|eval\| < 0.6` | `$14` (⩲) | `$15` (⩱) |
|
|
95
|
+
| `0.6 ≤ \|eval\| < 1.3` | `$16` (±) | `$17` (∓) |
|
|
96
|
+
| `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
|
|
97
|
+
| Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
|
|
98
|
+
|
|
99
|
+
**Don't paste raw engine numbers into comments.** `{Stockfish gives +0.35}` is noise — `$14` is the signal. Same for `{Lc0 says +0.15}`.
|
|
100
|
+
|
|
101
|
+
**Don't name the engine unless it adds signal.** Naming Stockfish adds nothing to a `$14` on a routine position. Naming it makes sense when there's a mismatch worth flagging: `$14 {Human predictor gives 47% win at 2200 vs 2600 — the Elo gap does the practical work.}`
|
|
102
|
+
|
|
103
|
+
### When prose ADDS to the NAG
|
|
104
|
+
|
|
105
|
+
Prose is worth writing when the NAG *understates* something the human should know:
|
|
106
|
+
|
|
107
|
+
Good (NAG says equal, prose adds the practical wrinkle):
|
|
108
|
+
```
|
|
109
|
+
set_comment(id, path, "Lc0 still gives Black a small pull — dark-square control is long-term, engine horizon can't quite reach it.")
|
|
110
|
+
set_nags(id, path, ["$10"])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Good (NAG tells the truth, prose flags a mismatch worth noting):
|
|
114
|
+
```
|
|
115
|
+
set_comment(id, path, "Human predictor: 47% win at 2200 vs 2600 — the Elo gap does the practical work despite the small objective edge.")
|
|
116
|
+
set_nags(id, path, ["$14"])
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Bad (prose duplicates the NAG, adds nothing):
|
|
120
|
+
```
|
|
121
|
+
set_comment(id, path, "Stockfish gives +0.35 for White here.")
|
|
122
|
+
set_nags(id, path, ["$14"])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Visual annotations (arrows + coloured squares)
|
|
126
|
+
|
|
127
|
+
`set_annotations(id, path, {arrows, highlights})` sets both together (an atomic replacement — pass both current and new). Passing empty arrays clears.
|
|
128
|
+
|
|
129
|
+
Colours (both arrows and squares): `green`, `red`, `yellow`, `light-blue`, `dark-blue`, `orange`.
|
|
130
|
+
|
|
131
|
+
Usage conventions:
|
|
132
|
+
|
|
133
|
+
| Colour | Typical use |
|
|
134
|
+
|------------|------------------------------------------------|
|
|
135
|
+
| green | Good move / plan / key square for you |
|
|
136
|
+
| red | Threat / opponent's target / danger square |
|
|
137
|
+
| yellow | Worth-noting / candidate |
|
|
138
|
+
| light-blue | Neutral pointer / diagram note |
|
|
139
|
+
| dark-blue | Alternative / secondary idea |
|
|
140
|
+
| orange | Attention / warning |
|
|
141
|
+
|
|
142
|
+
**Keep it light.** 1–3 arrows per node and 2–3 highlighted squares is plenty. Twenty arrows is noise, not signal — pick the most important ones. Highlights are labels, not decoration.
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
```
|
|
146
|
+
set_annotations(id, path, {
|
|
147
|
+
arrows: [
|
|
148
|
+
{ color: "green", from: "f2", to: "f4" },
|
|
149
|
+
{ color: "green", from: "c1", to: "h6" },
|
|
150
|
+
],
|
|
151
|
+
highlights: [
|
|
152
|
+
{ color: "green", square: "g6" },
|
|
153
|
+
{ color: "red", square: "h6" },
|
|
154
|
+
],
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
Renders as a green f2→f4 arrow, a green c1→h6 arrow, a green square on g6, and a red square on h6 — attached to whichever node's path you specified.
|
|
158
|
+
|
|
159
|
+
## Errors and how to recover
|
|
160
|
+
|
|
161
|
+
- **Illegal SAN**: `add_move` validates against the position and rejects illegal moves with a message including the position's FEN. Re-check what position you thought you were at, then retry.
|
|
162
|
+
- **Path out of bounds**: happens if you call a mutation with a path from an older read that's been changed since. Re-read the tree, recompute paths, retry.
|
|
163
|
+
- **Version conflict (409)**: someone (the user in the app, or a parallel agent session) edited the file since your last read. Re-read, decide whether to merge or override, retry with the new version.
|
package/docs/prep-files-guide.md
CHANGED
|
@@ -25,40 +25,18 @@ Six tools:
|
|
|
25
25
|
|
|
26
26
|
Duplicate files are the #1 way to lose your user's trust in this system.
|
|
27
27
|
|
|
28
|
-
## PGN
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6 6. Be3 e5 7. Nb3
|
|
39
|
-
{Main line. Firouzja plays this in 68% of his English Attacks (32 games, 2023-2025).}
|
|
40
|
-
7... Be6 8. f3
|
|
41
|
-
(8. Qd2 Nbd7 9. f3 h5 {Sideline he tried once and lost — probably patched.})
|
|
42
|
-
8... Nbd7 9. Qd2 h5
|
|
43
|
-
{Critical position for us. Lc0 at multipv=3 gives:
|
|
44
|
-
- h5: +0.12 (main line, active)
|
|
45
|
-
- Be7: +0.08 (calm, more classical)
|
|
46
|
-
- Rc8: 0.00 (Stockfish preferred)}
|
|
47
|
-
|
|
48
|
-
*
|
|
49
|
-
```
|
|
28
|
+
## PGN authoring — separate concern
|
|
29
|
+
|
|
30
|
+
Everything about **how to write good PGN** (structure, NAGs, arrows, coloured squares, variation discipline, common pitfalls) lives in `pgn-authoring.md` — call `read_pgn_authoring_guide` for the full doc. That guide is universal to any chess file you might write; this guide is about the prep-files *feature* on top.
|
|
31
|
+
|
|
32
|
+
Minimum you should know before calling `save_prep_file`:
|
|
33
|
+
|
|
34
|
+
- Mainline = your top recommendation. Alternative candidates go in parenthesised variations at the branching move.
|
|
35
|
+
- **Variations are MOVES**, not prose describing moves. `7...Be6 (7...h5 8.Nd5)` — never `{if Black plays h5 White responds with Nd5}`.
|
|
36
|
+
- **Plans, prep-signal, and interpretation** go in `{curly-brace comments}`. Cite tool output; don't invent chess prose.
|
|
37
|
+
- `Event` tag is the file's user-facing name.
|
|
50
38
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- **PGN tag pairs at the top** (`[Key "Value"]`) — `Event` is the name you show the user. `White` / `Black` set the game headers. `Date` is standard.
|
|
54
|
-
- **Mainline** = your top recommendation. Numbered SAN moves separated by whitespace.
|
|
55
|
-
- **Variations** in parentheses `(...)` at the branching move. Nesting is allowed but be conservative — three deep is a lot.
|
|
56
|
-
- **Comments** in curly braces `{...}` between moves. This is where you cite tool output:
|
|
57
|
-
- `{Lc0 says +0.15 in this line vs 0.00 for the alternative}`
|
|
58
|
-
- `{Firouzja plays this in 32/47 games with Black. Scores 41%.}`
|
|
59
|
-
- `{Stockfish sees h4 as a tactical shot; Lc0 disagrees. Deep look needed.}`
|
|
60
|
-
- **NAG glyphs** for evaluations at a move (optional): `$1` (!), `$2` (?), `$3` (!!), `$4` (??), `$6` (=), `$10` (=), `$14` (+/=). Placed right after the SAN: `7. Nb3 $1`.
|
|
61
|
-
- **Result marker** at the end: `*` for unfinished (prep files are always unfinished), `1-0`, `0-1`, `1/2-1/2`.
|
|
39
|
+
Full details, NAG table, arrow/highlight syntax, and worked example: `read_pgn_authoring_guide`.
|
|
62
40
|
|
|
63
41
|
## Editing without breaking the tree
|
|
64
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chessceo/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Model Context Protocol server for chess.ceo — 11.7M+ games, ~1.5M FIDE player profiles, opening preparation, live broadcasts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
45
45
|
"chess.js": "^1.4.0",
|
|
46
|
+
"chessops": "^0.15.1",
|
|
46
47
|
"zod": "^3.23.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|