@chessceo/mcp 0.39.1 → 0.42.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 +344 -37
- package/dist/pgn/mutations.js +22 -0
- package/dist/pgn/paths.js +32 -0
- package/docs/engine-usage.md +13 -0
- package/docs/pgn-authoring.md +80 -5
- 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",
|
|
@@ -479,6 +480,7 @@ const TOOLS = [
|
|
|
479
480
|
"• When they agree → high confidence. When they disagree → look at both scores and reason WHY (Stockfish sharply higher = tactic Lc0 missed; Lc0 higher = long-term positional edge past Stockfish's horizon). Never dismiss either — the disagreement is the signal.\n\n" +
|
|
480
481
|
"Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Signed 0-100 strength — same scale as the web UI's ContemptStrength slider (the server multiplies by 8 to produce Lc0's internal cp bias). Typical values: ±15 for a light nudge, ±30-60 for real fighting play, ±80-100 for maximum steer. Use it to find non-objective 'practical' ideas or when the user needs to lean toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
|
|
481
482
|
"Also useful: pass `moves` on top of `fen` to explore a variation without computing FENs yourself (e.g. fen='<tabiya>', moves='b4 a5 c3'). And the flip-side-to-move threat check documented in the guide is a great free trick.\n\n" +
|
|
483
|
+
"**PVs are capped at 6 plies by default (3 full moves), and lines that got truncated are marked with `pv_truncated: true`.** This is deliberate: the tail of a PV is where the engine's confidence collapses, AND pasting a long PV into `add_line` as if it were prepared repertoire is the #1 documented anti-pattern of this MCP — a 15-move PV is one line of engine output through positions where both sides had real choices, not a repertoire. To see further, don't raise `pv_max_plies`; instead, walk the tree one branch at a time with a fresh `cloud_analyse` at each position where the opponent has real alternatives — that's what makes it prep instead of pasted output. Only raise the cap when you're verifying a forcing sequence (a mate, a forced tactical resolution), not to build lines.\n\n" +
|
|
482
484
|
"For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
|
|
483
485
|
"Not for casual questions — this costs real money per second. Use `get_position_stats` for anything that doesn't require deep prep.\n\n" +
|
|
484
486
|
"**When called with `file_id`+`node_id` (preferred inside a prep file), the resulting eval is auto-stored on that node's `ceoEval` — you can then quote it with quote_engine_eval on any later call.** This is what makes engine attribution trustworthy: prose that says 'engines say X on node Y' can only be true if a call was actually made against node_id=Y.",
|
|
@@ -521,6 +523,12 @@ const TOOLS = [
|
|
|
521
523
|
items: { type: "string", enum: ["stockfish", "lc0"] },
|
|
522
524
|
description: "Which engines to run. Default = both. Use `[\"lc0\"]` to skip Stockfish (e.g. while a deep_analyse job is holding the SF slot on the same combo). Use `[\"stockfish\"]` when only the objective read matters. The skipped engine's field is omitted from the response.",
|
|
523
525
|
},
|
|
526
|
+
pv_max_plies: {
|
|
527
|
+
type: "integer",
|
|
528
|
+
minimum: 1,
|
|
529
|
+
maximum: 40,
|
|
530
|
+
description: "Cap each returned PV to this many plies (default 6 = 3 full moves). PVs beyond ~6 plies are speculative and are the anti-pattern behind pasted-engine-line 'prep' — don't raise unless you're specifically checking a forcing tactic or verifying a mate. When a line was truncated, the response marks it with `pv_truncated: true`.",
|
|
531
|
+
},
|
|
524
532
|
},
|
|
525
533
|
},
|
|
526
534
|
},
|
|
@@ -573,19 +581,34 @@ const TOOLS = [
|
|
|
573
581
|
" • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
|
|
574
582
|
" • `novelties` — nodes carrying the `$146` NAG.\n" +
|
|
575
583
|
" • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
|
|
584
|
+
" • `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
585
|
" • `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).",
|
|
586
|
+
"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
587
|
inputSchema: {
|
|
579
588
|
type: "object",
|
|
580
589
|
properties: {
|
|
581
590
|
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." },
|
|
591
|
+
filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "transpositions", "all"], description: "Which nodes to list." },
|
|
583
592
|
node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
|
|
584
593
|
max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
|
|
585
594
|
},
|
|
586
595
|
required: ["id", "filter"],
|
|
587
596
|
},
|
|
588
597
|
},
|
|
598
|
+
{
|
|
599
|
+
name: "list_transpositions",
|
|
600
|
+
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" +
|
|
601
|
+
"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" +
|
|
602
|
+
"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" +
|
|
603
|
+
"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.",
|
|
604
|
+
inputSchema: {
|
|
605
|
+
type: "object",
|
|
606
|
+
properties: {
|
|
607
|
+
id: { type: "string", description: "Prep file id." },
|
|
608
|
+
},
|
|
609
|
+
required: ["id"],
|
|
610
|
+
},
|
|
611
|
+
},
|
|
589
612
|
{
|
|
590
613
|
name: "create_prep_file",
|
|
591
614
|
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" +
|
|
@@ -630,7 +653,8 @@ const TOOLS = [
|
|
|
630
653
|
{
|
|
631
654
|
name: "add_line",
|
|
632
655
|
description: "Append a linear sequence of moves under `parent_id`. Each SAN in the list becomes the mainline child of the previous — one call instead of N add_move calls for a straight variation. If the parent already has other children, this whole line is appended as a variation (promote_variation the first move if you want it as the mainline).\n\n" +
|
|
633
|
-
"
|
|
656
|
+
"**Anti-pattern: pasting an engine PV as a single long `add_line`.** Real prep is a tree, not a line. Almost every position along a variation has more than one plausible move — pasting a 12+-ply engine PV without branching at those points is the #1 documented failure mode of this MCP: it produces a page that reads as prep but ignores every decision the opponent actually gets to make. Long unbranched lines get a warning field in the response starting at ~9 plies and a strong warning at 14+ plies. Rule of thumb: if you added ≥8 plies in one call, at least half of them should have branched. Genuine exceptions exist (forced mates, obligated exchange sequences) — in those cases add a comment naming what makes the sequence forced (`{Every move here is forced by the mate threat.}`), so the reader knows it's forced by chess, not by LLM laziness.\n\n" +
|
|
657
|
+
"Auto-saves. Returns `{node_id, line: [{node_id, san}, ...], version}` — `node_id` is the last (leaf) node's id, `line` is every node created in order so you can address any of them next. When long-and-linear, also includes `warning: \"...\"`.",
|
|
634
658
|
inputSchema: {
|
|
635
659
|
type: "object",
|
|
636
660
|
properties: {
|
|
@@ -862,12 +886,14 @@ const TOOLS = [
|
|
|
862
886
|
{
|
|
863
887
|
name: "find_position_in_courses",
|
|
864
888
|
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" +
|
|
889
|
+
"**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
890
|
"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
|
|
891
|
+
" • 'Does my chosen line have coverage?' → search from the position, read multiple hits, see whether the field agrees on the main response.\n" +
|
|
892
|
+
" • 'What do opposite-colour repertoires recommend against this move?' → search, then read every hit whose author/course maps to the other side.\n" +
|
|
893
|
+
" • '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" +
|
|
894
|
+
" • '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
895
|
"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" +
|
|
896
|
+
"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
897
|
"Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
|
|
872
898
|
inputSchema: {
|
|
873
899
|
type: "object",
|
|
@@ -888,11 +914,12 @@ const TOOLS = [
|
|
|
888
914
|
name: "read_course_at_position",
|
|
889
915
|
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
916
|
"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" +
|
|
917
|
+
"**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
918
|
"Usage patterns:\n" +
|
|
892
919
|
" • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
|
|
893
920
|
" • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
|
|
894
921
|
" • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
|
|
895
|
-
" • Compare how
|
|
922
|
+
" • **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
923
|
inputSchema: {
|
|
897
924
|
type: "object",
|
|
898
925
|
properties: {
|
|
@@ -1051,6 +1078,88 @@ async function fetchCompactEval(fen) {
|
|
|
1051
1078
|
}
|
|
1052
1079
|
// Rewrite the /api/agent/cloud-engines/analyse response (two engines,
|
|
1053
1080
|
// each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
|
|
1081
|
+
// Session-lifetime memory of which positions the LLM has actually asked
|
|
1082
|
+
// the DB about via `get_position_stats`. Keyed by the 3-field FEN
|
|
1083
|
+
// (piece placement + side to move + castling — same key used for
|
|
1084
|
+
// transposition detection). Used to warn on `add_move` / `add_line` under
|
|
1085
|
+
// a parent the LLM never DB-checked, which is the exact shape of the
|
|
1086
|
+
// bug where the LLM read course chapters and cargo-culted a "mainline"
|
|
1087
|
+
// that the actual games at the position don't play.
|
|
1088
|
+
//
|
|
1089
|
+
// One MCP server process per user, so this Set is effectively per-user
|
|
1090
|
+
// for the length of a session. Not persisted — a new session starts empty.
|
|
1091
|
+
const positionsStatsChecked = new Set();
|
|
1092
|
+
// Nodes we've ALREADY warned on for the "no stats check" pattern this
|
|
1093
|
+
// session, so repeated adds under the same parent don't spam the LLM.
|
|
1094
|
+
const noStatsWarned = new Set();
|
|
1095
|
+
// Detect the anti-patterns the LLM keeps producing in comment prose.
|
|
1096
|
+
// All of these restate what the app already renders elsewhere:
|
|
1097
|
+
// - spread lists ("5.O-O ≈50, 6.h3 ≈42, ...")
|
|
1098
|
+
// - raw centipawn values in prose ("≈-60", "+0.35", "at depth 24")
|
|
1099
|
+
// - long roster restatement ("146 GM games, Nakamura, Kramnik, MVL")
|
|
1100
|
+
// Return an array of warning strings — one per matched category — so the
|
|
1101
|
+
// LLM sees exactly which pattern to remove.
|
|
1102
|
+
function commentAntiPatterns(comment) {
|
|
1103
|
+
if (!comment || typeof comment !== "string")
|
|
1104
|
+
return [];
|
|
1105
|
+
const warns = [];
|
|
1106
|
+
// Spread list: ≈ followed by a 2-3-digit number, appearing 2+ times
|
|
1107
|
+
// (one appearance is a stray, two+ is a comma-separated spread the LLM
|
|
1108
|
+
// pasted from stats output).
|
|
1109
|
+
const spreadMatches = comment.match(/≈\s*[+\-−]?\d{1,3}/g) ?? [];
|
|
1110
|
+
if (spreadMatches.length >= 2) {
|
|
1111
|
+
warns.push("comment contains a spread list (≈ + counts) — the DB viewer already shows sibling counts and fashion scores next to every move, so this is doubled noise. Name the character of the choice instead (\"solid vs sharp\", \"old vs fashionable\") or drop the numbers.");
|
|
1112
|
+
}
|
|
1113
|
+
// Raw centipawn in prose: "+0.35", "-0.20", "+80" (not preceded by move
|
|
1114
|
+
// number). Also "at depth N" or "N nodes" — engine metadata as prose.
|
|
1115
|
+
if (/(?:^|[^\d.])[+-]\d\.\d\d(?!\d)/.test(comment) || /≈\s*[+\-−]?\d{2,3}\b/.test(comment) ||
|
|
1116
|
+
/\bat depth \d+\b/i.test(comment) || /\b\d{2,3}M nodes\b/.test(comment)) {
|
|
1117
|
+
warns.push("comment contains raw centipawn values or engine metadata — the app renders ceoEval + NAG glyph next to every node, so these numbers are doubled noise AND opaque (readers can't tell if ≈-60 means eval, spread, or something else). Set the NAG (set_nags) and let the glyph carry the judgment; drop the number from the prose.");
|
|
1118
|
+
}
|
|
1119
|
+
// Roster: "N GM games" pattern
|
|
1120
|
+
if (/\b\d{2,4}\s+GM games\b/i.test(comment)) {
|
|
1121
|
+
warns.push("comment restates game count — the app shows the count on hover. Either cite a specific game with signal (\"Caruana-Liang, Superbet 2026\") or drop the number.");
|
|
1122
|
+
}
|
|
1123
|
+
return warns;
|
|
1124
|
+
}
|
|
1125
|
+
// Return a warning string when an add_line is suspiciously long-and-linear
|
|
1126
|
+
// (the anti-pattern: LLM pastes a 15-ply engine PV into a single add_line
|
|
1127
|
+
// call as if it were prepared repertoire). Two thresholds so the message
|
|
1128
|
+
// escalates — a 10-ply Berlin mainline is fine, a 20-ply LLM extrapolation
|
|
1129
|
+
// almost never is. Threshold applies at the CALL level, not against
|
|
1130
|
+
// existing tree depth — the anti-pattern is a single tool call adding
|
|
1131
|
+
// many plies at once with no user thought about where the branching should
|
|
1132
|
+
// live.
|
|
1133
|
+
function longLineWarning(sansLength) {
|
|
1134
|
+
if (sansLength >= 14) {
|
|
1135
|
+
return `you added ${sansLength} plies in one call without branching — this is the shape of a pasted engine PV, not a repertoire. Real prep branches at every ply where the opponent has meaningful alternatives. Either (a) delete the tail and rebuild with add_move at each decision point, calling cloud_analyse + get_position_stats to see what actually gets played, or (b) if this really is one forcing sequence (mate combination, tactical winner), add a comment naming what makes it forced. Long unbranched lines with no comment default to "engine PV pasted as prep" in the reader's eyes.`;
|
|
1136
|
+
}
|
|
1137
|
+
if (sansLength >= 9) {
|
|
1138
|
+
return `${sansLength}-ply linear line — check that every ply is a genuine only-move or a documented mainline. If the opponent has real alternatives at any ply (get_position_stats would show 2+ moves with meaningful frequency), that ply should branch instead. Prep is a tree, not a line.`;
|
|
1139
|
+
}
|
|
1140
|
+
return undefined;
|
|
1141
|
+
}
|
|
1142
|
+
// Trim every PV in a converted cloud-analyse response to `maxPlies`
|
|
1143
|
+
// and mark each trimmed line with `pv_truncated: true` so the LLM
|
|
1144
|
+
// sees what happened. Applied ONLY to cloud_analyse (short synchronous
|
|
1145
|
+
// snapshot); deep_analyse is the explicit "give me the deep line"
|
|
1146
|
+
// tool and keeps its full PV.
|
|
1147
|
+
function capPvsInResponse(converted, maxPlies) {
|
|
1148
|
+
if (!converted || typeof converted !== "object")
|
|
1149
|
+
return;
|
|
1150
|
+
const r = converted;
|
|
1151
|
+
for (const eng of [r.stockfish, r.lc0]) {
|
|
1152
|
+
if (!eng || !Array.isArray(eng.lines))
|
|
1153
|
+
continue;
|
|
1154
|
+
for (const line of eng.lines) {
|
|
1155
|
+
if (Array.isArray(line.pv) && line.pv.length > maxPlies) {
|
|
1156
|
+
line.pv = line.pv.slice(0, maxPlies);
|
|
1157
|
+
line.pv_truncated = true;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
converted.pv_max_plies = maxPlies;
|
|
1162
|
+
}
|
|
1054
1163
|
function convertCloudSnapshotResponse(raw, startFen) {
|
|
1055
1164
|
if (!raw || typeof raw !== "object")
|
|
1056
1165
|
return raw;
|
|
@@ -1096,17 +1205,33 @@ function dispatchMutation(file, idIndex, op) {
|
|
|
1096
1205
|
};
|
|
1097
1206
|
const resolve = (id) => resolveNodeId(idIndex, id);
|
|
1098
1207
|
switch (kind) {
|
|
1099
|
-
case "add_move":
|
|
1100
|
-
|
|
1208
|
+
case "add_move": {
|
|
1209
|
+
const parentPath = resolve(nodeIdField("parent_id"));
|
|
1210
|
+
const parent = getNodeByPath(file.root, parentPath);
|
|
1211
|
+
const noStatsWarn = noStatsCheckWarning(parent);
|
|
1212
|
+
const step = addMove(file, parentPath, String(op.san));
|
|
1213
|
+
return { ...step, ...(noStatsWarn ? { warning: noStatsWarn } : {}) };
|
|
1214
|
+
}
|
|
1101
1215
|
case "add_line": {
|
|
1102
1216
|
const sans = Array.isArray(op.sans) ? op.sans.map(String) : [];
|
|
1103
1217
|
const parentPath = resolve(nodeIdField("parent_id"));
|
|
1218
|
+
const parent = getNodeByPath(file.root, parentPath);
|
|
1104
1219
|
const step = addLine(file, parentPath, sans);
|
|
1105
1220
|
const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : nodeIdField("parent_id");
|
|
1106
|
-
|
|
1221
|
+
// Same anti-pattern warnings as the standalone add_line case —
|
|
1222
|
+
// long unbranched line + no-stats-check parent are both bugs
|
|
1223
|
+
// whether they land solo or inside a batch.
|
|
1224
|
+
const longLineWarn = longLineWarning(sans.length);
|
|
1225
|
+
const noStatsWarn = noStatsCheckWarning(parent);
|
|
1226
|
+
const warnings = [longLineWarn, noStatsWarn].filter((s) => !!s);
|
|
1227
|
+
return { file: step.file, id: lastId, results: step.line, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
1228
|
+
}
|
|
1229
|
+
case "set_comment": {
|
|
1230
|
+
const commentStr = typeof op.comment === "string" ? op.comment : "";
|
|
1231
|
+
const commentWarns = commentAntiPatterns(commentStr);
|
|
1232
|
+
const step = setComment(file, resolve(nodeIdField("node_id")), commentStr);
|
|
1233
|
+
return { ...step, ...(commentWarns.length > 0 ? { warnings: commentWarns } : {}) };
|
|
1107
1234
|
}
|
|
1108
|
-
case "set_comment":
|
|
1109
|
-
return setComment(file, resolve(nodeIdField("node_id")), typeof op.comment === "string" ? op.comment : "");
|
|
1110
1235
|
case "set_nags":
|
|
1111
1236
|
return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
|
|
1112
1237
|
case "set_annotations": {
|
|
@@ -1151,7 +1276,12 @@ async function applyBatchMutations(args) {
|
|
|
1151
1276
|
const step = dispatchMutation(file, idIndex, op);
|
|
1152
1277
|
file = step.file;
|
|
1153
1278
|
idIndex = buildIdIndex(file.root);
|
|
1154
|
-
results.push({
|
|
1279
|
+
results.push({
|
|
1280
|
+
node_id: step.id,
|
|
1281
|
+
...(step.results !== undefined ? { line: step.results } : {}),
|
|
1282
|
+
...(step.warning ? { warning: step.warning } : {}),
|
|
1283
|
+
...(step.warnings && step.warnings.length > 0 ? { warnings: step.warnings } : {}),
|
|
1284
|
+
});
|
|
1155
1285
|
}
|
|
1156
1286
|
catch (err) {
|
|
1157
1287
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1221,6 +1351,29 @@ async function autoEvaluate(args) {
|
|
|
1221
1351
|
// If the caller anchored at the root, skip evaluating the root itself
|
|
1222
1352
|
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
1223
1353
|
walk(startNode, startNode.id === ROOT_ID);
|
|
1354
|
+
// Dedup transpositions: if two candidate targets share the same
|
|
1355
|
+
// 3-field FEN key, they're the same position reached by different
|
|
1356
|
+
// move orders. Analyse ONE of them — cloud_analyse auto-propagates
|
|
1357
|
+
// the resulting ceoEval to every other node with a matching key
|
|
1358
|
+
// (see storeEvalOnNode), so the twin ends up with the same eval
|
|
1359
|
+
// without a second engine call. Keep DFS-first (mainline-preferred)
|
|
1360
|
+
// occurrence.
|
|
1361
|
+
let skippedTranspositions = 0;
|
|
1362
|
+
{
|
|
1363
|
+
const seen = new Set();
|
|
1364
|
+
const deduped = [];
|
|
1365
|
+
for (const t of targets) {
|
|
1366
|
+
const key = positionKey(t.fen);
|
|
1367
|
+
if (seen.has(key)) {
|
|
1368
|
+
skippedTranspositions++;
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
seen.add(key);
|
|
1372
|
+
deduped.push(t);
|
|
1373
|
+
}
|
|
1374
|
+
targets.length = 0;
|
|
1375
|
+
targets.push(...deduped);
|
|
1376
|
+
}
|
|
1224
1377
|
// Nothing to do → return a done job synthetically so the caller doesn't
|
|
1225
1378
|
// need to special-case the empty response.
|
|
1226
1379
|
if (targets.length === 0) {
|
|
@@ -1264,6 +1417,9 @@ async function autoEvaluate(args) {
|
|
|
1264
1417
|
return {
|
|
1265
1418
|
job_id: jobId,
|
|
1266
1419
|
target_count: targets.length,
|
|
1420
|
+
// Transpositions inside the walk that we skipped because they'll
|
|
1421
|
+
// pick up the eval via auto-propagation. Zero when there are none.
|
|
1422
|
+
skipped_transpositions: skippedTranspositions,
|
|
1267
1423
|
status: "running",
|
|
1268
1424
|
// Rough time estimate at the current default movetime. Serialization
|
|
1269
1425
|
// on the per-combo semaphore means walltime ≈ target_count × movetime.
|
|
@@ -1817,9 +1973,29 @@ async function applyMutation(args, mutator) {
|
|
|
1817
1973
|
ok: true,
|
|
1818
1974
|
node_id: result.id,
|
|
1819
1975
|
...(result.results !== undefined ? { line: result.results } : {}),
|
|
1976
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
1977
|
+
...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
1820
1978
|
version: savedRow.version,
|
|
1821
1979
|
};
|
|
1822
1980
|
}
|
|
1981
|
+
// Compute the "you never DB-checked this parent" warning. Called from
|
|
1982
|
+
// add_move / add_line handlers with the parent node. Returns undefined
|
|
1983
|
+
// when either (a) the parent was checked this session (or is root — the
|
|
1984
|
+
// starting position doesn't need a DB check), (b) we already warned on
|
|
1985
|
+
// this parent (dedup so building a big branching subtree isn't spammy),
|
|
1986
|
+
// or (c) the mutator is running against a parent whose position has a
|
|
1987
|
+
// stored ceoEval (implies the LLM has done SOME analytical work here).
|
|
1988
|
+
function noStatsCheckWarning(parent) {
|
|
1989
|
+
if (parent.id === ROOT_ID)
|
|
1990
|
+
return undefined;
|
|
1991
|
+
const key = positionKey(parent.fen);
|
|
1992
|
+
if (positionsStatsChecked.has(key))
|
|
1993
|
+
return undefined;
|
|
1994
|
+
if (noStatsWarned.has(parent.id))
|
|
1995
|
+
return undefined;
|
|
1996
|
+
noStatsWarned.add(parent.id);
|
|
1997
|
+
return `no get_position_stats call for the parent (id=${parent.id}, ${parent.san}) this session. Course chapter titles describe what an author chose to cover, not what practical opponents play — treating "the So chapter says 6.O-O-O" as "the mainline is 6.O-O-O" is the exact pattern this warning exists to catch. Call get_position_stats at this position (via file_id+node_id=${parent.id}) BEFORE deciding which branches belong here; suppress this warning by making that call. Warned once per parent per session.`;
|
|
1998
|
+
}
|
|
1823
1999
|
async function loadPrepFile(id) {
|
|
1824
2000
|
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
1825
2001
|
const g = raw;
|
|
@@ -1829,7 +2005,13 @@ async function loadPrepFile(id) {
|
|
|
1829
2005
|
}
|
|
1830
2006
|
// Recursively project a PrepNode into the requested view. `depthLeft`
|
|
1831
2007
|
// null → unlimited; 0 → just the node without children.
|
|
1832
|
-
|
|
2008
|
+
//
|
|
2009
|
+
// `fenIndex` (optional) enables the `transposes_to` field — for each
|
|
2010
|
+
// node whose position also appears elsewhere in the SAME file, we
|
|
2011
|
+
// annotate it with the OTHER occurrences' ids. Pass null (the default)
|
|
2012
|
+
// to skip the annotation entirely; passing the map costs one lookup
|
|
2013
|
+
// per node projected.
|
|
2014
|
+
function projectNode(node, view, depthLeft, fenIndex = null) {
|
|
1833
2015
|
const base = {
|
|
1834
2016
|
id: node.id,
|
|
1835
2017
|
san: node.san,
|
|
@@ -1846,16 +2028,24 @@ function projectNode(node, view, depthLeft) {
|
|
|
1846
2028
|
if (node.annotations)
|
|
1847
2029
|
base.annotations = node.annotations;
|
|
1848
2030
|
}
|
|
2031
|
+
if (fenIndex && node.id !== ROOT_ID) {
|
|
2032
|
+
const group = fenIndex.get(positionKey(node.fen));
|
|
2033
|
+
if (group && group.length > 1) {
|
|
2034
|
+
const others = group.filter(n => n.id !== node.id).map(n => n.id);
|
|
2035
|
+
if (others.length > 0)
|
|
2036
|
+
base.transposes_to = others;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
1849
2039
|
// Children handling depends on view + depth budget.
|
|
1850
2040
|
const showChildren = depthLeft === null || depthLeft > 0;
|
|
1851
2041
|
const childDepth = depthLeft === null ? null : depthLeft - 1;
|
|
1852
2042
|
if (showChildren && node.children.length > 0) {
|
|
1853
2043
|
if (view === "spine") {
|
|
1854
2044
|
// Only follow children[0] — collapses the tree to the mainline.
|
|
1855
|
-
base.children = [projectNode(node.children[0], view, childDepth)];
|
|
2045
|
+
base.children = [projectNode(node.children[0], view, childDepth, fenIndex)];
|
|
1856
2046
|
}
|
|
1857
2047
|
else {
|
|
1858
|
-
base.children = node.children.map(c => projectNode(c, view, childDepth));
|
|
2048
|
+
base.children = node.children.map(c => projectNode(c, view, childDepth, fenIndex));
|
|
1859
2049
|
}
|
|
1860
2050
|
}
|
|
1861
2051
|
else {
|
|
@@ -1874,6 +2064,19 @@ async function readPrepFile(args) {
|
|
|
1874
2064
|
const idIndex = buildIdIndex(file.root);
|
|
1875
2065
|
const path = resolveNodeId(idIndex, startNodeId);
|
|
1876
2066
|
const anchor = getNodeByPath(file.root, path);
|
|
2067
|
+
const fenIndex = buildFenIndex(file.root);
|
|
2068
|
+
// How many DISTINCT positions in the file appear more than once,
|
|
2069
|
+
// and how many nodes are involved. Shown in the header so the LLM
|
|
2070
|
+
// sees at a glance whether transpositions matter here before diving
|
|
2071
|
+
// into the tree.
|
|
2072
|
+
let transGroups = 0;
|
|
2073
|
+
let transNodes = 0;
|
|
2074
|
+
for (const arr of fenIndex.values()) {
|
|
2075
|
+
if (arr.length > 1) {
|
|
2076
|
+
transGroups++;
|
|
2077
|
+
transNodes += arr.length;
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
1877
2080
|
const header = {
|
|
1878
2081
|
id: fileIdEcho ?? id,
|
|
1879
2082
|
version,
|
|
@@ -1881,6 +2084,8 @@ async function readPrepFile(args) {
|
|
|
1881
2084
|
view,
|
|
1882
2085
|
node_id: startNodeId,
|
|
1883
2086
|
max_depth: maxDepth,
|
|
2087
|
+
transposition_groups: transGroups,
|
|
2088
|
+
transposition_nodes: transNodes,
|
|
1884
2089
|
};
|
|
1885
2090
|
if (view === "pgn") {
|
|
1886
2091
|
// For the root, just return the file's actual PGN as-is. For a
|
|
@@ -1894,7 +2099,7 @@ async function readPrepFile(args) {
|
|
|
1894
2099
|
const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
|
|
1895
2100
|
return { ...header, pgn: subtreePgn };
|
|
1896
2101
|
}
|
|
1897
|
-
return { ...header, tree: projectNode(anchor, view, maxDepth) };
|
|
2102
|
+
return { ...header, tree: projectNode(anchor, view, maxDepth, fenIndex) };
|
|
1898
2103
|
}
|
|
1899
2104
|
// Produce a PGN string for a subtree rooted at `anchor`, truncated
|
|
1900
2105
|
// at `maxDepth` plies below (null = unlimited). Reuses the exporter
|
|
@@ -1933,6 +2138,7 @@ async function listNodes(args) {
|
|
|
1933
2138
|
const idIndex = buildIdIndex(file.root);
|
|
1934
2139
|
const path = resolveNodeId(idIndex, startNodeId);
|
|
1935
2140
|
const anchor = getNodeByPath(file.root, path);
|
|
2141
|
+
const fenIndex = filter === "transpositions" ? buildFenIndex(file.root) : null;
|
|
1936
2142
|
const hits = [];
|
|
1937
2143
|
const walk = (node, depthLeft, spineOnly) => {
|
|
1938
2144
|
// Root has no san — never emit it as a match. Everything else is fair game.
|
|
@@ -1960,6 +2166,14 @@ async function listNodes(args) {
|
|
|
1960
2166
|
case "mainline":
|
|
1961
2167
|
include = spineOnly;
|
|
1962
2168
|
break;
|
|
2169
|
+
case "transpositions": {
|
|
2170
|
+
const group = fenIndex.get(positionKey(node.fen));
|
|
2171
|
+
if (group && group.length > 1) {
|
|
2172
|
+
include = true;
|
|
2173
|
+
extra.transposes_to = group.filter(n => n.id !== node.id).map(n => n.id);
|
|
2174
|
+
}
|
|
2175
|
+
break;
|
|
2176
|
+
}
|
|
1963
2177
|
case "all":
|
|
1964
2178
|
include = true;
|
|
1965
2179
|
break;
|
|
@@ -1988,6 +2202,27 @@ async function listNodes(args) {
|
|
|
1988
2202
|
walk(anchor, maxDepth, rootIsSpineForFilter);
|
|
1989
2203
|
return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
|
|
1990
2204
|
}
|
|
2205
|
+
// list_transpositions — every position that occurs 2+ times in the
|
|
2206
|
+
// file, so the LLM knows where its analysis / prose will double up.
|
|
2207
|
+
async function listTranspositions(args) {
|
|
2208
|
+
const id = String(args.id);
|
|
2209
|
+
const { file } = await loadPrepFile(id);
|
|
2210
|
+
const fenIndex = buildFenIndex(file.root);
|
|
2211
|
+
const groups = [];
|
|
2212
|
+
for (const [key, arr] of fenIndex.entries()) {
|
|
2213
|
+
if (arr.length < 2)
|
|
2214
|
+
continue;
|
|
2215
|
+
groups.push({
|
|
2216
|
+
position_key: key,
|
|
2217
|
+
size: arr.length,
|
|
2218
|
+
node_ids: arr.map(n => n.id),
|
|
2219
|
+
sans: arr.map(n => n.san),
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
groups.sort((a, b) => b.size - a.size || a.position_key.localeCompare(b.position_key));
|
|
2223
|
+
const nodeCount = groups.reduce((s, g) => s + g.size, 0);
|
|
2224
|
+
return { file_id: id, group_count: groups.length, node_count: nodeCount, groups };
|
|
2225
|
+
}
|
|
1991
2226
|
// Strip cruft the LLM doesn't need from the DB-position response.
|
|
1992
2227
|
// Called AFTER trimGamesMovetext so plyNumber survives long enough to
|
|
1993
2228
|
// slice each game's movetext. Also renames the `transpositions` field
|
|
@@ -2260,22 +2495,41 @@ function getNodeByPath(root, path) {
|
|
|
2260
2495
|
}
|
|
2261
2496
|
return cur;
|
|
2262
2497
|
}
|
|
2263
|
-
// Persist a fresh ceoEval on the node referenced by the file handle
|
|
2264
|
-
//
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
//
|
|
2498
|
+
// Persist a fresh ceoEval on the node referenced by the file handle
|
|
2499
|
+
// AND on every other node in the same file that transposes to the
|
|
2500
|
+
// same position (matches on the frontend's 3-field FEN key: piece
|
|
2501
|
+
// placement + side to move + castling). Best-effort — if the file
|
|
2502
|
+
// version raced (another agent saved between our GET and our PUT),
|
|
2503
|
+
// we silently drop the store rather than fail the analysis the LLM
|
|
2504
|
+
// actually asked for. The eval is still returned in the response
|
|
2505
|
+
// either way.
|
|
2506
|
+
//
|
|
2507
|
+
// Return: ids of every node the eval was stamped on (empty on error).
|
|
2508
|
+
// The primary node's id is always first (if present).
|
|
2268
2509
|
async function storeEvalOnNode(handle, ev) {
|
|
2269
2510
|
try {
|
|
2270
|
-
const
|
|
2271
|
-
const
|
|
2511
|
+
const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
|
|
2512
|
+
const key = positionKey(anchor.fen);
|
|
2513
|
+
const fenIndex = buildFenIndex(handle.parsedFile.root);
|
|
2514
|
+
const group = fenIndex.get(key) ?? [anchor];
|
|
2515
|
+
// Resolve every transposed node back to its path. cloneOnPath
|
|
2516
|
+
// rebuilds the spine so we need paths, not references — the
|
|
2517
|
+
// id index was built against the original tree and every id in
|
|
2518
|
+
// `group` exists there.
|
|
2519
|
+
const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
|
|
2520
|
+
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
2521
|
+
const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
|
|
2522
|
+
const newPgn = exportPGN(newFile);
|
|
2272
2523
|
await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(handle.id)}`, {
|
|
2273
2524
|
pgn: newPgn,
|
|
2274
2525
|
expected_version: handle.version,
|
|
2275
2526
|
});
|
|
2527
|
+
// Ensure the primary node (the one the LLM addressed) comes first.
|
|
2528
|
+
const anchorId = anchor.id;
|
|
2529
|
+
return [anchorId, ...ids.filter(x => x !== anchorId)];
|
|
2276
2530
|
}
|
|
2277
2531
|
catch {
|
|
2278
|
-
|
|
2532
|
+
return [];
|
|
2279
2533
|
}
|
|
2280
2534
|
}
|
|
2281
2535
|
function stringifyForLog(v) {
|
|
@@ -2406,6 +2660,10 @@ async function callToolInner(name, args) {
|
|
|
2406
2660
|
if (ev)
|
|
2407
2661
|
converted.eval = ev;
|
|
2408
2662
|
}
|
|
2663
|
+
// Record that this position was DB-checked this session. Downstream
|
|
2664
|
+
// add_move / add_line under this parent won't fire the "no stats
|
|
2665
|
+
// check" warning. Keyed by 3-field FEN so transpositions count.
|
|
2666
|
+
positionsStatsChecked.add(positionKey(fen));
|
|
2409
2667
|
return converted;
|
|
2410
2668
|
}
|
|
2411
2669
|
case "describe_position": {
|
|
@@ -2504,6 +2762,21 @@ async function callToolInner(name, args) {
|
|
|
2504
2762
|
body.engines = args.engines;
|
|
2505
2763
|
const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
2506
2764
|
const converted = convertCloudSnapshotResponse(raw, fen);
|
|
2765
|
+
// PV cap: engine PVs beyond ~6 plies are speculative (the tail is
|
|
2766
|
+
// where the search's confidence collapses — SF at depth 24 has
|
|
2767
|
+
// seen the first few plies solidly and hedged everything after).
|
|
2768
|
+
// More importantly, LLMs paste long PVs into `add_line` as if
|
|
2769
|
+
// they were prepared repertoire. A 15-move PV pasted as a
|
|
2770
|
+
// variation is one line of engine output through positions
|
|
2771
|
+
// where both sides had real choices — not a repertoire. Cap the
|
|
2772
|
+
// affordance: return only what's load-bearing (3 full moves for
|
|
2773
|
+
// understanding the point), let the caller re-analyse the
|
|
2774
|
+
// resulting position if they want to see further. Override via
|
|
2775
|
+
// `pv_max_plies` for the rare case (deep tactics verification).
|
|
2776
|
+
const pvMaxPlies = typeof args.pv_max_plies === "number" && args.pv_max_plies > 0
|
|
2777
|
+
? Math.min(args.pv_max_plies, 40)
|
|
2778
|
+
: 6;
|
|
2779
|
+
capPvsInResponse(converted, pvMaxPlies);
|
|
2507
2780
|
// Node-addressed calls: persist the result on the node's ceoEval
|
|
2508
2781
|
// so a later quote_engine_eval can cite this measurement. This is
|
|
2509
2782
|
// the anti-hallucination hinge — prose that says "engines say X
|
|
@@ -2512,8 +2785,15 @@ async function callToolInner(name, args) {
|
|
|
2512
2785
|
// was supplied and the eval survives via the [%ceo-eval] escape.
|
|
2513
2786
|
if (resolved.file) {
|
|
2514
2787
|
const ev = analysisToStoredEval(converted);
|
|
2515
|
-
if (ev)
|
|
2516
|
-
await storeEvalOnNode(resolved.file, ev);
|
|
2788
|
+
if (ev) {
|
|
2789
|
+
const stamped = await storeEvalOnNode(resolved.file, ev);
|
|
2790
|
+
if (stamped.length > 1) {
|
|
2791
|
+
// Surface the propagation so the LLM sees exactly which
|
|
2792
|
+
// other nodes now carry this eval (and can skip them for
|
|
2793
|
+
// re-analysis).
|
|
2794
|
+
converted.also_stored_on = stamped.slice(1);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2517
2797
|
}
|
|
2518
2798
|
return converted;
|
|
2519
2799
|
}
|
|
@@ -2535,6 +2815,8 @@ async function callToolInner(name, args) {
|
|
|
2535
2815
|
return readPrepFile(args);
|
|
2536
2816
|
case "list_nodes":
|
|
2537
2817
|
return listNodes(args);
|
|
2818
|
+
case "list_transpositions":
|
|
2819
|
+
return listTranspositions(args);
|
|
2538
2820
|
case "create_prep_file":
|
|
2539
2821
|
return authedRequest("POST", "/api/agent/prep-files", {
|
|
2540
2822
|
name: String(args.name),
|
|
@@ -2542,17 +2824,42 @@ async function callToolInner(name, args) {
|
|
|
2542
2824
|
case "delete_prep_file":
|
|
2543
2825
|
return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
|
|
2544
2826
|
case "add_move":
|
|
2545
|
-
return applyMutation(args, (file, idIndex) => addMove(file, resolveNodeId(idIndex, argNodeId(args, "parent_id")), String(args.san)));
|
|
2546
|
-
case "add_line":
|
|
2547
2827
|
return applyMutation(args, (file, idIndex) => {
|
|
2548
|
-
const sans = Array.isArray(args.sans) ? args.sans.map(String) : [];
|
|
2549
2828
|
const parentPath = resolveNodeId(idIndex, argNodeId(args, "parent_id"));
|
|
2550
|
-
const
|
|
2829
|
+
const parent = getNodeByPath(file.root, parentPath);
|
|
2830
|
+
const noStatsWarn = noStatsCheckWarning(parent);
|
|
2831
|
+
const step = addMove(file, parentPath, String(args.san));
|
|
2832
|
+
return { ...step, ...(noStatsWarn ? { warning: noStatsWarn } : {}) };
|
|
2833
|
+
});
|
|
2834
|
+
case "add_line": {
|
|
2835
|
+
const sansArg = Array.isArray(args.sans) ? args.sans.map(String) : [];
|
|
2836
|
+
const longLineWarn = longLineWarning(sansArg.length);
|
|
2837
|
+
return applyMutation(args, (file, idIndex) => {
|
|
2838
|
+
const parentPath = resolveNodeId(idIndex, argNodeId(args, "parent_id"));
|
|
2839
|
+
const parent = getNodeByPath(file.root, parentPath);
|
|
2840
|
+
const noStatsWarn = noStatsCheckWarning(parent);
|
|
2841
|
+
const step = addLine(file, parentPath, sansArg);
|
|
2551
2842
|
const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : argNodeId(args, "parent_id");
|
|
2552
|
-
|
|
2843
|
+
const combined = [longLineWarn, noStatsWarn].filter((s) => !!s);
|
|
2844
|
+
return {
|
|
2845
|
+
file: step.file,
|
|
2846
|
+
id: lastId,
|
|
2847
|
+
results: step.line,
|
|
2848
|
+
...(combined.length > 0 ? { warnings: combined } : {}),
|
|
2849
|
+
};
|
|
2553
2850
|
});
|
|
2554
|
-
|
|
2555
|
-
|
|
2851
|
+
}
|
|
2852
|
+
case "set_comment": {
|
|
2853
|
+
const commentStr = typeof args.comment === "string" ? args.comment : "";
|
|
2854
|
+
const commentWarns = commentAntiPatterns(commentStr);
|
|
2855
|
+
return applyMutation(args, (file, idIndex) => {
|
|
2856
|
+
const step = setComment(file, resolveNodeId(idIndex, argNodeId(args)), commentStr);
|
|
2857
|
+
return {
|
|
2858
|
+
...step,
|
|
2859
|
+
...(commentWarns.length > 0 ? { warnings: commentWarns } : {}),
|
|
2860
|
+
};
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2556
2863
|
case "set_nags":
|
|
2557
2864
|
return applyMutation(args, (file, idIndex) => setNags(file, resolveNodeId(idIndex, argNodeId(args)), Array.isArray(args.nags) ? args.nags.map(String) : []));
|
|
2558
2865
|
case "set_annotations": {
|
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.
|
|
@@ -189,8 +191,19 @@ Two calls:
|
|
|
189
191
|
|
|
190
192
|
If the user is specifically preparing to *play* the black side in a must-win, add `contempt=-30` (or up to `-60` for a harder steer) on a follow-up call to see which lines Lc0 finds most fighting for Black. Compare against Stockfish's objective read to make sure the fighting choice isn't just losing.
|
|
191
193
|
|
|
194
|
+
## The PV is not a line to paste
|
|
195
|
+
|
|
196
|
+
`cloud_analyse` caps each PV at 6 plies (3 full moves) by default and marks longer ones `pv_truncated: true`. This is deliberate — the tail of a PV is where the engine's confidence collapses (SF at depth 24 has resolved the first few plies solidly and hedged everything after), and pasting long PVs into `add_line` as prep is the biggest documented anti-pattern of this whole system.
|
|
197
|
+
|
|
198
|
+
**A PV tells you what the engine sees, not what will be played.** A 15-ply PV pasted as a variation is one line of engine output through positions where the opponent had 2-3 real alternatives at almost every ply. That's not prep — that's the engine's preferred game, and no opponent plays the engine's preferred game.
|
|
199
|
+
|
|
200
|
+
**To see further into a line, walk the tree.** Take the position at the tail of your truncated PV, run a fresh `cloud_analyse` on it. That call gives you the multipv candidate set at THAT position — the opponent's actual options — which is what you need to decide whether to branch. Don't raise `pv_max_plies` unless you're verifying a forcing sequence (a mate, an obligated recapture chain).
|
|
201
|
+
|
|
202
|
+
Practical: raise `lc0_multipv` (default 8) to see the candidate spread on the current position, NOT to see further down one PV. If Lc0 shows moves 1-3 within 0.15 of each other, that's a branching point — three responses need coverage, not one PV.
|
|
203
|
+
|
|
192
204
|
## What NOT to do
|
|
193
205
|
|
|
206
|
+
- **Don't paste PVs as `add_line` variations.** See the section above and `pgn-authoring.md`'s "Prep is a TREE, not a line" section. This is not a stylistic preference — the tool warns you starting at 9 plies and warns hard at 14+, and the truncated `pv_truncated: true` marker is telling you the engine itself doesn't stand behind the tail.
|
|
194
207
|
- **Don't quote Lc0's contempt-biased eval as objective.** If you tell the user "Lc0 gives Black +0.30 here" without disclosing you set contempt=-30, that's misleading.
|
|
195
208
|
- **Don't run cloud analysis just for casual questions.** `cloud_analyse` costs the user real money per second. If the question is "is 1.e4 or 1.d4 better?", the free `analyse` (single Stockfish, 2s) or `get_position_stats` (11.7M-game database) is enough.
|
|
196
209
|
- **Don't ignore the disagreement.** When Stockfish and Lc0 diverge sharply, that's exactly when you should explain *why* to the user — not paper over it.
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -155,15 +155,90 @@ Prose is NEVER for:
|
|
|
155
155
|
- Move recommendations ("here White should play h4") — add_move it.
|
|
156
156
|
- Restating the eval a NAG already conveys.
|
|
157
157
|
- **Describing a sibling variation you already added as a branch.** If move A has variation B added as `add_move(A, sibling)`, don't ALSO write `{if B then ...}` on A. The reader clicks B on the board — the branch is already there.
|
|
158
|
-
- **Restating what the app already renders.** The reader opens the file in the app and sees: every sibling move (with count / avg rating / top players from the DB), each node's stored `ceoEval`, the NAG glyphs, the tree structure. Duplicating any of that in prose is pure noise.
|
|
158
|
+
- **Restating what the app already renders.** The reader opens the file in the app and sees: every sibling move (with count / avg rating / top players from the DB), each node's stored `ceoEval`, the NAG glyphs, the tree structure. Duplicating any of that in prose is pure noise. Three specific patterns to never emit:
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
-
|
|
163
|
-
-
|
|
160
|
+
**1. Spread lists.** The database viewer already shows sibling counts and fashion scores next to every move. Pasting them into prose is doubled noise.
|
|
161
|
+
|
|
162
|
+
- ❌ `{Spread: 5.O-O ≈50, 6.h3 ≈42, 6.a4 ≈35, 6.Be3 ≈27, 6.Nbd2 ≈18 — the more White delays castling the less he gets.}` — every number here is one click away.
|
|
163
|
+
- ❌ `{Spread: 5.Nf3 ≈56, 5.Bd3 ≈55, 5.Be3 ≈47, 5.Rb1 ≈35, 5.h3 ≈32.}` — same problem.
|
|
164
|
+
- ✅ `{Two roads: quiet 5.Bd3 keeps position closed, sharp 5.e5 concedes the centre for tempo — cover both.}` — names the *character*, not the numbers.
|
|
165
|
+
|
|
166
|
+
**2. Raw centipawn values in prose.** The app shows `ceoEval` as `+0.20 / −0.27` next to every node and the NAG glyph next to that. If you write `≈−60` or `+0.35` in a comment, no reader knows whether that's a spread number, an eval, or a made-up decoration — and every one of them is visible without your prose.
|
|
167
|
+
|
|
168
|
+
- ❌ `{Best try, but ≈80 — a fifth of a pawn worse than the ...Nd7 move order.}` — cp number, opaque.
|
|
169
|
+
- ❌ `{0.00 at depth 31, 259M nodes.}` — engine metadata masquerading as insight; the eval is already visible.
|
|
170
|
+
- ❌ `{≈−60. White's f5 sacrifice does not work with the centre already liquidated.}` — the number tells the reader nothing; the *reason* is the whole comment.
|
|
171
|
+
- ✅ `{White's f5 sacrifice doesn't work with the centre already liquidated — no target for the pawn.}` — same content, no fake precision.
|
|
172
|
+
- ✅ Or better: set the NAG (`$17` for clear Black advantage) and drop the prose entirely; the glyph carries the judgment.
|
|
173
|
+
|
|
174
|
+
**3. Top-player rosters.** Count + names of the top players are shown on hover in the app. `146 GM games, Nakamura, Kramnik, MVL, So all play this` restates two visible facts.
|
|
175
|
+
|
|
176
|
+
- ❌ `{150 GM games and Caruana's choice against Liang in 2026.}` — count is visible; the specific-game citation is fine on its own if it carries prep signal.
|
|
177
|
+
- ✅ `{Caruana played this against Liang, Superbet 2026 — the current top-choice among elite Black players.}` — same specific-game citation, no restated count.
|
|
178
|
+
|
|
179
|
+
**Positive vocabulary for what the app can't render.** The reader wants labels the DB doesn't provide:
|
|
180
|
+
|
|
181
|
+
- `{The old main line — dominant through 2015, replaced by 6.Bd2 after Ding-Carlsen 2016.}` — historical context, not visible.
|
|
182
|
+
- `{The current fashion — 40+ games since 2024, mostly at 2700+.}` — recency signal condensed to a phrase, not a number list.
|
|
183
|
+
- `{Solid try (holds objectively) vs the sharper 6.Nd5 (small edge but requires memory) — choose based on style.}` — practical framing, uses labels the reader can act on.
|
|
184
|
+
- `{Prophylactic — every White plan is based on Bg5, this pre-empts it.}` — one word (prophylactic / restraint / clamp / breakthrough) does the work of a paragraph.
|
|
164
185
|
|
|
165
186
|
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
187
|
|
|
188
|
+
- **Prose "prevents Y" claims — always show Y as a `?`-tagged variation instead.** When you write `{6.f3 is necessary to prevent ...Bxh3.}` the reader has to trust you that the tactic exists. When you instead add a sibling variation `6.O-O? Bxh3 7.gxh3 …` marked with `?` in the NAG, the reader can play through the refutation themselves. Two benefits: (1) grounds the claim in an actual move sequence, so hallucinated tactics get exposed at authoring time when SAN validation runs; (2) the reader learns the tactic instead of taking your word for it. Rule: any prose of the shape "X because it prevents/avoids/deals with Y" should be either supplemented by or replaced with a `?`-marked variation showing Y.
|
|
189
|
+
|
|
190
|
+
- ❌ `{6.f3 is necessary — 6.O-O? runs into ...Bxh3 winning the exchange.}` — no way to check.
|
|
191
|
+
- ✅ Under 5.Nc3, add both `6.f3` (mainline) AND `6.O-O` as a sibling variation with `set_nags(["$2"])` and continuation `[Bxh3, gxh3, ...]` showing the refutation. The prose on 6.f3 shrinks to `{The 6.O-O branch shows why f3 must come first.}` — six words, refutation is playable.
|
|
192
|
+
|
|
193
|
+
## Prep is a TREE, not a line — the pasted-engine-PV anti-pattern
|
|
194
|
+
|
|
195
|
+
This is the single biggest quality problem in current LLM output on this system: after a `cloud_analyse` call the LLM sees a PV like `[Nf3, Nc6, Bb5, a6, Ba4, Nf6, O-O, Be7, Re1, b5, Bb3, O-O, ...]` and pastes it into a single `add_line`. 15 moves in one call, no branching, no reason to think the opponent will play any of those specific moves — one line of engine output through positions where both sides had real choices.
|
|
196
|
+
|
|
197
|
+
**The reader can tell.** A pasted engine PV always looks the same: long, straight, no comments, no NAGs, ends in a position with no obvious relevance. It's the shape of the output, not the individual moves, that gives it away.
|
|
198
|
+
|
|
199
|
+
**The fix is branching, not length.** Every ply along a variation is a decision point for whoever's turn it is. If the opponent has more than one plausible move at that ply (`get_position_stats` shows two or more moves with meaningful frequency; `predict_human_move` shows two or more moves with real WDL differences; `cloud_analyse.lc0.lines` shows several evals within 0.2 of each other), then that ply MUST branch — you're not preparing if you only cover one response.
|
|
200
|
+
|
|
201
|
+
**Concrete rules:**
|
|
202
|
+
|
|
203
|
+
- **`cloud_analyse` PVs are capped at 6 plies by default and marked `pv_truncated: true` when longer.** This is not a bug — it's telling you that the engine's confidence tail is not repertoire material. To see further into a line, don't raise `pv_max_plies` — pick the position at the end of the truncated PV and run a fresh `cloud_analyse` on it. That's what makes it prep instead of pasted output.
|
|
204
|
+
|
|
205
|
+
- **`add_line` warns when you pass ≥9 plies** and warns hard at ≥14 plies. Long unbranched lines with no comment default to "engine PV pasted as prep" in the reader's eyes. Rule of thumb: if you added ≥8 plies in one call, at least half of them should have branched.
|
|
206
|
+
|
|
207
|
+
- **Genuine forcing sequences ARE allowed** — a 12-ply mate combination, an obligated exchange sequence where both sides have exactly one reasonable move at every ply. In those cases: (a) write a comment naming what makes it forced (`{Every move here is forced by the mate threat on h7.}`), and (b) don't stop where the engine PV stops — stop where the position becomes evaluatable ("winning endgame, technique wins"; "mate in 3, easy calculation from here").
|
|
208
|
+
|
|
209
|
+
**Correct pattern for building a variation.** At every ply:
|
|
210
|
+
|
|
211
|
+
1. What are the plausible replies? `get_position_stats` (frequencies), `cloud_analyse` with `lc0_multipv: 8` (candidate spread), `predict_human_move` (what people actually play).
|
|
212
|
+
2. If ≥2 are plausible → branch. `add_move` each, then recurse on each branch or `add_line` for each of the several straightforward continuations.
|
|
213
|
+
3. If exactly 1 → continue linearly; note *why* it's the only move in a comment.
|
|
214
|
+
|
|
215
|
+
The failure mode you're avoiding: writing prep that reads as if the opponent will helpfully play the engine's #1 preference at every ply. They won't; that's the whole point of prep.
|
|
216
|
+
|
|
217
|
+
## Course chapters describe COVERAGE, not consensus — always `get_position_stats` at mainline branch points
|
|
218
|
+
|
|
219
|
+
Concrete failure this rule was written to fix: a Modern Defence file made 6.O-O-O the mainline of the entire "5.Qd2 Nd7" branch, wrote a chapter around it, and analysed 15+ plies deep. `get_position_stats` at that position was NEVER called this session. Instead, the LLM ran `find_position_in_courses`, saw So / Kraai / Mihajlov all had chapters titled "5.Qd2 b5 6.O-O-O Bb7" — and cargo-culted that into "6.O-O-O is the main line". It isn't. 6.O-O-O is one of five White tries, and it wasn't even the most-played.
|
|
220
|
+
|
|
221
|
+
**Course chapter titles tell you what the author decided to cover, not what practical opponents play.** A White repertoire author picks one continuation per branch and writes it up in depth — the chapter is named after what they cover. That is not the same as "this is the mainline in current practice", nor is it the same as "this is the most-played move against 5...Nd7". Five different courses can all pick different 6th moves for the same position; five different courses can all pick the SAME 6th move for coverage reasons (that's the sharpest / most-testing / easiest-to-teach) even though the DB shows practice is split five ways.
|
|
222
|
+
|
|
223
|
+
**Rule:** before committing to a mainline branch — either `add_move` as the first child of a parent that's on the mainline, or `add_line` for a linear continuation you're calling THE way White/Black plays — you must have called `get_position_stats` at that parent position in this session. If you didn't, the mutation will warn (soft). Course reads are for what the authors *say about the move*, not what the mainline *is*.
|
|
224
|
+
|
|
225
|
+
**How the two tools relate at a branching decision:**
|
|
226
|
+
|
|
227
|
+
1. `get_position_stats(position)` — what moves are actually played, ranked by count / avg rating / fashion. This decides which moves need a branch (any move ≥5% frequency in reasonable practice; any move with unusually high avg rating even if less frequent).
|
|
228
|
+
2. `find_position_in_courses(position)` → `read_course_at_position(...)` — for each branch you decided to include, what do the authors say about it, and where do they disagree. This decides the *content* of each branch (recommended reply, key concepts, historical context), not which branches exist.
|
|
229
|
+
|
|
230
|
+
Getting these backwards is the failure this section exists to prevent.
|
|
231
|
+
|
|
232
|
+
## Transpositions: don't analyse (or comment on) the same position twice
|
|
233
|
+
|
|
234
|
+
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.
|
|
235
|
+
|
|
236
|
+
**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).
|
|
237
|
+
|
|
238
|
+
**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.
|
|
239
|
+
|
|
240
|
+
**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.
|
|
241
|
+
|
|
167
242
|
## Move-judgment symbols (NAGs)
|
|
168
243
|
|
|
169
244
|
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