@chessceo/mcp 0.23.0 → 0.26.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 +310 -63
- package/dist/pgn/exporter.js +48 -14
- package/dist/pgn/mutations.js +11 -0
- package/dist/pgn/parser.js +59 -3
- package/docs/pgn-authoring.md +49 -16
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { dirname, join } from "node:path";
|
|
|
14
14
|
import { Chess } from "chess.js";
|
|
15
15
|
import { parsePGN } from "./pgn/parser.js";
|
|
16
16
|
import { exportPGN } from "./pgn/exporter.js";
|
|
17
|
-
import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
17
|
+
import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
18
18
|
import { PathError } from "./pgn/paths.js";
|
|
19
19
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -76,6 +76,8 @@ const AUTHED_TOOLS = new Set([
|
|
|
76
76
|
"delete_subtree",
|
|
77
77
|
"promote_variation",
|
|
78
78
|
"set_tag",
|
|
79
|
+
"apply_mutations",
|
|
80
|
+
"auto_evaluate",
|
|
79
81
|
"predict_human_move",
|
|
80
82
|
]);
|
|
81
83
|
function isAuthedToolCall(body) {
|
|
@@ -186,6 +188,7 @@ const TOOLS = [
|
|
|
186
188
|
name: "get_player_preparation",
|
|
187
189
|
description: "For a given player, colour and starting position, return both the moves the player actually chose (frequency + win rate) and the underlying games. Position is specified either as a move sequence in SAN (`line`) or a raw FEN. Use `line` iteratively to walk the opening tree: call once with empty `line`, pick a move, call again with `line` extended by that move, etc.\n\n" +
|
|
188
190
|
"GROUNDING: every claim about the opponent's repertoire must trace back to this tool's output. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at the actual game counts / win rates in the response. Don't invent 'the opponent typically plays X' — check first. Compute is cheap: run this on more branches instead of pattern-matching from a chess book.\n\n" +
|
|
191
|
+
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read at the requested position (top move + score + PV) and the corresponding NAG. Do NOT fire a separate cloud_analyse for the same FEN — the eval is right there.\n\n" +
|
|
189
192
|
"Reading the response — CRITICAL:\n" +
|
|
190
193
|
"• Win % is one weight, not a verdict. Recommend 1.b3 over 1.d4 because 60% > 50% is wrong. Sample size matters (3 games at 66% is noise; 300 at 55% is signal); avgWhite / avgBlack per move show the rating context (a big score often means a rating gap, not repertoire truth).\n" +
|
|
191
194
|
"• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted; a weak opponent won't patch it, a strong or improving one already has (but structural weaknesses like 'bad in Catalan structures' hold anyway).\n" +
|
|
@@ -227,7 +230,8 @@ const TOOLS = [
|
|
|
227
230
|
},
|
|
228
231
|
{
|
|
229
232
|
name: "get_position_stats",
|
|
230
|
-
description: "Move statistics from all 11.7M+ indexed games at a given position — game counts, win percentages, top continuations. Answers 'from position P, how often does White play 4. O-O vs 4. d3, and which scores better'
|
|
233
|
+
description: "Move statistics from all 11.7M+ indexed games at a given position — game counts, win percentages, top continuations. Answers 'from position P, how often does White play 4. O-O vs 4. d3, and which scores better'.\n\n" +
|
|
234
|
+
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read (top move + score + PV) and the corresponding NAG. Do NOT fire a separate cloud_analyse for the same FEN — the eval is right there.",
|
|
231
235
|
inputSchema: {
|
|
232
236
|
type: "object",
|
|
233
237
|
properties: {
|
|
@@ -252,35 +256,6 @@ const TOOLS = [
|
|
|
252
256
|
},
|
|
253
257
|
},
|
|
254
258
|
},
|
|
255
|
-
{
|
|
256
|
-
name: "analyse",
|
|
257
|
-
description: "Short Stockfish evaluation at a position. Returns the top-N candidate moves with score (centipawns from side-to-move POV, positive = advantage; or mate distance) and the principal variation for each. Defaults: 2s think time, top-3 lines. PV moves come back in SAN (e4, Nf3, Bxc4 — not UCI). Free (no cloud instance needed) — use liberally.\n\n" +
|
|
258
|
-
"GROUNDING: cite this tool's actual output when you claim things about positions. Don't invent evaluations from general principles or training data — if you don't have engine output for a FEN, call this. Compute is cheap.\n\n" +
|
|
259
|
-
"Position input is flexible — pass `fen`, or `moves` from startpos, or `fen + moves` to walk from an arbitrary position. Also see the flip-side-to-move threat check in read_engine_usage_guide.\n\n" +
|
|
260
|
-
"Use this to sanity-check candidate lines from get_position_stats or get_player_preparation — human game frequency tells you what people play, engine evaluation tells you what's actually good.",
|
|
261
|
-
inputSchema: {
|
|
262
|
-
type: "object",
|
|
263
|
-
properties: {
|
|
264
|
-
fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
|
|
265
|
-
moves: {
|
|
266
|
-
type: "string",
|
|
267
|
-
description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
|
|
268
|
-
},
|
|
269
|
-
movetime_ms: {
|
|
270
|
-
type: "integer",
|
|
271
|
-
minimum: 100,
|
|
272
|
-
maximum: 10000,
|
|
273
|
-
description: "Think time in milliseconds (default 2000).",
|
|
274
|
-
},
|
|
275
|
-
multipv: {
|
|
276
|
-
type: "integer",
|
|
277
|
-
minimum: 1,
|
|
278
|
-
maximum: 10,
|
|
279
|
-
description: "Number of candidate lines to return (default 3).",
|
|
280
|
-
},
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
259
|
{
|
|
285
260
|
name: "predict_human_move",
|
|
286
261
|
description: "Predicts what a HUMAN of the given rating will most likely play from a position. Rating-conditioned neural net (ResNet-20x256) — you get the top-N most likely moves with their probabilities and a WDL value head (White POV).\n\n" +
|
|
@@ -610,6 +585,56 @@ const TOOLS = [
|
|
|
610
585
|
required: ["id", "path"],
|
|
611
586
|
},
|
|
612
587
|
},
|
|
588
|
+
{
|
|
589
|
+
name: "apply_mutations",
|
|
590
|
+
description: "Batch: apply a list of mutations in one call. One load-parse-mutate-export-save cycle for N ops, so building a 100-move repertoire costs one HTTP round-trip and one save instead of 100. This is the RIGHT way to build a file — use single mutations only for surgical follow-up edits.\n\n" +
|
|
591
|
+
"Each mutation is `{op, path, ...args}` where `op` is one of: add_move, set_comment, set_nags, set_annotations, delete_subtree, promote_variation, set_tag. Same arg shape as the individual tools. Ops apply in order; `path` is resolved against the tree AFTER preceding ops in the batch — so after `{op: 'add_move', path: [0], san: 'Nc3'}` the new node's path is `[0, N]` where N is the parent's previous children count (or `[0, 0]` if it was empty).\n\n" +
|
|
592
|
+
"Any op error aborts the batch (nothing saved). Response is `{ok, results: [{path}], version}` giving the effective path each op landed at plus the new file version.",
|
|
593
|
+
inputSchema: {
|
|
594
|
+
type: "object",
|
|
595
|
+
properties: {
|
|
596
|
+
id: { type: "string" },
|
|
597
|
+
expected_version: { type: "integer" },
|
|
598
|
+
mutations: {
|
|
599
|
+
type: "array",
|
|
600
|
+
minItems: 1,
|
|
601
|
+
items: {
|
|
602
|
+
type: "object",
|
|
603
|
+
properties: {
|
|
604
|
+
op: { type: "string", enum: ["add_move", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
|
|
605
|
+
path: { type: "array", items: { type: "integer", minimum: 0 } },
|
|
606
|
+
san: { type: "string" },
|
|
607
|
+
comment: { type: "string" },
|
|
608
|
+
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" } },
|
|
609
|
+
arrows: { type: "array" },
|
|
610
|
+
highlights: { type: "array" },
|
|
611
|
+
key: { type: "string" },
|
|
612
|
+
value: { type: "string" },
|
|
613
|
+
},
|
|
614
|
+
required: ["op"],
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
required: ["id", "mutations"],
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
name: "auto_evaluate",
|
|
623
|
+
description: "Walk the tree from `path` (default `[]` = whole file) and auto-assign NAGs to every node based on the Stockfish eval from cloud_analyse. Uses the standard eval → NAG thresholds (|eval|<0.25 → $10, <0.6 → $14/$15, <1.3 → $16/$17, ≥1.3 → $18/$19). Requires a running cloud combo instance.\n\n" +
|
|
624
|
+
"This is the automate-the-boring-part tool. Instead of hand-annotating each move with a NAG, you build the tree via apply_mutations (no NAGs needed), then call auto_evaluate once and every node gets the right glyph.\n\n" +
|
|
625
|
+
"Costs real money — hits cloud_analyse per node. A 200-node tree at 1.5s/node = ~5 min of engine time. Use `only_missing=true` (default) to skip nodes that already have a NAG on subsequent runs. Runs 4 evaluations in parallel to save wall time.",
|
|
626
|
+
inputSchema: {
|
|
627
|
+
type: "object",
|
|
628
|
+
properties: {
|
|
629
|
+
id: { type: "string" },
|
|
630
|
+
path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Subtree root (default = whole file)." },
|
|
631
|
+
only_missing: { type: "boolean", description: "Skip nodes that already carry a NAG (default true)." },
|
|
632
|
+
movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
|
|
633
|
+
expected_version: { type: "integer" },
|
|
634
|
+
},
|
|
635
|
+
required: ["id"],
|
|
636
|
+
},
|
|
637
|
+
},
|
|
613
638
|
{
|
|
614
639
|
name: "set_tag",
|
|
615
640
|
description: "Set or clear a game-level PGN tag (Event, Site, Date, White, Black, Result, or any custom tag). Passing empty string removes the tag. Auto-saves.",
|
|
@@ -646,7 +671,8 @@ const TOOLS = [
|
|
|
646
671
|
},
|
|
647
672
|
{
|
|
648
673
|
name: "prep_snapshot",
|
|
649
|
-
description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate)
|
|
674
|
+
description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).\n\n" +
|
|
675
|
+
"AUTO-EVAL: if a cloud combo instance is running, the response includes a top-level `.eval` (Stockfish + Lc0 read at the shared position) so you get four signals in one call. Do NOT fire cloud_analyse separately for the same FEN.",
|
|
650
676
|
inputSchema: {
|
|
651
677
|
type: "object",
|
|
652
678
|
properties: {
|
|
@@ -717,19 +743,16 @@ function uciMoveToSAN(startFen, uci) {
|
|
|
717
743
|
return uci;
|
|
718
744
|
}
|
|
719
745
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
line.pv = uciLineToSAN(startFen, line.pv);
|
|
730
|
-
}
|
|
746
|
+
async function fetchCompactEval(fen) {
|
|
747
|
+
try {
|
|
748
|
+
const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
|
|
749
|
+
const converted = convertCloudSnapshotResponse(raw, fen);
|
|
750
|
+
const stored = analysisToStoredEval(converted, fen);
|
|
751
|
+
return storedEvalToCompact(stored, converted);
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return null;
|
|
731
755
|
}
|
|
732
|
-
return raw;
|
|
733
756
|
}
|
|
734
757
|
// Rewrite the /api/agent/cloud-engines/analyse response (two engines,
|
|
735
758
|
// each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
|
|
@@ -767,6 +790,221 @@ function argPath(args) {
|
|
|
767
790
|
}
|
|
768
791
|
return out;
|
|
769
792
|
}
|
|
793
|
+
// Dispatch table for the batch tool: name → mutator that returns the
|
|
794
|
+
// standard { file, path } shape. Reused for individual and batch calls.
|
|
795
|
+
function dispatchMutation(file, op) {
|
|
796
|
+
const kind = String(op.op);
|
|
797
|
+
const path = Array.isArray(op.path) ? op.path : [];
|
|
798
|
+
switch (kind) {
|
|
799
|
+
case "add_move":
|
|
800
|
+
return addMove(file, path, String(op.san));
|
|
801
|
+
case "set_comment":
|
|
802
|
+
return setComment(file, path, typeof op.comment === "string" ? op.comment : "");
|
|
803
|
+
case "set_nags":
|
|
804
|
+
return setNags(file, path, Array.isArray(op.nags) ? op.nags.map(String) : []);
|
|
805
|
+
case "set_annotations": {
|
|
806
|
+
const arrows = Array.isArray(op.arrows) ? op.arrows : [];
|
|
807
|
+
const highlights = Array.isArray(op.highlights) ? op.highlights : [];
|
|
808
|
+
const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
|
|
809
|
+
return setAnnotations(file, path, ann);
|
|
810
|
+
}
|
|
811
|
+
case "set_ceo_eval": {
|
|
812
|
+
const ev = op.ceoEval;
|
|
813
|
+
return setCeoEval(file, path, ev ?? null);
|
|
814
|
+
}
|
|
815
|
+
case "delete_subtree":
|
|
816
|
+
return deleteSubtree(file, path);
|
|
817
|
+
case "promote_variation":
|
|
818
|
+
return promoteVariation(file, path);
|
|
819
|
+
case "set_tag":
|
|
820
|
+
return { file: setTag(file, String(op.key), String(op.value ?? "")), path: [] };
|
|
821
|
+
default:
|
|
822
|
+
throw new Error(`unknown mutation op: ${kind}`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// Batch: load, parse, apply N mutations in order, export, save. All-or-nothing —
|
|
826
|
+
// any error aborts and nothing is saved.
|
|
827
|
+
async function applyBatchMutations(args) {
|
|
828
|
+
const id = String(args.id);
|
|
829
|
+
const mutations = Array.isArray(args.mutations) ? args.mutations : [];
|
|
830
|
+
if (mutations.length === 0)
|
|
831
|
+
throw new Error("mutations array required");
|
|
832
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
833
|
+
const g = raw;
|
|
834
|
+
if (typeof g.pgnContent !== "string")
|
|
835
|
+
throw new Error("prep file missing pgnContent");
|
|
836
|
+
let file = parsePGN(g.pgnContent);
|
|
837
|
+
const results = [];
|
|
838
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
839
|
+
const op = mutations[i];
|
|
840
|
+
try {
|
|
841
|
+
const step = dispatchMutation(file, op);
|
|
842
|
+
file = step.file;
|
|
843
|
+
results.push({ path: step.path });
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
847
|
+
throw new Error(`mutation #${i} (${String(op.op)}) failed: ${msg}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const newPgn = exportPGN(file);
|
|
851
|
+
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
852
|
+
const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
|
|
853
|
+
pgn: newPgn,
|
|
854
|
+
expected_version: expected,
|
|
855
|
+
});
|
|
856
|
+
const savedRow = saved;
|
|
857
|
+
return { ok: true, results, version: savedRow.version };
|
|
858
|
+
}
|
|
859
|
+
// Auto-evaluate: walk the tree from `path`, run cloud_analyse on each node,
|
|
860
|
+
// stash the compact per-engine eval in the node's `ceoEval` field (which
|
|
861
|
+
// survives across sessions and appears on every read_prep_file), and
|
|
862
|
+
// derive the NAG from the SF score. Both writes go via a single batch
|
|
863
|
+
// mutation at the end so a 200-node evaluate is one save.
|
|
864
|
+
async function autoEvaluate(args) {
|
|
865
|
+
const id = String(args.id);
|
|
866
|
+
const startPath = Array.isArray(args.path) ? args.path : [];
|
|
867
|
+
const onlyMissing = args.only_missing !== false; // default true
|
|
868
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
|
|
869
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
870
|
+
const g = raw;
|
|
871
|
+
if (typeof g.pgnContent !== "string")
|
|
872
|
+
throw new Error("prep file missing pgnContent");
|
|
873
|
+
const file = parsePGN(g.pgnContent);
|
|
874
|
+
const targets = [];
|
|
875
|
+
const walk = (node, path) => {
|
|
876
|
+
if (path.length > 0) {
|
|
877
|
+
if (!onlyMissing || !node.ceoEval) {
|
|
878
|
+
targets.push({ path, fen: node.fen });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
for (let i = 0; i < node.children.length; i++)
|
|
882
|
+
walk(node.children[i], [...path, i]);
|
|
883
|
+
};
|
|
884
|
+
const startNode = pathIntoTree(file.root, startPath);
|
|
885
|
+
walk(startNode, startPath);
|
|
886
|
+
if (targets.length === 0)
|
|
887
|
+
return { ok: true, evaluated: 0, skipped: 0, version: g.version };
|
|
888
|
+
// Dispatch cloud_analyse in parallel with a 4-way cap so we don't
|
|
889
|
+
// over-saturate the engine-ws connection cap (single-client per port).
|
|
890
|
+
const stored = [];
|
|
891
|
+
const CONCURRENCY = 4;
|
|
892
|
+
let cursor = 0;
|
|
893
|
+
async function worker() {
|
|
894
|
+
while (cursor < targets.length) {
|
|
895
|
+
const idx = cursor++;
|
|
896
|
+
const t = targets[idx];
|
|
897
|
+
try {
|
|
898
|
+
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
899
|
+
const ev = analysisToStoredEval(analysis, t.fen);
|
|
900
|
+
if (ev)
|
|
901
|
+
stored.push({ path: t.path, ev });
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// best-effort — a single failed node shouldn't kill the batch
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
909
|
+
if (stored.length === 0)
|
|
910
|
+
return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
|
|
911
|
+
const batchMutations = [];
|
|
912
|
+
for (const s of stored) {
|
|
913
|
+
batchMutations.push({ op: "set_ceo_eval", path: s.path, ceoEval: s.ev });
|
|
914
|
+
if (s.ev.nag)
|
|
915
|
+
batchMutations.push({ op: "set_nags", path: s.path, nags: [s.ev.nag] });
|
|
916
|
+
}
|
|
917
|
+
const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
|
|
918
|
+
const sr = saveResult;
|
|
919
|
+
return { ok: true, evaluated: stored.length, skipped: targets.length - stored.length, version: sr.version };
|
|
920
|
+
}
|
|
921
|
+
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
922
|
+
function pathIntoTree(root, path) {
|
|
923
|
+
let cur = root;
|
|
924
|
+
for (let i = 0; i < path.length; i++) {
|
|
925
|
+
if (path[i] < 0 || path[i] >= cur.children.length) {
|
|
926
|
+
throw new Error(`path segment ${i}=${path[i]} out of bounds`);
|
|
927
|
+
}
|
|
928
|
+
cur = cur.children[path[i]];
|
|
929
|
+
}
|
|
930
|
+
return cur;
|
|
931
|
+
}
|
|
932
|
+
// Read the cloud analyse response and build a StoredEval (compact form —
|
|
933
|
+
// no PVs, White-POV cp / mate, depth, and derived NAG). Returns null if
|
|
934
|
+
// there's no usable Stockfish signal (SF is the source of truth for
|
|
935
|
+
// the NAG per docs).
|
|
936
|
+
function analysisToStoredEval(analysis, fen) {
|
|
937
|
+
if (!analysis || typeof analysis !== "object")
|
|
938
|
+
return null;
|
|
939
|
+
const r = analysis;
|
|
940
|
+
const whiteToMove = / w /.test(fen);
|
|
941
|
+
const flip = (n) => (whiteToMove ? n : -n);
|
|
942
|
+
const engineEval = (block) => {
|
|
943
|
+
const line = block?.lines?.[0];
|
|
944
|
+
if (!line)
|
|
945
|
+
return undefined;
|
|
946
|
+
const depth = line.depth ?? block?.depth;
|
|
947
|
+
if (typeof line.mate === "number")
|
|
948
|
+
return { mate: flip(line.mate), depth };
|
|
949
|
+
if (typeof line.scoreCp === "number")
|
|
950
|
+
return { cp: flip(line.scoreCp), depth };
|
|
951
|
+
return undefined;
|
|
952
|
+
};
|
|
953
|
+
const sf = engineEval(r.stockfish);
|
|
954
|
+
const lc0 = engineEval(r.lc0);
|
|
955
|
+
if (!sf && !lc0)
|
|
956
|
+
return null;
|
|
957
|
+
const ev = {};
|
|
958
|
+
if (sf)
|
|
959
|
+
ev.sf = sf;
|
|
960
|
+
if (lc0)
|
|
961
|
+
ev.lc0 = lc0;
|
|
962
|
+
ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
|
|
963
|
+
return ev;
|
|
964
|
+
}
|
|
965
|
+
function nagFromCp(cp, mate) {
|
|
966
|
+
let effective;
|
|
967
|
+
if (typeof mate === "number")
|
|
968
|
+
effective = mate > 0 ? 10000 : -10000;
|
|
969
|
+
else if (typeof cp === "number")
|
|
970
|
+
effective = cp;
|
|
971
|
+
else
|
|
972
|
+
return null;
|
|
973
|
+
const abs = Math.abs(effective);
|
|
974
|
+
if (abs < 25)
|
|
975
|
+
return "$10";
|
|
976
|
+
if (abs < 60)
|
|
977
|
+
return effective > 0 ? "$14" : "$15";
|
|
978
|
+
if (abs < 130)
|
|
979
|
+
return effective > 0 ? "$16" : "$17";
|
|
980
|
+
return effective > 0 ? "$18" : "$19";
|
|
981
|
+
}
|
|
982
|
+
// Adapter for the compact eval attached to live query responses. Same
|
|
983
|
+
// derivation logic; different output shape (needs the .nag + summary
|
|
984
|
+
// used by get_position_stats / prep_snapshot).
|
|
985
|
+
function storedEvalToCompact(ev, analysis) {
|
|
986
|
+
if (!ev)
|
|
987
|
+
return null;
|
|
988
|
+
const a = analysis;
|
|
989
|
+
const compact = { nag: ev.nag ?? null };
|
|
990
|
+
if (ev.sf) {
|
|
991
|
+
compact.stockfish = {
|
|
992
|
+
cp: ev.sf.cp,
|
|
993
|
+
mate: ev.sf.mate,
|
|
994
|
+
bestMove: a.stockfish?.bestMove,
|
|
995
|
+
pv: a.stockfish?.lines?.[0]?.pv,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
if (ev.lc0) {
|
|
999
|
+
compact.lc0 = {
|
|
1000
|
+
cp: ev.lc0.cp,
|
|
1001
|
+
mate: ev.lc0.mate,
|
|
1002
|
+
bestMove: a.lc0?.bestMove,
|
|
1003
|
+
pv: a.lc0?.lines?.[0]?.pv,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
return compact;
|
|
1007
|
+
}
|
|
770
1008
|
// Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
|
|
771
1009
|
// save with optimistic lock. Auto-saves so every tool call is atomic;
|
|
772
1010
|
// the LLM never sees intermediate state.
|
|
@@ -910,27 +1148,30 @@ async function callToolInner(name, args) {
|
|
|
910
1148
|
params.limit = args.limit;
|
|
911
1149
|
if (typeof args.offset === "number")
|
|
912
1150
|
params.offset = args.offset;
|
|
913
|
-
const
|
|
914
|
-
|
|
1151
|
+
const effectiveFen = resolveFenFromArgs(args);
|
|
1152
|
+
const [raw, ev] = await Promise.all([
|
|
1153
|
+
get("/api/chess/prep/by-player", params),
|
|
1154
|
+
fetchCompactEval(effectiveFen),
|
|
1155
|
+
]);
|
|
1156
|
+
const converted = convertAvailableMovesToSAN(raw, effectiveFen);
|
|
1157
|
+
if (ev && converted && typeof converted === "object")
|
|
1158
|
+
converted.eval = ev;
|
|
1159
|
+
return converted;
|
|
915
1160
|
}
|
|
916
1161
|
case "get_position_stats": {
|
|
917
1162
|
const fen = resolveFenFromArgs(args);
|
|
918
|
-
const raw = await
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
if (typeof args.multipv === "number")
|
|
931
|
-
params.multipv = args.multipv;
|
|
932
|
-
const raw = await get("/api/chess/database/analyse", params);
|
|
933
|
-
return convertAnalyseResponse(raw, fen);
|
|
1163
|
+
const [raw, ev] = await Promise.all([
|
|
1164
|
+
get("/api/chess/database/main", {
|
|
1165
|
+
fen,
|
|
1166
|
+
limit: typeof args.limit === "number" ? args.limit : 20,
|
|
1167
|
+
sort: "relevance",
|
|
1168
|
+
}),
|
|
1169
|
+
fetchCompactEval(fen),
|
|
1170
|
+
]);
|
|
1171
|
+
const converted = convertAvailableMovesToSAN(raw, fen);
|
|
1172
|
+
if (ev && converted && typeof converted === "object")
|
|
1173
|
+
converted.eval = ev;
|
|
1174
|
+
return converted;
|
|
934
1175
|
}
|
|
935
1176
|
case "predict_human_move": {
|
|
936
1177
|
const fen = resolveFenFromArgs(args);
|
|
@@ -1032,6 +1273,10 @@ async function callToolInner(name, args) {
|
|
|
1032
1273
|
file: setTag(file, String(args.key), String(args.value ?? "")),
|
|
1033
1274
|
path: [],
|
|
1034
1275
|
}));
|
|
1276
|
+
case "apply_mutations":
|
|
1277
|
+
return applyBatchMutations(args);
|
|
1278
|
+
case "auto_evaluate":
|
|
1279
|
+
return autoEvaluate(args);
|
|
1035
1280
|
case "read_engine_usage_guide":
|
|
1036
1281
|
return { guide: ENGINE_USAGE_DOC };
|
|
1037
1282
|
case "read_prep_strategy_guide":
|
|
@@ -1075,13 +1320,15 @@ async function callToolInner(name, args) {
|
|
|
1075
1320
|
compact: "true",
|
|
1076
1321
|
...(line.length > 0 ? { line } : { fen }),
|
|
1077
1322
|
});
|
|
1078
|
-
const [opponent, you, general] = await Promise.all([
|
|
1323
|
+
const [opponent, you, general, ev] = await Promise.all([
|
|
1079
1324
|
get("/api/chess/prep/by-player", prepParams(opp, oppColor)),
|
|
1080
1325
|
get("/api/chess/prep/by-player", prepParams(me, myColor)),
|
|
1081
1326
|
get("/api/chess/database/main", { fen, limit: 20, sort: "relevance" }),
|
|
1327
|
+
fetchCompactEval(fen),
|
|
1082
1328
|
]);
|
|
1083
1329
|
return {
|
|
1084
1330
|
position: { line, fen, my_color: myColor },
|
|
1331
|
+
eval: ev,
|
|
1085
1332
|
opponent: convertAvailableMovesToSAN(opponent, fen),
|
|
1086
1333
|
you: convertAvailableMovesToSAN(you, fen),
|
|
1087
1334
|
general: convertAvailableMovesToSAN(general, fen),
|
package/dist/pgn/exporter.js
CHANGED
|
@@ -106,23 +106,57 @@ function renderNagsAndComment(node) {
|
|
|
106
106
|
}
|
|
107
107
|
return bits.join(" ");
|
|
108
108
|
}
|
|
109
|
-
// Build the [%cal ...] / [%csl ...]
|
|
110
|
-
//
|
|
109
|
+
// Build the [%cal ...] / [%csl ...] / [%ceo-eval ...] fragments if
|
|
110
|
+
// this node has any. Returns "" if none.
|
|
111
111
|
function renderNodeAnnotation(node) {
|
|
112
|
-
if (!node.annotations)
|
|
113
|
-
return "";
|
|
114
112
|
const bits = [];
|
|
115
|
-
if (node.annotations
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
if (node.annotations) {
|
|
114
|
+
if (node.annotations.arrows.length > 0) {
|
|
115
|
+
const entries = node.annotations.arrows
|
|
116
|
+
.map(a => `${codeFromColor(a.color)}${a.from}${a.to}`)
|
|
117
|
+
.join(",");
|
|
118
|
+
bits.push(`[%cal ${entries}]`);
|
|
119
|
+
}
|
|
120
|
+
if (node.annotations.highlights.length > 0) {
|
|
121
|
+
const entries = node.annotations.highlights
|
|
122
|
+
.map(h => `${codeFromColor(h.color)}${h.square}`)
|
|
123
|
+
.join(",");
|
|
124
|
+
bits.push(`[%csl ${entries}]`);
|
|
125
|
+
}
|
|
120
126
|
}
|
|
121
|
-
if (node.
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
.
|
|
125
|
-
bits.push(`[%csl ${entries}]`);
|
|
127
|
+
if (node.ceoEval) {
|
|
128
|
+
const s = renderCeoEval(node.ceoEval);
|
|
129
|
+
if (s)
|
|
130
|
+
bits.push(s);
|
|
126
131
|
}
|
|
127
132
|
return bits.join(" ");
|
|
128
133
|
}
|
|
134
|
+
// Serialise a stored eval as `[%ceo-eval sf=+0.20/38 lc0=+0.35/24 nag=$14]`.
|
|
135
|
+
// Compact: no PVs (regenerable via cloud_analyse), decimal cp for
|
|
136
|
+
// readability, mate as `MN`/`-MN`, depth as `/N`.
|
|
137
|
+
function renderCeoEval(ev) {
|
|
138
|
+
const bits = [];
|
|
139
|
+
if (ev.sf)
|
|
140
|
+
bits.push(`sf=${formatEngineEval(ev.sf)}`);
|
|
141
|
+
if (ev.lc0)
|
|
142
|
+
bits.push(`lc0=${formatEngineEval(ev.lc0)}`);
|
|
143
|
+
if (ev.nag)
|
|
144
|
+
bits.push(`nag=${ev.nag}`);
|
|
145
|
+
return bits.length > 0 ? `[%ceo-eval ${bits.join(" ")}]` : "";
|
|
146
|
+
}
|
|
147
|
+
function formatEngineEval(e) {
|
|
148
|
+
let body;
|
|
149
|
+
if (typeof e.mate === "number") {
|
|
150
|
+
body = e.mate >= 0 ? `+M${e.mate}` : `M${e.mate}`; // "+M5" / "M-5"
|
|
151
|
+
}
|
|
152
|
+
else if (typeof e.cp === "number") {
|
|
153
|
+
const pawns = e.cp / 100;
|
|
154
|
+
body = pawns >= 0 ? `+${pawns.toFixed(2)}` : pawns.toFixed(2);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
body = "+0.00";
|
|
158
|
+
}
|
|
159
|
+
if (typeof e.depth === "number")
|
|
160
|
+
body += `/${e.depth}`;
|
|
161
|
+
return body;
|
|
162
|
+
}
|
package/dist/pgn/mutations.js
CHANGED
|
@@ -108,6 +108,17 @@ export function promoteVariation(file, path) {
|
|
|
108
108
|
parent.children = [promoted, ...rest];
|
|
109
109
|
return { file: { tags: file.tags, root: newRoot }, path: [...parentPath, 0] };
|
|
110
110
|
}
|
|
111
|
+
// Replace the stored ceoEval on the node at `path`. Passing null clears.
|
|
112
|
+
// This is what auto_evaluate calls per-node; also exposed as an op the
|
|
113
|
+
// LLM can invoke directly if it wants to overwrite a stale eval.
|
|
114
|
+
export function setCeoEval(file, path, ev) {
|
|
115
|
+
const { root: newRoot, target } = cloneOnPath(file.root, path);
|
|
116
|
+
if (ev === null || (!ev.sf && !ev.lc0 && !ev.nag))
|
|
117
|
+
delete target.ceoEval;
|
|
118
|
+
else
|
|
119
|
+
target.ceoEval = ev;
|
|
120
|
+
return { file: { tags: file.tags, root: newRoot }, path };
|
|
121
|
+
}
|
|
111
122
|
// Set or clear a tag. Passing null / empty removes.
|
|
112
123
|
export function setTag(file, key, value) {
|
|
113
124
|
const cleanedKey = key.trim();
|
package/dist/pgn/parser.js
CHANGED
|
@@ -13,8 +13,33 @@ import { opposite } from "chessops/util";
|
|
|
13
13
|
import { colorFromCode, } from "./types.js";
|
|
14
14
|
const CAL_RE = /\[%cal\s+([^\]]+)\]/g;
|
|
15
15
|
const CSL_RE = /\[%csl\s+([^\]]+)\]/g;
|
|
16
|
-
const
|
|
17
|
-
|
|
16
|
+
const CEO_EVAL_RE = /\[%ceo-eval\s+([^\]]+)\]/g;
|
|
17
|
+
const ANY_CMD_RE = /\[%[a-z-]+\s+[^\]]+\]/g;
|
|
18
|
+
// Parse a `sf=+0.20/38` or `lc0=M-5/22` fragment into its numeric parts.
|
|
19
|
+
// Returns null if the value doesn't parse; the parser tolerates missing
|
|
20
|
+
// depth and mate notation.
|
|
21
|
+
function parseEngineEvalFragment(raw) {
|
|
22
|
+
const m = raw.match(/^([+-]?)(?:M(-?\d+)|(\d+(?:\.\d+)?))(?:\/(\d+))?$/i);
|
|
23
|
+
if (!m)
|
|
24
|
+
return null;
|
|
25
|
+
const sign = m[1] === "-" ? -1 : 1;
|
|
26
|
+
const out = {};
|
|
27
|
+
if (m[2] !== undefined) {
|
|
28
|
+
out.mate = sign * parseInt(m[2], 10);
|
|
29
|
+
}
|
|
30
|
+
else if (m[3] !== undefined) {
|
|
31
|
+
// pawn units → centipawns
|
|
32
|
+
out.cp = Math.round(sign * parseFloat(m[3]) * 100);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (m[4] !== undefined)
|
|
38
|
+
out.depth = parseInt(m[4], 10);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
// Parse a comment string, splitting out visual annotations + stored
|
|
42
|
+
// evals from prose.
|
|
18
43
|
export function parseCommentAnnotations(comment) {
|
|
19
44
|
const arrows = [];
|
|
20
45
|
const highlights = [];
|
|
@@ -32,9 +57,36 @@ export function parseCommentAnnotations(comment) {
|
|
|
32
57
|
highlights.push({ color: colorFromCode(e[1]), square: e[2].toLowerCase() });
|
|
33
58
|
}
|
|
34
59
|
}
|
|
60
|
+
let ceoEval;
|
|
61
|
+
for (const m of comment.matchAll(CEO_EVAL_RE)) {
|
|
62
|
+
const ev = ceoEval ?? {};
|
|
63
|
+
for (const entry of m[1].trim().split(/\s+/)) {
|
|
64
|
+
const eq = entry.indexOf("=");
|
|
65
|
+
if (eq < 0)
|
|
66
|
+
continue;
|
|
67
|
+
const k = entry.slice(0, eq).toLowerCase();
|
|
68
|
+
const v = entry.slice(eq + 1);
|
|
69
|
+
if (k === "sf") {
|
|
70
|
+
const parsed = parseEngineEvalFragment(v);
|
|
71
|
+
if (parsed)
|
|
72
|
+
ev.sf = parsed;
|
|
73
|
+
}
|
|
74
|
+
else if (k === "lc0") {
|
|
75
|
+
const parsed = parseEngineEvalFragment(v);
|
|
76
|
+
if (parsed)
|
|
77
|
+
ev.lc0 = parsed;
|
|
78
|
+
}
|
|
79
|
+
else if (k === "nag") {
|
|
80
|
+
if (/^\$\d+$/.test(v))
|
|
81
|
+
ev.nag = v;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (ev.sf || ev.lc0 || ev.nag)
|
|
85
|
+
ceoEval = ev;
|
|
86
|
+
}
|
|
35
87
|
const text = comment.replace(ANY_CMD_RE, "").replace(/\s+/g, " ").trim();
|
|
36
88
|
const annotations = arrows.length || highlights.length ? { arrows, highlights } : undefined;
|
|
37
|
-
return { text, annotations };
|
|
89
|
+
return { text, annotations, ceoEval };
|
|
38
90
|
}
|
|
39
91
|
// Parse full PGN → PrepFile. Throws on unrecoverable errors (no games,
|
|
40
92
|
// invalid starting FEN). Illegal SAN moves in the movetext are skipped
|
|
@@ -62,6 +114,8 @@ export function parsePGN(pgn) {
|
|
|
62
114
|
root.comment = parsed.text;
|
|
63
115
|
if (parsed.annotations)
|
|
64
116
|
root.annotations = parsed.annotations;
|
|
117
|
+
if (parsed.ceoEval)
|
|
118
|
+
root.ceoEval = parsed.ceoEval;
|
|
65
119
|
}
|
|
66
120
|
const build = (childNode, treeParent, pos, ply) => {
|
|
67
121
|
if (!childNode.data || typeof childNode.data.san !== "string")
|
|
@@ -97,6 +151,8 @@ export function parsePGN(pgn) {
|
|
|
97
151
|
node.comment = parsed.text;
|
|
98
152
|
if (parsed.annotations)
|
|
99
153
|
node.annotations = parsed.annotations;
|
|
154
|
+
if (parsed.ceoEval)
|
|
155
|
+
node.ceoEval = parsed.ceoEval;
|
|
100
156
|
}
|
|
101
157
|
if (Array.isArray(childNode.data.nags) && childNode.data.nags.length > 0) {
|
|
102
158
|
node.nags = childNode.data.nags.map((n) => `$${n}`);
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -16,29 +16,54 @@ Nodes are addressed by **path**: an array of child indices from the root.
|
|
|
16
16
|
|
|
17
17
|
## Workflow
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Reading:
|
|
20
20
|
|
|
21
21
|
- `read_prep_file(id)` → returns `{version, tags, tree}`. Every node in `tree` has `san`, `fen`, `ply`, optional `comment`, `nags`, `annotations`, and `children`.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
22
|
+
|
|
23
|
+
Building (use these — one call for many ops):
|
|
24
|
+
|
|
25
|
+
- **`apply_mutations(id, mutations[])`** — batch. This is the primary build tool. Send 100 mutations in one call → one load-parse-mutate-save cycle instead of 100. When you're writing a repertoire from scratch, EVERYTHING should go through this. Individual mutation tools are for surgical follow-up edits, not for bulk work.
|
|
26
|
+
- **`auto_evaluate(id, path?)`** — walk the tree from `path` and auto-assign the right NAG to every node by running cloud_analyse on each position. Standard threshold table (below). Costs real engine time but frees you from hand-annotating evals. Skip nodes that already have a NAG by default.
|
|
27
|
+
|
|
28
|
+
Per-op mutation tools (single-op calls, use for edits after the bulk build):
|
|
29
|
+
|
|
30
|
+
- `add_move(id, path, san)` — appends a new child. If `path` has children, new node becomes a variation.
|
|
31
|
+
- `set_comment(id, path, comment)` — replace comment (empty string clears).
|
|
32
|
+
- `set_nags(id, path, nags)` — replace NAGs (empty array clears).
|
|
33
|
+
- `set_annotations(id, path, {arrows, highlights})` — replace visual annotations.
|
|
34
|
+
- `delete_subtree(id, path)` — remove node + descendants.
|
|
27
35
|
- `promote_variation(id, path)` — make the node at `path` its parent's mainline.
|
|
28
|
-
- `set_tag(id, key, value)` — set/clear a game-level tag.
|
|
36
|
+
- `set_tag(id, key, value)` — set/clear a game-level PGN tag.
|
|
29
37
|
|
|
30
|
-
|
|
38
|
+
All mutations **auto-save** with optimistic locking. Response includes the new `version`; pass it as `expected_version` on your next call to catch concurrent edits.
|
|
31
39
|
|
|
32
40
|
## Typical build order
|
|
33
41
|
|
|
34
42
|
1. `read_prep_file` — see what's there.
|
|
35
|
-
2. `
|
|
36
|
-
3. `
|
|
37
|
-
4.
|
|
38
|
-
5. `promote_variation` if a variation is more important than the current mainline.
|
|
39
|
-
6. `delete_subtree` to prune.
|
|
43
|
+
2. `apply_mutations([...])` — one call with your whole intended build (all `add_move` for the moves and variations, plus any `set_comment`/`set_annotations` you already know at author time). Skip `set_nags` — auto_evaluate handles that.
|
|
44
|
+
3. `auto_evaluate(id)` — walk the tree, get engine evals, assign NAGs. One shot.
|
|
45
|
+
4. Individual mutation tools ONLY for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
The build-cost math: a 200-move file via individual `add_move` calls is 200 saves ≈ 100+ seconds of tool overhead. The same file via one `apply_mutations` call is one save ≈ 500ms. Use batch by default.
|
|
48
|
+
|
|
49
|
+
### Path math in a batch
|
|
50
|
+
|
|
51
|
+
Paths in each mutation resolve against the tree AFTER previous ops in the batch. When you `add_move(path=[0], san='c5')`, the new c5 lands at `[0, 0]` if [0] had no children, or `[0, N]` if it had N. Your next mutation in the batch can address the new node directly. The tree is deterministic — you can compute all paths ahead of time.
|
|
52
|
+
|
|
53
|
+
For a linear mainline build from an empty file:
|
|
54
|
+
```
|
|
55
|
+
{op: "add_move", path: [], san: "e4"} // lands at [0]
|
|
56
|
+
{op: "add_move", path: [0], san: "c5"} // lands at [0, 0]
|
|
57
|
+
{op: "add_move", path: [0, 0], san: "Nf3"} // lands at [0, 0, 0]
|
|
58
|
+
{op: "add_move", path: [0, 0, 0], san: "d6"} // lands at [0, 0, 0, 0]
|
|
59
|
+
...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For a variation at some point:
|
|
63
|
+
```
|
|
64
|
+
{op: "add_move", path: [0, 0], san: "Nc3"} // variation branching after move 2; lands at [0, 0, 1]
|
|
65
|
+
{op: "add_move", path: [0, 0, 1], san: "d5"} // continue the variation; lands at [0, 0, 1, 0]
|
|
66
|
+
```
|
|
42
67
|
|
|
43
68
|
## Variations vs prose
|
|
44
69
|
|
|
@@ -56,6 +81,7 @@ Prose is NEVER for:
|
|
|
56
81
|
- Move sequences ("then Nf3, then Bg5, ...") — use variations.
|
|
57
82
|
- Move recommendations ("here White should play h4") — add_move it.
|
|
58
83
|
- Restating the eval a NAG already conveys.
|
|
84
|
+
- **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.
|
|
59
85
|
|
|
60
86
|
## Move-judgment symbols (NAGs)
|
|
61
87
|
|
|
@@ -96,9 +122,16 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
|
|
|
96
122
|
| `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
|
|
97
123
|
| Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
|
|
98
124
|
|
|
99
|
-
**Don't
|
|
125
|
+
**Prefer auto_evaluate for the NAG.** Don't hand-write NAGs when building — call `auto_evaluate(id)` after your build and it assigns the right glyph to every node from actual engine analysis. Manual `set_nags` is for overrides (mark a novelty with $146, flag a `$5 !?` piece sac).
|
|
126
|
+
|
|
127
|
+
**Persisted evals travel with the file.** Every node with an eval gets `ceoEval: { sf: {cp: 25, depth: 32}, lc0: {cp: 30, depth: 18}, nag: "$14" }` on subsequent `read_prep_file` calls. Stored as `[%ceo-eval sf=+0.25/32 lc0=+0.30/18 nag=$14]` inside the PGN comment (an escape tag the app hides from the board view, just like [%cal] / [%csl]). So a re-read after auto_evaluate gives you every position's number without re-running cloud_analyse. Query tools (get_position_stats, get_player_preparation, prep_snapshot) also auto-attach a live `eval` at the request position when a cloud engine is running.
|
|
128
|
+
|
|
129
|
+
**Engine numbers in prose: brief only.** If you must reference a number, keep it compact:
|
|
130
|
+
|
|
131
|
+
- Good: `{... +0.2}` — bare number at the end of a note
|
|
132
|
+
- Bad: `{Stockfish depth 22 shows +0.25, Lc0 depth 18 shows +0.4, both engines agree}` — verbose noise
|
|
100
133
|
|
|
101
|
-
**Don't name the engine unless it adds signal.**
|
|
134
|
+
**Don't name the engine unless it adds signal.** "SF: +0.15" for a routine position is noise. Name it when there's a mismatch worth flagging: `{Human predictor gives Black 47% win despite the -0.35 objective eval — Elo gap does the work.}`
|
|
102
135
|
|
|
103
136
|
### When prose ADDS to the NAG
|
|
104
137
|
|
package/package.json
CHANGED