@chessceo/mcp 0.39.1 → 0.40.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 +155 -23
- package/dist/pgn/mutations.js +22 -0
- package/dist/pgn/paths.js +32 -0
- package/docs/engine-usage.md +2 -0
- package/docs/pgn-authoring.md +10 -0
- package/docs/prep-strategy.md +7 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -16,8 +16,8 @@ import { Chess } from "chess.js";
|
|
|
16
16
|
import { parsePGN } from "./pgn/parser.js";
|
|
17
17
|
import { exportPGN } from "./pgn/exporter.js";
|
|
18
18
|
import { describePosition } from "./pgn/describe.js";
|
|
19
|
-
import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
20
|
-
import { buildIdIndex, NodeIdError, PathError, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
|
|
19
|
+
import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setCeoEvalMany, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
20
|
+
import { buildFenIndex, buildIdIndex, NodeIdError, PathError, positionKey, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
|
|
21
21
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
23
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -77,6 +77,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
77
77
|
"search_prep_files",
|
|
78
78
|
"read_prep_file",
|
|
79
79
|
"list_nodes",
|
|
80
|
+
"list_transpositions",
|
|
80
81
|
"create_prep_file",
|
|
81
82
|
"delete_prep_file",
|
|
82
83
|
"add_move",
|
|
@@ -573,19 +574,34 @@ const TOOLS = [
|
|
|
573
574
|
" • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
|
|
574
575
|
" • `novelties` — nodes carrying the `$146` NAG.\n" +
|
|
575
576
|
" • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
|
|
577
|
+
" • `transpositions` — nodes that share their position with at least one other node in the same file (piece placement + side to move + castling rights match). Response includes `transposes_to: [node_id, …]` per hit so you can see the partners without a second call. Use this BEFORE auto_evaluate on a large branch to see where analysis will double up, and BEFORE writing prose to know which nodes can share commentary via 'transposes to line X'.\n" +
|
|
576
578
|
" • `all` — every node id. Use only when you really need the whole list.\n\n" +
|
|
577
|
-
"Response: `{ file_id, filter, count, nodes: [{node_id, san, ply, ...}] }`. `...` is filter-specific — e.g. `has_comment` includes the first 80 chars of the comment; `missing_eval` includes nothing extra (just the addressing).",
|
|
579
|
+
"Response: `{ file_id, filter, count, nodes: [{node_id, san, ply, ...}] }`. `...` is filter-specific — e.g. `has_comment` includes the first 80 chars of the comment; `transpositions` includes `transposes_to`; `missing_eval` includes nothing extra (just the addressing).",
|
|
578
580
|
inputSchema: {
|
|
579
581
|
type: "object",
|
|
580
582
|
properties: {
|
|
581
583
|
id: { type: "string", description: "Prep file id." },
|
|
582
|
-
filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "all"], description: "Which nodes to list." },
|
|
584
|
+
filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "transpositions", "all"], description: "Which nodes to list." },
|
|
583
585
|
node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
|
|
584
586
|
max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
|
|
585
587
|
},
|
|
586
588
|
required: ["id", "filter"],
|
|
587
589
|
},
|
|
588
590
|
},
|
|
591
|
+
{
|
|
592
|
+
name: "list_transpositions",
|
|
593
|
+
description: "Group every position in a prep file that appears more than once — the same piece placement + side-to-move + castling rights reached by different move orders. Chess move orders diverge and re-converge constantly (1.d4 Nf6 2.c4 e6 3.Nc3 vs 1.c4 e6 2.Nc3 Nf6 3.d4 land on the same position); if you analyse both branches independently or write the same commentary twice, you're wasting engine time and inviting inconsistency.\n\n" +
|
|
594
|
+
"Call this BEFORE `auto_evaluate` on a big subtree to see how much work will actually be new, and BEFORE writing prose to know which nodes can share a comment or should point at each other with 'transposes to line X'.\n\n" +
|
|
595
|
+
"Note: engine evals auto-propagate — when `cloud_analyse({file_id, node_id})` stores `ceoEval` on a node, it also stamps every transposition of that position in the same file (see the response's `also_stored_on`). And `auto_evaluate({only_missing: true})` naturally skips the twin because it now has an eval. So detection is cheap AND propagation is automatic; this tool is for prose planning and one-shot audits, not for gating engine work.\n\n" +
|
|
596
|
+
"Response: `{ file_id, group_count, node_count, groups: [{ position_key, size, node_ids, sans }] }`. `position_key` is the 3-field FEN prefix used as the match key; `size` is how many nodes share it; `sans` are the moves that led to each occurrence (parallel with `node_ids`, DFS order — first entry is the earliest/mainline-preferred occurrence). Only groups with size ≥ 2 are returned; sorted by size descending.",
|
|
597
|
+
inputSchema: {
|
|
598
|
+
type: "object",
|
|
599
|
+
properties: {
|
|
600
|
+
id: { type: "string", description: "Prep file id." },
|
|
601
|
+
},
|
|
602
|
+
required: ["id"],
|
|
603
|
+
},
|
|
604
|
+
},
|
|
589
605
|
{
|
|
590
606
|
name: "create_prep_file",
|
|
591
607
|
description: "Create a new (empty) prep file. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
|
|
@@ -862,12 +878,14 @@ const TOOLS = [
|
|
|
862
878
|
{
|
|
863
879
|
name: "find_position_in_courses",
|
|
864
880
|
description: "Look up which of the USER's own Chessable / PGN courses cover a position. This is the LLM's window into what the user has personally studied — not a general database. Two-step: `find_position_in_courses` returns metadata (course, chapter, author, updated_at, notes_chars, `course_file_id`); `read_course_at_position` fetches the actual commentary + variations from a specific hit.\n\n" +
|
|
881
|
+
"**Read multiple hits, not just the top one.** A search commonly returns 3-10 courses covering the same position. Different authors recommend different moves, weight lines differently, and disagree about which sidelines matter — that disagreement is exactly the information you want. Default assumption: read the top 3-5 hits by recency, more if the position is critical (novelty candidate, main-line trunk, sharp tactical junction). Reading only the first hit gives you one author's opinion; reading five gives you the actual state of theory as your user's library sees it.\n\n" +
|
|
865
882
|
"Use it as a reference library, not memory. Query patterns:\n" +
|
|
866
|
-
" • 'Does my chosen line have coverage?' → search from the position, see
|
|
867
|
-
" • 'What do opposite-colour repertoires recommend against this move?' → search,
|
|
868
|
-
" • 'Has anyone tried my novelty before?' → search the position, if hits exist read
|
|
883
|
+
" • 'Does my chosen line have coverage?' → search from the position, read multiple hits, see whether the field agrees on the main response.\n" +
|
|
884
|
+
" • 'What do opposite-colour repertoires recommend against this move?' → search, then read every hit whose author/course maps to the other side.\n" +
|
|
885
|
+
" • 'Has anyone tried my novelty before?' → search the position, if hits exist read all of them (a novelty that appears in ONE 2019 course is still a novelty to serious opponents; a novelty covered by three 2025 courses is not).\n" +
|
|
886
|
+
" • 'What are the main disagreements between authors?' → read the top 3-5 hits, diff the recommended moves against each other; if two Chessable authors branch differently at move 8, that's a decision point worth annotating in your own file.\n\n" +
|
|
869
887
|
"Default sort is `recency` (most-recently-updated file first — theory shifts, 10-year-old material is less trustworthy than 2-month-old). Switch to `notes` when you specifically want the deepest annotated chapter regardless of age.\n\n" +
|
|
870
|
-
"Returns: `{fen, found, total_occurrences, sort, excluded, hits: [{course_file_id, course, file, author, chapter, line, ply, notes_chars, subtree_moves, updated_at}], truncated}`. Pass `course_file_id` to `read_course_at_position` to actually see the material.\n\n" +
|
|
888
|
+
"Returns: `{fen, found, total_occurrences, sort, excluded, hits: [{course_file_id, course, file, author, chapter, line, ply, notes_chars, subtree_moves, updated_at}], truncated}`. Pass `course_file_id` to `read_course_at_position` to actually see the material — and pass it more than once, on the top few hits, not just the first one.\n\n" +
|
|
871
889
|
"Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
|
|
872
890
|
inputSchema: {
|
|
873
891
|
type: "object",
|
|
@@ -888,11 +906,12 @@ const TOOLS = [
|
|
|
888
906
|
name: "read_course_at_position",
|
|
889
907
|
description: "Read the actual commentary + variations from a course file at a specific position. Second half of the find→read pair — `find_position_in_courses` returns metadata; this returns the material itself.\n\n" +
|
|
890
908
|
"Response includes the subtree as PGN (comments, NAGs, `[%cal]`/`[%csl]` arrows all preserved), plus the moves-to-position and chapter metadata. Depth-capped by `max_plies_below` (default 20) to keep responses small — widen when you want to see deeper analysis, or call with a different `fen` to jump to another position in the same file.\n\n" +
|
|
909
|
+
"**Called once per search is a smell.** When `find_position_in_courses` returned 5 hits and you only read the first, you have 1 author's view of the position, not a survey. Read the top 3-5 hits by default; compare their recommendations and disagreements — that comparison is the value the user's library provides over your training data.\n\n" +
|
|
891
910
|
"Usage patterns:\n" +
|
|
892
911
|
" • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
|
|
893
912
|
" • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
|
|
894
913
|
" • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
|
|
895
|
-
" • Compare how
|
|
914
|
+
" • **Compare how multiple authors annotate the same position → several calls with different `course_file_id`s (this is the common case, not the exception).** If the top hits recommend different moves, that's a decision point worth annotating with the disagreement itself.",
|
|
896
915
|
inputSchema: {
|
|
897
916
|
type: "object",
|
|
898
917
|
properties: {
|
|
@@ -1221,6 +1240,29 @@ async function autoEvaluate(args) {
|
|
|
1221
1240
|
// If the caller anchored at the root, skip evaluating the root itself
|
|
1222
1241
|
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
1223
1242
|
walk(startNode, startNode.id === ROOT_ID);
|
|
1243
|
+
// Dedup transpositions: if two candidate targets share the same
|
|
1244
|
+
// 3-field FEN key, they're the same position reached by different
|
|
1245
|
+
// move orders. Analyse ONE of them — cloud_analyse auto-propagates
|
|
1246
|
+
// the resulting ceoEval to every other node with a matching key
|
|
1247
|
+
// (see storeEvalOnNode), so the twin ends up with the same eval
|
|
1248
|
+
// without a second engine call. Keep DFS-first (mainline-preferred)
|
|
1249
|
+
// occurrence.
|
|
1250
|
+
let skippedTranspositions = 0;
|
|
1251
|
+
{
|
|
1252
|
+
const seen = new Set();
|
|
1253
|
+
const deduped = [];
|
|
1254
|
+
for (const t of targets) {
|
|
1255
|
+
const key = positionKey(t.fen);
|
|
1256
|
+
if (seen.has(key)) {
|
|
1257
|
+
skippedTranspositions++;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
seen.add(key);
|
|
1261
|
+
deduped.push(t);
|
|
1262
|
+
}
|
|
1263
|
+
targets.length = 0;
|
|
1264
|
+
targets.push(...deduped);
|
|
1265
|
+
}
|
|
1224
1266
|
// Nothing to do → return a done job synthetically so the caller doesn't
|
|
1225
1267
|
// need to special-case the empty response.
|
|
1226
1268
|
if (targets.length === 0) {
|
|
@@ -1264,6 +1306,9 @@ async function autoEvaluate(args) {
|
|
|
1264
1306
|
return {
|
|
1265
1307
|
job_id: jobId,
|
|
1266
1308
|
target_count: targets.length,
|
|
1309
|
+
// Transpositions inside the walk that we skipped because they'll
|
|
1310
|
+
// pick up the eval via auto-propagation. Zero when there are none.
|
|
1311
|
+
skipped_transpositions: skippedTranspositions,
|
|
1267
1312
|
status: "running",
|
|
1268
1313
|
// Rough time estimate at the current default movetime. Serialization
|
|
1269
1314
|
// on the per-combo semaphore means walltime ≈ target_count × movetime.
|
|
@@ -1829,7 +1874,13 @@ async function loadPrepFile(id) {
|
|
|
1829
1874
|
}
|
|
1830
1875
|
// Recursively project a PrepNode into the requested view. `depthLeft`
|
|
1831
1876
|
// null → unlimited; 0 → just the node without children.
|
|
1832
|
-
|
|
1877
|
+
//
|
|
1878
|
+
// `fenIndex` (optional) enables the `transposes_to` field — for each
|
|
1879
|
+
// node whose position also appears elsewhere in the SAME file, we
|
|
1880
|
+
// annotate it with the OTHER occurrences' ids. Pass null (the default)
|
|
1881
|
+
// to skip the annotation entirely; passing the map costs one lookup
|
|
1882
|
+
// per node projected.
|
|
1883
|
+
function projectNode(node, view, depthLeft, fenIndex = null) {
|
|
1833
1884
|
const base = {
|
|
1834
1885
|
id: node.id,
|
|
1835
1886
|
san: node.san,
|
|
@@ -1846,16 +1897,24 @@ function projectNode(node, view, depthLeft) {
|
|
|
1846
1897
|
if (node.annotations)
|
|
1847
1898
|
base.annotations = node.annotations;
|
|
1848
1899
|
}
|
|
1900
|
+
if (fenIndex && node.id !== ROOT_ID) {
|
|
1901
|
+
const group = fenIndex.get(positionKey(node.fen));
|
|
1902
|
+
if (group && group.length > 1) {
|
|
1903
|
+
const others = group.filter(n => n.id !== node.id).map(n => n.id);
|
|
1904
|
+
if (others.length > 0)
|
|
1905
|
+
base.transposes_to = others;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1849
1908
|
// Children handling depends on view + depth budget.
|
|
1850
1909
|
const showChildren = depthLeft === null || depthLeft > 0;
|
|
1851
1910
|
const childDepth = depthLeft === null ? null : depthLeft - 1;
|
|
1852
1911
|
if (showChildren && node.children.length > 0) {
|
|
1853
1912
|
if (view === "spine") {
|
|
1854
1913
|
// Only follow children[0] — collapses the tree to the mainline.
|
|
1855
|
-
base.children = [projectNode(node.children[0], view, childDepth)];
|
|
1914
|
+
base.children = [projectNode(node.children[0], view, childDepth, fenIndex)];
|
|
1856
1915
|
}
|
|
1857
1916
|
else {
|
|
1858
|
-
base.children = node.children.map(c => projectNode(c, view, childDepth));
|
|
1917
|
+
base.children = node.children.map(c => projectNode(c, view, childDepth, fenIndex));
|
|
1859
1918
|
}
|
|
1860
1919
|
}
|
|
1861
1920
|
else {
|
|
@@ -1874,6 +1933,19 @@ async function readPrepFile(args) {
|
|
|
1874
1933
|
const idIndex = buildIdIndex(file.root);
|
|
1875
1934
|
const path = resolveNodeId(idIndex, startNodeId);
|
|
1876
1935
|
const anchor = getNodeByPath(file.root, path);
|
|
1936
|
+
const fenIndex = buildFenIndex(file.root);
|
|
1937
|
+
// How many DISTINCT positions in the file appear more than once,
|
|
1938
|
+
// and how many nodes are involved. Shown in the header so the LLM
|
|
1939
|
+
// sees at a glance whether transpositions matter here before diving
|
|
1940
|
+
// into the tree.
|
|
1941
|
+
let transGroups = 0;
|
|
1942
|
+
let transNodes = 0;
|
|
1943
|
+
for (const arr of fenIndex.values()) {
|
|
1944
|
+
if (arr.length > 1) {
|
|
1945
|
+
transGroups++;
|
|
1946
|
+
transNodes += arr.length;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1877
1949
|
const header = {
|
|
1878
1950
|
id: fileIdEcho ?? id,
|
|
1879
1951
|
version,
|
|
@@ -1881,6 +1953,8 @@ async function readPrepFile(args) {
|
|
|
1881
1953
|
view,
|
|
1882
1954
|
node_id: startNodeId,
|
|
1883
1955
|
max_depth: maxDepth,
|
|
1956
|
+
transposition_groups: transGroups,
|
|
1957
|
+
transposition_nodes: transNodes,
|
|
1884
1958
|
};
|
|
1885
1959
|
if (view === "pgn") {
|
|
1886
1960
|
// For the root, just return the file's actual PGN as-is. For a
|
|
@@ -1894,7 +1968,7 @@ async function readPrepFile(args) {
|
|
|
1894
1968
|
const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
|
|
1895
1969
|
return { ...header, pgn: subtreePgn };
|
|
1896
1970
|
}
|
|
1897
|
-
return { ...header, tree: projectNode(anchor, view, maxDepth) };
|
|
1971
|
+
return { ...header, tree: projectNode(anchor, view, maxDepth, fenIndex) };
|
|
1898
1972
|
}
|
|
1899
1973
|
// Produce a PGN string for a subtree rooted at `anchor`, truncated
|
|
1900
1974
|
// at `maxDepth` plies below (null = unlimited). Reuses the exporter
|
|
@@ -1933,6 +2007,7 @@ async function listNodes(args) {
|
|
|
1933
2007
|
const idIndex = buildIdIndex(file.root);
|
|
1934
2008
|
const path = resolveNodeId(idIndex, startNodeId);
|
|
1935
2009
|
const anchor = getNodeByPath(file.root, path);
|
|
2010
|
+
const fenIndex = filter === "transpositions" ? buildFenIndex(file.root) : null;
|
|
1936
2011
|
const hits = [];
|
|
1937
2012
|
const walk = (node, depthLeft, spineOnly) => {
|
|
1938
2013
|
// Root has no san — never emit it as a match. Everything else is fair game.
|
|
@@ -1960,6 +2035,14 @@ async function listNodes(args) {
|
|
|
1960
2035
|
case "mainline":
|
|
1961
2036
|
include = spineOnly;
|
|
1962
2037
|
break;
|
|
2038
|
+
case "transpositions": {
|
|
2039
|
+
const group = fenIndex.get(positionKey(node.fen));
|
|
2040
|
+
if (group && group.length > 1) {
|
|
2041
|
+
include = true;
|
|
2042
|
+
extra.transposes_to = group.filter(n => n.id !== node.id).map(n => n.id);
|
|
2043
|
+
}
|
|
2044
|
+
break;
|
|
2045
|
+
}
|
|
1963
2046
|
case "all":
|
|
1964
2047
|
include = true;
|
|
1965
2048
|
break;
|
|
@@ -1988,6 +2071,27 @@ async function listNodes(args) {
|
|
|
1988
2071
|
walk(anchor, maxDepth, rootIsSpineForFilter);
|
|
1989
2072
|
return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
|
|
1990
2073
|
}
|
|
2074
|
+
// list_transpositions — every position that occurs 2+ times in the
|
|
2075
|
+
// file, so the LLM knows where its analysis / prose will double up.
|
|
2076
|
+
async function listTranspositions(args) {
|
|
2077
|
+
const id = String(args.id);
|
|
2078
|
+
const { file } = await loadPrepFile(id);
|
|
2079
|
+
const fenIndex = buildFenIndex(file.root);
|
|
2080
|
+
const groups = [];
|
|
2081
|
+
for (const [key, arr] of fenIndex.entries()) {
|
|
2082
|
+
if (arr.length < 2)
|
|
2083
|
+
continue;
|
|
2084
|
+
groups.push({
|
|
2085
|
+
position_key: key,
|
|
2086
|
+
size: arr.length,
|
|
2087
|
+
node_ids: arr.map(n => n.id),
|
|
2088
|
+
sans: arr.map(n => n.san),
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
groups.sort((a, b) => b.size - a.size || a.position_key.localeCompare(b.position_key));
|
|
2092
|
+
const nodeCount = groups.reduce((s, g) => s + g.size, 0);
|
|
2093
|
+
return { file_id: id, group_count: groups.length, node_count: nodeCount, groups };
|
|
2094
|
+
}
|
|
1991
2095
|
// Strip cruft the LLM doesn't need from the DB-position response.
|
|
1992
2096
|
// Called AFTER trimGamesMovetext so plyNumber survives long enough to
|
|
1993
2097
|
// slice each game's movetext. Also renames the `transpositions` field
|
|
@@ -2260,22 +2364,41 @@ function getNodeByPath(root, path) {
|
|
|
2260
2364
|
}
|
|
2261
2365
|
return cur;
|
|
2262
2366
|
}
|
|
2263
|
-
// Persist a fresh ceoEval on the node referenced by the file handle
|
|
2264
|
-
//
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
//
|
|
2367
|
+
// Persist a fresh ceoEval on the node referenced by the file handle
|
|
2368
|
+
// AND on every other node in the same file that transposes to the
|
|
2369
|
+
// same position (matches on the frontend's 3-field FEN key: piece
|
|
2370
|
+
// placement + side to move + castling). Best-effort — if the file
|
|
2371
|
+
// version raced (another agent saved between our GET and our PUT),
|
|
2372
|
+
// we silently drop the store rather than fail the analysis the LLM
|
|
2373
|
+
// actually asked for. The eval is still returned in the response
|
|
2374
|
+
// either way.
|
|
2375
|
+
//
|
|
2376
|
+
// Return: ids of every node the eval was stamped on (empty on error).
|
|
2377
|
+
// The primary node's id is always first (if present).
|
|
2268
2378
|
async function storeEvalOnNode(handle, ev) {
|
|
2269
2379
|
try {
|
|
2270
|
-
const
|
|
2271
|
-
const
|
|
2380
|
+
const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
|
|
2381
|
+
const key = positionKey(anchor.fen);
|
|
2382
|
+
const fenIndex = buildFenIndex(handle.parsedFile.root);
|
|
2383
|
+
const group = fenIndex.get(key) ?? [anchor];
|
|
2384
|
+
// Resolve every transposed node back to its path. cloneOnPath
|
|
2385
|
+
// rebuilds the spine so we need paths, not references — the
|
|
2386
|
+
// id index was built against the original tree and every id in
|
|
2387
|
+
// `group` exists there.
|
|
2388
|
+
const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
|
|
2389
|
+
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
2390
|
+
const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
|
|
2391
|
+
const newPgn = exportPGN(newFile);
|
|
2272
2392
|
await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(handle.id)}`, {
|
|
2273
2393
|
pgn: newPgn,
|
|
2274
2394
|
expected_version: handle.version,
|
|
2275
2395
|
});
|
|
2396
|
+
// Ensure the primary node (the one the LLM addressed) comes first.
|
|
2397
|
+
const anchorId = anchor.id;
|
|
2398
|
+
return [anchorId, ...ids.filter(x => x !== anchorId)];
|
|
2276
2399
|
}
|
|
2277
2400
|
catch {
|
|
2278
|
-
|
|
2401
|
+
return [];
|
|
2279
2402
|
}
|
|
2280
2403
|
}
|
|
2281
2404
|
function stringifyForLog(v) {
|
|
@@ -2512,8 +2635,15 @@ async function callToolInner(name, args) {
|
|
|
2512
2635
|
// was supplied and the eval survives via the [%ceo-eval] escape.
|
|
2513
2636
|
if (resolved.file) {
|
|
2514
2637
|
const ev = analysisToStoredEval(converted);
|
|
2515
|
-
if (ev)
|
|
2516
|
-
await storeEvalOnNode(resolved.file, ev);
|
|
2638
|
+
if (ev) {
|
|
2639
|
+
const stamped = await storeEvalOnNode(resolved.file, ev);
|
|
2640
|
+
if (stamped.length > 1) {
|
|
2641
|
+
// Surface the propagation so the LLM sees exactly which
|
|
2642
|
+
// other nodes now carry this eval (and can skip them for
|
|
2643
|
+
// re-analysis).
|
|
2644
|
+
converted.also_stored_on = stamped.slice(1);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2517
2647
|
}
|
|
2518
2648
|
return converted;
|
|
2519
2649
|
}
|
|
@@ -2535,6 +2665,8 @@ async function callToolInner(name, args) {
|
|
|
2535
2665
|
return readPrepFile(args);
|
|
2536
2666
|
case "list_nodes":
|
|
2537
2667
|
return listNodes(args);
|
|
2668
|
+
case "list_transpositions":
|
|
2669
|
+
return listTranspositions(args);
|
|
2538
2670
|
case "create_prep_file":
|
|
2539
2671
|
return authedRequest("POST", "/api/agent/prep-files", {
|
|
2540
2672
|
name: String(args.name),
|
package/dist/pgn/mutations.js
CHANGED
|
@@ -172,6 +172,28 @@ export function setCeoEval(file, path, ev) {
|
|
|
172
172
|
target.ceoEval = ev;
|
|
173
173
|
return { file: { tags: file.tags, root: newRoot }, id: target.id };
|
|
174
174
|
}
|
|
175
|
+
// Set the same ceoEval on every path in `paths`. One clone-and-return
|
|
176
|
+
// rather than N sequential setCeoEval calls. Used by cloud_analyse to
|
|
177
|
+
// propagate a single measurement to every transposition of the position
|
|
178
|
+
// in the file — the LLM shouldn't have to re-analyse a position it
|
|
179
|
+
// already measured under a different move order.
|
|
180
|
+
export function setCeoEvalMany(file, paths, ev) {
|
|
181
|
+
if (paths.length === 0)
|
|
182
|
+
return { file, ids: [] };
|
|
183
|
+
// Sort deepest-first so cloning one target doesn't invalidate later
|
|
184
|
+
// ones' path references — cloneOnPath re-parents everything along
|
|
185
|
+
// the path, so mutating a shallower path after a deeper one is safe;
|
|
186
|
+
// sorting is defensive.
|
|
187
|
+
const sorted = [...paths].sort((a, b) => b.length - a.length);
|
|
188
|
+
let cur = file;
|
|
189
|
+
const ids = [];
|
|
190
|
+
for (const p of sorted) {
|
|
191
|
+
const step = setCeoEval(cur, p, ev);
|
|
192
|
+
cur = step.file;
|
|
193
|
+
ids.push(step.id);
|
|
194
|
+
}
|
|
195
|
+
return { file: cur, ids };
|
|
196
|
+
}
|
|
175
197
|
// Set or clear a tag. Passing null / empty removes.
|
|
176
198
|
export function setTag(file, key, value) {
|
|
177
199
|
const cleanedKey = key.trim();
|
package/dist/pgn/paths.js
CHANGED
|
@@ -101,6 +101,38 @@ export function getParent(root, path) {
|
|
|
101
101
|
const index = path[path.length - 1];
|
|
102
102
|
return { parent, index };
|
|
103
103
|
}
|
|
104
|
+
// Transposition key: the part of the FEN that decides whether two
|
|
105
|
+
// positions are the same for opening/preparation purposes. Matches the
|
|
106
|
+
// frontend's rule (frontend/src/game/gamestate/services/TreeService.ts
|
|
107
|
+
// fenPositionMatch): piece placement + side to move + castling rights.
|
|
108
|
+
// En-passant square, halfmove clock and fullmove number are excluded —
|
|
109
|
+
// they diverge across move orders that reach the same position, and
|
|
110
|
+
// treating them as significant would defeat the whole point of
|
|
111
|
+
// transposition detection.
|
|
112
|
+
export function positionKey(fen) {
|
|
113
|
+
return fen.split(" ").slice(0, 3).join(" ");
|
|
114
|
+
}
|
|
115
|
+
// Group every node in the tree by transposition key. Returned in DFS
|
|
116
|
+
// order — the FIRST node in each list is the earliest (mainline-preferred)
|
|
117
|
+
// occurrence, which is what the LLM should treat as the canonical anchor
|
|
118
|
+
// for prose ("this transposes to line X"). Groups with only one member
|
|
119
|
+
// are still included, so callers can check membership cheaply; filter
|
|
120
|
+
// for size ≥ 2 to get actual transpositions.
|
|
121
|
+
export function buildFenIndex(root) {
|
|
122
|
+
const index = new Map();
|
|
123
|
+
const walk = (node) => {
|
|
124
|
+
const key = positionKey(node.fen);
|
|
125
|
+
const arr = index.get(key);
|
|
126
|
+
if (arr)
|
|
127
|
+
arr.push(node);
|
|
128
|
+
else
|
|
129
|
+
index.set(key, [node]);
|
|
130
|
+
for (const c of node.children)
|
|
131
|
+
walk(c);
|
|
132
|
+
};
|
|
133
|
+
walk(root);
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
104
136
|
// Deep-clone nodes on the path from root to the mutation target,
|
|
105
137
|
// leaving unrelated subtrees shared. Downstream code treats siblings
|
|
106
138
|
// as immutable so this sharing is safe.
|
package/docs/engine-usage.md
CHANGED
|
@@ -16,6 +16,8 @@ When you call `cloud_analyse`, chess.ceo runs Stockfish and Lc0 in parallel on t
|
|
|
16
16
|
|
|
17
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
18
|
|
|
19
|
+
**Transposition propagation.** `cloud_analyse({file_id, node_id})` also stamps the resulting `ceoEval` on every OTHER node in the same file that reaches the same position by a different move order (3-field FEN match: pieces + side-to-move + castling). The response includes `also_stored_on: [id, id]` when this happens, and a follow-up `quote_engine_eval` on any of those twin nodes returns the same measurement — no second analysis needed. `auto_evaluate` also dedupes candidates by the same key and returns `skipped_transpositions` so you can see how much engine time it saved. See `list_transpositions` / `list_nodes({filter: "transpositions"})` for auditing where duplication exists before you start.
|
|
20
|
+
|
|
19
21
|
**Concrete failure modes to avoid:**
|
|
20
22
|
|
|
21
23
|
- 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
|
@@ -164,6 +164,16 @@ Prose is NEVER for:
|
|
|
164
164
|
|
|
165
165
|
Test: if the reader can see it by looking at the position or clicking a branch, don't write it. Prose is only for plans, prep-signal, or WHY — the layer the app can't derive.
|
|
166
166
|
|
|
167
|
+
## Transpositions: don't analyse (or comment on) the same position twice
|
|
168
|
+
|
|
169
|
+
Move orders diverge and re-converge constantly. `1.d4 Nf6 2.c4 e6 3.Nc3` and `1.c4 e6 2.Nc3 Nf6 3.d4` land on the same position. The tree model doesn't merge those into one node — it stores both nodes with the same position — so if you're not careful you'll analyse both, quote engine numbers on both, and write two different comments for what is the same chess.
|
|
170
|
+
|
|
171
|
+
**Detection.** `read_prep_file` shows `transposes_to: [id, id]` on every node whose position appears elsewhere in the file, and the header carries `transposition_groups` / `transposition_nodes` counts. `list_nodes({filter: "transpositions"})` lists just the affected nodes. `list_transpositions` groups every duplicated position with its member nodes. Match key is the frontend's rule: piece placement + side to move + castling rights (en-passant + clocks intentionally ignored).
|
|
172
|
+
|
|
173
|
+
**Auto-propagation.** When `cloud_analyse({file_id, node_id})` stores `ceoEval` on a node, it also stamps every transposition of that position in the same file — see `also_stored_on: [id, id]` in the response. This means `auto_evaluate({only_missing: true})` naturally skips the twin. `auto_evaluate` also dedupes candidates before it starts and returns `skipped_transpositions` in the initial response so you can see how much engine time was saved.
|
|
174
|
+
|
|
175
|
+
**Prose.** For commentary, pick a canonical node (usually the mainline-order occurrence — first in `transposes_to`) and write the plans / prep-signal / novelty there. On the twin, either point at it (`{transposes to 3.Nc3 in the mainline — see the note there}`) or leave the comment empty. Don't paste the same three sentences on both — they'll drift as you edit one and forget the other.
|
|
176
|
+
|
|
167
177
|
## Move-judgment symbols (NAGs)
|
|
168
178
|
|
|
169
179
|
NAGs are the compact way to attach an evaluation to a move. Pass as `$N` strings to `set_nags`.
|
package/docs/prep-strategy.md
CHANGED
|
@@ -91,16 +91,18 @@ Two-tool workflow:
|
|
|
91
91
|
|
|
92
92
|
Query patterns worth reaching for:
|
|
93
93
|
|
|
94
|
-
- **"Does my chosen line have coverage?"** — search from the target position. If several recent courses cover it, read the top
|
|
95
|
-
- **"What do opposite-colour repertoires recommend against this move?"** — same position, look at hits authored for the OTHER side. That's the LLM's window into "what will opponents have been told to play here".
|
|
96
|
-
- **"Has anyone tried my novelty before?"** — search the post-novelty position. Zero hits = genuine novelty (good). Hits = it's been tried; read
|
|
97
|
-
- **"Compare how
|
|
94
|
+
- **"Does my chosen line have coverage?"** — search from the target position. If several recent courses cover it, read the top 3-5 hits and see how much they agree; if they diverge, the position is a decision point worth annotating with the disagreement itself.
|
|
95
|
+
- **"What do opposite-colour repertoires recommend against this move?"** — same position, look at hits authored for the OTHER side. That's the LLM's window into "what will opponents have been told to play here". Read every same-side hit — a repertoire that appears in one course may still be someone's line.
|
|
96
|
+
- **"Has anyone tried my novelty before?"** — search the post-novelty position. Zero hits = genuine novelty (good). Hits = it's been tried; **read all of them**, not just the first — a novelty covered by three 2025 courses is not a novelty, but one that appears only in a single 2019 course probably still is.
|
|
97
|
+
- **"Compare how multiple authors annotate the same critical position"** — several `read_course_at_position` calls with different `course_file_id`s from the same search. This is the common case, not a special one.
|
|
98
|
+
|
|
99
|
+
**Read multiple hits, not just the top one.** The default action after `find_position_in_courses` returns 5 hits is FIVE `read_course_at_position` calls, not one. Different Chessable authors recommend different moves at critical junctions, and that disagreement is exactly the signal your user needs — it tells them where prep depth actually matters versus where the field is unanimous. Reading only the top hit gives the LLM one author's opinion presented as consensus; reading five gives the actual state of theory as the library sees it. The only excuse to stop after one is that the position is trivial (mainline endgame draw, hyper-quiet) and there's nothing to disagree about.
|
|
98
100
|
|
|
99
101
|
Discipline:
|
|
100
102
|
|
|
101
103
|
- Course material is a signal, not truth. A course written 2019 may be objectively refuted by current engine analysis; cross-check with `cloud_analyse` before adopting a course's recommendation wholesale.
|
|
102
104
|
- Recency ranking matters most in fast-moving lines (Najdorf, KID, Grünfeld) and less in stable structures (Caro-Kann, Slav mainlines). Weight accordingly.
|
|
103
|
-
- Cite the
|
|
105
|
+
- Cite the sources when you use them: `{Ganguly (Reinventing the Ragozin, 2025) recommends 12.Rd1; Aagaard (Attacking Manual II) prefers 12.a3 — decide based on what you're comfortable with.}` Naming multiple authors when they disagree gives the reader something to act on; naming one when others also cover the position understates the picture.
|
|
104
106
|
|
|
105
107
|
Not available if fenfind isn't installed on the server; both tools respond with `status: "not_available"` in that case.
|
|
106
108
|
|
package/package.json
CHANGED