@chessceo/mcp 0.29.0 → 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 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.
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). 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).
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).
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.
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:**
63
64
  ```
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]
65
+ {op: "add_line", parent_id: "r", sans: ["e4","c5","Nf3","Nc6","Bb5"]}
66
+ // → returns { node_id: "<id-of-Bb5>", line: [{node_id, san}, ...] }
67
+ ```
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
@@ -122,7 +133,21 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
122
133
  | `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
123
134
  | Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
124
135
 
125
- **Prefer auto_evaluate for the NAG.** Don't hand-write NAGs when buildingcall `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).
136
+ **NAGs are editorial, not automatic.** `auto_evaluate` persists engine numbers on every node but does NOT set visible NAGs because a repertoire full of 0.00 opening theory does not want a `$10` (=) glyph plastered on every move. That's noise, not signal. NAGs are your call: mark moves that carry a judgment a reader can't derive by looking at the board.
137
+
138
+ Where NAGs actually earn their place:
139
+
140
+ - **`$146` (novelty)** — a move that hasn't appeared at this level. Only set if you actually know this (e.g. saw 0 games via `get_position_stats`).
141
+ - **`$5 !?` (interesting/speculative)** — a committal move you're recommending anyway (a piece sac, a positional pawn concession).
142
+ - **`$1 !` / `$3 !!`** — objectively good moves the reader might miss.
143
+ - **`$2 ?` / `$4 ??` / `$6 ?!`** — mistakes in a sideline you're showing to reject.
144
+ - **`$14/$15` (⩲/⩱)** and **`$16/$17` (±/∓)** — assessments on positions where the direction of pressure matters for the plan you're teaching. Skip these on 0.00 theory nodes.
145
+ - **`$18/$19` (+−/−+)** — decisive advantages, use where the reader should recognise the position is winning.
146
+ - **`$44` (compensation)** on a genuine gambit; **`$13` (∞)** on a genuinely sharp/unclear position that engines don't resolve.
147
+
148
+ Where NAGs are noise: every `$10` "=" glyph on every equal position. If the whole tree is 0.00, that's the *default state* — leave it unmarked and the reader understands.
149
+
150
+ When reading back a file, `node.ceoEval.nag` carries the threshold-derived NAG (`$10` / `$14` / `$16` / `$18` etc.) — treat it as a *suggestion*, not an automatic write. Promote it to a visible NAG only when a glyph on that move actually helps the reader.
126
151
 
127
152
  **Persisted evals travel with the file.** Every node with an eval gets `ceoEval: { sf: {cp: 25, depth: 32}, lc0: {cp: 30, depth: 18}, nag: "$14" }` on subsequent `read_prep_file` calls. Stored as `[%ceo-eval sf=+0.25/32 lc0=+0.30/18 nag=$14]` inside the PGN comment (an escape tag the app hides from the board view, just like [%cal] / [%csl]). So a re-read after auto_evaluate gives you every position's number without re-running cloud_analyse. Query tools (get_position_stats, get_player_preparation, prep_snapshot) also auto-attach a live `eval` at the request position when a cloud engine is running.
128
153
 
@@ -133,6 +158,15 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
133
158
 
