@chessceo/mcp 0.44.0 → 0.46.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.
@@ -25,9 +25,20 @@ export function exportPGN(file) {
25
25
  parts.push(`[${k} "${escapeTagValue(v)}"]`);
26
26
  }
27
27
  parts.push(""); // blank line between headers and movetext
28
- // Movetext.
28
+ // Movetext. Root can carry a text comment AND visual annotations
29
+ // AND a stored eval — all rendered in a single {…} block before the
30
+ // first move so the parser's regexes pick them all up on re-parse.
31
+ // (v0.46 fix: previously only the visual/eval annotation was emitted
32
+ // and it went out unbraced — a plain comment set on the root via
33
+ // set_comment silently disappeared during export.)
34
+ const rootPreludeBits = [];
35
+ if (file.root.comment && file.root.comment.trim().length > 0) {
36
+ rootPreludeBits.push(file.root.comment.trim());
37
+ }
29
38
  const rootAnnotation = renderNodeAnnotation(file.root);
30
- const rootPrelude = rootAnnotation ? `${rootAnnotation} ` : "";
39
+ if (rootAnnotation)
40
+ rootPreludeBits.push(rootAnnotation);
41
+ const rootPrelude = rootPreludeBits.length > 0 ? `{${rootPreludeBits.join(" ")}} ` : "";
31
42
  const movetext = rootPrelude + renderChildren(file.root.children, file.root.ply + 1, /* forceMoveNumber */ true);
32
43
  const result = file.tags.Result ?? "*";
33
44
  parts.push(`${movetext.trim()} ${result}`);
@@ -15,7 +15,15 @@ import { deriveNodeId, ROOT_ID } from "./paths.js";
15
15
  const CAL_RE = /\[%cal\s+([^\]]+)\]/g;
16
16
  const CSL_RE = /\[%csl\s+([^\]]+)\]/g;
17
17
  const CEO_EVAL_RE = /\[%ceo-eval\s+([^\]]+)\]/g;
18
- const ANY_CMD_RE = /\[%[a-z-]+\s+[^\]]+\]/g;
18
+ // Only strip the three escape commands we structurally parse (into
19
+ // annotations + ceoEval). Everything else — [%eval], [%wdl], [%clk],
20
+ // [%emt], and any future/unknown [%foo] — is preserved verbatim in
21
+ // the comment text so a parse → export round-trip is lossless.
22
+ // v0.46 fix: previously ANY_CMD_RE matched `\[%[a-z-]+\s+[^\]]+\]`
23
+ // and silently dropped every unknown escape (real report: a
24
+ // `{[%eval] [%wdl]}` comment on an imported Lichess PGN got stripped
25
+ // on a set_tag save cycle).
26
+ const KNOWN_CMD_RE = /\[%(?:cal|csl|ceo-eval)\s+[^\]]+\]/g;
19
27
  // Parse a `sf=+0.20/38` or `lc0=M-5/22` fragment into its numeric parts.
20
28
  // Returns null if the value doesn't parse; the parser tolerates missing
21
29
  // depth and mate notation.
@@ -85,16 +93,41 @@ export function parseCommentAnnotations(comment) {
85
93
  if (ev.sf || ev.lc0 || ev.nag)
86
94
  ceoEval = ev;
87
95
  }
88
- const text = comment.replace(ANY_CMD_RE, "").replace(/\s+/g, " ").trim();
96
+ const text = comment.replace(KNOWN_CMD_RE, "").replace(/\s+/g, " ").trim();
89
97
  const annotations = arrows.length || highlights.length ? { arrows, highlights } : undefined;
90
98
  return { text, annotations, ceoEval };
91
99
  }
100
+ // chessops' parsePgn silently drops any {…} block that sits BEFORE
101
+ // the first move — we've verified this: `{root note} 1. e4` parses
102
+ // with the "root note" nowhere in the resulting tree. That broke
103
+ // set_comment(node_id: "r") round-trips (v0.46 bug report). Extract
104
+ // the leading root-level comment ourselves before handing the rest
105
+ // to chessops. Anything past the leading `{…}` is left untouched so
106
+ // chessops can do its normal job.
107
+ function extractRootComment(pgn) {
108
+ const sepMatch = /\r?\n\r?\n/.exec(pgn);
109
+ if (!sepMatch)
110
+ return { pgn, rootComment: null };
111
+ const headerEnd = sepMatch.index + sepMatch[0].length;
112
+ const body = pgn.slice(headerEnd);
113
+ // Leading whitespace, then a {…} block. Non-greedy so we stop at
114
+ // the first `}` even if the file has more comments later.
115
+ const m = /^\s*\{([\s\S]*?)\}\s*/.exec(body);
116
+ if (!m)
117
+ return { pgn, rootComment: null };
118
+ const rest = body.slice(m[0].length);
119
+ return {
120
+ pgn: pgn.slice(0, headerEnd) + rest,
121
+ rootComment: m[1],
122
+ };
123
+ }
92
124
  // Parse full PGN → PrepFile. Throws on unrecoverable errors (no games,
