@chessceo/mcp 0.44.0 → 0.48.2
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/analysis/auto.js +291 -0
- package/dist/analysis/deep.js +157 -0
- package/dist/analysis/file_handle.js +156 -0
- package/dist/analysis/response.js +181 -0
- package/dist/courses.js +167 -0
- package/dist/index.js +64 -1513
- package/dist/pgn/exporter.js +13 -2
- package/dist/pgn/parser.js +46 -7
- package/dist/pgn/paths.js +37 -14
- package/dist/pgn/types.js +8 -5
- package/dist/prep/library.js +92 -0
- package/dist/prep/mutations.js +176 -0
- package/dist/prep/read.js +242 -0
- package/dist/response_transforms.js +169 -0
- package/dist/tools.js +35 -28
- package/dist/warnings.js +44 -1
- package/docs/pgn-authoring.md +13 -1
- package/docs/summary-authoring.md +97 -0
- package/package.json +1 -1
package/dist/pgn/exporter.js
CHANGED
|
@@ -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
|
-
|
|
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}`);
|
package/dist/pgn/parser.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
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).
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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,
|
|
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.
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
//
|
|
16
|
-
// is ~10^-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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,176 @@
|
|
|
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, positionalNagOnIntermediateWarning, } 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
|
+
const nagsArr = Array.isArray(op.nags) ? op.nags.map(String) : [];
|
|
83
|
+
const targetPath = resolve(nodeIdField("node_id"));
|
|
84
|
+
const targetNode = getNodeByPath(file.root, targetPath);
|
|
85
|
+
const nagWarn = positionalNagOnIntermediateWarning(targetNode, nagsArr);
|
|
86
|
+
const step = setNags(file, targetPath, nagsArr);
|
|
87
|
+
return { ...step, ...(nagWarn ? { warning: nagWarn } : {}) };
|
|
88
|
+
}
|
|
89
|
+
case "set_annotations": {
|
|
90
|
+
const arrows = Array.isArray(op.arrows) ? op.arrows : [];
|
|
91
|
+
const highlights = Array.isArray(op.highlights) ? op.highlights : [];
|
|
92
|
+
const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
|
|
93
|
+
return setAnnotations(file, resolve(nodeIdField("node_id")), ann);
|
|
94
|
+
}
|
|
95
|
+
case "set_ceo_eval": {
|
|
96
|
+
const ev = op.ceoEval;
|
|
97
|
+
return setCeoEval(file, resolve(nodeIdField("node_id")), ev ?? null);
|
|
98
|
+
}
|
|
99
|
+
case "delete_subtree":
|
|
100
|
+
return deleteSubtree(file, resolve(nodeIdField("node_id")));
|
|
101
|
+
case "promote_variation":
|
|
102
|
+
return promoteVariation(file, resolve(nodeIdField("node_id")));
|
|
103
|
+
case "set_tag":
|
|
104
|
+
return { file: setTag(file, String(op.key), String(op.value ?? "")), id: ROOT_ID };
|
|
105
|
+
default:
|
|
106
|
+
throw new Error(`unknown mutation op: ${kind}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Batch: load, parse, apply N mutations in order, export, save.
|
|
110
|
+
// All-or-nothing — any error aborts and nothing is saved. The id index
|
|
111
|
+
// is rebuilt after each op so nodes created earlier in the batch can be
|
|
112
|
+
// addressed by later ops via their newly-derived node_id.
|
|
113
|
+
export async function applyBatchMutations(args) {
|
|
114
|
+
const id = String(args.id);
|
|
115
|
+
const mutations = Array.isArray(args.mutations) ? args.mutations : [];
|
|
116
|
+
if (mutations.length === 0)
|
|
117
|
+
throw new Error("mutations array required");
|
|
118
|
+
const g = await fetchGame(id);
|
|
119
|
+
let file = parsePGN(g.pgnContent);
|
|
120
|
+
let idIndex = buildIdIndex(file.root);
|
|
121
|
+
const results = [];
|
|
122
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
123
|
+
const op = mutations[i];
|
|
124
|
+
try {
|
|
125
|
+
const step = dispatchMutation(file, idIndex, op);
|
|
126
|
+
file = step.file;
|
|
127
|
+
idIndex = buildIdIndex(file.root);
|
|
128
|
+
results.push({
|
|
129
|
+
node_id: step.id,
|
|
130
|
+
...(step.results !== undefined ? { line: step.results } : {}),
|
|
131
|
+
...(step.warning ? { warning: step.warning } : {}),
|
|
132
|
+
...(step.warnings && step.warnings.length > 0 ? { warnings: step.warnings } : {}),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
137
|
+
throw new Error(`mutation #${i} (${String(op.op)}) failed: ${msg}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const newPgn = exportPGN(file);
|
|
141
|
+
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
142
|
+
const saved = await saveGame(id, newPgn, expected);
|
|
143
|
+
return { ok: true, results, version: saved.version };
|
|
144
|
+
}
|
|
145
|
+
// Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
|
|
146
|
+
// save with optimistic lock. Auto-saves so every tool call is atomic;
|
|
147
|
+
// the LLM never sees intermediate state. The mutator is called with
|
|
148
|
+
// both the parsed file and its id → path index, so the mutation can
|
|
149
|
+
// resolve node_ids without rebuilding the index itself.
|
|
150
|
+
export async function applyMutation(args, mutator) {
|
|
151
|
+
const id = String(args.id);
|
|
152
|
+
const g = await fetchGame(id);
|
|
153
|
+
const file = parsePGN(g.pgnContent);
|
|
154
|
+
const idIndex = buildIdIndex(file.root);
|
|
155
|
+
let result;
|
|
156
|
+
try {
|
|
157
|
+
result = mutator(file, idIndex);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (err instanceof MutationError || err instanceof PathError || err instanceof NodeIdError) {
|
|
161
|
+
throw new Error(`mutation rejected: ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
const newPgn = exportPGN(result.file);
|
|
166
|
+
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
167
|
+
const saved = await saveGame(id, newPgn, expected);
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
node_id: result.id,
|
|
171
|
+
...(result.results !== undefined ? { line: result.results } : {}),
|
|
172
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
173
|
+
...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
174
|
+
version: saved.version,
|
|
175
|
+
};
|
|
176
|
+
}
|