@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.
@@ -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,194 @@
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
+ Reading:
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
+
23
+ Building (use these — one call for many ops):
24
+
25
+ - **`apply_mutations(id, mutations[])`** — batch. This is the primary build tool. Send 100 mutations in one call → one load-parse-mutate-save cycle instead of 100. When you're writing a repertoire from scratch, EVERYTHING should go through this. Individual mutation tools are for surgical follow-up edits, not for bulk work.
26
+ - **`auto_evaluate(id, path?)`** — walk the tree from `path` and auto-assign the right NAG to every node by running cloud_analyse on each position. Standard threshold table (below). Costs real engine time but frees you from hand-annotating evals. Skip nodes that already have a NAG by default.
27
+
28
+ Per-op mutation tools (single-op calls, use for edits after the bulk build):
29
+
30
+ - `add_move(id, path, san)` — appends a new child. If `path` has children, new node becomes a variation.
31
+ - `set_comment(id, path, comment)` — replace comment (empty string clears).
32
+ - `set_nags(id, path, nags)` — replace NAGs (empty array clears).
33
+ - `set_annotations(id, path, {arrows, highlights})` — replace visual annotations.
34
+ - `delete_subtree(id, path)` — remove node + descendants.
35
+ - `promote_variation(id, path)` — make the node at `path` its parent's mainline.
36
+ - `set_tag(id, key, value)` — set/clear a game-level PGN tag.
37
+
38
+ All mutations **auto-save** with optimistic locking. Response includes the new `version`; pass it as `expected_version` on your next call to catch concurrent edits.
39
+
40
+ ## Typical build order
41
+
42
+ 1. `read_prep_file` — see what's there.
43
+ 2. `apply_mutations([...])` — one call with your whole intended build (all `add_move` for the moves and variations, plus any `set_comment`/`set_annotations` you already know at author time). Skip `set_nags` — auto_evaluate handles that.
44
+ 3. `auto_evaluate(id)` — walk the tree, get engine evals, assign NAGs. One shot.
45
+ 4. Individual mutation tools ONLY for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
46
+
47
+ The build-cost math: a 200-move file via individual `add_move` calls is 200 saves ≈ 100+ seconds of tool overhead. The same file via one `apply_mutations` call is one save ≈ 500ms. Use batch by default.
48
+
49
+ ### Path math in a batch
50
+
51
+ Paths in each mutation resolve against the tree AFTER previous ops in the batch. When you `add_move(path=[0], san='c5')`, the new c5 lands at `[0, 0]` if [0] had no children, or `[0, N]` if it had N. Your next mutation in the batch can address the new node directly. The tree is deterministic — you can compute all paths ahead of time.
52
+
53
+ For a linear mainline build from an empty file:
54
+ ```
55
+ {op: "add_move", path: [], san: "e4"} // lands at [0]
56
+ {op: "add_move", path: [0], san: "c5"} // lands at [0, 0]
57
+ {op: "add_move", path: [0, 0], san: "Nf3"} // lands at [0, 0, 0]
58
+ {op: "add_move", path: [0, 0, 0], san: "d6"} // lands at [0, 0, 0, 0]
59
+ ...
60
+ ```
61
+
62
+ For a variation at some point:
63
+ ```
64
+ {op: "add_move", path: [0, 0], san: "Nc3"} // variation branching after move 2; lands at [0, 0, 1]
65
+ {op: "add_move", path: [0, 0, 1], san: "d5"} // continue the variation; lands at [0, 0, 1, 0]
66
+ ```
67
+
68
+ ## Variations vs prose
69
+
70
+ **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.
71
+
72
+ Prose comments are for what MOVES cannot say:
73
+
74
+ - **Plans**: `{Plan: exchange dark-square bishops, then break with f5.}`
75
+ - **Prep-signal**: `{Firouzja plays this in 32 games since 2023, scores 41%.}`
76
+ - **Interpretation the app can't derive**: `{IQP structure; Black's plan is …Nb4.}`
77
+ - **Practical layer beyond the objective eval**: `{Objectively equal, but Black must remember 8 precise moves; White plays this blindfolded.}`
78
+
79
+ Prose is NEVER for:
80
+
81
+ - Move sequences ("then Nf3, then Bg5, ...") — use variations.
82
+ - Move recommendations ("here White should play h4") — add_move it.
83
+ - Restating the eval a NAG already conveys.
84
+ - **Describing a sibling variation you already added as a branch.** If move A has variation B added as `add_move(A, sibling)`, don't ALSO write `{if B then ...}` on A. The reader clicks B on the board — the branch is already there.
85
+
86
+ ## Move-judgment symbols (NAGs)
87
+
88
+ NAGs are the compact way to attach an evaluation to a move. Pass as `$N` strings to `set_nags`.
89
+
90
+ | NAG | Symbol | Meaning |
91
+ |-------|---------|----------------------------------------------------------------|
92
+ | `$1` | `!` | Good move |
93
+ | `$2` | `?` | Mistake |
94
+ | `$3` | `!!` | Brilliant move |
95
+ | `$4` | `??` | Blunder |
96
+ | `$5` | `!?` | Interesting / speculative |
97
+ | `$6` | `?!` | Dubious |
98
+ | `$10` | `=` | Equal |
99
+ | `$13` | `∞` | **Unclear** — position genuinely hard to evaluate |
100
+ | `$14` | `⩲` | White slightly better |
101
+ | `$15` | `⩱` | Black slightly better |
102
+ | `$16` | `±` | White clearly better |
103
+ | `$17` | `∓` | Black clearly better |
104
+ | `$18` | `+−` | Winning for White |
105
+ | `$19` | `−+` | Winning for Black |
106
+ | `$36` | `↑` | With initiative |
107
+ | `$40` | `→` | With attack |
108
+ | `$44` | `=/∞` | **Compensation** for the material |
109
+ | `$132`| `⇆` | With counterplay |
110
+ | `$140`| `∆` | With the idea … |
111
+ | `$146`| `N` | **Novelty** — this move has not been played before at this level |
112
+
113
+ ### Eval → NAG thresholds (the rule)
114
+
115
+ Engine numbers go into the NAG, not into prose. Convert the eval to the correct NAG once and be done.
116
+
117
+ | Engine eval (White POV) | NAG (White ahead) | NAG (Black ahead) |
118
+ |-------------------------|-------------------------|-------------------------|
119
+ | `\|eval\| < 0.25` | `$10` (=) | `$10` (=) |
120
+ | `0.25 ≤ \|eval\| < 0.6` | `$14` (⩲) | `$15` (⩱) |
121
+ | `0.6 ≤ \|eval\| < 1.3` | `$16` (±) | `$17` (∓) |
122
+ | `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
123
+ | Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
124
+
125
+ **Prefer auto_evaluate for the NAG.** Don't hand-write NAGs when building — call `auto_evaluate(id)` after your build and it assigns the right glyph to every node from actual engine analysis. Manual `set_nags` is for overrides (mark a novelty with $146, flag a `$5 !?` piece sac).
126
+
127
+ **Engine numbers in prose: brief only.** If you must reference a number, keep it compact:
128
+
129
+ - Good: `{... +0.2}` — bare number at the end of a note
130
+ - Bad: `{Stockfish depth 22 shows +0.25, Lc0 depth 18 shows +0.4, both engines agree}` — verbose noise
131
+
132
+ **Don't name the engine unless it adds signal.** "SF: +0.15" for a routine position is noise. Name it when there's a mismatch worth flagging: `{Human predictor gives Black 47% win despite the -0.35 objective eval — Elo gap does the work.}`
133
+
134
+ ### When prose ADDS to the NAG
135
+
136
+ Prose is worth writing when the NAG *understates* something the human should know:
137
+
138
+ Good (NAG says equal, prose adds the practical wrinkle):
139
+ ```
140
+ set_comment(id, path, "Lc0 still gives Black a small pull — dark-square control is long-term, engine horizon can't quite reach it.")
141
+ set_nags(id, path, ["$10"])
142
+ ```
143
+
144
+ Good (NAG tells the truth, prose flags a mismatch worth noting):
145
+ ```
146
+ set_comment(id, path, "Human predictor: 47% win at 2200 vs 2600 — the Elo gap does the practical work despite the small objective edge.")
147
+ set_nags(id, path, ["$14"])
148
+ ```
149
+
150
+ Bad (prose duplicates the NAG, adds nothing):
151
+ ```
152
+ set_comment(id, path, "Stockfish gives +0.35 for White here.")
153
+ set_nags(id, path, ["$14"])
154
+ ```
155
+
156
+ ## Visual annotations (arrows + coloured squares)
157
+
158
+ `set_annotations(id, path, {arrows, highlights})` sets both together (an atomic replacement — pass both current and new). Passing empty arrays clears.
159
+
160
+ Colours (both arrows and squares): `green`, `red`, `yellow`, `light-blue`, `dark-blue`, `orange`.
161
+
162
+ Usage conventions:
163
+
164
+ | Colour | Typical use |
165
+ |------------|------------------------------------------------|
166
+ | green | Good move / plan / key square for you |
167
+ | red | Threat / opponent's target / danger square |
168
+ | yellow | Worth-noting / candidate |
169
+ | light-blue | Neutral pointer / diagram note |
170
+ | dark-blue | Alternative / secondary idea |
171
+ | orange | Attention / warning |
172
+
173
+ **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.
174
+
175
+ Example:
176
+ ```
177
+ set_annotations(id, path, {
178
+ arrows: [
179
+ { color: "green", from: "f2", to: "f4" },
180
+ { color: "green", from: "c1", to: "h6" },
181
+ ],
182
+ highlights: [
183
+ { color: "green", square: "g6" },
184
+ { color: "red", square: "h6" },
185
+ ],
186
+ })
187
+ ```
188
+ 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.
189
+
190
+ ## Errors and how to recover
191
+
192
+ - **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.
193
+ - **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.
194
+ - **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.