93
125
  // invalid starting FEN). Illegal SAN moves in the movetext are skipped
94
126
  // silently — matches the frontend's tolerant behaviour so a game with
95
127
  // one typo doesn't nuke the whole tree.
96
128
  export function parsePGN(pgn) {
97
- const games = parsePgn(pgn);
129
+ const { pgn: cleanPgn, rootComment: preComment } = extractRootComment(pgn);
130
+ const games = parsePgn(cleanPgn);
98
131
  if (games.length === 0)
99
132
  throw new Error("no games in PGN");
100
133
  const game = games[0];
@@ -107,10 +140,16 @@ export function parsePGN(pgn) {
107
140
  const startPos = startResult.value;
108
141
  const rootFen = makeFen(startPos.toSetup());
109
142
  const root = { id: ROOT_ID, san: null, fen: rootFen, ply: 0, children: [] };
110
- // Root-level comment on the game (rare, but supported).
111
- const gameComments = game.moves.comments;
112
- if (gameComments && gameComments.length > 0) {
113
- const parsed = parseCommentAnnotations(gameComments.join(" "));
143
+ // Root-level comment on the game. Two sources:
144
+ // 1. `game.moves.comments` — kept as a fallback in case chessops
145
+ // ever populates it (currently doesn't for pre-move-1 blocks).
146
+ // 2. `preComment` — the {…} we pre-extracted above.
147
+ const gameCommentsRaw = game.moves.comments ?? [];
148
+ const rootCommentBits = [...gameCommentsRaw];
149
+ if (preComment)
150
+ rootCommentBits.push(preComment);
151
+ if (rootCommentBits.length > 0) {
152
+ const parsed = parseCommentAnnotations(rootCommentBits.join(" "));
114
153
  if (parsed.text)
115
154
  root.comment = parsed.text;
116
155
  if (parsed.annotations)
package/dist/pgn/paths.js CHANGED
@@ -13,21 +13,28 @@
13
13
  import { createHash } from "node:crypto";
14
14
  // Special ID for the root position (starting FEN, no move played).
15
15
  export const ROOT_ID = "r";
16
- // 8-hex-char (32-bit) content hash. Root is "r". Every other node's
16
+ // 12-hex-char (48-bit) content hash. Root is "r". Every other node's
17
17
  // id is derived from (parent.id, san). Two children of the same parent
18
18
  // with the same SAN are impossible in real chess — same SAN from the
19
19
  // same position IS the same move — so within-parent same-id events
20
20
  // mean the caller tried to duplicate a move that already exists;
21
21
  // `addMove` handles that by joining to the existing child rather than
22
- // appending a duplicate (see mutations.ts). Cross-tree birthday
23
- // collisions in 32 bits at 1000 nodes are ~10^-4 — rare, and thrown
24
- // at parse time rather than silently masked.
22
+ // appending a duplicate (see mutations.ts).
23
+ //
24
+ // v0.46: widened from 32 → 48 bit. At the 32-bit width birthday
25
+ // collisions in real prep files ran ~10^-4 at 1000 nodes and were
26
+ // hit in practice, throwing at buildIdIndex time and rendering the
27
+ // whole file unreadable. 48 bits drops that to ~10^-9 at 1000 nodes
28
+ // (~10^-7 at 10k). If a collision still lands, buildIdIndex now
29
+ // keeps the first occurrence and logs rather than throwing, so
30
+ // reads survive; only mutations against the collided id go to the
31
+ // first-found node.
25
32
  export function deriveNodeId(parentId, san) {
26
33
  const h = createHash("sha256");
27
34
  h.update(parentId);
28
35
  h.update("|");
29
36
  h.update(san);
30
- return h.digest("hex").slice(0, 8);
37
+ return h.digest("hex").slice(0, 12);
31
38
  }
32
39
  // Bad-node-id errors so the LLM sees exactly what went wrong. Message
33
40
  // carries the offending id, which is often a copy-paste bug (missing
@@ -46,19 +53,35 @@ export class PathError extends Error {
46
53
  this.path = path;
47
54
  }
48
55
  }
49
- // Build the id → path index for a whole tree. Also detects the rare
50
- // collision (two nodes with the same 32-bit id) and throws so the
51
- // caller can surface a clear message.
56
+ // Build the id → path index for a whole tree.
57
+ //
58
+ // v0.46: collisions no longer throw. At 48-bit width they're
59
+ // statistically improbable (~10^-9 at 1000 nodes), but if one does
60
+ // land — e.g. a persisted PGN whose IDs were derived by an older
61
+ // 32-bit build — throwing would render the whole file unreadable
62
+ // via the MCP surface. Instead, we keep the FIRST occurrence in the
63
+ // index and emit a stderr line naming both nodes. Reads survive
64
+ // (`read_prep_file` returns the full tree; the collided IDs both
65
+ // appear in the tree even though only one is addressable via
66
+ // `resolveNodeId`). Mutations against the collided id hit the
67
+ // first-found node deterministically. Downstream tools that care —
68
+ // `list_nodes`, `find_position_in_files`, engine calls that don't
69
+ // need a node handle — keep working normally.
52
70
  export function buildIdIndex(root) {
53
71
  const index = new Map();
54
72
  const walk = (node, path) => {
55
- if (index.has(node.id)) {
56
- throw new Error(`node id collision on "${node.id}" — two distinct nodes in the tree ` +
57
- `hash to the same 32-bit id. This is statistically rare (~10^-4 at ` +
58
- `1000 nodes); if you see this, please report the file so we can ` +
59
- `widen the hash.`);
73
+ const existing = index.get(node.id);
74
+ if (existing !== undefined) {
75
+ // Keep the earlier occurrence; log so operators can widen further
76
+ // if this recurs. Message names both nodes' SAN + ply so the file
77
+ // is identifiable.
78
+ console.error(`[mcp] node id collision on "${node.id}" — first at path ${JSON.stringify(existing)}, ` +
79
+ `now at path ${JSON.stringify(path)} (san=${node.san}, ply=${node.ply}). ` +
80
+ `Keeping the first; mutations against this id will hit that node.`);
81
+ }
82
+ else {
83
+ index.set(node.id, path);
60
84
  }
61
- index.set(node.id, path);
62
85
  for (let i = 0; i < node.children.length; i++) {
63
86
  walk(node.children[i], [...path, i]);
64
87
  }
package/dist/pgn/types.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Nodes are addressed by a stable, content-derived `id`:
4
4
  // root.id = "r"
5
- // node.id = first 8 hex chars of sha256(parent.id + "|" + san)
5
+ // node.id = first 12 hex chars of sha256(parent.id + "|" + san)
6
6
  //
7
7
  // The derivation is a pure function of the tree structure, so IDs
8
8
  // survive parse → mutate → export → reparse without needing to be
@@ -12,10 +12,13 @@
12
12
  // op inserts a sibling) simply cannot happen. See src/pgn/paths.ts
13
13
  // for the id → path resolution used to power mutation calls.
14
14
  //
15
- // 32-bit width (8 hex chars) means birthday-collision probability
16
- // is ~10^-4 even at 1000 nodes — the largest prep files we see are
17
- // well under that. On the rare parse-time collision we throw a clear
18
- // error rather than persisting anything ambiguous.
15
+ // v0.46: 48-bit width (12 hex chars) means birthday-collision
16
+ // probability is ~10^-9 at 1000 nodes and ~10^-7 at 10k. Was 32-bit
17
+ // (8 hex chars) through v0.45; collisions hit ~10^-4 there and were
18
+ // observed in live prep files. If a collision still lands at the new
19
+ // width, buildIdIndex keeps the first occurrence and logs to stderr
20
+ // rather than throwing — the file stays readable, only mutations
21
+ // against the collided id are ambiguous.
19
22
  // Named colours as they appear in the parsed tree. The wire format uses
20
23
  // single-letter codes (G, R, Y, C, B, O); the tree uses these longer
21
24
  // names to match how the frontend represents them.
@@ -0,0 +1,92 @@
1
+ // Discovery + creation over the user's full PGN library. v0.43 removed
2
+ // the hidden `/mcp`-folder scoping; every function here now reaches
3
+ // across every non-encrypted collection the user owns.
4
+ //
5
+ // Extracted from index.ts in v0.44 as part of the file split.
6
+ import { authedRequest, createGame, makeFileId, PGN_BASE, unwrap, } from "../http.js";
7
+ import { resolveFromNodeOrFen } from "../analysis/file_handle.js";
8
+ export async function listCollections(_args) {
9
+ const raw = await authedRequest("GET", PGN_BASE);
10
+ const collections = unwrap(raw) ?? [];
11
+ return {
12
+ collections: collections.map(c => ({
13
+ id: c.id,
14
+ title: c.title,
15
+ icon: c.icon,
16
+ folder_path: c.folderPath,
17
+ game_count: c.gameCount,
18
+ position_search_enabled: c.positionSearchEnabled,
19
+ updated_at: c.updatedAt,
20
+ })),
21
+ };
22
+ }
23
+ // Convert a browser-returned game list row into the LLM shape (composite
24
+ // id, cleaned field names).
25
+ function projectGameRow(row) {
26
+ const collId = row.collectionId ?? "";
27
+ return {
28
+ id: collId ? makeFileId(collId, row.id) : row.id,
29
+ collection_id: collId,
30
+ collection_title: row.collectionTitle,
31
+ event: row.event,
32
+ white: row.white_player,
33
+ black: row.black_player,
34
+ eco: row.eco,
35
+ opening: row.opening,
36
+ updated_at: row.updated_at,
37
+ ply: row.ply,
38
+ };
39
+ }
40
+ export async function listPrepFiles(args) {
41
+ const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
42
+ if (!collectionId) {
43
+ throw new Error("collection_id required — call list_collections to see your options, or search across collections with search_prep_files / find_position_in_files");
44
+ }
45
+ // Browser handler at GET /me/pgns/{id}/games returns a paginated list.
46
+ const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games?page=1&limit=200`);
47
+ const data = unwrap(raw);
48
+ const games = data?.games ?? (Array.isArray(data) ? data : []);
49
+ return { collection_id: collectionId, prep_files: games.map(projectGameRow) };
50
+ }
51
+ export async function searchPrepFiles(args) {
52
+ const q = typeof args.query === "string" ? args.query.trim() : "";
53
+ if (!q)
54
+ throw new Error("query required");
55
+ const raw = await authedRequest("GET", `${PGN_BASE}/games/search?q=${encodeURIComponent(q)}&limit=100`);
56
+ const data = unwrap(raw);
57
+ const games = data?.games ?? [];
58
+ return { query: q, prep_files: games.map(projectGameRow) };
59
+ }
60
+ export async function findPositionInFiles(args) {
61
+ // FEN can come from a node handle OR a direct fen/moves/line. Reuse
62
+ // the same resolver everything else uses.
63
+ const resolved = await resolveFromNodeOrFen(args);
64
+ const fen = resolved.fen;
65
+ const raw = await authedRequest("GET", `${PGN_BASE}/games/search?position=${encodeURIComponent(fen)}&limit=100`);
66
+ const data = unwrap(raw);
67
+ const games = data?.games ?? [];
68
+ return {
69
+ fen,
70
+ match_count: games.length,
71
+ prep_files: games.map(projectGameRow),
72
+ };
73
+ }
74
+ export async function createPrepFile(args) {
75
+ const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
76
+ if (!collectionId) {
77
+ throw new Error("collection_id required — call list_collections to pick where the new file lives. There is no default landing folder any more (v0.43: the old hidden /mcp collection was removed).");
78
+ }
79
+ const name = String(args.name || "").trim();
80
+ if (!name)
81
+ throw new Error("name is required");
82
+ // Seed with a PGN carrying the LLM-chosen name as the Event tag so
83
+ // subsequent list_prep_files calls display something useful.
84
+ const seedPgn = `[Event "${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n\n*\n`;
85
+ const game = await createGame(collectionId, seedPgn);
86
+ return {
87
+ ok: true,
88
+ id: makeFileId(game.collectionId ?? collectionId, game.id),
89
+ collection_id: game.collectionId ?? collectionId,
90
+ version: game.version,
91
+ };
92
+ }
@@ -0,0 +1,170 @@
1
+ // Mutation orchestration for prep files.
2
+ //
3
+ // Every mutation tool goes through one of two entry points:
4
+ // - `applyMutation(args, mutator)` — the standard case handler wrapper.
5
+ // Fetches the file, runs the mutator, saves. Auto-saves so every
6
+ // tool call is atomic; the LLM never sees intermediate state.
7
+ // - `applyBatchMutations(args)` — the `apply_mutations` tool. Loads
8
+ // once, runs N ops in order via `dispatchMutation`, saves once.
9
+ // All-or-nothing.
10
+ //
11
+ // `dispatchMutation` is the switch that maps `op` names to the actual
12
+ // mutation helpers from `pgn/mutations` plus the anti-pattern warning
13
+ // scanners.
14
+ //
15
+ // Extracted from index.ts in v0.44 as part of the file split.
16
+ import { fetchGame, saveGame } from "../http.js";
17
+ import { parsePGN } from "../pgn/parser.js";
18
+ import { exportPGN } from "../pgn/exporter.js";
19
+ import { buildIdIndex, NodeIdError, PathError, resolveNodeId, ROOT_ID, } from "../pgn/paths.js";
20
+ import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "../pgn/mutations.js";
21
+ import { getNodeByPath } from "../analysis/file_handle.js";
22
+ import { commentAntiPatterns, longLineWarning, noDescribeWarning, noStatsCheckWarning, } from "../warnings.js";
23
+ // Extract a node id from the args. Accepts either `node_id` or a
24
+ // `parent_id` alias for the add-style tools. Throws with a helpful
25
+ // message if malformed.
26
+ export function argNodeId(args, key = "node_id") {
27
+ const raw = args[key];
28
+ if (typeof raw !== "string" || raw.length === 0) {
29
+ throw new Error(`\`${key}\` is required (call read_prep_file to get valid node ids)`);
30
+ }
31
+ return raw.trim();
32
+ }
33
+ // Dispatch table for the batch tool: name → mutator that returns
34
+ // { file, id } where id is the node the mutation touched. The batch
35
+ // caller rebuilds the id → path index between ops so newly-created
36
+ // nodes are addressable within the same batch.
37
+ export function dispatchMutation(file, idIndex, op) {
38
+ const kind = String(op.op);
39
+ // Small local helper — resolves a node_id (or parent_id) op field to
40
+ // a path against the CURRENT tree state.
41
+ const nodeIdField = (key) => {
42
+ const raw = op[key];
43
+ if (typeof raw !== "string" || raw.length === 0) {
44
+ throw new Error(`\`${key}\` required on op ${kind}`);
45
+ }
46
+ return raw.trim();
47
+ };
48
+ const resolve = (id) => resolveNodeId(idIndex, id);
49
+ switch (kind) {
50
+ case "add_move": {
51
+ const parentPath = resolve(nodeIdField("parent_id"));
52
+ const parent = getNodeByPath(file.root, parentPath);
53
+ const noStatsWarn = noStatsCheckWarning(parent);
54
+ const step = addMove(file, parentPath, String(op.san));
55
+ return { ...step, ...(noStatsWarn ? { warning: noStatsWarn } : {}) };
56
+ }
57
+ case "add_line": {
58
+ const sans = Array.isArray(op.sans) ? op.sans.map(String) : [];
59
+ const parentPath = resolve(nodeIdField("parent_id"));
60
+ const parent = getNodeByPath(file.root, parentPath);
61
+ const step = addLine(file, parentPath, sans);
62
+ const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : nodeIdField("parent_id");
63
+ // Same anti-pattern warnings as the standalone add_line case —
64
+ // long unbranched line + no-stats-check parent are both bugs
65
+ // whether they land solo or inside a batch.
66
+ const longLineWarn = longLineWarning(sans.length);
67
+ const noStatsWarn = noStatsCheckWarning(parent);
68
+ const warnings = [longLineWarn, noStatsWarn].filter((s) => !!s);
69
+ return { file: step.file, id: lastId, results: step.line, ...(warnings.length > 0 ? { warnings } : {}) };
70
+ }
71
+ case "set_comment": {
72
+ const commentStr = typeof op.comment === "string" ? op.comment : "";
73
+ const commentWarns = commentAntiPatterns(commentStr);
74
+ const targetPath = resolve(nodeIdField("node_id"));
75
+ const targetNode = getNodeByPath(file.root, targetPath);
76
+ const describeWarn = noDescribeWarning(targetNode, commentStr);
77
+ const step = setComment(file, targetPath, commentStr);
78
+ const all = [...commentWarns, ...(describeWarn ? [describeWarn] : [])];
79
+ return { ...step, ...(all.length > 0 ? { warnings: all } : {}) };
80
+ }
81
+ case "set_nags":
82
+ return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
83
+ case "set_annotations": {
84
+ const arrows = Array.isArray(op.arrows) ? op.arrows : [];
85
+ const highlights = Array.isArray(op.highlights) ? op.highlights : [];
86
+ const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
87
+ return setAnnotations(file, resolve(nodeIdField("node_id")), ann);
88
+ }
89
+ case "set_ceo_eval": {
90
+ const ev = op.ceoEval;
91
+ return setCeoEval(file, resolve(nodeIdField("node_id")), ev ?? null);
92
+ }
93
+ case "delete_subtree":
94
+ return deleteSubtree(file, resolve(nodeIdField("node_id")));
95
+ case "promote_variation":
96
+ return promoteVariation(file, resolve(nodeIdField("node_id")));
97
+ case "set_tag":
98
+ return { file: setTag(file, String(op.key), String(op.value ?? "")), id: ROOT_ID };
99
+ default:
100
+ throw new Error(`unknown mutation op: ${kind}`);
101
+ }
102
+ }
103
+ // Batch: load, parse, apply N mutations in order, export, save.
104
+ // All-or-nothing — any error aborts and nothing is saved. The id index
105
+ // is rebuilt after each op so nodes created earlier in the batch can be
106
+ // addressed by later ops via their newly-derived node_id.
107
+ export async function applyBatchMutations(args) {
108
+ const id = String(args.id);
109
+ const mutations = Array.isArray(args.mutations) ? args.mutations : [];
110
+ if (mutations.length === 0)
111
+ throw new Error("mutations array required");
112
+ const g = await fetchGame(id);
113
+ let file = parsePGN(g.pgnContent);
114
+ let idIndex = buildIdIndex(file.root);
115
+ const results = [];
116
+ for (let i = 0; i < mutations.length; i++) {
117
+ const op = mutations[i];
118
+ try {
119
+ const step = dispatchMutation(file, idIndex, op);
120
+ file = step.file;
121
+ idIndex = buildIdIndex(file.root);
122
+ results.push({
123
+ node_id: step.id,
124
+ ...(step.results !== undefined ? { line: step.results } : {}),
125
+ ...(step.warning ? { warning: step.warning } : {}),
126
+ ...(step.warnings && step.warnings.length > 0 ? { warnings: step.warnings } : {}),
127
+ });
128
+ }
129
+ catch (err) {
130
+ const msg = err instanceof Error ? err.message : String(err);
131
+ throw new Error(`mutation #${i} (${String(op.op)}) failed: ${msg}`);
132
+ }
133
+ }
134
+ const newPgn = exportPGN(file);
135
+ const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
136
+ const saved = await saveGame(id, newPgn, expected);
137
+ return { ok: true, results, version: saved.version };
138
+ }
139
+ // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
140
+ // save with optimistic lock. Auto-saves so every tool call is atomic;
141
+ // the LLM never sees intermediate state. The mutator is called with
142
+ // both the parsed file and its id → path index, so the mutation can
143
+ // resolve node_ids without rebuilding the index itself.
144
+ export async function applyMutation(args, mutator) {
145
+ const id = String(args.id);
146
+ const g = await fetchGame(id);
147
+ const file = parsePGN(g.pgnContent);
148
+ const idIndex = buildIdIndex(file.root);
149
+ let result;
150
+ try {
151
+ result = mutator(file, idIndex);
152
+ }
153
+ catch (err) {
154
+ if (err instanceof MutationError || err instanceof PathError || err instanceof NodeIdError) {
155
+ throw new Error(`mutation rejected: ${err.message}`);
156
+ }
157
+ throw err;
158
+ }
159
+ const newPgn = exportPGN(result.file);
160
+ const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
161
+ const saved = await saveGame(id, newPgn, expected);
162
+ return {
163
+ ok: true,
164
+ node_id: result.id,
165
+ ...(result.results !== undefined ? { line: result.results } : {}),
166
+ ...(result.warning ? { warning: result.warning } : {}),
167
+ ...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
168
+ version: saved.version,
169
+ };
170
+ }