@chessceo/mcp 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +352 -153
- package/dist/pgn/mutations.js +55 -27
- package/dist/pgn/parser.js +9 -2
- package/dist/pgn/paths.js +73 -10
- package/dist/pgn/types.js +17 -6
- package/docs/engine-usage.md +10 -0
- package/docs/pgn-authoring.md +67 -33
- package/docs/prep-files-guide.md +20 -15
- package/docs/prep-strategy.md +3 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,8 +15,8 @@ import { Chess } from "chess.js";
|
|
|
15
15
|
import { parsePGN } from "./pgn/parser.js";
|
|
16
16
|
import { exportPGN } from "./pgn/exporter.js";
|
|
17
17
|
import { describePosition } from "./pgn/describe.js";
|
|
18
|
-
import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
19
|
-
import { PathError } from "./pgn/paths.js";
|
|
18
|
+
import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
|
|
19
|
+
import { buildIdIndex, NodeIdError, PathError, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
|
|
20
20
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
21
21
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
22
22
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -71,6 +71,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
71
71
|
"create_prep_file",
|
|
72
72
|
"delete_prep_file",
|
|
73
73
|
"add_move",
|
|
74
|
+
"add_line",
|
|
74
75
|
"set_comment",
|
|
75
76
|
"set_nags",
|
|
76
77
|
"set_annotations",
|
|
@@ -79,6 +80,7 @@ const AUTHED_TOOLS = new Set([
|
|
|
79
80
|
"set_tag",
|
|
80
81
|
"apply_mutations",
|
|
81
82
|
"auto_evaluate",
|
|
83
|
+
"quote_engine_eval",
|
|
82
84
|
"predict_human_move",
|
|
83
85
|
]);
|
|
84
86
|
function isAuthedToolCall(body) {
|
|
@@ -193,7 +195,7 @@ const TOOLS = [
|
|
|
193
195
|
"Reading the response — CRITICAL:\n" +
|
|
194
196
|
"• 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" +
|
|
195
197
|
"• 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" +
|
|
196
|
-
"• Recency > career. The last 12-24 months dominate. This endpoint's compact/LLM view deliberately omits per-move `
|
|
198
|
+
"• Recency > career. The last 12-24 months dominate. This endpoint's compact/LLM view deliberately omits per-move `fashionScore` — at the individual level it's trailing noise. The general DB endpoint (`get_position_stats`) keeps it, where it's real fashion signal (what the top field is playing this month).\n" +
|
|
197
199
|
"• Opponent will deviate early. Prep is a tree, not a line — cover the 2 most likely replies at each real branching point, not one 20-move line.\n" +
|
|
198
200
|
"• Surprise is a scalpel. Don't tell a lifelong 1.e4 player to switch to 1.d4 — meta-signal screams prep. Rare secondary lines within the user's existing repertoire (e.g. 6.Bc4 instead of usual 6.Bg5 vs the Najdorf) are where surprise is real.\n\n" +
|
|
199
201
|
"For the full guide call the `read_prep_strategy_guide` tool.",
|
|
@@ -206,17 +208,25 @@ const TOOLS = [
|
|
|
206
208
|
enum: ["white", "black"],
|
|
207
209
|
description: "Which colour the player is analysed with.",
|
|
208
210
|
},
|
|
211
|
+
file_id: {
|
|
212
|
+
type: "string",
|
|
213
|
+
description: "Prep file id to address the position from. Combine with `node_id` — the server derives the FEN from the tree, so you don't paste a FEN string. **Prefer this over `fen`/`line`/`moves` when a prep file is open.**",
|
|
214
|
+
},
|
|
215
|
+
node_id: {
|
|
216
|
+
type: "string",
|
|
217
|
+
description: "Node id inside `file_id` whose position to query. Root is 'r'. When set, overrides `fen`/`line`/`moves`.",
|
|
218
|
+
},
|
|
209
219
|
line: {
|
|
210
220
|
type: "string",
|
|
211
|
-
description: "Move sequence in SAN from the starting position, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for startpos. Alias: `moves` (same thing).",
|
|
221
|
+
description: "Move sequence in SAN from the starting position, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for startpos. Alias: `moves` (same thing). Only used if `node_id` is not set.",
|
|
212
222
|
},
|
|
213
223
|
moves: {
|
|
214
224
|
type: "string",
|
|
215
|
-
description: "SAN moves to apply on top of `fen` (or on top of startpos if no fen). Same shape as `line`.
|
|
225
|
+
description: "SAN moves to apply on top of `fen` (or on top of startpos if no fen). Same shape as `line`. Wins over `line` if both are set. Only used if `node_id` is not set.",
|
|
216
226
|
},
|
|
217
227
|
fen: {
|
|
218
228
|
type: "string",
|
|
219
|
-
description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone.",
|
|
229
|
+
description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone. Only used if `node_id` is not set.",
|
|
220
230
|
},
|
|
221
231
|
limit: {
|
|
222
232
|
type: "integer",
|
|
@@ -236,21 +246,29 @@ const TOOLS = [
|
|
|
236
246
|
"- `gm-classical` — GM classical games (both players ≥2500, real thinking-time). BEST for opening prep — every move is signal, avgElo ~2600 across all listed moves.\n" +
|
|
237
247
|
"- `main` — the whole 11.7M-game DB. Widest coverage but noisiest (includes 1000-Elo blunder-fests in the move stats). Use as fallback when gm-classical's totalCount is too small to be informative.\n\n" +
|
|
238
248
|
"Game movetext is trimmed to the moves AFTER the queried position (using each game's plyNumber). Saves ~70% of the bytes vs full movetext.\n\n" +
|
|
239
|
-
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN.",
|
|
249
|
+
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN. When called with `file_id`+`node_id`, the eval is also auto-stored on that node's `ceoEval` — later readable via quote_engine_eval.",
|
|
240
250
|
inputSchema: {
|
|
241
251
|
type: "object",
|
|
242
252
|
properties: {
|
|
253
|
+
file_id: {
|
|
254
|
+
type: "string",
|
|
255
|
+
description: "Prep file id. **Prefer file_id+node_id over `fen`** when a prep file is open — the server derives the FEN from the tree.",
|
|
256
|
+
},
|
|
257
|
+
node_id: {
|
|
258
|
+
type: "string",
|
|
259
|
+
description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`/`line`.",
|
|
260
|
+
},
|
|
243
261
|
fen: {
|
|
244
262
|
type: "string",
|
|
245
|
-
description: "Starting position as FEN. Combine with `moves`
|
|
263
|
+
description: "Starting position as FEN. Combine with `moves`. Only used if `node_id` is not set.",
|
|
246
264
|
},
|
|
247
265
|
moves: {
|
|
248
266
|
type: "string",
|
|
249
|
-
description: "Optional SAN moves
|
|
267
|
+
description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
250
268
|
},
|
|
251
269
|
line: {
|
|
252
270
|
type: "string",
|
|
253
|
-
description: "Synonym for `moves` from
|
|
271
|
+
description: "Synonym for `moves` from startpos; kept for compatibility.",
|
|
254
272
|
},
|
|
255
273
|
limit: {
|
|
256
274
|
type: "integer",
|
|
@@ -271,12 +289,14 @@ const TOOLS = [
|
|
|
271
289
|
description: "Structured facts about a chess position: piece placements per colour (SAN-style letters), material balance in pawn units, list of every contested piece (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant square, side to move, and the full list of LEGAL MOVES for the side to move. Pure computation — no engine, ~1 ms per call.\n\n" +
|
|
272
290
|
"USE THIS BEFORE COMMENTING ON A POSITION. LLMs are not reliable at reading FEN strings — you'll misplace pieces or invent captures. This tool gives you the same board state a human sees.\n\n" +
|
|
273
291
|
"ALSO USE THIS if `add_move` rejects an illegal SAN — the `.legalMoves` array shows exactly what's playable from that position.\n\n" +
|
|
274
|
-
"Position input
|
|
292
|
+
"Position input: prefer `file_id`+`node_id` if you're inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
|
|
275
293
|
inputSchema: {
|
|
276
294
|
type: "object",
|
|
277
295
|
properties: {
|
|
278
|
-
|
|
279
|
-
|
|
296
|
+
file_id: { type: "string", description: "Prep file id. When combined with `node_id`, describes that node's position." },
|
|
297
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
|
|
298
|
+
fen: { type: "string", description: "Starting position as FEN (defaults to startpos). Only used if `node_id` is not set." },
|
|
299
|
+
moves: { type: "string", description: "Optional SAN moves to apply on top of `fen`. Only used if `node_id` is not set." },
|
|
280
300
|
},
|
|
281
301
|
},
|
|
282
302
|
},
|
|
@@ -290,14 +310,16 @@ const TOOLS = [
|
|
|
290
310
|
"• After you've found what the opponent SHOULD do (with the engine), check what they'll ACTUALLY do at their rating. If the top human move is a mistake, you have a real practical advantage.\n" +
|
|
291
311
|
"• The WDL head is rating-aware — a 400-point gap will show up as a big win probability even in equal positions (the model has learned that human errors compound).\n" +
|
|
292
312
|
"• Pass `prev_fen` (most recent first) when analysing mid-trade positions — without history the model treats the position as quiet.\n\n" +
|
|
293
|
-
"Position input
|
|
313
|
+
"Position input: prefer `file_id`+`node_id` when inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`. Default rating 2400 both sides. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
|
|
294
314
|
inputSchema: {
|
|
295
315
|
type: "object",
|
|
296
316
|
properties: {
|
|
297
|
-
|
|
317
|
+
file_id: { type: "string", description: "Prep file id. Combine with `node_id` to point at a tree node's position." },
|
|
318
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
|
|
319
|
+
fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
|
|
298
320
|
moves: {
|
|
299
321
|
type: "string",
|
|
300
|
-
description: "Optional SAN moves to apply on top of `fen` (or startpos).",
|
|
322
|
+
description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
301
323
|
},
|
|
302
324
|
white_elo: {
|
|
303
325
|
type: "integer",
|
|
@@ -418,14 +440,17 @@ const TOOLS = [
|
|
|
418
440
|
"Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Practical range -20..+20. Use it to find non-objective 'practical' ideas or when the user needs to steer toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
|
|
419
441
|
"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" +
|
|
420
442
|
"For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
|
|
421
|
-
"Not for casual questions — this costs real money per second. Use
|
|
443
|
+
"Not for casual questions — this costs real money per second. Use `get_position_stats` for anything that doesn't require deep prep.\n\n" +
|
|
444
|
+
"**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.",
|
|
422
445
|
inputSchema: {
|
|
423
446
|
type: "object",
|
|
424
447
|
properties: {
|
|
425
|
-
|
|
448
|
+
file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id over fen** when inside a prep file — the FEN comes from the tree AND the result is stored on the node." },
|
|
449
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
|
|
450
|
+
fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
|
|
426
451
|
moves: {
|
|
427
452
|
type: "string",
|
|
428
|
-
description: "Optional SAN moves to apply on top of `fen` (or
|
|
453
|
+
description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
|
|
429
454
|
},
|
|
430
455
|
movetime_ms: {
|
|
431
456
|
type: "integer",
|
|
@@ -446,7 +471,6 @@ const TOOLS = [
|
|
|
446
471
|
description: "Lc0 contempt bias. 0 = objective (default). Positive favours White, negative favours Black; stay within -20..+20 in practice. Not applied to Stockfish. See engine_usage_primer prompt for when to use.",
|
|
447
472
|
},
|
|
448
473
|
},
|
|
449
|
-
required: ["fen"],
|
|
450
474
|
},
|
|
451
475
|
},
|
|
452
476
|
{
|
|
@@ -467,8 +491,9 @@ const TOOLS = [
|
|
|
467
491
|
},
|
|
468
492
|
{
|
|
469
493
|
name: "read_prep_file",
|
|
470
|
-
description: "Read one prep file. Returns a compact tree (
|
|
471
|
-
"
|
|
494
|
+
description: "Read one prep file. Returns a compact tree (each node carries a stable `id`, `san`, `fen`, `ply`, optional `comment`/`nags`/`annotations`/`ceoEval`, and `children`) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN — all edits go through the mutation tools (add_move / add_line / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
|
|
495
|
+
"**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash derived from its parent's id + its SAN. Sibling insertions, deletions and variation promotions do NOT change any other node's id. Pass this id as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
|
|
496
|
+
"**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_player_preparation, quote_engine_eval). Use it whenever a file is open — the server derives the FEN from the tree, so you can't 'analyse the wrong position' by mis-typing a FEN.",
|
|
472
497
|
inputSchema: {
|
|
473
498
|
type: "object",
|
|
474
499
|
properties: {
|
|
@@ -505,56 +530,71 @@ const TOOLS = [
|
|
|
505
530
|
},
|
|
506
531
|
{
|
|
507
532
|
name: "add_move",
|
|
508
|
-
description: "Append a move as a new child of the node
|
|
509
|
-
"Auto-saves. Returns `{
|
|
533
|
+
description: "Append a move as a new child of the node identified by `parent_id`. If the parent already has children, the new move becomes a variation (appended at the end); use promote_variation afterwards to make it the mainline. SAN is validated against the position — illegal moves are rejected with a clear error.\n\n" +
|
|
534
|
+
"Auto-saves. Returns `{node_id, version}` — the id of the new node (pass this to follow-up set_comment / set_nags / etc.) and the new file version for optimistic locking. **Node ids are content-derived and stable** — sibling insertions, deletions, and promotions do NOT change any other node's id.",
|
|
510
535
|
inputSchema: {
|
|
511
536
|
type: "object",
|
|
512
537
|
properties: {
|
|
513
538
|
id: { type: "string", description: "Prep file id." },
|
|
514
|
-
|
|
539
|
+
parent_id: { type: "string", description: "Node id of the parent (the position the move is played FROM). Root id is 'r'." },
|
|
515
540
|
san: { type: "string", description: "The move in SAN notation (e.g. 'Nf3', 'exd5', 'O-O', 'Qxf7+')." },
|
|
516
541
|
expected_version: { type: "integer", description: "Optimistic-lock check; pass the `version` from your last read." },
|
|
517
542
|
},
|
|
518
|
-
required: ["id", "
|
|
543
|
+
required: ["id", "parent_id", "san"],
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
name: "add_line",
|
|
548
|
+
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" +
|
|
549
|
+
"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.",
|
|
550
|
+
inputSchema: {
|
|
551
|
+
type: "object",
|
|
552
|
+
properties: {
|
|
553
|
+
id: { type: "string", description: "Prep file id." },
|
|
554
|
+
parent_id: { type: "string", description: "Node id of the parent to build from. Root is 'r'." },
|
|
555
|
+
sans: { type: "array", items: { type: "string" }, minItems: 1, description: "SAN moves in order, e.g. ['e4','e5','Nf3','Nc6','Bb5']." },
|
|
556
|
+
expected_version: { type: "integer" },
|
|
557
|
+
},
|
|
558
|
+
required: ["id", "parent_id", "sans"],
|
|
519
559
|
},
|
|
520
560
|
},
|
|
521
561
|
{
|
|
522
562
|
name: "set_comment",
|
|
523
|
-
description: "Set (or clear, with empty string) the text comment on the node
|
|
563
|
+
description: "Set (or clear, with empty string) the text comment on the node identified by `node_id`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.",
|
|
524
564
|
inputSchema: {
|
|
525
565
|
type: "object",
|
|
526
566
|
properties: {
|
|
527
567
|
id: { type: "string", description: "Prep file id." },
|
|
528
|
-
|
|
568
|
+
node_id: { type: "string", description: "Node id from read_prep_file / add_move." },
|
|
529
569
|
comment: { type: "string", description: "New comment text. Empty string clears." },
|
|
530
570
|
expected_version: { type: "integer" },
|
|
531
571
|
},
|
|
532
|
-
required: ["id", "
|
|
572
|
+
required: ["id", "node_id", "comment"],
|
|
533
573
|
},
|
|
534
574
|
},
|
|
535
575
|
{
|
|
536
576
|
name: "set_nags",
|
|
537
|
-
description: "Replace the list of NAGs on the node
|
|
577
|
+
description: "Replace the list of NAGs on the node identified by `node_id`. Empty array clears them. NAGs are your EDITORIAL call — see read_pgn_authoring_guide for the discipline (novelty $146, sharp choice $5, decisive $18/$19, etc.). Do NOT set $10 '=' on every equal position; that's board noise. Auto-saves.",
|
|
538
578
|
inputSchema: {
|
|
539
579
|
type: "object",
|
|
540
580
|
properties: {
|
|
541
581
|
id: { type: "string" },
|
|
542
|
-
|
|
582
|
+
node_id: { type: "string" },
|
|
543
583
|
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" }, description: "NAG list, e.g. ['$14'] or ['$146', '$44']." },
|
|
544
584
|
expected_version: { type: "integer" },
|
|
545
585
|
},
|
|
546
|
-
required: ["id", "
|
|
586
|
+
required: ["id", "node_id", "nags"],
|
|
547
587
|
},
|
|
548
588
|
},
|
|
549
589
|
{
|
|
550
590
|
name: "set_annotations",
|
|
551
|
-
description: "Replace the visual annotations (arrows + coloured squares) on the node
|
|
591
|
+
description: "Replace the visual annotations (arrows + coloured squares) on the node identified by `node_id`. Passing empty arrays clears them.\n\n" +
|
|
552
592
|
"Colours: green, red, yellow, light-blue, dark-blue, orange. Keep it LIGHT: 1-3 arrows and 2-3 squares per move maximum. Twenty arrows is noise, not signal. Auto-saves.",
|
|
553
593
|
inputSchema: {
|
|
554
594
|
type: "object",
|
|
555
595
|
properties: {
|
|
556
596
|
id: { type: "string" },
|
|
557
|
-
|
|
597
|
+
node_id: { type: "string" },
|
|
558
598
|
arrows: {
|
|
559
599
|
type: "array",
|
|
560
600
|
items: {
|
|
@@ -580,40 +620,40 @@ const TOOLS = [
|
|
|
580
620
|
},
|
|
581
621
|
expected_version: { type: "integer" },
|
|
582
622
|
},
|
|
583
|
-
required: ["id", "
|
|
623
|
+
required: ["id", "node_id"],
|
|
584
624
|
},
|
|
585
625
|
},
|
|
586
626
|
{
|
|
587
627
|
name: "delete_subtree",
|
|
588
|
-
description: "Delete the node
|
|
628
|
+
description: "Delete the node identified by `node_id` and all its descendants. Refuses to delete the root. Auto-saves.",
|
|
589
629
|
inputSchema: {
|
|
590
630
|
type: "object",
|
|
591
631
|
properties: {
|
|
592
632
|
id: { type: "string" },
|
|
593
|
-
|
|
633
|
+
node_id: { type: "string" },
|
|
594
634
|
expected_version: { type: "integer" },
|
|
595
635
|
},
|
|
596
|
-
required: ["id", "
|
|
636
|
+
required: ["id", "node_id"],
|
|
597
637
|
},
|
|
598
638
|
},
|
|
599
639
|
{
|
|
600
640
|
name: "promote_variation",
|
|
601
|
-
description: "Make the node
|
|
641
|
+
description: "Make the node identified by `node_id` its parent's mainline (children[0]), demoting the current mainline (and any other siblings) into variation order. Silently no-op if already the mainline. Auto-saves.",
|
|
602
642
|
inputSchema: {
|
|
603
643
|
type: "object",
|
|
604
644
|
properties: {
|
|
605
645
|
id: { type: "string" },
|
|
606
|
-
|
|
646
|
+
node_id: { type: "string", description: "Node id of the variation to promote. Cannot be root." },
|
|
607
647
|
expected_version: { type: "integer" },
|
|
608
648
|
},
|
|
609
|
-
required: ["id", "
|
|
649
|
+
required: ["id", "node_id"],
|
|
610
650
|
},
|
|
611
651
|
},
|
|
612
652
|
{
|
|
613
653
|
name: "apply_mutations",
|
|
614
654
|
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" +
|
|
615
|
-
"Each mutation is `{op,
|
|
616
|
-
"Any op error aborts the batch (nothing saved). Response is `{ok, results: [{
|
|
655
|
+
"Each mutation is `{op, node_id | parent_id, ...args}` where `op` is one of: add_move, add_line, set_comment, set_nags, set_annotations, delete_subtree, promote_variation, set_tag. Same arg shape as the individual tools. Ops apply in order; because node ids are content-derived (hash of parent_id + san), a node created by an early op has a deterministic id you can reference in later ops in the same batch.\n\n" +
|
|
656
|
+
"Any op error aborts the batch (nothing saved). Response is `{ok, results: [{node_id, line?}], version}` — one entry per op with the id it landed on (add_line also returns the full line array).",
|
|
617
657
|
inputSchema: {
|
|
618
658
|
type: "object",
|
|
619
659
|
properties: {
|
|
@@ -625,9 +665,11 @@ const TOOLS = [
|
|
|
625
665
|
items: {
|
|
626
666
|
type: "object",
|
|
627
667
|
properties: {
|
|
628
|
-
op: { type: "string", enum: ["add_move", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
|
|
629
|
-
|
|
668
|
+
op: { type: "string", enum: ["add_move", "add_line", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
|
|
669
|
+
node_id: { type: "string" },
|
|
670
|
+
parent_id: { type: "string" },
|
|
630
671
|
san: { type: "string" },
|
|
672
|
+
sans: { type: "array", items: { type: "string" } },
|
|
631
673
|
comment: { type: "string" },
|
|
632
674
|
nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" } },
|
|
633
675
|
arrows: { type: "array" },
|
|
@@ -644,21 +686,35 @@ const TOOLS = [
|
|
|
644
686
|
},
|
|
645
687
|
{
|
|
646
688
|
name: "auto_evaluate",
|
|
647
|
-
description: "Walk the tree from `
|
|
648
|
-
"
|
|
649
|
-
"
|
|
689
|
+
description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. Requires a running cloud combo instance.\n\n" +
|
|
690
|
+
"**Does NOT set visible NAGs.** NAG placement is your call, not the engine's — an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move (that's just noise on the board), and a `!` on a novelty or a `?!` on a risky committal is a judgment call the engine can't make. Use this tool to persist the raw numbers on every node, then re-read the file and hand-pick NAGs on the moves where a glyph carries real signal.\n\n" +
|
|
691
|
+
"On re-read every evaluated node carries `ceoEval: { sf: {cp, depth}, lc0: {cp, depth} }` — the numbers travel with the file. Read those back with quote_engine_eval before writing prose that references engine numbers. Use `only_missing=true` (default) to skip nodes already evaluated on repeat runs.\n\n" +
|
|
692
|
+
"Costs real money — hits cloud_analyse per node. A 200-node tree at 1.5s/node = ~5 min of engine time. Runs 4 evaluations in parallel to save wall time.",
|
|
650
693
|
inputSchema: {
|
|
651
694
|
type: "object",
|
|
652
695
|
properties: {
|
|
653
696
|
id: { type: "string" },
|
|
654
|
-
|
|
655
|
-
only_missing: { type: "boolean", description: "Skip nodes that already carry a
|
|
697
|
+
node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
|
|
698
|
+
only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
|
|
656
699
|
movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
|
|
657
700
|
expected_version: { type: "integer" },
|
|
658
701
|
},
|
|
659
702
|
required: ["id"],
|
|
660
703
|
},
|
|
661
704
|
},
|
|
705
|
+
{
|
|
706
|
+
name: "quote_engine_eval",
|
|
707
|
+
description: "Return the stored engine eval for a node, or null if that node was never analysed. **Call this before writing prose or NAGs that quote engine numbers** — if it returns null, you have no measurement to cite. Do NOT infer an eval for the node from siblings or children; either analyse it (cloud_analyse with node_id) or omit the number from your prose.\n\n" +
|
|
708
|
+
"Response: `{ ceoEval: { sf: {cp, depth}, lc0: {cp, depth}, nag } | null }`. `cp` is White-POV centipawns as an integer (+20 = +0.20). `nag` is the threshold-derived glyph as a SUGGESTION — promote to a visible NAG via set_nags only when a glyph on that move carries editorial signal.",
|
|
709
|
+
inputSchema: {
|
|
710
|
+
type: "object",
|
|
711
|
+
properties: {
|
|
712
|
+
id: { type: "string", description: "Prep file id." },
|
|
713
|
+
node_id: { type: "string", description: "Node id whose stored eval you want to quote." },
|
|
714
|
+
},
|
|
715
|
+
required: ["id", "node_id"],
|
|
716
|
+
},
|
|
717
|
+
},
|
|
662
718
|
{
|
|
663
719
|
name: "set_tag",
|
|
664
720
|
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.",
|
|
@@ -703,13 +759,15 @@ const TOOLS = [
|
|
|
703
759
|
fide_id_me: { type: "integer", description: "Your FIDE ID." },
|
|
704
760
|
fide_id_opponent: { type: "integer", description: "Opponent's FIDE ID." },
|
|
705
761
|
my_color: { type: "string", enum: ["white", "black"], description: "The colour YOU will play." },
|
|
762
|
+
file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id** when inside a prep file." },
|
|
763
|
+
node_id: { type: "string", description: "Node id inside `file_id`. When set, overrides `line`/`fen`." },
|
|
706
764
|
line: {
|
|
707
765
|
type: "string",
|
|
708
|
-
description: "Move sequence in SAN, space-separated. Empty = starting position.
|
|
766
|
+
description: "Move sequence in SAN, space-separated. Empty = starting position. Only used if `node_id` is not set.",
|
|
709
767
|
},
|
|
710
768
|
fen: {
|
|
711
769
|
type: "string",
|
|
712
|
-
description: "Alternative to line — raw FEN of the target position.",
|
|
770
|
+
description: "Alternative to line — raw FEN of the target position. Only used if `node_id` is not set.",
|
|
713
771
|
},
|
|
714
772
|
},
|
|
715
773
|
required: ["fide_id_me", "fide_id_opponent", "my_color"],
|
|
@@ -798,56 +856,70 @@ function convertCloudSnapshotResponse(raw, startFen) {
|
|
|
798
856
|
}
|
|
799
857
|
return raw;
|
|
800
858
|
}
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
for (let i = 0; i < raw.length; i++) {
|
|
809
|
-
const n = raw[i];
|
|
810
|
-
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
811
|
-
throw new Error(`path[${i}] must be a non-negative integer (got ${JSON.stringify(n)})`);
|
|
812
|
-
}
|
|
813
|
-
out.push(n);
|
|
859
|
+
// Extract a node id from the args. Accepts either `node_id` or a
|
|
860
|
+
// `parent_id` alias for the add-style tools. Throws with a helpful
|
|
861
|
+
// message if malformed.
|
|
862
|
+
function argNodeId(args, key = "node_id") {
|
|
863
|
+
const raw = args[key];
|
|
864
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
865
|
+
throw new Error(`\`${key}\` is required (call read_prep_file to get valid node ids)`);
|
|
814
866
|
}
|
|
815
|
-
return
|
|
867
|
+
return raw.trim();
|
|
816
868
|
}
|
|
817
|
-
// Dispatch table for the batch tool: name → mutator that returns
|
|
818
|
-
//
|
|
819
|
-
|
|
869
|
+
// Dispatch table for the batch tool: name → mutator that returns
|
|
870
|
+
// { file, id } where id is the node the mutation touched. The batch
|
|
871
|
+
// caller rebuilds the id → path index between ops so newly-created
|
|
872
|
+
// nodes are addressable within the same batch.
|
|
873
|
+
function dispatchMutation(file, idIndex, op) {
|
|
820
874
|
const kind = String(op.op);
|
|
821
|
-
|
|
875
|
+
// Small local helper — resolves a node_id (or parent_id) op field to
|
|
876
|
+
// a path against the CURRENT tree state.
|
|
877
|
+
const nodeIdField = (key) => {
|
|
878
|
+
const raw = op[key];
|
|
879
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
880
|
+
throw new Error(`\`${key}\` required on op ${kind}`);
|
|
881
|
+
}
|
|
882
|
+
return raw.trim();
|
|
883
|
+
};
|
|
884
|
+
const resolve = (id) => resolveNodeId(idIndex, id);
|
|
822
885
|
switch (kind) {
|
|
823
886
|
case "add_move":
|
|
824
|
-
return addMove(file,
|
|
887
|
+
return addMove(file, resolve(nodeIdField("parent_id")), String(op.san));
|
|
888
|
+
case "add_line": {
|
|
889
|
+
const sans = Array.isArray(op.sans) ? op.sans.map(String) : [];
|
|
890
|
+
const parentPath = resolve(nodeIdField("parent_id"));
|
|
891
|
+
const step = addLine(file, parentPath, sans);
|
|
892
|
+
const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : nodeIdField("parent_id");
|
|
893
|
+
return { file: step.file, id: lastId, results: step.line };
|
|
894
|
+
}
|
|
825
895
|
case "set_comment":
|
|
826
|
-
return setComment(file,
|
|
896
|
+
return setComment(file, resolve(nodeIdField("node_id")), typeof op.comment === "string" ? op.comment : "");
|
|
827
897
|
case "set_nags":
|
|
828
|
-
return setNags(file,
|
|
898
|
+
return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
|
|
829
899
|
case "set_annotations": {
|
|
830
900
|
const arrows = Array.isArray(op.arrows) ? op.arrows : [];
|
|
831
901
|
const highlights = Array.isArray(op.highlights) ? op.highlights : [];
|
|
832
902
|
const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
|
|
833
|
-
return setAnnotations(file,
|
|
903
|
+
return setAnnotations(file, resolve(nodeIdField("node_id")), ann);
|
|
834
904
|
}
|
|
835
905
|
case "set_ceo_eval": {
|
|
836
906
|
const ev = op.ceoEval;
|
|
837
|
-
return setCeoEval(file,
|
|
907
|
+
return setCeoEval(file, resolve(nodeIdField("node_id")), ev ?? null);
|
|
838
908
|
}
|
|
839
909
|
case "delete_subtree":
|
|
840
|
-
return deleteSubtree(file,
|
|
910
|
+
return deleteSubtree(file, resolve(nodeIdField("node_id")));
|
|
841
911
|
case "promote_variation":
|
|
842
|
-
return promoteVariation(file,
|
|
912
|
+
return promoteVariation(file, resolve(nodeIdField("node_id")));
|
|
843
913
|
case "set_tag":
|
|
844
|
-
return { file: setTag(file, String(op.key), String(op.value ?? "")),
|
|
914
|
+
return { file: setTag(file, String(op.key), String(op.value ?? "")), id: ROOT_ID };
|
|
845
915
|
default:
|
|
846
916
|
throw new Error(`unknown mutation op: ${kind}`);
|
|
847
917
|
}
|
|
848
918
|
}
|
|
849
|
-
// Batch: load, parse, apply N mutations in order, export, save.
|
|
850
|
-
// any error aborts and nothing is saved.
|
|
919
|
+
// Batch: load, parse, apply N mutations in order, export, save.
|
|
920
|
+
// All-or-nothing — any error aborts and nothing is saved. The id index
|
|
921
|
+
// is rebuilt after each op so nodes created earlier in the batch can be
|
|
922
|
+
// addressed by later ops via their newly-derived node_id.
|
|
851
923
|
async function applyBatchMutations(args) {
|
|
852
924
|
const id = String(args.id);
|
|
853
925
|
const mutations = Array.isArray(args.mutations) ? args.mutations : [];
|
|
@@ -858,13 +930,15 @@ async function applyBatchMutations(args) {
|
|
|
858
930
|
if (typeof g.pgnContent !== "string")
|
|
859
931
|
throw new Error("prep file missing pgnContent");
|
|
860
932
|
let file = parsePGN(g.pgnContent);
|
|
933
|
+
let idIndex = buildIdIndex(file.root);
|
|
861
934
|
const results = [];
|
|
862
935
|
for (let i = 0; i < mutations.length; i++) {
|
|
863
936
|
const op = mutations[i];
|
|
864
937
|
try {
|
|
865
|
-
const step = dispatchMutation(file, op);
|
|
938
|
+
const step = dispatchMutation(file, idIndex, op);
|
|
866
939
|
file = step.file;
|
|
867
|
-
|
|
940
|
+
idIndex = buildIdIndex(file.root);
|
|
941
|
+
results.push({ node_id: step.id, ...(step.results !== undefined ? { line: step.results } : {}) });
|
|
868
942
|
}
|
|
869
943
|
catch (err) {
|
|
870
944
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -887,7 +961,9 @@ async function applyBatchMutations(args) {
|
|
|
887
961
|
// mutation at the end so a 200-node evaluate is one save.
|
|
888
962
|
async function autoEvaluate(args) {
|
|
889
963
|
const id = String(args.id);
|
|
890
|
-
const
|
|
964
|
+
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
|
|
965
|
+
? String(args.node_id)
|
|
966
|
+
: ROOT_ID;
|
|
891
967
|
const onlyMissing = args.only_missing !== false; // default true
|
|
892
968
|
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
|
|
893
969
|
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
@@ -895,18 +971,22 @@ async function autoEvaluate(args) {
|
|
|
895
971
|
if (typeof g.pgnContent !== "string")
|
|
896
972
|
throw new Error("prep file missing pgnContent");
|
|
897
973
|
const file = parsePGN(g.pgnContent);
|
|
974
|
+
const idIndex = buildIdIndex(file.root);
|
|
975
|
+
const startPath = resolveNodeId(idIndex, startNodeId);
|
|
898
976
|
const targets = [];
|
|
899
|
-
const walk = (node,
|
|
900
|
-
if (
|
|
977
|
+
const walk = (node, isStartAndRoot) => {
|
|
978
|
+
if (!isStartAndRoot) {
|
|
901
979
|
if (!onlyMissing || !node.ceoEval) {
|
|
902
|
-
targets.push({
|
|
980
|
+
targets.push({ nodeId: node.id, fen: node.fen });
|
|
903
981
|
}
|
|
904
982
|
}
|
|
905
|
-
for (
|
|
906
|
-
walk(
|
|
983
|
+
for (const child of node.children)
|
|
984
|
+
walk(child, false);
|
|
907
985
|
};
|
|
908
|
-
const startNode =
|
|
909
|
-
|
|
986
|
+
const startNode = getNodeByPath(file.root, startPath);
|
|
987
|
+
// If the caller anchored at the root, skip evaluating the root itself
|
|
988
|
+
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
989
|
+
walk(startNode, startNode.id === ROOT_ID);
|
|
910
990
|
if (targets.length === 0)
|
|
911
991
|
return { ok: true, evaluated: 0, skipped: 0, version: g.version };
|
|
912
992
|
// Dispatch cloud_analyse in parallel with a 4-way cap so we don't
|
|
@@ -922,7 +1002,7 @@ async function autoEvaluate(args) {
|
|
|
922
1002
|
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
923
1003
|
const ev = analysisToStoredEval(analysis, t.fen);
|
|
924
1004
|
if (ev)
|
|
925
|
-
stored.push({
|
|
1005
|
+
stored.push({ nodeId: t.nodeId, ev });
|
|
926
1006
|
}
|
|
927
1007
|
catch {
|
|
928
1008
|
// best-effort — a single failed node shouldn't kill the batch
|
|
@@ -932,11 +1012,16 @@ async function autoEvaluate(args) {
|
|
|
932
1012
|
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
933
1013
|
if (stored.length === 0)
|
|
934
1014
|
return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
|
|
1015
|
+
// Persist the raw numbers only — never touch visible NAGs. NAG glyphs
|
|
1016
|
+
// are the LLM's editorial call (novelty, sharp choice, real mistake),
|
|
1017
|
+
// not an automatic mapping from the engine number. Stamping `$10` on
|
|
1018
|
+
// every 0.00 position in an opening tree just clutters the board.
|
|
1019
|
+
// The threshold-derived NAG still lives INSIDE `ceoEval.nag` so the
|
|
1020
|
+
// LLM can read it back and decide whether to promote it to a visible
|
|
1021
|
+
// NAG on a case-by-case basis.
|
|
935
1022
|
const batchMutations = [];
|
|
936
1023
|
for (const s of stored) {
|
|
937
|
-
batchMutations.push({ op: "set_ceo_eval",
|
|
938
|
-
if (s.ev.nag)
|
|
939
|
-
batchMutations.push({ op: "set_nags", path: s.path, nags: [s.ev.nag] });
|
|
1024
|
+
batchMutations.push({ op: "set_ceo_eval", node_id: s.nodeId, ceoEval: s.ev });
|
|
940
1025
|
}
|
|
941
1026
|
const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
|
|
942
1027
|
const sr = saveResult;
|
|
@@ -1031,7 +1116,9 @@ function storedEvalToCompact(ev, analysis) {
|
|
|
1031
1116
|
}
|
|
1032
1117
|
// Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
|
|
1033
1118
|
// save with optimistic lock. Auto-saves so every tool call is atomic;
|
|
1034
|
-
// the LLM never sees intermediate state.
|
|
1119
|
+
// the LLM never sees intermediate state. The mutator is called with
|
|
1120
|
+
// both the parsed file and its id → path index, so the mutation can
|
|
1121
|
+
// resolve node_ids without rebuilding the index itself.
|
|
1035
1122
|
async function applyMutation(args, mutator) {
|
|
1036
1123
|
const id = String(args.id);
|
|
1037
1124
|
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
|
|
@@ -1039,12 +1126,13 @@ async function applyMutation(args, mutator) {
|
|
|
1039
1126
|
if (typeof g.pgnContent !== "string")
|
|
1040
1127
|
throw new Error("prep file missing pgnContent");
|
|
1041
1128
|
const file = parsePGN(g.pgnContent);
|
|
1129
|
+
const idIndex = buildIdIndex(file.root);
|
|
1042
1130
|
let result;
|
|
1043
1131
|
try {
|
|
1044
|
-
result = mutator(file);
|
|
1132
|
+
result = mutator(file, idIndex);
|
|
1045
1133
|
}
|
|
1046
1134
|
catch (err) {
|
|
1047
|
-
if (err instanceof MutationError || err instanceof PathError) {
|
|
1135
|
+
if (err instanceof MutationError || err instanceof PathError || err instanceof NodeIdError) {
|
|
1048
1136
|
throw new Error(`mutation rejected: ${err.message}`);
|
|
1049
1137
|
}
|
|
1050
1138
|
throw err;
|
|
@@ -1058,10 +1146,52 @@ async function applyMutation(args, mutator) {
|
|
|
1058
1146
|
const savedRow = saved;
|
|
1059
1147
|
return {
|
|
1060
1148
|
ok: true,
|
|
1061
|
-
|
|
1149
|
+
node_id: result.id,
|
|
1150
|
+
...(result.results !== undefined ? { line: result.results } : {}),
|
|
1062
1151
|
version: savedRow.version,
|
|
1063
1152
|
};
|
|
1064
1153
|
}
|
|
1154
|
+
// Strip cruft the LLM doesn't need from the DB-position response.
|
|
1155
|
+
// Called AFTER trimGamesMovetext so plyNumber survives long enough to
|
|
1156
|
+
// slice each game's movetext. Also renames the `transpositions` field
|
|
1157
|
+
// to something the LLM can parse without knowing chess-DB jargon.
|
|
1158
|
+
function stripPositionResponse(r) {
|
|
1159
|
+
if (!r || typeof r !== "object")
|
|
1160
|
+
return;
|
|
1161
|
+
const t = r;
|
|
1162
|
+
delete t.hash; // internal zobrist string
|
|
1163
|
+
delete t.source; // internal "database" marker; we overwrite with our own .source
|
|
1164
|
+
delete t.totalGames; // duplicates statistics.totalCount often; hasMore covers pagination
|
|
1165
|
+
if (Array.isArray(t.moves)) {
|
|
1166
|
+
for (const m of t.moves) {
|
|
1167
|
+
if (typeof m.transpositions === "number") {
|
|
1168
|
+
m.reachedViaTransposition = m.transpositions;
|
|
1169
|
+
delete m.transpositions;
|
|
1170
|
+
}
|
|
1171
|
+
// Backend calls it "hotness" — a 0-100 time-decayed popularity score
|
|
1172
|
+
// (recent + played often = high). Rename to something an LLM can read
|
|
1173
|
+
// without guessing it means "on a winning streak".
|
|
1174
|
+
if (typeof m.hotness === "number") {
|
|
1175
|
+
m.fashionScore = m.hotness;
|
|
1176
|
+
delete m.hotness;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (Array.isArray(t.games)) {
|
|
1181
|
+
for (const g of t.games) {
|
|
1182
|
+
delete g.gameId;
|
|
1183
|
+
delete g.whiteTitle;
|
|
1184
|
+
delete g.blackTitle;
|
|
1185
|
+
delete g.whiteTeam;
|
|
1186
|
+
delete g.blackTeam;
|
|
1187
|
+
delete g.round;
|
|
1188
|
+
delete g.plyNumber;
|
|
1189
|
+
delete g.relevance;
|
|
1190
|
+
delete g.site;
|
|
1191
|
+
delete g.ply;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1065
1195
|
// Trim every game's `moves` field to just the plies AFTER the queried
|
|
1066
1196
|
// position, using each game's `plyNumber`. Massive token save — a game
|
|
1067
1197
|
// 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
|
|
@@ -1168,6 +1298,65 @@ function resolveFenFromArgs(args) {
|
|
|
1168
1298
|
}
|
|
1169
1299
|
return board.fen();
|
|
1170
1300
|
}
|
|
1301
|
+
// Async resolver used by every engine / DB tool. If the caller supplied
|
|
1302
|
+
// file_id + node_id (preferred), load the file, resolve the node, and
|
|
1303
|
+
// return both the FEN and a handle we can use to persist ceoEval later.
|
|
1304
|
+
// Otherwise fall back to the raw fen/moves/line inputs.
|
|
1305
|
+
async function resolveFromNodeOrFen(args) {
|
|
1306
|
+
const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
|
|
1307
|
+
const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
|
|
1308
|
+
if (fileId && nodeId) {
|
|
1309
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
|
|
1310
|
+
const g = raw;
|
|
1311
|
+
if (typeof g.pgnContent !== "string")
|
|
1312
|
+
throw new Error("prep file missing pgnContent");
|
|
1313
|
+
const parsedFile = parsePGN(g.pgnContent);
|
|
1314
|
+
const idIndex = buildIdIndex(parsedFile.root);
|
|
1315
|
+
const nodePath = resolveNodeId(idIndex, nodeId);
|
|
1316
|
+
const node = getNodeByPath(parsedFile.root, nodePath);
|
|
1317
|
+
return {
|
|
1318
|
+
fen: node.fen,
|
|
1319
|
+
file: {
|
|
1320
|
+
id: fileId,
|
|
1321
|
+
version: g.version ?? 0,
|
|
1322
|
+
parsedFile,
|
|
1323
|
+
idIndex,
|
|
1324
|
+
nodePath,
|
|
1325
|
+
fen: node.fen,
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
return { fen: resolveFenFromArgs(args) };
|
|
1330
|
+
}
|
|
1331
|
+
// Local wrapper — the mutation module re-exports paths.getNode so this
|
|
1332
|
+
// import stays consistent with the rest of the file's imports.
|
|
1333
|
+
function getNodeByPath(root, path) {
|
|
1334
|
+
let cur = root;
|
|
1335
|
+
for (const idx of path) {
|
|
1336
|
+
if (idx < 0 || idx >= cur.children.length)
|
|
1337
|
+
throw new Error(`invalid node path segment ${idx}`);
|
|
1338
|
+
cur = cur.children[idx];
|
|
1339
|
+
}
|
|
1340
|
+
return cur;
|
|
1341
|
+
}
|
|
1342
|
+
// Persist a fresh ceoEval on the node referenced by the file handle.
|
|
1343
|
+
// Best-effort — if the file version raced (another agent saved
|
|
1344
|
+
// between our GET and our PUT), we silently drop the store rather
|
|
1345
|
+
// than fail the analysis the LLM actually asked for. The eval is
|
|
1346
|
+
// still returned in the response either way.
|
|
1347
|
+
async function storeEvalOnNode(handle, ev) {
|
|
1348
|
+
try {
|
|
1349
|
+
const step = setCeoEval(handle.parsedFile, handle.nodePath, ev);
|
|
1350
|
+
const newPgn = exportPGN(step.file);
|
|
1351
|
+
await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(handle.id)}`, {
|
|
1352
|
+
pgn: newPgn,
|
|
1353
|
+
expected_version: handle.version,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
// Best-effort — the analysis result is what the LLM asked for.
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1171
1360
|
function stringifyForLog(v) {
|
|
1172
1361
|
let s;
|
|
1173
1362
|
try {
|
|
@@ -1204,31 +1393,21 @@ async function callToolInner(name, args) {
|
|
|
1204
1393
|
case "get_player_profile":
|
|
1205
1394
|
return get("/api/chess/players/profile", { fideId: Number(args.fide_id) });
|
|
1206
1395
|
case "get_player_preparation": {
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
// history it didn't see anyway.
|
|
1213
|
-
const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
|
|
1214
|
-
const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
|
|
1215
|
-
const lineArg = typeof args.line === "string" ? args.line.trim() : "";
|
|
1396
|
+
// Node-first: if file_id+node_id was given, derive the FEN from the
|
|
1397
|
+
// tree (never trust an LLM-supplied FEN when a node reference is
|
|
1398
|
+
// available). Otherwise fall back to fen/moves/line.
|
|
1399
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1400
|
+
const effectiveFen = resolved.fen;
|
|
1216
1401
|
const params = {
|
|
1217
1402
|
fideId: Number(args.fide_id),
|
|
1218
1403
|
color: String(args.color),
|
|
1219
1404
|
compact: "true",
|
|
1405
|
+
fen: effectiveFen,
|
|
1220
1406
|
};
|
|
1221
|
-
if (fenArg || movesArg) {
|
|
1222
|
-
params.fen = resolveFenFromArgs(args);
|
|
1223
|
-
}
|
|
1224
|
-
else if (lineArg) {
|
|
1225
|
-
params.line = lineArg;
|
|
1226
|
-
}
|
|
1227
1407
|
if (typeof args.limit === "number")
|
|
1228
1408
|
params.limit = args.limit;
|
|
1229
1409
|
if (typeof args.offset === "number")
|
|
1230
1410
|
params.offset = args.offset;
|
|
1231
|
-
const effectiveFen = resolveFenFromArgs(args);
|
|
1232
1411
|
const [raw, ev] = await Promise.all([
|
|
1233
1412
|
get("/api/chess/prep/by-player", params),
|
|
1234
1413
|
fetchCompactEval(effectiveFen),
|
|
@@ -1236,13 +1415,15 @@ async function callToolInner(name, args) {
|
|
|
1236
1415
|
const converted = convertAvailableMovesToSAN(raw, effectiveFen);
|
|
1237
1416
|
if (converted && typeof converted === "object") {
|
|
1238
1417
|
trimGamesMovetext(converted);
|
|
1418
|
+
stripPositionResponse(converted);
|
|
1239
1419
|
if (ev)
|
|
1240
1420
|
converted.eval = ev;
|
|
1241
1421
|
}
|
|
1242
1422
|
return converted;
|
|
1243
1423
|
}
|
|
1244
1424
|
case "get_position_stats": {
|
|
1245
|
-
const
|
|
1425
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1426
|
+
const fen = resolved.fen;
|
|
1246
1427
|
const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
|
|
1247
1428
|
const source = rawSource === "main" ? "main" : "gm-classical";
|
|
1248
1429
|
const [raw, ev] = await Promise.all([
|
|
@@ -1256,16 +1437,20 @@ async function callToolInner(name, args) {
|
|
|
1256
1437
|
const converted = convertAvailableMovesToSAN(raw, fen);
|
|
1257
1438
|
if (converted && typeof converted === "object") {
|
|
1258
1439
|
trimGamesMovetext(converted);
|
|
1440
|
+
stripPositionResponse(converted);
|
|
1259
1441
|
converted.source = source;
|
|
1260
1442
|
if (ev)
|
|
1261
1443
|
converted.eval = ev;
|
|
1262
1444
|
}
|
|
1263
1445
|
return converted;
|
|
1264
1446
|
}
|
|
1265
|
-
case "describe_position":
|
|
1266
|
-
|
|
1447
|
+
case "describe_position": {
|
|
1448
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1449
|
+
return describePosition(resolved.fen);
|
|
1450
|
+
}
|
|
1267
1451
|
case "predict_human_move": {
|
|
1268
|
-
const
|
|
1452
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1453
|
+
const fen = resolved.fen;
|
|
1269
1454
|
const qs = new URLSearchParams();
|
|
1270
1455
|
qs.set("fen", fen);
|
|
1271
1456
|
if (typeof args.white_elo === "number")
|
|
@@ -1307,7 +1492,8 @@ async function callToolInner(name, args) {
|
|
|
1307
1492
|
case "stop_cloud_engine":
|
|
1308
1493
|
return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
|
|
1309
1494
|
case "cloud_analyse": {
|
|
1310
|
-
const
|
|
1495
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1496
|
+
const fen = resolved.fen;
|
|
1311
1497
|
const body = { fen };
|
|
1312
1498
|
if (typeof args.movetime_ms === "number")
|
|
1313
1499
|
body.movetime_ms = args.movetime_ms;
|
|
@@ -1316,7 +1502,19 @@ async function callToolInner(name, args) {
|
|
|
1316
1502
|
if (typeof args.contempt === "number")
|
|
1317
1503
|
body.contempt = args.contempt;
|
|
1318
1504
|
const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
1319
|
-
|
|
1505
|
+
const converted = convertCloudSnapshotResponse(raw, fen);
|
|
1506
|
+
// Node-addressed calls: persist the result on the node's ceoEval
|
|
1507
|
+
// so a later quote_engine_eval can cite this measurement. This is
|
|
1508
|
+
// the anti-hallucination hinge — prose that says "engines say X
|
|
1509
|
+
// on node Y" can only trace back to a call actually made against
|
|
1510
|
+
// node_id=Y, because the store only fires when file_id+node_id
|
|
1511
|
+
// was supplied and the eval survives via the [%ceo-eval] escape.
|
|
1512
|
+
if (resolved.file) {
|
|
1513
|
+
const ev = analysisToStoredEval(converted, fen);
|
|
1514
|
+
if (ev)
|
|
1515
|
+
await storeEvalOnNode(resolved.file, ev);
|
|
1516
|
+
}
|
|
1517
|
+
return converted;
|
|
1320
1518
|
}
|
|
1321
1519
|
case "list_prep_files":
|
|
1322
1520
|
return authedRequest("GET", "/api/agent/prep-files");
|
|
@@ -1342,32 +1540,53 @@ async function callToolInner(name, args) {
|
|
|
1342
1540
|
case "delete_prep_file":
|
|
1343
1541
|
return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
|
|
1344
1542
|
case "add_move":
|
|
1345
|
-
return applyMutation(args, file => addMove(file,
|
|
1543
|
+
return applyMutation(args, (file, idIndex) => addMove(file, resolveNodeId(idIndex, argNodeId(args, "parent_id")), String(args.san)));
|
|
1544
|
+
case "add_line":
|
|
1545
|
+
return applyMutation(args, (file, idIndex) => {
|
|
1546
|
+
const sans = Array.isArray(args.sans) ? args.sans.map(String) : [];
|
|
1547
|
+
const parentPath = resolveNodeId(idIndex, argNodeId(args, "parent_id"));
|
|
1548
|
+
const step = addLine(file, parentPath, sans);
|
|
1549
|
+
const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : argNodeId(args, "parent_id");
|
|
1550
|
+
return { file: step.file, id: lastId, results: step.line };
|
|
1551
|
+
});
|
|
1346
1552
|
case "set_comment":
|
|
1347
|
-
return applyMutation(args, file => setComment(file,
|
|
1553
|
+
return applyMutation(args, (file, idIndex) => setComment(file, resolveNodeId(idIndex, argNodeId(args)), typeof args.comment === "string" ? args.comment : ""));
|
|
1348
1554
|
case "set_nags":
|
|
1349
|
-
return applyMutation(args, file => setNags(file,
|
|
1555
|
+
return applyMutation(args, (file, idIndex) => setNags(file, resolveNodeId(idIndex, argNodeId(args)), Array.isArray(args.nags) ? args.nags.map(String) : []));
|
|
1350
1556
|
case "set_annotations": {
|
|
1351
1557
|
const arrowsRaw = Array.isArray(args.arrows) ? args.arrows : [];
|
|
1352
1558
|
const highlightsRaw = Array.isArray(args.highlights) ? args.highlights : [];
|
|
1353
1559
|
const ann = (arrowsRaw.length === 0 && highlightsRaw.length === 0)
|
|
1354
1560
|
? null
|
|
1355
1561
|
: { arrows: arrowsRaw, highlights: highlightsRaw };
|
|
1356
|
-
return applyMutation(args, file => setAnnotations(file,
|
|
1562
|
+
return applyMutation(args, (file, idIndex) => setAnnotations(file, resolveNodeId(idIndex, argNodeId(args)), ann));
|
|
1357
1563
|
}
|
|
1358
1564
|
case "delete_subtree":
|
|
1359
|
-
return applyMutation(args, file => deleteSubtree(file,
|
|
1565
|
+
return applyMutation(args, (file, idIndex) => deleteSubtree(file, resolveNodeId(idIndex, argNodeId(args))));
|
|
1360
1566
|
case "promote_variation":
|
|
1361
|
-
return applyMutation(args, file => promoteVariation(file,
|
|
1567
|
+
return applyMutation(args, (file, idIndex) => promoteVariation(file, resolveNodeId(idIndex, argNodeId(args))));
|
|
1362
1568
|
case "set_tag":
|
|
1363
1569
|
return applyMutation(args, file => ({
|
|
1364
1570
|
file: setTag(file, String(args.key), String(args.value ?? "")),
|
|
1365
|
-
|
|
1571
|
+
id: ROOT_ID,
|
|
1366
1572
|
}));
|
|
1367
1573
|
case "apply_mutations":
|
|
1368
1574
|
return applyBatchMutations(args);
|
|
1369
1575
|
case "auto_evaluate":
|
|
1370
1576
|
return autoEvaluate(args);
|
|
1577
|
+
case "quote_engine_eval": {
|
|
1578
|
+
const fileId = String(args.id);
|
|
1579
|
+
const nodeId = argNodeId(args);
|
|
1580
|
+
const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
|
|
1581
|
+
const g = raw;
|
|
1582
|
+
if (typeof g.pgnContent !== "string")
|
|
1583
|
+
throw new Error("prep file missing pgnContent");
|
|
1584
|
+
const file = parsePGN(g.pgnContent);
|
|
1585
|
+
const idIndex = buildIdIndex(file.root);
|
|
1586
|
+
const path = resolveNodeId(idIndex, nodeId);
|
|
1587
|
+
const node = getNodeByPath(file.root, path);
|
|
1588
|
+
return { ceoEval: node.ceoEval ?? null };
|
|
1589
|
+
}
|
|
1371
1590
|
case "read_engine_usage_guide":
|
|
1372
1591
|
return { guide: ENGINE_USAGE_DOC };
|
|
1373
1592
|
case "read_prep_strategy_guide":
|
|
@@ -1381,35 +1600,15 @@ async function callToolInner(name, args) {
|
|
|
1381
1600
|
const opp = Number(args.fide_id_opponent);
|
|
1382
1601
|
const myColor = String(args.my_color);
|
|
1383
1602
|
const oppColor = myColor === "white" ? "black" : "white";
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
// parallel instead of doing a preliminary round-trip.
|
|
1389
|
-
if (!fen) {
|
|
1390
|
-
const board = new Chess();
|
|
1391
|
-
if (line.length > 0) {
|
|
1392
|
-
for (const raw of line.split(/\s+/)) {
|
|
1393
|
-
// Tolerant of move-number tokens like "1." / "12..." that some
|
|
1394
|
-
// clients include; chess.js rejects those outright.
|
|
1395
|
-
const san = raw.replace(/^\d+\.+/, "");
|
|
1396
|
-
if (!san)
|
|
1397
|
-
continue;
|
|
1398
|
-
try {
|
|
1399
|
-
board.move(san);
|
|
1400
|
-
}
|
|
1401
|
-
catch {
|
|
1402
|
-
throw new Error(`bad SAN token '${raw}' in line`);
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
fen = board.fen();
|
|
1407
|
-
}
|
|
1603
|
+
// Prefer file_id+node_id — derives FEN from the tree, no LLM-typed
|
|
1604
|
+
// FEN in the loop. Falls back to line/fen for scratch positions.
|
|
1605
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1606
|
+
const fen = resolved.fen;
|
|
1408
1607
|
const prepParams = (fideId, color) => ({
|
|
1409
1608
|
fideId,
|
|
1410
1609
|
color,
|
|
1411
1610
|
compact: "true",
|
|
1412
|
-
|
|
1611
|
+
fen,
|
|
1413
1612
|
});
|
|
1414
1613
|
const [opponent, you, general, ev] = await Promise.all([
|
|
1415
1614
|
get("/api/chess/prep/by-player", prepParams(opp, oppColor)),
|
|
@@ -1418,7 +1617,7 @@ async function callToolInner(name, args) {
|
|
|
1418
1617
|
fetchCompactEval(fen),
|
|
1419
1618
|
]);
|
|
1420
1619
|
return {
|
|
1421
|
-
position: {
|
|
1620
|
+
position: { fen, my_color: myColor, ...(resolved.file ? { node_id: typeof args.node_id === "string" ? args.node_id : undefined } : {}) },
|
|
1422
1621
|
eval: ev,
|
|
1423
1622
|
opponent: convertAvailableMovesToSAN(opponent, fen),
|
|
1424
1623
|
you: convertAvailableMovesToSAN(you, fen),
|