@chessceo/mcp 0.42.0 → 0.43.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 +282 -72
- package/docs/pgn-authoring.md +3 -1
- package/docs/prep-files-guide.md +22 -12
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -73,8 +73,10 @@ const AUTHED_TOOLS = new Set([
|
|
|
73
73
|
"list_cloud_engines",
|
|
74
74
|
"stop_cloud_engine",
|
|
75
75
|
"cloud_analyse",
|
|
76
|
+
"list_collections",
|
|
76
77
|
"list_prep_files",
|
|
77
78
|
"search_prep_files",
|
|
79
|
+
"find_position_in_files",
|
|
78
80
|
"read_prep_file",
|
|
79
81
|
"list_nodes",
|
|
80
82
|
"list_transpositions",
|
|
@@ -174,6 +176,72 @@ async function authedRequest(method, path, body) {
|
|
|
174
176
|
}
|
|
175
177
|
return text.length ? JSON.parse(text) : null;
|
|
176
178
|
}
|
|
179
|
+
// ── Backend I/O layer ──────────────────────────────────────────────
|
|
180
|
+
//
|
|
181
|
+
// v0.43: MCP surface moved off the single-`/mcp`-folder model onto the
|
|
182
|
+
// full user PGN library. LLM-facing tool ids are opaque composites of
|
|
183
|
+
// the form `<collection_id>:<game_id>` so every existing tool that
|
|
184
|
+
// takes `id` keeps taking `id` — the composite splits back into two
|
|
185
|
+
// pieces at the HTTP layer. Backend routes live under
|
|
186
|
+
// `/api/agent/pgns/*` (same handlers as `/me/pgns/*` browser routes;
|
|
187
|
+
// a path-rewrite middleware in the server main.go maps the two).
|
|
188
|
+
const PGN_BASE = "/api/agent/pgns";
|
|
189
|
+
// Browser handlers wrap successful responses as
|
|
190
|
+
// { success: true, message: "...", data: <actual thing> }
|
|
191
|
+
// via handlers.RespondSuccess. Peel that off; leave anything without
|
|
192
|
+
// the envelope unchanged (some endpoints return raw bodies).
|
|
193
|
+
function unwrap(raw) {
|
|
194
|
+
if (raw &&
|
|
195
|
+
typeof raw === "object" &&
|
|
196
|
+
"data" in raw &&
|
|
197
|
+
raw.success === true) {
|
|
198
|
+
return raw.data;
|
|
199
|
+
}
|
|
200
|
+
return raw;
|
|
201
|
+
}
|
|
202
|
+
// LLM-facing "prep file id" is always "<collection_id>:<game_id>" —
|
|
203
|
+
// makeFileId to compose from a listing, splitFileId at the HTTP edge.
|
|
204
|
+
function makeFileId(collectionId, gameId) {
|
|
205
|
+
return `${collectionId}:${gameId}`;
|
|
206
|
+
}
|
|
207
|
+
function splitFileId(id) {
|
|
208
|
+
const idx = id.indexOf(":");
|
|
209
|
+
if (idx <= 0 || idx === id.length - 1) {
|
|
210
|
+
throw new Error(`invalid prep file id "${id}" — expected "<collection_id>:<game_id>" ` +
|
|
211
|
+
`(get one from list_prep_files, search_prep_files, find_position_in_files, ` +
|
|
212
|
+
`or the create_prep_file return value)`);
|
|
213
|
+
}
|
|
214
|
+
return { collectionId: id.slice(0, idx), gameId: id.slice(idx + 1) };
|
|
215
|
+
}
|
|
216
|
+
// GET one game by composite id. Throws on 404.
|
|
217
|
+
async function fetchGame(id) {
|
|
218
|
+
const { collectionId, gameId } = splitFileId(id);
|
|
219
|
+
const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
|
|
220
|
+
const g = unwrap(raw);
|
|
221
|
+
if (!g || typeof g.pgnContent !== "string") {
|
|
222
|
+
throw new Error("prep file missing pgnContent");
|
|
223
|
+
}
|
|
224
|
+
return g;
|
|
225
|
+
}
|
|
226
|
+
// PUT the game body. Returns the saved game (new version).
|
|
227
|
+
async function saveGame(id, pgn, expectedVersion) {
|
|
228
|
+
const { collectionId, gameId } = splitFileId(id);
|
|
229
|
+
const body = { pgnContent: pgn };
|
|
230
|
+
if (typeof expectedVersion === "number")
|
|
231
|
+
body.baseVersion = expectedVersion;
|
|
232
|
+
const raw = await authedRequest("PUT", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`, body);
|
|
233
|
+
return unwrap(raw);
|
|
234
|
+
}
|
|
235
|
+
// POST a new game into the given collection. Returns the created game.
|
|
236
|
+
async function createGame(collectionId, pgn) {
|
|
237
|
+
const raw = await authedRequest("POST", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games`, { pgnContent: pgn });
|
|
238
|
+
return unwrap(raw);
|
|
239
|
+
}
|
|
240
|
+
// DELETE (soft) a game by composite id.
|
|
241
|
+
async function deleteGame(id) {
|
|
242
|
+
const { collectionId, gameId } = splitFileId(id);
|
|
243
|
+
await authedRequest("DELETE", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
|
|
244
|
+
}
|
|
177
245
|
// ── Tool definitions ───────────────────────────────────────────────
|
|
178
246
|
//
|
|
179
247
|
// Descriptions are written for the LLM, not humans — they should hint
|
|
@@ -332,7 +400,8 @@ const TOOLS = [
|
|
|
332
400
|
},
|
|
333
401
|
{
|
|
334
402
|
name: "describe_position",
|
|
335
|
-
description: "
|
|
403
|
+
description: "**CALL WHEN**: about to write ANY comment on a position that describes what's happening on the board — piece activity, structure, plans, weaknesses. This is the single biggest lever for prose quality in the whole system. Live audit: nodes where describe_position was called first produced comments grounded specifically in the position (correct piece squares, real pawn structure, actual weak squares); nodes where it wasn't produced generic pattern-matched prose that confidently named pieces on wrong squares. `set_comment` now emits a warning whenever a substantive comment lands on a node whose position was never grounded via describe_position this session — that warning is telling you to fix a class of hallucination that already showed up in your output. Cheap: chess-primitive analysis is instant, Stockfish leg is ~50-100 ms, no billing.\n\n" +
|
|
404
|
+
"Everything you need to understand a position in one call. Pieces get misplaced when reading a FEN, hanging pieces missed, 'the knight on d5' turns out to not exist.\n\n" +
|
|
336
405
|
"Returns three layers:\n\n" +
|
|
337
406
|
"**Board state** — piece placements per colour, material balance in pawn units, contested pieces (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant, side to move, full LEGAL MOVES list. Use `.legalMoves` when `add_move` rejects an illegal SAN.\n\n" +
|
|
338
407
|
"**Structural analysis** — chess-concept observations a human sees at a glance:\n" +
|
|
@@ -533,13 +602,27 @@ const TOOLS = [
|
|
|
533
602
|
},
|
|
534
603
|
},
|
|
535
604
|
{
|
|
536
|
-
name: "
|
|
537
|
-
description: "List
|
|
605
|
+
name: "list_collections",
|
|
606
|
+
description: "List the user's own PGN collections — every collection they've created, not just prep files. Response: `{collections: [{id, title, icon, folder_path, game_count, updated_at, position_search_enabled}]}`.\n\n" +
|
|
607
|
+
"**Call this before create_prep_file** — the LLM must pick a collection to write into (no default landing folder any more; the old hidden `/mcp` collection was retired in v0.43). Also useful when the user asks a position-shaped question — LLM can then run find_position_in_files to see which of these collections already covers the position.\n\n" +
|
|
608
|
+
"Encrypted collections (client-side-encrypted PGN) are excluded — the server can't read their contents, so they'd be dead weight on this surface.",
|
|
538
609
|
inputSchema: { type: "object", properties: {} },
|
|
539
610
|
},
|
|
611
|
+
{
|
|
612
|
+
name: "list_prep_files",
|
|
613
|
+
description: "List the games (prep files) inside one of the user's collections. **Requires `collection_id`** — call `list_collections` first if you don't have one. Returns id (composite `<collection_id>:<game_id>`, opaque to the LLM — pass as-is to read_prep_file / mutation tools), PGN header fields, updated_at.\n\n" +
|
|
614
|
+
"For cross-collection discovery use `search_prep_files` (text) or `find_position_in_files` (position); list_prep_files is the browse-one-collection tool.",
|
|
615
|
+
inputSchema: {
|
|
616
|
+
type: "object",
|
|
617
|
+
properties: {
|
|
618
|
+
collection_id: { type: "string", description: "Collection id from list_collections. Required." },
|
|
619
|
+
},
|
|
620
|
+
required: ["collection_id"],
|
|
621
|
+
},
|
|
622
|
+
},
|
|
540
623
|
{
|
|
541
624
|
name: "search_prep_files",
|
|
542
|
-
description: "Text search over the user's prep files (matches PGN headers, comments, and content). Use
|
|
625
|
+
description: "Text search over the user's prep files ACROSS ALL their collections (matches PGN headers, comments, and content). Use when you know a keyword — e.g. search_prep_files(query='Firouzja') or search_prep_files(query='Najdorf'). Cheaper than paging list_collections + list_prep_files to find one file by name.",
|
|
543
626
|
inputSchema: {
|
|
544
627
|
type: "object",
|
|
545
628
|
properties: {
|
|
@@ -548,6 +631,22 @@ const TOOLS = [
|
|
|
548
631
|
required: ["query"],
|
|
549
632
|
},
|
|
550
633
|
},
|
|
634
|
+
{
|
|
635
|
+
name: "find_position_in_files",
|
|
636
|
+
description: "Position search across every one of the user's EDITABLE prep files (all their non-encrypted collections). Given a FEN, returns which of the user's files reach that exact position (or a transposition of it — matched by zobrist hash, so move-order variants are found automatically). Recency-sorted.\n\n" +
|
|
637
|
+
"Distinct from `find_position_in_courses`: courses are READ-ONLY reference material (Chessable PGNs, downloaded backups); this searches the user's OWN editable prep. Common workflow: user asks about a position → call this first to see if their existing prep covers it → if yes, extend that file; if no, consider whether to start new prep.\n\n" +
|
|
638
|
+
"Position input: `file_id`+`node_id` (from an already-open prep file), or `fen`, or `moves` from startpos, or `fen`+`moves`.",
|
|
639
|
+
inputSchema: {
|
|
640
|
+
type: "object",
|
|
641
|
+
properties: {
|
|
642
|
+
file_id: { type: "string", description: "Prep file id. With `node_id`, derives FEN from the tree." },
|
|
643
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
|
|
644
|
+
fen: { type: "string", description: "Position as FEN. Only used if `file_id`/`node_id` not set." },
|
|
645
|
+
moves: { type: "string", description: "SAN moves from startpos (or on top of fen)." },
|
|
646
|
+
line: { type: "string", description: "Alias for moves." },
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
},
|
|
551
650
|
{
|
|
552
651
|
name: "read_prep_file",
|
|
553
652
|
description: "Read one prep file. Response always includes `id`, `version`, `tags`. The tree/PGN part is controlled by `view` and `node_id`/`max_depth` — large files (500+ nodes) can otherwise blow the LLM's token limit.\n\n" +
|
|
@@ -611,17 +710,23 @@ const TOOLS = [
|
|
|
611
710
|
},
|
|
612
711
|
{
|
|
613
712
|
name: "create_prep_file",
|
|
614
|
-
description: "Create a new (empty) prep file. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
|
|
615
|
-
"
|
|
713
|
+
description: "Create a new (empty) prep file in the specified collection. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
|
|
714
|
+
"**collection_id is REQUIRED** — call `list_collections` first to pick where it lives. There is no default landing folder any more (v0.43: the old hidden `/mcp` collection was removed; prep files now live wherever the user organizes them).\n\n" +
|
|
715
|
+
"**Duplicate-check first.** Call `search_prep_files(query=<opponent / opening keyword>)` OR `find_position_in_files(fen=...)` before creating — a second 'Prep vs Firouzja' file when one already exists is a common LLM failure mode. If a file already covers the topic, extend that one instead.\n\n" +
|
|
716
|
+
"Response: `{ok, id, collection_id, version}` — `id` is a composite you pass to every other prep-file tool as `id` or `file_id`.",
|
|
616
717
|
inputSchema: {
|
|
617
718
|
type: "object",
|
|
618
719
|
properties: {
|
|
720
|
+
collection_id: {
|
|
721
|
+
type: "string",
|
|
722
|
+
description: "Collection id from list_collections. Required — this is where the new file lands.",
|
|
723
|
+
},
|
|
619
724
|
name: {
|
|
620
725
|
type: "string",
|
|
621
726
|
description: "User-facing name — becomes the [Event] tag. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
|
|
622
727
|
},
|
|
623
728
|
},
|
|
624
|
-
required: ["name"],
|
|
729
|
+
required: ["collection_id", "name"],
|
|
625
730
|
},
|
|
626
731
|
},
|
|
627
732
|
{
|
|
@@ -668,7 +773,10 @@ const TOOLS = [
|
|
|
668
773
|
},
|
|
669
774
|
{
|
|
670
775
|
name: "set_comment",
|
|
671
|
-
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
|
|
776
|
+
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.\n\n" +
|
|
777
|
+
"**Two guardrails fire in the response as `warnings: [...]`:**\n" +
|
|
778
|
+
" 1. **Content scan** — comments containing spread lists (`≈50, ≈42, …`), raw centipawn values (`≈−60`, `+0.35`, `at depth 24`), or roster restatement (`146 GM games — Nakamura, …`) are all restating what the app already renders. The warning names the fix (set the NAG and drop the number; label the character not the numbers; cite a specific game instead of a count).\n" +
|
|
779
|
+
" 2. **Ungrounded prose** — substantive comments (≥40 chars) on a node whose position was never passed to `describe_position` this session are prone to hallucinated structural claims (piece on wrong square, invented captures, misidentified pawn structure). Call `describe_position` with `file_id`+`node_id` BEFORE writing prose about the position; the same node's warning clears once the position is described.",
|
|
672
780
|
inputSchema: {
|
|
673
781
|
type: "object",
|
|
674
782
|
properties: {
|
|
@@ -1092,6 +1200,16 @@ const positionsStatsChecked = new Set();
|
|
|
1092
1200
|
// Nodes we've ALREADY warned on for the "no stats check" pattern this
|
|
1093
1201
|
// session, so repeated adds under the same parent don't spam the LLM.
|
|
1094
1202
|
const noStatsWarned = new Set();
|
|
1203
|
+
// Session-lifetime memory of positions the LLM has called
|
|
1204
|
+
// `describe_position` on. Same 3-field FEN key. Live-log audit
|
|
1205
|
+
// (2026-07-27 Modern Defence session): 13 describe_position calls
|
|
1206
|
+
// vs 50+ set_comment ops — most comments were written blind. When
|
|
1207
|
+
// describe_position IS called before commentary, prose accuracy
|
|
1208
|
+
// jumps sharply (user's own observation). This tracks the same way
|
|
1209
|
+
// as positionsStatsChecked and drives the noDescribeWarning below.
|
|
1210
|
+
const positionsDescribed = new Set();
|
|
1211
|
+
// Once-per-node dedup for the describe warning.
|
|
1212
|
+
const noDescribeWarned = new Set();
|
|
1095
1213
|
// Detect the anti-patterns the LLM keeps producing in comment prose.
|
|
1096
1214
|
// All of these restate what the app already renders elsewhere:
|
|
1097
1215
|
// - spread lists ("5.O-O ≈50, 6.h3 ≈42, ...")
|
|
@@ -1229,8 +1347,12 @@ function dispatchMutation(file, idIndex, op) {
|
|
|
1229
1347
|
case "set_comment": {
|
|
1230
1348
|
const commentStr = typeof op.comment === "string" ? op.comment : "";
|
|
1231
1349
|
const commentWarns = commentAntiPatterns(commentStr);
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1350
|
+
const targetPath = resolve(nodeIdField("node_id"));
|
|
1351
|
+
const targetNode = getNodeByPath(file.root, targetPath);
|
|
1352
|
+
const describeWarn = noDescribeWarning(targetNode, commentStr);
|
|
1353
|
+
const step = setComment(file, targetPath, commentStr);
|
|
1354
|
+
const all = [...commentWarns, ...(describeWarn ? [describeWarn] : [])];
|
|
1355
|
+
return { ...step, ...(all.length > 0 ? { warnings: all } : {}) };
|
|
1234
1356
|
}
|
|
1235
1357
|
case "set_nags":
|
|
1236
1358
|
return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
|
|
@@ -1263,10 +1385,7 @@ async function applyBatchMutations(args) {
|
|
|
1263
1385
|
const mutations = Array.isArray(args.mutations) ? args.mutations : [];
|
|
1264
1386
|
if (mutations.length === 0)
|
|
1265
1387
|
throw new Error("mutations array required");
|
|
1266
|
-
const
|
|
1267
|
-
const g = raw;
|
|
1268
|
-
if (typeof g.pgnContent !== "string")
|
|
1269
|
-
throw new Error("prep file missing pgnContent");
|
|
1388
|
+
const g = await fetchGame(id);
|
|
1270
1389
|
let file = parsePGN(g.pgnContent);
|
|
1271
1390
|
let idIndex = buildIdIndex(file.root);
|
|
1272
1391
|
const results = [];
|
|
@@ -1290,12 +1409,8 @@ async function applyBatchMutations(args) {
|
|
|
1290
1409
|
}
|
|
1291
1410
|
const newPgn = exportPGN(file);
|
|
1292
1411
|
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
1293
|
-
const saved = await
|
|
1294
|
-
|
|
1295
|
-
expected_version: expected,
|
|
1296
|
-
});
|
|
1297
|
-
const savedRow = saved;
|
|
1298
|
-
return { ok: true, results, version: savedRow.version };
|
|
1412
|
+
const saved = await saveGame(id, newPgn, expected);
|
|
1413
|
+
return { ok: true, results, version: saved.version };
|
|
1299
1414
|
}
|
|
1300
1415
|
const evalJobs = new Map();
|
|
1301
1416
|
// GC finished jobs after this long so status polling remains useful
|
|
@@ -1330,10 +1445,7 @@ async function autoEvaluate(args) {
|
|
|
1330
1445
|
: ROOT_ID;
|
|
1331
1446
|
const onlyMissing = args.only_missing !== false; // default true
|
|
1332
1447
|
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
|
|
1333
|
-
const
|
|
1334
|
-
const g = raw;
|
|
1335
|
-
if (typeof g.pgnContent !== "string")
|
|
1336
|
-
throw new Error("prep file missing pgnContent");
|
|
1448
|
+
const g = await fetchGame(id);
|
|
1337
1449
|
const file = parsePGN(g.pgnContent);
|
|
1338
1450
|
const idIndex = buildIdIndex(file.root);
|
|
1339
1451
|
const startPath = resolveNodeId(idIndex, startNodeId);
|
|
@@ -1946,10 +2058,7 @@ function storedEvalToCompact(ev, analysis) {
|
|
|
1946
2058
|
// resolve node_ids without rebuilding the index itself.
|
|
1947
2059
|
async function applyMutation(args, mutator) {
|
|
1948
2060
|
const id = String(args.id);
|
|
1949
|
-
const
|
|
1950
|
-
const g = raw;
|
|
1951
|
-
if (typeof g.pgnContent !== "string")
|
|
1952
|
-
throw new Error("prep file missing pgnContent");
|
|
2061
|
+
const g = await fetchGame(id);
|
|
1953
2062
|
const file = parsePGN(g.pgnContent);
|
|
1954
2063
|
const idIndex = buildIdIndex(file.root);
|
|
1955
2064
|
let result;
|
|
@@ -1964,20 +2073,36 @@ async function applyMutation(args, mutator) {
|
|
|
1964
2073
|
}
|
|
1965
2074
|
const newPgn = exportPGN(result.file);
|
|
1966
2075
|
const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
|
|
1967
|
-
const saved = await
|
|
1968
|
-
pgn: newPgn,
|
|
1969
|
-
expected_version: expected,
|
|
1970
|
-
});
|
|
1971
|
-
const savedRow = saved;
|
|
2076
|
+
const saved = await saveGame(id, newPgn, expected);
|
|
1972
2077
|
return {
|
|
1973
2078
|
ok: true,
|
|
1974
2079
|
node_id: result.id,
|
|
1975
2080
|
...(result.results !== undefined ? { line: result.results } : {}),
|
|
1976
2081
|
...(result.warning ? { warning: result.warning } : {}),
|
|
1977
2082
|
...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
1978
|
-
version:
|
|
2083
|
+
version: saved.version,
|
|
1979
2084
|
};
|
|
1980
2085
|
}
|
|
2086
|
+
// Compute the "you never called describe_position on this node" warning.
|
|
2087
|
+
// Fires from set_comment when the comment is substantive (>= 40 chars —
|
|
2088
|
+
// anything shorter is a label / pointer, doesn't need structural
|
|
2089
|
+
// grounding). LLMs are unreliable at reading FEN strings and confidently
|
|
2090
|
+
// describe positions that don't match the actual board; describe_position
|
|
2091
|
+
// is a pure-computation grounding pass that reliably fixes this. Warn
|
|
2092
|
+
// once per node.
|
|
2093
|
+
function noDescribeWarning(node, comment) {
|
|
2094
|
+
if (node.id === ROOT_ID)
|
|
2095
|
+
return undefined;
|
|
2096
|
+
if (comment.length < 40)
|
|
2097
|
+
return undefined;
|
|
2098
|
+
const key = positionKey(node.fen);
|
|
2099
|
+
if (positionsDescribed.has(key))
|
|
2100
|
+
return undefined;
|
|
2101
|
+
if (noDescribeWarned.has(node.id))
|
|
2102
|
+
return undefined;
|
|
2103
|
+
noDescribeWarned.add(node.id);
|
|
2104
|
+
return `substantive comment (${comment.length} chars) on a node whose position was never grounded via describe_position this session (id=${node.id}, ${node.san}). LLMs invent captures, miscount pieces, and swap files/ranks when reading FEN strings — describe_position is a pure-computation pass (~1 ms, no engine cost, structural facts + Stockfish's per-term eval breakdown) that reliably prevents this class of hallucination. In live audits, prose accuracy jumps sharply on nodes where describe_position was called first. Call describe_position with file_id+node_id=${node.id} BEFORE writing prose. Warned once per node.`;
|
|
2105
|
+
}
|
|
1981
2106
|
// Compute the "you never DB-checked this parent" warning. Called from
|
|
1982
2107
|
// add_move / add_line handlers with the parent node. Returns undefined
|
|
1983
2108
|
// when either (a) the parent was checked this session (or is root — the
|
|
@@ -1997,11 +2122,11 @@ function noStatsCheckWarning(parent) {
|
|
|
1997
2122
|
return `no get_position_stats call for the parent (id=${parent.id}, ${parent.san}) this session. Course chapter titles describe what an author chose to cover, not what practical opponents play — treating "the So chapter says 6.O-O-O" as "the mainline is 6.O-O-O" is the exact pattern this warning exists to catch. Call get_position_stats at this position (via file_id+node_id=${parent.id}) BEFORE deciding which branches belong here; suppress this warning by making that call. Warned once per parent per session.`;
|
|
1998
2123
|
}
|
|
1999
2124
|
async function loadPrepFile(id) {
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho:
|
|
2125
|
+
const g = await fetchGame(id);
|
|
2126
|
+
// Echo the composite id back so read_prep_file responses match the
|
|
2127
|
+
// exact id the LLM passed in. The backend returns the raw game_id;
|
|
2128
|
+
// recompose so the LLM never sees the split form.
|
|
2129
|
+
return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: id, pgn: g.pgnContent };
|
|
2005
2130
|
}
|
|
2006
2131
|
// Recursively project a PrepNode into the requested view. `depthLeft`
|
|
2007
2132
|
// null → unlimited; 0 → just the node without children.
|
|
@@ -2053,6 +2178,91 @@ function projectNode(node, view, depthLeft, fenIndex = null) {
|
|
|
2053
2178
|
}
|
|
2054
2179
|
return base;
|
|
2055
2180
|
}
|
|
2181
|
+
async function listCollections(_args) {
|
|
2182
|
+
const raw = await authedRequest("GET", PGN_BASE);
|
|
2183
|
+
const collections = unwrap(raw) ?? [];
|
|
2184
|
+
return {
|
|
2185
|
+
collections: collections.map(c => ({
|
|
2186
|
+
id: c.id,
|
|
2187
|
+
title: c.title,
|
|
2188
|
+
icon: c.icon,
|
|
2189
|
+
folder_path: c.folderPath,
|
|
2190
|
+
game_count: c.gameCount,
|
|
2191
|
+
position_search_enabled: c.positionSearchEnabled,
|
|
2192
|
+
updated_at: c.updatedAt,
|
|
2193
|
+
})),
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
// Convert a browser-returned game list row into the LLM shape (composite
|
|
2197
|
+
// id, cleaned field names).
|
|
2198
|
+
function projectGameRow(row) {
|
|
2199
|
+
const collId = row.collectionId ?? "";
|
|
2200
|
+
return {
|
|
2201
|
+
id: collId ? makeFileId(collId, row.id) : row.id,
|
|
2202
|
+
collection_id: collId,
|
|
2203
|
+
collection_title: row.collectionTitle,
|
|
2204
|
+
event: row.event,
|
|
2205
|
+
white: row.white_player,
|
|
2206
|
+
black: row.black_player,
|
|
2207
|
+
eco: row.eco,
|
|
2208
|
+
opening: row.opening,
|
|
2209
|
+
updated_at: row.updated_at,
|
|
2210
|
+
ply: row.ply,
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
async function listPrepFiles(args) {
|
|
2214
|
+
const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
|
|
2215
|
+
if (!collectionId) {
|
|
2216
|
+
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");
|
|
2217
|
+
}
|
|
2218
|
+
// Browser handler at GET /me/pgns/{id}/games returns a paginated list.
|
|
2219
|
+
const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games?page=1&limit=200`);
|
|
2220
|
+
const data = unwrap(raw);
|
|
2221
|
+
const games = data?.games ?? (Array.isArray(data) ? data : []);
|
|
2222
|
+
return { collection_id: collectionId, prep_files: games.map(projectGameRow) };
|
|
2223
|
+
}
|
|
2224
|
+
async function searchPrepFiles(args) {
|
|
2225
|
+
const q = typeof args.query === "string" ? args.query.trim() : "";
|
|
2226
|
+
if (!q)
|
|
2227
|
+
throw new Error("query required");
|
|
2228
|
+
const raw = await authedRequest("GET", `${PGN_BASE}/games/search?q=${encodeURIComponent(q)}&limit=100`);
|
|
2229
|
+
const data = unwrap(raw);
|
|
2230
|
+
const games = data?.games ?? [];
|
|
2231
|
+
return { query: q, prep_files: games.map(projectGameRow) };
|
|
2232
|
+
}
|
|
2233
|
+
async function findPositionInFiles(args) {
|
|
2234
|
+
// FEN can come from a node handle OR a direct fen/moves/line. Reuse
|
|
2235
|
+
// the same resolver everything else uses.
|
|
2236
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
2237
|
+
const fen = resolved.fen;
|
|
2238
|
+
const raw = await authedRequest("GET", `${PGN_BASE}/games/search?position=${encodeURIComponent(fen)}&limit=100`);
|
|
2239
|
+
const data = unwrap(raw);
|
|
2240
|
+
const games = data?.games ?? [];
|
|
2241
|
+
return {
|
|
2242
|
+
fen,
|
|
2243
|
+
match_count: games.length,
|
|
2244
|
+
prep_files: games.map(projectGameRow),
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
async function createPrepFile(args) {
|
|
2248
|
+
const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
|
|
2249
|
+
if (!collectionId) {
|
|
2250
|
+
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).");
|
|
2251
|
+
}
|
|
2252
|
+
const name = String(args.name || "").trim();
|
|
2253
|
+
if (!name)
|
|
2254
|
+
throw new Error("name is required");
|
|
2255
|
+
// Seed with a PGN carrying the LLM-chosen name as the Event tag so
|
|
2256
|
+
// subsequent list_prep_files calls display something useful.
|
|
2257
|
+
const seedPgn = `[Event "${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n\n*\n`;
|
|
2258
|
+
const game = await createGame(collectionId, seedPgn);
|
|
2259
|
+
return {
|
|
2260
|
+
ok: true,
|
|
2261
|
+
id: makeFileId(game.collectionId ?? collectionId, game.id),
|
|
2262
|
+
collection_id: game.collectionId ?? collectionId,
|
|
2263
|
+
version: game.version,
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2056
2266
|
async function readPrepFile(args) {
|
|
2057
2267
|
const id = String(args.id);
|
|
2058
2268
|
const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
|
|
@@ -2429,10 +2639,7 @@ async function resolveFromNodeOrFen(args) {
|
|
|
2429
2639
|
const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
|
|
2430
2640
|
const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
|
|
2431
2641
|
if (fileId && nodeId) {
|
|
2432
|
-
const
|
|
2433
|
-
const g = raw;
|
|
2434
|
-
if (typeof g.pgnContent !== "string")
|
|
2435
|
-
throw new Error("prep file missing pgnContent");
|
|
2642
|
+
const g = await fetchGame(fileId);
|
|
2436
2643
|
const parsedFile = parsePGN(g.pgnContent);
|
|
2437
2644
|
const idIndex = buildIdIndex(parsedFile.root);
|
|
2438
2645
|
const nodePath = resolveNodeId(idIndex, nodeId);
|
|
@@ -2448,18 +2655,15 @@ async function resolveFromNodeOrFen(args) {
|
|
|
2448
2655
|
// node_id had been supplied (auto-persist on match).
|
|
2449
2656
|
const fen = resolveFenFromArgs(args);
|
|
2450
2657
|
try {
|
|
2451
|
-
const
|
|
2452
|
-
const
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
const
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
|
|
2461
|
-
};
|
|
2462
|
-
}
|
|
2658
|
+
const g = await fetchGame(fileId);
|
|
2659
|
+
const parsedFile = parsePGN(g.pgnContent);
|
|
2660
|
+
const match = findNodeByFen(parsedFile.root, fen);
|
|
2661
|
+
if (match) {
|
|
2662
|
+
const idIndex = buildIdIndex(parsedFile.root);
|
|
2663
|
+
return {
|
|
2664
|
+
fen,
|
|
2665
|
+
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
|
|
2666
|
+
};
|
|
2463
2667
|
}
|
|
2464
2668
|
}
|
|
2465
2669
|
catch {
|
|
@@ -2520,10 +2724,7 @@ async function storeEvalOnNode(handle, ev) {
|
|
|
2520
2724
|
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
2521
2725
|
const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
|
|
2522
2726
|
const newPgn = exportPGN(newFile);
|
|
2523
|
-
await
|
|
2524
|
-
pgn: newPgn,
|
|
2525
|
-
expected_version: handle.version,
|
|
2526
|
-
});
|
|
2727
|
+
await saveGame(handle.id, newPgn, handle.version);
|
|
2527
2728
|
// Ensure the primary node (the one the LLM addressed) comes first.
|
|
2528
2729
|
const anchorId = anchor.id;
|
|
2529
2730
|
return [anchorId, ...ids.filter(x => x !== anchorId)];
|
|
@@ -2687,6 +2888,10 @@ async function callToolInner(name, args) {
|
|
|
2687
2888
|
if (ev && ev.found === true) {
|
|
2688
2889
|
merged.engineEvalTerms = { terms: ev.terms, total: ev.total };
|
|
2689
2890
|
}
|
|
2891
|
+
// Record so set_comment on this node won't fire the "not described"
|
|
2892
|
+
// warning. Keyed by 3-field FEN so a described position is
|
|
2893
|
+
// credited across its transpositions too.
|
|
2894
|
+
positionsDescribed.add(positionKey(resolved.fen));
|
|
2690
2895
|
return merged;
|
|
2691
2896
|
}
|
|
2692
2897
|
case "predict_human_move": {
|
|
@@ -2807,10 +3012,14 @@ async function callToolInner(name, args) {
|
|
|
2807
3012
|
return findPositionInCourses(args);
|
|
2808
3013
|
case "read_course_at_position":
|
|
2809
3014
|
return readCourseAtPosition(args);
|
|
3015
|
+
case "list_collections":
|
|
3016
|
+
return listCollections(args);
|
|
2810
3017
|
case "list_prep_files":
|
|
2811
|
-
return
|
|
3018
|
+
return listPrepFiles(args);
|
|
2812
3019
|
case "search_prep_files":
|
|
2813
|
-
return
|
|
3020
|
+
return searchPrepFiles(args);
|
|
3021
|
+
case "find_position_in_files":
|
|
3022
|
+
return findPositionInFiles(args);
|
|
2814
3023
|
case "read_prep_file":
|
|
2815
3024
|
return readPrepFile(args);
|
|
2816
3025
|
case "list_nodes":
|
|
@@ -2818,11 +3027,11 @@ async function callToolInner(name, args) {
|
|
|
2818
3027
|
case "list_transpositions":
|
|
2819
3028
|
return listTranspositions(args);
|
|
2820
3029
|
case "create_prep_file":
|
|
2821
|
-
return
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
3030
|
+
return createPrepFile(args);
|
|
3031
|
+
case "delete_prep_file": {
|
|
3032
|
+
await deleteGame(String(args.id));
|
|
3033
|
+
return { ok: true };
|
|
3034
|
+
}
|
|
2826
3035
|
case "add_move":
|
|
2827
3036
|
return applyMutation(args, (file, idIndex) => {
|
|
2828
3037
|
const parentPath = resolveNodeId(idIndex, argNodeId(args, "parent_id"));
|
|
@@ -2853,10 +3062,14 @@ async function callToolInner(name, args) {
|
|
|
2853
3062
|
const commentStr = typeof args.comment === "string" ? args.comment : "";
|
|
2854
3063
|
const commentWarns = commentAntiPatterns(commentStr);
|
|
2855
3064
|
return applyMutation(args, (file, idIndex) => {
|
|
2856
|
-
const
|
|
3065
|
+
const targetPath = resolveNodeId(idIndex, argNodeId(args));
|
|
3066
|
+
const targetNode = getNodeByPath(file.root, targetPath);
|
|
3067
|
+
const describeWarn = noDescribeWarning(targetNode, commentStr);
|
|
3068
|
+
const step = setComment(file, targetPath, commentStr);
|
|
3069
|
+
const all = [...commentWarns, ...(describeWarn ? [describeWarn] : [])];
|
|
2857
3070
|
return {
|
|
2858
3071
|
...step,
|
|
2859
|
-
...(
|
|
3072
|
+
...(all.length > 0 ? { warnings: all } : {}),
|
|
2860
3073
|
};
|
|
2861
3074
|
});
|
|
2862
3075
|
}
|
|
@@ -2890,10 +3103,7 @@ async function callToolInner(name, args) {
|
|
|
2890
3103
|
case "quote_engine_eval": {
|
|
2891
3104
|
const fileId = String(args.id);
|
|
2892
3105
|
const nodeId = argNodeId(args);
|
|
2893
|
-
const
|
|
2894
|
-
const g = raw;
|
|
2895
|
-
if (typeof g.pgnContent !== "string")
|
|
2896
|
-
throw new Error("prep file missing pgnContent");
|
|
3106
|
+
const g = await fetchGame(fileId);
|
|
2897
3107
|
const file = parsePGN(g.pgnContent);
|
|
2898
3108
|
const idIndex = buildIdIndex(file.root);
|
|
2899
3109
|
const path = resolveNodeId(idIndex, nodeId);
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -316,7 +316,9 @@ Contempt scale is signed 0-100 (same as the web UI's ContemptStrength slider). T
|
|
|
316
316
|
|
|
317
317
|
### Before you write any commentary: describe_position
|
|
318
318
|
|
|
319
|
-
|
|
319
|
+
**This is the biggest lever for prose quality in the whole system.** Live audit of a recent session — 13 `describe_position` calls versus 50+ `set_comment` ops. The nodes where `describe_position` was called first produced comments that grounded specifically in the position (correct piece squares, real pawn structure, actual weak squares). The nodes where it wasn't produced generic prose that pattern-matched to similar-*looking* positions and confidently named pieces on wrong squares. This gap is why `set_comment` now emits a warning whenever a substantive comment (≥40 chars) lands on a node whose position was never grounded via `describe_position` this session.
|
|
320
|
+
|
|
321
|
+
LLMs are not reliable at reading FEN strings — you'll swap files/ranks, invent captures, miscount pieces. Before you write a comment describing what's happening in a position, call `describe_position` (with `file_id`+`node_id` inside a prep file). Pure computation (~1 ms, no engine), returns two layers:
|
|
320
322
|
|
|
321
323
|
**Board state** — piece placements, material, contested pieces (attackers + defenders), hanging list, check state, castling, en passant, legal moves. Fixes the *"Black's queen on c7 is defended by the knight on d5"* failure when actually there's no knight on d5 and the queen is on c8.
|
|
322
324
|
|
package/docs/prep-files-guide.md
CHANGED
|
@@ -4,16 +4,23 @@ You can save chess prep to the user's chess.ceo account and read it back across
|
|
|
4
4
|
|
|
5
5
|
## The mental model
|
|
6
6
|
|
|
7
|
-
The user has **
|
|
7
|
+
The user has **any number of PGN collections** in their chess.ceo library — you have full access to every non-encrypted one. A **prep file** is one PGN game with variations, inside a collection. Every prep file id is a composite `<collection_id>:<game_id>` — opaque to you, pass it through unchanged to any tool that takes `id` or `file_id`.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Discovery tools:
|
|
10
10
|
|
|
11
|
-
- `
|
|
12
|
-
- `
|
|
13
|
-
- `
|
|
14
|
-
- `
|
|
11
|
+
- `list_collections` — show me all the user's collections (their organizational scheme is theirs; browse before creating)
|
|
12
|
+
- `list_prep_files(collection_id)` — the games inside one collection
|
|
13
|
+
- `search_prep_files(query)` — text search across ALL of the user's collections
|
|
14
|
+
- `find_position_in_files(fen)` — position search across ALL of the user's collections (matched by zobrist hash so move-order variants are found automatically). Distinct from `find_position_in_courses` — that's the user's read-only reference library (Chessable etc.); this is their own editable prep
|
|
15
|
+
|
|
16
|
+
Per-file tools:
|
|
17
|
+
|
|
18
|
+
- `read_prep_file(id)` — parsed tree (every node carries a stable content-derived `id`) + tags + `version`
|
|
19
|
+
- `create_prep_file(collection_id, name)` — new empty file inside the given collection, `name` becomes the [Event] tag
|
|
15
20
|
- `delete_prep_file(id)` — soft delete (user can restore from app)
|
|
16
21
|
|
|
22
|
+
**No default landing folder.** v0.43 removed the old hidden `/mcp` collection — prep files now live wherever the user organizes them. Every `create_prep_file` call REQUIRES `collection_id`; call `list_collections` first if you don't have one. Permanent delete is not exposed on this surface — the user does that from the app.
|
|
23
|
+
|
|
17
24
|
Mutation tools (edit an existing file — you never touch raw PGN):
|
|
18
25
|
|
|
19
26
|
- `apply_mutations(id, [...])` — batch: N ops in one save. **Primary build tool.**
|
|
@@ -25,11 +32,12 @@ Every mutation call takes a `node_id` (or `parent_id` for add-style ops) and aut
|
|
|
25
32
|
|
|
26
33
|
**Before creating a new file, search for an existing one.** LLMs make three "Prep vs Firouzja" files in a row all the time. Always:
|
|
27
34
|
|
|
28
|
-
1.
|
|
29
|
-
2.
|
|
30
|
-
3.
|
|
35
|
+
1. **Text search first**: `search_prep_files(query=<opponent name or opening keyword>)` — searches across every collection the user owns.
|
|
36
|
+
2. **Position search when the request is position-shaped** ("prep me against 6.f3 in the Najdorf"): `find_position_in_files(fen=<the specific tabiya>)` — catches files that reach the position via a different move order too. This is often more accurate than text search because file names don't always mention every position they cover.
|
|
37
|
+
3. Read the ones that look relevant.
|
|
38
|
+
4. Decide: extend an existing one or genuinely start fresh (`create_prep_file(collection_id, name)`).
|
|
31
39
|
|
|
32
|
-
Duplicate files are the #1 way to lose your user's trust in this system.
|
|
40
|
+
Duplicate files are the #1 way to lose your user's trust in this system. Two searches (text + position) cost roughly nothing and catch nearly all overlap.
|
|
33
41
|
|
|
34
42
|
## Before writing any prose: read the examples
|
|
35
43
|
|
|
@@ -107,6 +115,8 @@ Keep it short enough to fit in a picker (30-40 chars). Long titles get truncated
|
|
|
107
115
|
- User asks "I found a novelty in the Najdorf" and a Najdorf file exists → extend.
|
|
108
116
|
- Rule of thumb: if the user's request semantically overlaps with an existing file's [Event] name or main opening line, extend.
|
|
109
117
|
|
|
110
|
-
##
|
|
118
|
+
## Appearance
|
|
119
|
+
|
|
120
|
+
Prep files land in whichever collection the user picked (via `create_prep_file(collection_id, name)`). They're first-class citizens in that collection — the user can browse, edit, share, or delete them from the app exactly like manually-created games. Your only visibility signal is the [Event] tag; make it descriptive.
|
|
111
121
|
|
|
112
|
-
The
|
|
122
|
+
The `/mcp` "AI Prep" hidden folder from earlier versions no longer exists. If the user has a collection literally named "AI Prep" it's one they created themselves.
|
package/package.json
CHANGED