@chessceo/mcp 0.30.2 → 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 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) {
@@ -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`. Use this when you have a starting FEN and want to walk from there — e.g. fen='<game tabiya>', moves='b4 a5 c3' to explore that continuation. Wins over `line` if both are set.",
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` to walk from there.",
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 to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
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 the starting position; kept for compatibility.",
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 is flexible pass `fen`, or `moves` from startpos, or `fen + moves` to walk from there.",
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
- fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
279
- moves: { type: "string", description: "Optional SAN moves to apply on top of `fen`." },
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 is flexible pass `fen`, or `moves` from startpos, or `fen + moves`. Default rating 2400 both sides. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
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
- fen: { type: "string", description: "Starting position as FEN." },
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 the free `analyse` tool (single Stockfish, 2s) or `get_position_stats` for anything that doesn't require deep prep.",
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
- fen: { type: "string", description: "Starting position as FEN. Combine with `moves` to walk from there." },
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 on top of startpos). Example: fen='<tabiya>', moves='b4 a5 c3' analyses the position after those three moves.",
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 (path-addressable, one node per move) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN — all edits go through the mutation tools (add_move / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
471
- "Path addressing: paths are arrays of child indices. `[]` = root position. `[0]` = the first mainline move. `[0, 1]` = the second variation branching after the first move. `[0, 0, 0]` = three plies deep on the mainline. Every mutation returns the effective path it landed at.",
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 at `path`. If the node already has children, the new move becomes a variation (appended at the end). Use promote_variation afterwards if you want it to be the mainline. SAN is validated against the position — illegal moves are rejected with a clear error.\n\n" +
509
- "Auto-saves. Returns `{path, version}` — the effective path the new node landed at (which you pass to follow-up set_comment / set_nags / etc.) and the new file version for optimistic locking on your next mutation.",
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
- path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the parent node the move is played FROM." },
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", "path", "san"],
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 at `path`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.",
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
- path: { type: "array", items: { type: "integer", minimum: 0 } },
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", "path", "comment"],
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 at `path`. Empty array clears them. See read_pgn_authoring_guide for the full NAG table and eval→NAG thresholds (|eval|<0.25 $10; <0.6 $14/$15; <1.3 → $16/$17; ≥1.3 $18/$19). Auto-saves.",
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
- path: { type: "array", items: { type: "integer", minimum: 0 } },
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", "path", "nags"],
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 at `path`. Passing empty arrays clears them.\n\n" +
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
- path: { type: "array", items: { type: "integer", minimum: 0 } },
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", "path"],
623
+ required: ["id", "node_id"],
584
624
  },
585
625
  },
586
626
  {
587
627
  name: "delete_subtree",
588
- description: "Delete the node at `path` and all its descendants. Refuses to delete the root. Auto-saves.",
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
- path: { type: "array", items: { type: "integer", minimum: 0 } },
633
+ node_id: { type: "string" },
594
634
  expected_version: { type: "integer" },
595
635
  },
596
- required: ["id", "path"],
636
+ required: ["id", "node_id"],
597
637
  },
598
638
  },
