@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.
- package/dist/index.js +352 -153
- package/dist/pgn/mutations.js +55 -27
- package/dist/pgn/parser.js +9 -2
- package/dist/pgn/paths.js +73 -10
- package/dist/pgn/types.js +17 -6
- package/docs/engine-usage.md +10 -0
- package/docs/pgn-authoring.md +67 -33
- package/docs/prep-files-guide.md +20 -15
- package/docs/prep-strategy.md +3 -3
- package/package.json +1 -1
package/dist/pgn/mutations.js
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// the
|
|
21
|
-
export function addMove(file,
|
|
22
|
-
const parent = getNode(file.root,
|
|
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,
|
|
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
|
-
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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
|
|
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 },
|
|
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
|
|
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 },
|
|
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
|
|
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 },
|
|
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
|
|
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";
|
package/dist/pgn/parser.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 —
|
|
14
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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.
|
package/docs/engine-usage.md
CHANGED
|
@@ -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.
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -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
|
-
##
|
|
5
|
+
## Node id addressing
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Every node has a stable **`id`** — the LLM's handle on that node across mutation calls.
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- **`
|
|
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,
|
|
31
|
-
- `
|
|
32
|
-
- `
|
|
33
|
-
- `
|
|
34
|
-
- `
|
|
35
|
-
- `
|
|
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 (
|
|
44
|
-
3. `auto_evaluate(id)` — walk the tree
|
|
45
|
-
4.
|
|
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
|
-
###
|
|
50
|
+
### Chaining node_ids across a batch
|
|
50
51
|
|
|
51
|
-
|
|
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
|
-
|
|
54
|
+
Concretely, the response to a batch is:
|
|
54
55
|
```
|
|
55
|
-
{
|
|
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
|
-
|
|
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: "
|
|
65
|
-
{
|
|
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
|
-
**
|
|
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:
|
package/docs/prep-files-guide.md
CHANGED
|
@@ -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
|
-
|
|
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)` —
|
|
14
|
-
- `create_prep_file(name
|
|
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
|
|
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
|
-
|
|
51
|
+
Failure modes the tool layer now blocks (that used to trip LLMs writing raw PGN):
|
|
46
52
|
|
|
47
|
-
- **Unbalanced parentheses
|
|
48
|
-
- **Bad SAN
|
|
49
|
-
- **Forgotten move numbers
|
|
50
|
-
- **Nested variations losing context
|
|
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
|
|
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.
|
|
59
|
-
3. Retry
|
|
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/docs/prep-strategy.md
CHANGED
|
@@ -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)
|
|
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 `
|
|
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 `
|
|
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