134
159
  **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.}`
135
160
 
161
+ **Only quote engine numbers for positions you actually called `cloud_analyse` on** (or that carry a `ceoEval` from an earlier auto_evaluate, or an auto-attached `.eval` from a query tool). Do NOT infer an eval for a parent position from its children and then write "both engines 0.00" on the parent — that reads as a measurement but is a guess. If the position wasn't analysed, either analyse it or omit the number: prose can say "both continuations run to 0.00, so this looks balanced" without claiming an eval that wasn't taken.
162
+
163
+ **When you set `contempt`, attribute it to Lc0 only.** Contempt affects Lc0 exclusively — Stockfish always analyses objectively. So don't write "both engines at contempt −15" — that misleads the reader into thinking SF was biased too. Correct phrasings:
164
+
165
+ - `{Lc0 (contempt −15): 0.00 — even biased toward Black, still balanced. SF objective: 0.00.}`
166
+ - `{With Lc0 nudged toward Black (contempt −15) it still picks Qg6 at 0.00; SF's objective read also 0.00.}`
167
+
168
+ If you only quote the Lc0 number, disclose the bias in the same sentence — `{Lc0 gives Black +0.30}` with an undisclosed `contempt=-15` is a false objectivity claim.
169
+
136
170
  ### Before you write any commentary: describe_position
137
171
 
138
172
  LLMs are not reliable at reading FEN strings — you'll swap files/ranks, invent captures, miscount pieces. Before you write a comment describing what's happening in a position, call `describe_position(fen)`. It's pure computation (no engine, ~1ms) and returns the same board state a human sees:
@@ -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
 
@@ -21,7 +21,7 @@ The failure mode is *authoritative-sounding recipe = 30% real tool output + 70%
21
21
 
22
22
  ## Numbers are inputs, not verdicts
23
23
 
24
- The move statistics endpoints return win %, game counts, and (in the big DB) hotness. Treat every one of these as a *weight*, not a *rule*.
24
+ The move statistics endpoints return win %, game counts, and (in the big DB) a `fashionScore` (0–100, how in-fashion the move is right now at the top level — recent + played often). Treat every one of these as a *weight*, not a *rule*.
25
25
 
26
26
  - **Sample size scales trust.** 3 games at 66% is noise; 300 at 55% is signal. A great score is nice — with volume. When the opponent has only 2-4 games in a variation, the "opponent-specific" score is basically the general-DB score anyway.
27
27
  - **Score doesn't automatically indicate a level gap.** A 60% variation isn't necessarily "stronger players crushing weaker ones." Look at the per-move `avgWhite` / `avgBlack` fields (returned on every move statistic) before drawing conclusions about who is playing whom.
@@ -41,7 +41,7 @@ Every recommendation should be filtered through: "does this survive the fact tha
41
41
 
42
42
  The opponent's last 12-24 months matter far more than a 10-year career average. Repertoires evolve — a lifelong Najdorf player might have quietly become a Petroff player last year, and their old career stats will lie to you if you skim.
43
43
 
44
- **Related product note (2026-07-23):** the `get_player_preparation` endpoint (compact / LLM view) deliberately strips the per-move `hotness` field. At the individual-player level, "hotness" is trailing noise — the opponent's opening trend is already captured in game dates. Hotness stays on the general-DB endpoint, where it's genuinely useful: it means fashion (what the whole top field is playing this month).
44
+ **Related product note (2026-07-23):** the `get_player_preparation` endpoint (compact / LLM view) deliberately strips the per-move `fashionScore` field. At the individual-player level, fashion is trailing noise — the opponent's opening trend is already captured in game dates. `fashionScore` stays on the general-DB endpoint (`get_position_stats`), where it's genuinely useful: it's what the whole top field is playing this month.
45
45
 
46
46
  ## Prep is a tree, not a line
47
47
 
@@ -68,7 +68,7 @@ Deep prep is a weapon you spend, not one you own. A prepared novelty against a s
68
68
 
69
69
  ## Move-order tricks aren't visible in raw win rates
70
70
 
71
- The `trs` (transpositions) field on move statistics tells you how much a given position folds into related structures. Move-order tricks are legitimate prep and position stats alone can't recommend them.
71
+ The `reachedViaTransposition` field on move statistics tells you how many of the games at that position arrived via a different move order — a hint at how much the line folds into related structures. Move-order tricks are legitimate prep and position stats alone can't recommend them.
72
72
 
73
73
  - Considering 1.Nf3 or 1.c4 as a duck around 1.d4? First check the opponent's repertoire *against 1.d4*. If they play the King's Indian, most 1.Nf3/1.c4 lines transpose anyway — the trick doesn't help.
74
74
  - Move orders are useful when the opponent plays something that specifically depends on the move order — e.g. a Nimzo player who never gets to play the Nimzo because you go 1.Nf3-2.g3.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.29.0",
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": {