599
639
  {
600
640
  name: "promote_variation",
601
- description: "Make the node at `path` 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.",
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
- path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the node to promote. Length 1." },
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", "path"],
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, 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" +
616
- "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.",
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
- path: { type: "array", items: { type: "integer", minimum: 0 } },
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,15 +686,15 @@ const TOOLS = [
644
686
  },
645
687
  {
646
688
  name: "auto_evaluate",
647
- description: "Walk the tree from `path` (default `[]` = whole file) and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every node via cloud_analyse. Requires a running cloud combo instance.\n\n" +
648
- "**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 (novelty, sharp choice, real mistake, decisive advantage).\n\n" +
649
- "On re-read every node with a stored eval carries `ceoEval: { sf: {cp, depth}, lc0: {cp, depth} }` — the numbers travel with the file. Use `only_missing=true` (default) to skip nodes already evaluated on repeat runs.\n\n" +
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" +
650
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.",
651
693
  inputSchema: {
652
694
  type: "object",
653
695
  properties: {
654
696
  id: { type: "string" },
655
- path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Subtree root (default = whole file)." },
697
+ node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
656
698
  only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
657
699
  movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
658
700
  expected_version: { type: "integer" },
@@ -660,6 +702,19 @@ const TOOLS = [
660
702
  required: ["id"],
661
703
  },
662
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
+ },
663
718
  {
664
719
  name: "set_tag",
665
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.",
@@ -704,13 +759,15 @@ const TOOLS = [
704
759
  fide_id_me: { type: "integer", description: "Your FIDE ID." },
705
760
  fide_id_opponent: { type: "integer", description: "Opponent's FIDE ID." },
706
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`." },
707
764
  line: {
708
765
  type: "string",
709
- description: "Move sequence in SAN, space-separated. Empty = starting position. Example: 'e4 c5 Nf3'. Either line or fen (or neither for the starting position).",
766
+ description: "Move sequence in SAN, space-separated. Empty = starting position. Only used if `node_id` is not set.",
710
767
  },
711
768
  fen: {
712
769
  type: "string",
713
- 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.",
714
771
  },
715
772
  },
716
773
  required: ["fide_id_me", "fide_id_opponent", "my_color"],
@@ -799,56 +856,70 @@ function convertCloudSnapshotResponse(raw, startFen) {
799
856
  }
800
857
  return raw;
801
858
  }
802
- // Path arg parsing for the mutation tools. Rejects malformed input
803
- // with a clear message the LLM can act on.
804
- function argPath(args) {
805
- const raw = args.path;
806
- if (!Array.isArray(raw))
807
- throw new Error("`path` must be an array of non-negative integers");
808
- const out = [];
809
- for (let i = 0; i < raw.length; i++) {
810
- const n = raw[i];
811
- if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
812
- throw new Error(`path[${i}] must be a non-negative integer (got ${JSON.stringify(n)})`);
813
- }
814
- 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)`);
815
866
  }
816
- return out;
867
+ return raw.trim();
817
868
  }
818
- // Dispatch table for the batch tool: name → mutator that returns the
819
- // standard { file, path } shape. Reused for individual and batch calls.
820
- function dispatchMutation(file, op) {
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) {
821
874
  const kind = String(op.op);
822
- const path = Array.isArray(op.path) ? op.path : [];
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);
823
885
  switch (kind) {
824
886
  case "add_move":
825
- return addMove(file, path, String(op.san));
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
+ }
826
895
  case "set_comment":
827
- return setComment(file, path, typeof op.comment === "string" ? op.comment : "");
896
+ return setComment(file, resolve(nodeIdField("node_id")), typeof op.comment === "string" ? op.comment : "");
828
897
  case "set_nags":
829
- return setNags(file, path, Array.isArray(op.nags) ? op.nags.map(String) : []);
898
+ return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
830
899
  case "set_annotations": {
831
900
  const arrows = Array.isArray(op.arrows) ? op.arrows : [];
832
901
  const highlights = Array.isArray(op.highlights) ? op.highlights : [];
833
902
  const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
834
- return setAnnotations(file, path, ann);
903
+ return setAnnotations(file, resolve(nodeIdField("node_id")), ann);
835
904
  }
836
905
  case "set_ceo_eval": {
837
906
  const ev = op.ceoEval;
838
- return setCeoEval(file, path, ev ?? null);
907
+ return setCeoEval(file, resolve(nodeIdField("node_id")), ev ?? null);
839
908
  }
840
909
  case "delete_subtree":
841
- return deleteSubtree(file, path);
910
+ return deleteSubtree(file, resolve(nodeIdField("node_id")));
842
911
  case "promote_variation":
843
- return promoteVariation(file, path);
912
+ return promoteVariation(file, resolve(nodeIdField("node_id")));
844
913
  case "set_tag":
845
- return { file: setTag(file, String(op.key), String(op.value ?? "")), path: [] };
914
+ return { file: setTag(file, String(op.key), String(op.value ?? "")), id: ROOT_ID };
846
915
  default:
847
916
  throw new Error(`unknown mutation op: ${kind}`);
848
917
  }
849
918
  }
