@chessceo/mcp 0.30.2 → 0.31.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.
@@ -1,7 +1,7 @@
1
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.
2
+ // PrepFile (structural sharing where safe) plus the node_id of the
3
+ // touched node (the LLM addresses nodes by content-derived id — see
4
+ // paths.ts so tools return the id, not the path).
5
5
  //
6
6
  // All move-making mutations validate SAN against the current FEN via
7
7
  // chessops before touching the tree — a bad move surfaces as a thrown
@@ -11,15 +11,15 @@ import { parseFen } from "chessops/fen";
11
11
  import { Chess } from "chessops/chess";
12
12
  import { parseSan } from "chessops/san";
13
13
  import { makeFen } from "chessops/fen";
14
- import { cloneOnPath, getNode, PathError } from "./paths.js";
14
+ import { cloneOnPath, deriveNodeId, getNode, PathError } from "./paths.js";
15
15
  export class MutationError extends Error {
16
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);
17
+ // Add a SAN move as a new child under `parentPath`. Appends becomes
18
+ // a variation if the parent already has children (mainline is
19
+ // children[0]). Use promoteVariation afterwards to switch mainline.
20
+ // Returns the new node's stable id (derived from parent.id + san).
21
+ export function addMove(file, parentPath, san) {
22
+ const parent = getNode(file.root, parentPath);
23
23
  const move = validateSan(parent.fen, san);
24
24
  const posSetup = parseFen(parent.fen);
25
25
  if (posSetup.isErr)
@@ -30,19 +30,44 @@ export function addMove(file, path, san) {
30
30
  const board = pos.value;
31
31
  board.play(move);
32
32
  const childFen = makeFen(board.toSetup());
33
+ const newId = deriveNodeId(parent.id, san);
33
34
  const newNode = {
35
+ id: newId,
34
36
  san,
35
37
  fen: childFen,
36
38
  ply: parent.ply + 1,
37
39
  children: [],
38
40
  };
39
- const { root: newRoot, target } = cloneOnPath(file.root, path);
41
+ const { root: newRoot, target } = cloneOnPath(file.root, parentPath);
40
42
  target.children = [...target.children, newNode];
41
43
  return {
42
44
  file: { tags: file.tags, root: newRoot },
43
- path: [...path, target.children.length - 1],
45
+ id: newId,
44
46
  };
45
47
  }
48
+ // Add a linear sequence of moves under `parentPath` — one call for a
49
+ // whole variation instead of N add_move calls. Each move becomes the
50
+ // mainline child (children[0]) of the previous. Returns the ids and
51
+ // SANs of every added node so the LLM can address any of them next.
52
+ export function addLine(file, parentPath, sans) {
53
+ if (sans.length === 0)
54
+ throw new MutationError("add_line requires at least one san");
55
+ let cur = file;
56
+ let curPath = parentPath;
57
+ const line = [];
58
+ for (const san of sans) {
59
+ const step = addMove(cur, curPath, san);
60
+ cur = step.file;
61
+ // The move we just appended is the parent's last child — extend
62
+ // the walked path with that child index so the next move lands on
63
+ // top of it (extending the line, not creating a sibling).
64
+ const parent = getNode(cur.root, curPath);
65
+ const childIdx = parent.children.length - 1;
66
+ curPath = [...curPath, childIdx];
67
+ line.push({ id: step.id, san });
68
+ }
69
+ return { file: cur, line };
70
+ }
46
71
  // Replace the comment on the node at `path`. Passing empty string /
47
72
  // null clears it.
48
73
  export function setComment(file, path, comment) {
@@ -52,7 +77,7 @@ export function setComment(file, path, comment) {
52
77
  target.comment = trimmed;
53
78
  else
54
79
  delete target.comment;
55
- return { file: { tags: file.tags, root: newRoot }, path };
80
+ return { file: { tags: file.tags, root: newRoot }, id: target.id };
56
81
  }
57
82
  // Replace the NAG list. Empty array clears.
58
83
  export function setNags(file, path, nags) {
@@ -64,7 +89,7 @@ export function setNags(file, path, nags) {
64
89
  target.nags = cleaned;
65
90
  else
66
91
  delete target.nags;
67
- return { file: { tags: file.tags, root: newRoot }, path };
92
+ return { file: { tags: file.tags, root: newRoot }, id: target.id };
68
93
  }
69
94
  // Replace visual annotations (arrows + highlighted squares). Empty
70
95
  // arrows and highlights arrays clear the annotations entirely.
@@ -75,11 +100,10 @@ export function setAnnotations(file, path, ann) {
75
100
  delete target.annotations;
76
101
  else
77
102
  target.annotations = { arrows: ann.arrows, highlights: ann.highlights };
78
- return { file: { tags: file.tags, root: newRoot }, path };
103
+ return { file: { tags: file.tags, root: newRoot }, id: target.id };
79
104
  }
80
105
  // 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.
106
+ // the root. Returns the id of the deleted node's parent.
83
107
  export function deleteSubtree(file, path) {
84
108
  if (path.length === 0)
85
109
  throw new MutationError("cannot delete root");
@@ -90,34 +114,38 @@ export function deleteSubtree(file, path) {
90
114
  throw new PathError(`delete index ${removeIdx} out of bounds`, path);
91
115
  }
92
116
  parent.children = parent.children.filter((_, i) => i !== removeIdx);
93
- return { file: { tags: file.tags, root: newRoot }, path: parentPath };
117
+ return { file: { tags: file.tags, root: newRoot }, id: parent.id };
94
118
  }
95
119
  // Promote the node at `path` to be its parent's first child (the
96
120
  // mainline). Silently no-ops if it's already the mainline. Refuses on
97
- // root (root has no parent to promote against).
121
+ // root (root has no parent to promote against). Returns the promoted
122
+ // node's id (unchanged by the reorder — IDs don't depend on sibling
123
+ // position).
98
124
  export function promoteVariation(file, path) {
99
125
  if (path.length === 0)
100
126
  throw new MutationError("cannot promote root");
101
127
  const parentPath = path.slice(0, -1);
102
128
  const idx = path[path.length - 1];
103
- if (idx === 0)
104
- return { file, path }; // already mainline
129
+ if (idx === 0) {
130
+ // Already mainline — return the existing id without touching anything.
131
+ const target = getNode(file.root, path);
132
+ return { file, id: target.id };
133
+ }
105
134
  const { root: newRoot, target: parent } = cloneOnPath(file.root, parentPath);
106
135
  const promoted = parent.children[idx];
107
136
  const rest = parent.children.filter((_, i) => i !== idx);
108
137
  parent.children = [promoted, ...rest];
109
- return { file: { tags: file.tags, root: newRoot }, path: [...parentPath, 0] };
138
+ return { file: { tags: file.tags, root: newRoot }, id: promoted.id };
110
139
  }
111
140
  // Replace the stored ceoEval on the node at `path`. Passing null clears.
112
- // This is what auto_evaluate calls per-node; also exposed as an op the
113
- // LLM can invoke directly if it wants to overwrite a stale eval.
141
+ // This is what auto_evaluate / cloud_analyse(node_id) calls per-node.
114
142
  export function setCeoEval(file, path, ev) {
115
143
  const { root: newRoot, target } = cloneOnPath(file.root, path);
116
144
  if (ev === null || (!ev.sf && !ev.lc0 && !ev.nag))
117
145
  delete target.ceoEval;
118
146
  else
119
147
  target.ceoEval = ev;
120
- return { file: { tags: file.tags, root: newRoot }, path };
148
+ return { file: { tags: file.tags, root: newRoot }, id: target.id };
121
149
  }
122
150
  // Set or clear a tag. Passing null / empty removes.
123
151
  export function setTag(file, key, value) {
@@ -144,7 +172,7 @@ function validateSan(fen, san) {
144
172
  const board = pos.value;
145
173
  const move = parseSan(board, san);
146
174
  if (!move) {
147
- throw new MutationError(`illegal SAN "${san}" at fen "${fen}". Call describe_position on this fen to see the full list of legal moves.`);
175
+ throw new MutationError(`illegal SAN "${san}" at fen "${fen}". Call describe_position on this node to see the full list of legal moves.`);
148
176
  }
149
177
  return move;
150
178
  }
@@ -163,4 +191,4 @@ export function pathOf(root, target) {
163
191
  return null;
164
192
  }
165
193
  // Re-exports so index.ts only imports from mutations.
166
- export { getNode, getParent } from "./paths.js";
194
+ export { getNode, getParent, buildIdIndex, resolveNodeId, NodeIdError, ROOT_ID } from "./paths.js";
@@ -11,6 +11,7 @@ import { parseSan } from "chessops/san";
11
11
  import { makeFen } from "chessops/fen";
12
12
  import { opposite } from "chessops/util";
13
13
  import { colorFromCode, } from "./types.js";
14
+ import { deriveNodeId, ROOT_ID } from "./paths.js";
14
15
  const CAL_RE = /\[%cal\s+([^\]]+)\]/g;
15
16
  const CSL_RE = /\[%csl\s+([^\]]+)\]/g;
16
17
  const CEO_EVAL_RE = /\[%ceo-eval\s+([^\]]+)\]/g;
@@ -105,7 +106,7 @@ export function parsePGN(pgn) {
105
106
  }
106
107
  const startPos = startResult.value;
107
108
  const rootFen = makeFen(startPos.toSetup());
108
- const root = { san: null, fen: rootFen, ply: 0, children: [] };
109
+ const root = { id: ROOT_ID, san: null, fen: rootFen, ply: 0, children: [] };
109
110
  // Root-level comment on the game (rare, but supported).
110
111
  const gameComments = game.moves.comments;
111
112
  if (gameComments && gameComments.length > 0) {
@@ -135,7 +136,13 @@ export function parsePGN(pgn) {
135
136
  pos.play(move);
136
137
  nodeFen = makeFen(pos.toSetup());
137
138
  }
138
- const node = { san, fen: nodeFen, ply, children: [] };
139
+ const node = {
140
+ id: deriveNodeId(treeParent.id, san),
141
+ san,
142
+ fen: nodeFen,
143
+ ply,
144
+ children: [],
145
+ };
139
146
  // Comment on this move (both "starting comments" attached to
140
147
  // variations and "after-move comments" — we fold both into the
141
148
  // node's own comment; the LLM doesn't need the PGN's positional
package/dist/pgn/paths.js CHANGED
@@ -1,7 +1,40 @@
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.
1
+ // Node addressing for the LLM: stable content-derived IDs, resolved
2
+ // internally to array-of-child-indices `Path` for the mutation
3
+ // implementation. LLM-facing tools never accept a path they take a
4
+ // `node_id` (or `parent_id` for add_move) and this module turns it
5
+ // into the path the mutation code walks.
6
+ //
7
+ // The ID → path index is built once per parse and read many times per
8
+ // batch. Every mutation preserves IDs on unchanged nodes (IDs are pure
9
+ // functions of parent.id + san, so adding/removing/promoting siblings
10
+ // leaves every ID untouched), so callers don't need to rebuild the
11
+ // index between ops in a batch — they just extend it with the newly
12
+ // created node's id when applyMutations lands one.
13
+ import { createHash } from "node:crypto";
14
+ // Special ID for the root position (starting FEN, no move played).
15
+ export const ROOT_ID = "r";
16
+ // 8-hex-char content hash. Root is "r". Every other node's id is
17
+ // derived from (parent.id, san). Two nodes with the same (parent, san)
18
+ // can't co-exist as siblings in chess anyway, so within-parent
19
+ // collisions are impossible. Cross-tree collisions in 32 bits at 1000
20
+ // nodes are ~10^-4 — we throw on the rare hit rather than mask it.
21
+ export function deriveNodeId(parentId, san) {
22
+ const h = createHash("sha256");
23
+ h.update(parentId);
24
+ h.update("|");
25
+ h.update(san);
26
+ return h.digest("hex").slice(0, 8);
27
+ }
28
+ // Bad-node-id errors so the LLM sees exactly what went wrong. Message
29
+ // carries the offending id, which is often a copy-paste bug (missing
30
+ // character, wrong file id, stale reference to a deleted node).
31
+ export class NodeIdError extends Error {
32
+ nodeId;
33
+ constructor(msg, nodeId) {
34
+ super(`${msg} (node_id=${nodeId})`);
35
+ this.nodeId = nodeId;
36
+ }
37
+ }
5
38
  export class PathError extends Error {
6
39
  path;
7
40
  constructor(msg, path) {
@@ -9,9 +42,40 @@ export class PathError extends Error {
9
42
  this.path = path;
10
43
  }
11
44
  }
45
+ // Build the id → path index for a whole tree. Also detects the rare
46
+ // collision (two nodes with the same 32-bit id) and throws so the
47
+ // caller can surface a clear message.
48
+ export function buildIdIndex(root) {
49
+ const index = new Map();
50
+ const walk = (node, path) => {
51
+ if (index.has(node.id)) {
52
+ throw new Error(`node id collision on "${node.id}" — two distinct nodes in the tree ` +
53
+ `hash to the same 32-bit id. This is statistically rare (~10^-4 at ` +
54
+ `1000 nodes); if you see this, please report the file so we can ` +
55
+ `widen the hash.`);
56
+ }
57
+ index.set(node.id, path);
58
+ for (let i = 0; i < node.children.length; i++) {
59
+ walk(node.children[i], [...path, i]);
60
+ }
61
+ };
62
+ walk(root, []);
63
+ return index;
64
+ }
65
+ // Resolve a node_id to a Path against a specific tree. Throws
66
+ // NodeIdError with a message the LLM can act on.
67
+ export function resolveNodeId(index, nodeId) {
68
+ const path = index.get(nodeId);
69
+ if (!path) {
70
+ throw new NodeIdError(`no node with this id in the tree. Either it was deleted since your ` +
71
+ `last read, you have a typo, or you're addressing a different file — ` +
72
+ `call read_prep_file to refresh`, nodeId);
73
+ }
74
+ return path;
75
+ }
12
76
  // 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.
77
+ // escapes the tree — used internally by the mutation code after
78
+ // node_id path resolution.
15
79
  export function getNode(root, path) {
16
80
  let cur = root;
17
81
  for (let i = 0; i < path.length; i++) {
@@ -33,10 +97,9 @@ export function getParent(root, path) {
33
97
  const index = path[path.length - 1];
34
98
  return { parent, index };
35
99
  }
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).
100
+ // Deep-clone nodes on the path from root to the mutation target,
101
+ // leaving unrelated subtrees shared. Downstream code treats siblings
102
+ // as immutable so this sharing is safe.
40
103
  export function cloneOnPath(root, path) {
41
104
  const newRoot = { ...root, children: [...root.children] };
42
105
  const parentChain = [newRoot];
package/dist/pgn/types.js CHANGED
@@ -1,10 +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.
1
+ // Compact tree types the LLM works with.
5
2
  //
6
- // Paths ARE stable across parse/export/reparse cycles because move
7
- // order is deterministic and we don't rearrange children on export.
3
+ // Nodes are addressed by a stable, content-derived `id`:
4
+ // root.id = "r"
5
+ // node.id = first 8 hex chars of sha256(parent.id + "|" + san)
6
+ //
7
+ // The derivation is a pure function of the tree structure, so IDs
8
+ // survive parse → mutate → export → reparse without needing to be
9
+ // persisted in the PGN. Sibling insertions, deletions and variation
10
+ // promotions all leave every other node's ID unchanged — the failure
11
+ // mode of path-based addressing (later paths shifting when an earlier
12
+ // op inserts a sibling) simply cannot happen. See src/pgn/paths.ts
13
+ // for the id → path resolution used to power mutation calls.
14
+ //
15
+ // 32-bit width (8 hex chars) means birthday-collision probability
16
+ // is ~10^-4 even at 1000 nodes — the largest prep files we see are
17
+ // well under that. On the rare parse-time collision we throw a clear
18
+ // error rather than persisting anything ambiguous.
8
19
  // Named colours as they appear in the parsed tree. The wire format uses
9
20
  // single-letter codes (G, R, Y, C, B, O); the tree uses these longer
10
21
  // names to match how the frontend represents them.
@@ -6,6 +6,16 @@ When you call `cloud_analyse`, chess.ceo runs Stockfish and Lc0 in parallel on t
6
6
 
7
7
  **Every claim you make about a position must trace back to actual engine output from this session.** Not from a book you were trained on, not from generic chess principles, not from a plausible-sounding pattern. If you don't have a Stockfish or Lc0 line for the exact FEN you're discussing, run one. Compute is cheap — `cloud_analyse` at ~2s each is fine to call 5-10 times while walking a tree; you can burn 20-30 seconds of billable time and it's still cents.
8
8
 
9
+ ### The node-id + quote_engine_eval protocol
10
+
11
+ **When you're inside a prep file, call `cloud_analyse` with `file_id`+`node_id` — never with a hand-typed FEN.** The server derives the FEN from the tree node and, critically, **auto-stores the resulting eval on that node's `ceoEval`**. This is what makes engine attribution trustworthy end-to-end:
12
+
13
+ 1. `cloud_analyse({ id, node_id })` — runs analysis on the node's exact position, stores `{sf, lc0, nag}` on the node.
14
+ 2. Later, before you write "engines say X on this position" in a comment, call `quote_engine_eval({ id, node_id })` — it returns the stored eval or `null`.
15
+ 3. If `quote_engine_eval` returns `null`, you have no measurement to cite. **Do NOT infer an eval for a node from siblings, from children, or from a "position that looks similar."** Either analyse the node (`cloud_analyse`) or omit the number from your prose entirely.
16
+
17
+ Concrete failure this rule blocks: the LLM says *"9...Bb7: both engines 0.00"* after only calling `cloud_analyse` on the child positions (post-1.d4, post-castling). Both continuations really returned 0.00, but the Bb7 node was never analysed, and the claim reads to the user as a measurement. With this protocol, `quote_engine_eval(node_id=Bb7)` would return null and the LLM would either analyse it or reword to *"both continuations run to 0.00, so this position looks balanced"* (soft inference, honestly labelled).
18
+
9
19
  **Concrete failure modes to avoid:**
10
20
 
11
21
  - Inventing an evaluation. If you say "this is +0.4 for White", that number must come from an engine call. Not a guess, not a vibe.
@@ -1,68 +1,79 @@
1
1
  # PGN authoring guide
2
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.
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 by id, never raw text or paths. No paren-counting, no move-numbering, no SAN typos surviving your edit, no index drift when a sibling is inserted.
4
4
 
5
- ## Path addressing
5
+ ## Node id addressing
6
6
 
7
- Nodes are addressed by **path**: an array of child indices from the root.
7
+ Every node has a stable **`id`** the LLM's handle on that node across mutation calls.
8
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.
9
+ - Root id is `"r"`.
10
+ - Every other node id is an 8-hex-char content hash derived from `(parent.id, san)`.
11
+ - **IDs never change when other nodes are added, deleted or promoted.** Sibling insertions do not shift ids. Variation promotion does not shift ids. A node's id is a pure function of its position in the tree's SAN structure.
12
+ - **IDs are self-checking.** If you send a node_id the tree doesn't know, resolution fails immediately with a clear error the LLM can't "make up" ids and have them silently work.
14
13
 
15
- `children[0]` is always the mainline; `children[1..N]` are alternative variations in declaration order. `promote_variation` swaps this order.
14
+ Every read gives you the id on every node. Every mutation call takes `node_id` (or `parent_id` for add_move / add_line) and returns the id it landed on. You chain ids from one call to the next.
16
15
 
17
16
  ## Workflow
18
17
 
19
18
  Reading:
20
19
 
21
- - `read_prep_file(id)` → returns `{version, tags, tree}`. Every node in `tree` has `san`, `fen`, `ply`, optional `comment`, `nags`, `annotations`, and `children`.
20
+ - `read_prep_file(id)` → returns `{version, tags, tree}`. Every node has `id`, `san`, `fen`, `ply`, optional `comment`, `nags`, `annotations`, `ceoEval`, and `children`.
22
21
 
23
22
  Building (use these — one call for many ops):
24
23
 
25
24
  - **`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 populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every node by running cloud_analyse on each position. **Does NOT set visible NAGs** — the numbers land in a hidden `[%ceo-eval]` tag so you can read them back later. NAG placement (`$1` `!`, `$14` `⩲`, `$16` `±`, `$18` `+−`, etc.) is your editorial call, not an automatic mapping — see the section on NAG discipline below. Skip nodes that already have a stored eval by default.
25
+ - **`add_line(id, parent_id, sans[])`** — a whole linear variation in one op. Cleaner than N `add_move` ops for straight lines.
26
+ - **`auto_evaluate(id, node_id?)`** — walk from a node and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. **Does NOT set visible NAGs** — the numbers land in a hidden `[%ceo-eval]` tag so you can read them back later. NAG placement (`$1` `!`, `$14` `⩲`, `$16` `±`, `$18` `+−`, etc.) is your editorial call. Skip nodes that already have a stored eval by default.
27
27
 
28
28
  Per-op mutation tools (single-op calls, use for edits after the bulk build):
29
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.
30
+ - `add_move(id, parent_id, san)` — appends a new child. If the parent has children, new node becomes a variation.
31
+ - `add_line(id, parent_id, sans[])` — appends a whole linear continuation as a sequence of children.
32
+ - `set_comment(id, node_id, comment)` — replace comment (empty string clears).
33
+ - `set_nags(id, node_id, nags)` — replace NAGs (empty array clears).
34
+ - `set_annotations(id, node_id, {arrows, highlights})` — replace visual annotations.
35
+ - `delete_subtree(id, node_id)` — remove node + descendants.
36
+ - `promote_variation(id, node_id)` — make the referenced node its parent's mainline.
36
37
  - `set_tag(id, key, value)` — set/clear a game-level PGN tag.
37
38
 
38
39
  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
 
40
41
  ## Typical build order
41
42
 
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, plus any `set_nags` where you already have a clear judgment — novelty `$146`, `!?` speculative sac, obvious `?` blunder in a sideline you're rejecting).
43
+ 1. `read_prep_file` — see what's there. Every node has an `id` you'll pass to the mutation and engine/DB tools.
44
+ 2. `apply_mutations([...])` — one call with your whole intended build (a mix of `add_move` / `add_line` for structure, plus any `set_comment`/`set_annotations` you already know at author time, plus any `set_nags` where you already have a clear judgment — novelty `$146`, `!?` speculative sac, obvious `?` blunder in a sideline you're rejecting).
44
45
  3. `auto_evaluate(id)` — walk the tree and PERSIST engine numbers on every node. Does not touch visible NAGs. Cheap way to get every position's Stockfish + Lc0 read baked into the file for later reference.
45
46
  4. Re-read the file and add NAGs where they carry real signal (see NAG discipline below). Use individual mutation tools for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
46
47
 
47
48
  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
 
49
- ### Path math in a batch
50
+ ### Chaining node_ids across a batch
50
51
 
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
+ Node ids are content-derived, so when you `add_move` inside a batch, the id of the new node is a deterministic function of the parent's id + the SAN. In practice: **use the id the previous op returned as the `parent_id` of the next op** (the batch response gives you each `node_id` in order, and `add_line` gives you the full `line: [{node_id, san}, …]`).
52
53
 
53
- For a linear mainline build from an empty file:
54
+ Concretely, the response to a batch is:
54
55
  ```
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
- ...
56
+ { ok: true, results: [{node_id: "3c7f592e"}, {node_id: "22012be0"}, ...], version: 42 }
60
57
  ```
61
58
 
62
- For a variation at some point:
59
+ If you know the tree ahead of time (writing from scratch), the deterministic recipe means you don't have to guess. But by far the easiest pattern is **`add_line` for straight lines, `add_move` for branch points** — you don't need to compute ids yourself.
60
+
61
+ ### Two clean batch patterns
62
+
63
+ **Straight mainline:**
64
+ ```
65
+ {op: "add_line", parent_id: "r", sans: ["e4","c5","Nf3","Nc6","Bb5"]}
66
+ // → returns { node_id: "<id-of-Bb5>", line: [{node_id, san}, ...] }
63
67
  ```
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]
68
+
69
+ **Mainline + variation at a branch point:**
70
+ ```
71
+ // First, the mainline:
72
+ {op: "add_line", parent_id: "r", sans: ["e4","c5","Nf3","d6"]}
73
+ // → line[2] is the Nf3 node — grab its node_id, call it NF3.
74
+
75
+ // Then the variation branching after Nf3:
76
+ {op: "add_line", parent_id: NF3, sans: ["Nc6","Bb5","Bd7"]}
66
77
  ```
67
78
 
68
79
  ## Variations vs prose
@@ -6,15 +6,21 @@ You can save chess prep to the user's chess.ceo account and read it back across
6
6
 
7
7
  The user has **one** collection dedicated to your work — labelled "AI Prep" in their chess.ceo app with a 🤖 icon. Inside it, each **prep file** is one PGN game with variations. You never see the collection itself; the tools operate directly on the files inside it.
8
8
 
9
- Six tools:
9
+ File-level tools:
10
10
 
11
11
  - `list_prep_files` — show me all my prep files
12
12
  - `search_prep_files(query)` — find by opponent name / opening keyword
13
- - `read_prep_file(id)` — full PGN + parsed mainline tree + `version`
14
- - `create_prep_file(name, pgn?)` — new file, `name` becomes the [Event] tag
15
- - `save_prep_file(id, pgn, expected_version)` — replace the whole PGN
13
+ - `read_prep_file(id)` — parsed tree (every node carries a stable `id`) + tags + `version`
14
+ - `create_prep_file(name)` — new empty file, `name` becomes the [Event] tag
16
15
  - `delete_prep_file(id)` — soft delete (user can restore from app)
17
16
 
17
+ Mutation tools (edit an existing file — you never touch raw PGN):
18
+
19
+ - `apply_mutations(id, [...])` — batch: N ops in one save. **Primary build tool.**
20
+ - `add_move`, `add_line`, `set_comment`, `set_nags`, `set_annotations`, `delete_subtree`, `promote_variation`, `set_tag` — individual ops for surgical follow-ups. See `read_pgn_authoring_guide` for the full mutation vocabulary.
21
+
22
+ Every mutation call takes a `node_id` (or `parent_id` for add-style ops) and auto-saves. Nodes are addressed by stable content-derived id, not by path — sibling insertions / deletions / promotions never shift ids. Root id is `"r"`.
23
+
18
24
  ## The single most important habit
19
25
 
20
26
  **Before creating a new file, search for an existing one.** LLMs make three "Prep vs Firouzja" files in a row all the time. Always:
@@ -40,23 +46,22 @@ Full details, NAG table, arrow/highlight syntax, and worked example: `read_pgn_a
40
46
 
41
47
  ## Editing without breaking the tree
42
48
 
43
- You get the whole PGN back from `read_prep_file`, edit it in your head, send back via `save_prep_file`. There's no partial-patch API small edits still resend the full text. The file is small (repertoire = maybe 5-20 KB), that's fine.
49
+ You never send raw PGN. `read_prep_file` gives you the parsed tree with stable node ids; the mutation tools accept those ids and handle the parse/edit/serialize cycle for you. SAN is validated per mutation, move numbers are automatic, parenthesised variations are impossible to leave unbalanced.
44
50
 
45
- Common LLM failure modes to catch yourself doing:
51
+ Failure modes the tool layer now blocks (that used to trip LLMs writing raw PGN):
46
52
 
47
- - **Unbalanced parentheses.** Every `(` needs a matching `)`. Count them if you added variations. The backend parses on save; if invalid, you get 400 with the parser error and have to retry.
48
- - **Bad SAN.** `Nfd7` where you meant `Nbd7`. Always trace the position in your head (or use the tree from read_prep_file's response) before writing a move.
49
- - **Forgotten move numbers.** `1. e4 c5 2. Nf3` after each White move you need `<num>.`, after each Black move the number continues implicitly until the next full move. In variations at Black's move, PGN wants `2... Nc6`.
50
- - **Nested variations losing context.** `1.e4 e5 (1...c5 (2.Nf3 d6))` the inner variation branches at `2.Nf3` off the c5 sideline, not off the mainline. This gets confusing fast; two levels is usually enough.
51
- - **Blank Event tag** — the user's list_prep_files response shows the Event tag as the name. Empty tag = "Untitled" everywhere.
53
+ - **Unbalanced parentheses** no longer possible; you address nodes, not text.
54
+ - **Bad SAN** — every `add_move` / `add_line` validates against the parent's FEN. Illegal moves are rejected with the position's FEN in the error so you can call `describe_position` to see legal moves.
55
+ - **Forgotten move numbers** — the exporter reconstructs them.
56
+ - **Nested variations losing context** `add_move(parent_id=X, san=…)` binds unambiguously to node X.
52
57
 
53
58
  ## Optimistic locking
54
59
 
55
- `read_prep_file` returns a `version` integer. Pass it back as `expected_version` on `save_prep_file`. If someone else (the user in the app, or a parallel agent session) updated the file since you read it, save returns `409 Conflict` with the current version. You should:
60
+ `read_prep_file` returns a `version` integer. Pass it back as `expected_version` on any mutation. If someone else (the user in the app, or a parallel agent session) updated the file since you read it, the save returns `409 Conflict` with the current version. You should:
56
61
 
57
- 1. Re-read the file to see what changed.
58
- 2. Merge your edits with theirs.
59
- 3. Retry the save with the new version.
62
+ 1. Re-read the file to see what changed (node ids are stable across the concurrent edit, so your local references may still work).
63
+ 2. Decide whether to merge or override.
64
+ 3. Retry with the new version.
60
65
 
61
66
  If you don't pass `expected_version`, it's last-write-wins — you might silently overwrite the user's manual edits. Only skip it when you know the file is untouched (e.g. you just created it).
62
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.30.2",
3
+ "version": "0.31.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": {