850
- // Batch: load, parse, apply N mutations in order, export, save. All-or-nothing —
851
- // 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.
852
923
  async function applyBatchMutations(args) {
853
924
  const id = String(args.id);
854
925
  const mutations = Array.isArray(args.mutations) ? args.mutations : [];
@@ -859,13 +930,15 @@ async function applyBatchMutations(args) {
859
930
  if (typeof g.pgnContent !== "string")
860
931
  throw new Error("prep file missing pgnContent");
861
932
  let file = parsePGN(g.pgnContent);
933
+ let idIndex = buildIdIndex(file.root);
862
934
  const results = [];
863
935
  for (let i = 0; i < mutations.length; i++) {
864
936
  const op = mutations[i];
865
937
  try {
866
- const step = dispatchMutation(file, op);
938
+ const step = dispatchMutation(file, idIndex, op);
867
939
  file = step.file;
868
- results.push({ path: step.path });
940
+ idIndex = buildIdIndex(file.root);
941
+ results.push({ node_id: step.id, ...(step.results !== undefined ? { line: step.results } : {}) });
869
942
  }
870
943
  catch (err) {
871
944
  const msg = err instanceof Error ? err.message : String(err);
@@ -888,7 +961,9 @@ async function applyBatchMutations(args) {
888
961
  // mutation at the end so a 200-node evaluate is one save.
889
962
  async function autoEvaluate(args) {
890
963
  const id = String(args.id);
891
- const startPath = Array.isArray(args.path) ? args.path : [];
964
+ const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
965
+ ? String(args.node_id)
966
+ : ROOT_ID;
892
967
  const onlyMissing = args.only_missing !== false; // default true
893
968
  const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
894
969
  const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
@@ -896,18 +971,22 @@ async function autoEvaluate(args) {
896
971
  if (typeof g.pgnContent !== "string")
897
972
  throw new Error("prep file missing pgnContent");
898
973
  const file = parsePGN(g.pgnContent);
974
+ const idIndex = buildIdIndex(file.root);
975
+ const startPath = resolveNodeId(idIndex, startNodeId);
899
976
  const targets = [];
900
- const walk = (node, path) => {
901
- if (path.length > 0) {
977
+ const walk = (node, isStartAndRoot) => {
978
+ if (!isStartAndRoot) {
902
979
  if (!onlyMissing || !node.ceoEval) {
903
- targets.push({ path, fen: node.fen });
980
+ targets.push({ nodeId: node.id, fen: node.fen });
904
981
  }
905
982
  }
906
- for (let i = 0; i < node.children.length; i++)
907
- walk(node.children[i], [...path, i]);
983
+ for (const child of node.children)
984
+ walk(child, false);
908
985
  };
909
- const startNode = pathIntoTree(file.root, startPath);
910
- walk(startNode, startPath);
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);
911
990
  if (targets.length === 0)
912
991
  return { ok: true, evaluated: 0, skipped: 0, version: g.version };
913
992
  // Dispatch cloud_analyse in parallel with a 4-way cap so we don't
@@ -923,7 +1002,7 @@ async function autoEvaluate(args) {
923
1002
  const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
924
1003
  const ev = analysisToStoredEval(analysis, t.fen);
925
1004
  if (ev)
926
- stored.push({ path: t.path, ev });
1005
+ stored.push({ nodeId: t.nodeId, ev });
927
1006
  }
928
1007
  catch {
929
1008
  // best-effort — a single failed node shouldn't kill the batch
@@ -933,9 +1012,16 @@ async function autoEvaluate(args) {
933
1012
  await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
934
1013
  if (stored.length === 0)
935
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.
936
1022
  const batchMutations = [];
937
1023
  for (const s of stored) {
938
- batchMutations.push({ op: "set_ceo_eval", path: s.path, ceoEval: s.ev });
1024
+ batchMutations.push({ op: "set_ceo_eval", node_id: s.nodeId, ceoEval: s.ev });
939
1025
  }
940
1026
  const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
941
1027
  const sr = saveResult;
@@ -1030,7 +1116,9 @@ function storedEvalToCompact(ev, analysis) {
1030
1116
  }
1031
1117
  // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
1032
1118
  // save with optimistic lock. Auto-saves so every tool call is atomic;
1033
- // 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.
1034
1122
  async function applyMutation(args, mutator) {
1035
1123
  const id = String(args.id);
1036
1124
  const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
@@ -1038,12 +1126,13 @@ async function applyMutation(args, mutator) {
1038
1126
  if (typeof g.pgnContent !== "string")
1039
1127
  throw new Error("prep file missing pgnContent");
1040
1128
  const file = parsePGN(g.pgnContent);
1129
+ const idIndex = buildIdIndex(file.root);
1041
1130
  let result;
1042
1131
  try {
1043
- result = mutator(file);
1132
+ result = mutator(file, idIndex);
1044
1133
  }
1045
1134
  catch (err) {
1046
- if (err instanceof MutationError || err instanceof PathError) {
1135
+ if (err instanceof MutationError || err instanceof PathError || err instanceof NodeIdError) {
1047
1136
  throw new Error(`mutation rejected: ${err.message}`);
1048
1137
  }
1049
1138
  throw err;
@@ -1057,7 +1146,8 @@ async function applyMutation(args, mutator) {
1057
1146
  const savedRow = saved;
1058
1147
  return {
1059
1148
  ok: true,
1060
- path: result.path,
1149
+ node_id: result.id,
1150
+ ...(result.results !== undefined ? { line: result.results } : {}),
1061
1151
  version: savedRow.version,
1062
1152
  };
1063
1153
  }
@@ -1208,6 +1298,65 @@ function resolveFenFromArgs(args) {
1208
1298
  }
1209
1299
  return board.fen();
1210
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
+ }
1211
1360
  function stringifyForLog(v) {
1212
1361
  let s;
1213
1362
  try {
@@ -1244,31 +1393,21 @@ async function callToolInner(name, args) {
1244
1393
  case "get_player_profile":
1245
1394
  return get("/api/chess/players/profile", { fideId: Number(args.fide_id) });
1246
1395
  case "get_player_preparation": {
1247
- // Prefer sending `line` to the backend when the caller specified moves
1248
- // WITHOUT a fen the backend then attaches the cumulative-line SAN to
1249
- // each move in the response (nicer for the LLM's follow-up walks).
1250
- // When a fen was supplied, always resolve the effective position
1251
- // client-side and send just fen — the backend can't reconstruct
1252
- // history it didn't see anyway.
1253
- const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
1254
- const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
1255
- 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;
1256
1401
  const params = {
1257
1402
  fideId: Number(args.fide_id),
1258
1403
  color: String(args.color),
1259
1404
  compact: "true",
1405
+ fen: effectiveFen,
1260
1406
  };
1261
- if (fenArg || movesArg) {
1262
- params.fen = resolveFenFromArgs(args);
1263
- }
1264
- else if (lineArg) {
1265
- params.line = lineArg;
1266
- }
1267
1407
  if (typeof args.limit === "number")
1268
1408
  params.limit = args.limit;
1269
1409
  if (typeof args.offset === "number")
1270
1410
  params.offset = args.offset;
1271
- const effectiveFen = resolveFenFromArgs(args);
1272
1411
  const [raw, ev] = await Promise.all([
1273
1412
  get("/api/chess/prep/by-player", params),
1274
1413
  fetchCompactEval(effectiveFen),
@@ -1283,7 +1422,8 @@ async function callToolInner(name, args) {
1283
1422
  return converted;
1284
1423
  }
1285
1424
  case "get_position_stats": {
1286
- const fen = resolveFenFromArgs(args);
1425
+ const resolved = await resolveFromNodeOrFen(args);
1426
+ const fen = resolved.fen;
1287
1427
  const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
1288
1428
  const source = rawSource === "main" ? "main" : "gm-classical";
1289
1429
  const [raw, ev] = await Promise.all([
@@ -1304,10 +1444,13 @@ async function callToolInner(name, args) {
1304
1444
  }
1305
1445
  return converted;
1306
1446
  }
1307
- case "describe_position":
1308
- return describePosition(resolveFenFromArgs(args));
1447
+ case "describe_position": {
1448
+ const resolved = await resolveFromNodeOrFen(args);
1449
+ return describePosition(resolved.fen);
1450
+ }
1309
1451
  case "predict_human_move": {
1310
- const fen = resolveFenFromArgs(args);
1452
+ const resolved = await resolveFromNodeOrFen(args);
1453
+ const fen = resolved.fen;
1311
1454
  const qs = new URLSearchParams();
1312
1455
  qs.set("fen", fen);
1313
1456
  if (typeof args.white_elo === "number")
@@ -1349,7 +1492,8 @@ async function callToolInner(name, args) {
1349
1492
  case "stop_cloud_engine":
1350
1493
  return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
1351
1494
  case "cloud_analyse": {
1352
- const fen = resolveFenFromArgs(args);
1495
+ const resolved = await resolveFromNodeOrFen(args);
1496
+ const fen = resolved.fen;
1353
1497
  const body = { fen };
1354
1498
  if (typeof args.movetime_ms === "number")
1355
1499
  body.movetime_ms = args.movetime_ms;
@@ -1358,7 +1502,19 @@ async function callToolInner(name, args) {
1358
1502
  if (typeof args.contempt === "number")
1359
1503
  body.contempt = args.contempt;
1360
1504
  const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1361
- return convertCloudSnapshotResponse(raw, fen);
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;
1362
1518
  }
1363
1519
  case "list_prep_files":
1364
1520
  return authedRequest("GET", "/api/agent/prep-files");
@@ -1384,32 +1540,53 @@ async function callToolInner(name, args) {
1384
1540
  case "delete_prep_file":
1385
1541
  return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
1386
1542
  case "add_move":
1387
- return applyMutation(args, file => addMove(file, argPath(args), String(args.san)));
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
+ });
1388
1552
  case "set_comment":
1389
- return applyMutation(args, file => setComment(file, argPath(args), typeof args.comment === "string" ? args.comment : ""));
1553
+ return applyMutation(args, (file, idIndex) => setComment(file, resolveNodeId(idIndex, argNodeId(args)), typeof args.comment === "string" ? args.comment : ""));
1390
1554
  case "set_nags":
1391
- return applyMutation(args, file => setNags(file, argPath(args), Array.isArray(args.nags) ? args.nags.map(String) : []));
1555
+ return applyMutation(args, (file, idIndex) => setNags(file, resolveNodeId(idIndex, argNodeId(args)), Array.isArray(args.nags) ? args.nags.map(String) : []));
1392
1556
  case "set_annotations": {
1393
1557
  const arrowsRaw = Array.isArray(args.arrows) ? args.arrows : [];
1394
1558
  const highlightsRaw = Array.isArray(args.highlights) ? args.highlights : [];
1395
1559
  const ann = (arrowsRaw.length === 0 && highlightsRaw.length === 0)
1396
1560
  ? null
1397
1561
  : { arrows: arrowsRaw, highlights: highlightsRaw };
1398
- return applyMutation(args, file => setAnnotations(file, argPath(args), ann));
1562
+ return applyMutation(args, (file, idIndex) => setAnnotations(file, resolveNodeId(idIndex, argNodeId(args)), ann));
1399
1563
  }
1400
1564
  case "delete_subtree":
1401
- return applyMutation(args, file => deleteSubtree(file, argPath(args)));
1565
+ return applyMutation(args, (file, idIndex) => deleteSubtree(file, resolveNodeId(idIndex, argNodeId(args))));
1402
1566
  case "promote_variation":
1403
- return applyMutation(args, file => promoteVariation(file, argPath(args)));
1567
+ return applyMutation(args, (file, idIndex) => promoteVariation(file, resolveNodeId(idIndex, argNodeId(args))));
1404
1568
  case "set_tag":
1405
1569
  return applyMutation(args, file => ({
1406
1570
  file: setTag(file, String(args.key), String(args.value ?? "")),
1407
- path: [],
1571
+ id: ROOT_ID,
1408
1572
  }));
1409
1573
  case "apply_mutations":
1410
1574
  return applyBatchMutations(args);
1411
1575
  case "auto_evaluate":
1412
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
+ }
1413
1590
  case "read_engine_usage_guide":
1414
1591
  return { guide: ENGINE_USAGE_DOC };
1415
1592
  case "read_prep_strategy_guide":
@@ -1423,35 +1600,15 @@ async function callToolInner(name, args) {
1423
1600
  const opp = Number(args.fide_id_opponent);
1424
1601
  const myColor = String(args.my_color);
1425
1602
  const oppColor = myColor === "white" ? "black" : "white";
1426
- const line = typeof args.line === "string" ? args.line.trim() : "";
1427
- let fen = typeof args.fen === "string" ? args.fen.trim() : "";
1428
- // General DB lookup needs a FEN. If we only have a line, compute it
1429
- // locally with chess.js — one dep, keeps the three data-fetches truly
1430
- // parallel instead of doing a preliminary round-trip.
1431
- if (!fen) {
1432
- const board = new Chess();
1433
- if (line.length > 0) {
1434
- for (const raw of line.split(/\s+/)) {
1435
- // Tolerant of move-number tokens like "1." / "12..." that some
1436
- // clients include; chess.js rejects those outright.
1437
- const san = raw.replace(/^\d+\.+/, "");
1438
- if (!san)
1439
- continue;
1440
- try {
1441
- board.move(san);
1442
- }
1443
- catch {
1444
- throw new Error(`bad SAN token '${raw}' in line`);
1445
- }
1446
- }
1447
- }
1448
- fen = board.fen();
1449
- }
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;
1450
1607
  const prepParams = (fideId, color) => ({
1451
1608
  fideId,
1452
1609
  color,
1453
1610
  compact: "true",
1454
- ...(line.length > 0 ? { line } : { fen }),
1611
+ fen,
1455
1612
  });
1456
1613
  const [opponent, you, general, ev] = await Promise.all([
1457
1614
  get("/api/chess/prep/by-player", prepParams(opp, oppColor)),
@@ -1460,7 +1617,7 @@ async function callToolInner(name, args) {
1460
1617
  fetchCompactEval(fen),
1461
1618
  ]);
1462
1619
  return {
1463
- position: { line, fen, my_color: myColor },
1620
+ position: { fen, my_color: myColor, ...(resolved.file ? { node_id: typeof args.node_id === "string" ? args.node_id : undefined } : {}) },
1464
1621
  eval: ev,
1465
1622
  opponent: convertAvailableMovesToSAN(opponent, fen),
1466
1623
  you: convertAvailableMovesToSAN(you, fen),