@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/index.js CHANGED
@@ -7,24 +7,30 @@
7
7
  // endpoints — see the public contract at https://chess.ceo/llms.txt.
8
8
  // No API key, no auth, no state; the API's own rate limits apply.
9
9
  import { createServer as createHttpServer } from "node:http";
10
- import { spawn } from "node:child_process";
11
10
  import { readFileSync, existsSync } from "node:fs";
12
11
  import { fileURLToPath } from "node:url";
13
12
  import { dirname, join } from "node:path";
14
- import { Chess } from "chess.js";
15
13
  import { parsePGN } from "./pgn/parser.js";
16
- import { exportPGN } from "./pgn/exporter.js";
17
14
  import { describePosition } from "./pgn/describe.js";
18
- import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setCeoEvalMany, setComment, setNags, setTag, } from "./pgn/mutations.js";
19
- import { buildFenIndex, buildIdIndex, NodeIdError, PathError, positionKey, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
15
+ import { addLine, addMove, deleteSubtree, promoteVariation, setAnnotations, setComment, setNags, setTag, } from "./pgn/mutations.js";
16
+ import { buildIdIndex, positionKey, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
20
17
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
19
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
23
20
  import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
24
21
  import { TOOLS } from "./tools.js";
25
22
  import { PROMPTS } from "./prompts.js";
26
- import { commentAntiPatterns, longLineWarning, noDescribeWarning, noStatsCheckWarning, positionsDescribed, positionsStatsChecked, } from "./warnings.js";
27
- import { authContext, authedRequest, createGame, deleteGame, fetchGame, get, makeFileId, PGN_BASE, restoreGame, saveGame, unwrap, } from "./http.js";
23
+ import { commentAntiPatterns, longLineWarning, noDescribeWarning, noStatsCheckWarning, positionalNagOnIntermediateWarning, positionsDescribed, positionsStatsChecked, } from "./warnings.js";
24
+ import { authContext, authedRequest, deleteGame, fetchGame, get, makeFileId, restoreGame, } from "./http.js";
25
+ import { analysisToStoredEval, capPvsInResponse, convertCloudSnapshotResponse, fetchCompactEval, } from "./analysis/response.js";
26
+ import { autoEvaluate, autoEvaluateCancel, autoEvaluateStatus, } from "./analysis/auto.js";
27
+ import { deepAnalyseCancel, deepAnalyseStart, deepAnalyseStatus, } from "./analysis/deep.js";
28
+ import { getNodeByPath, resolveFromNodeOrFen, storeEvalOnNode, } from "./analysis/file_handle.js";
29
+ import { findPositionInCourses, readCourseAtPosition, runSfEval } from "./courses.js";
30
+ import { applyBatchMutations, applyMutation, argNodeId, } from "./prep/mutations.js";
31
+ import { listNodes, listTranspositions, readPrepFile, } from "./prep/read.js";
32
+ import { createPrepFile, findPositionInFiles, listCollections, listPrepFiles, searchPrepFiles, } from "./prep/library.js";
33
+ import { convertAvailableMovesToSAN, normalizeSourceForBackend, stripPositionResponse, trimGamesMovetext, } from "./response_transforms.js";
28
34
  // Tools that require an MCP token — cloud engine + prep-file tools
29
35
  // operate on the caller's own account so we can't service them
30
36
  // anonymously. The streamable-http transport uses this list to decide
@@ -97,6 +103,7 @@ const ENGINE_USAGE_DOC = loadBundledDoc("engine-usage.md", "Engine usage guide")
97
103
  const PREP_STRATEGY_DOC = loadBundledDoc("prep-strategy.md", "Prep strategy guide");
98
104
  const PREP_FILES_DOC = loadBundledDoc("prep-files-guide.md", "Prep files guide");
99
105
  const PGN_AUTHORING_DOC = loadBundledDoc("pgn-authoring.md", "PGN authoring guide");
106
+ const SUMMARY_AUTHORING_DOC = loadBundledDoc("summary-authoring.md", "Summary authoring guide");
100
107
  // Reference PGNs authored by a strong human coach. LLM pulls these when
101
108
  // it wants to see the commentary style, NAG discipline, and annotation
102
109
  // density we want it to hit. Kept as raw PGN so the LLM can parse them
@@ -104,1506 +111,51 @@ const PGN_AUTHORING_DOC = loadBundledDoc("pgn-authoring.md", "PGN authoring guid
104
111
  // all intact) — not summarised into English.
105
112
  const EXAMPLE_OVERVIEW_PGN = loadBundledDoc("examples/italian-fried-liver.pgn", "Italian Fried Liver overview example");
106
113
  const EXAMPLE_REPERTOIRE_PGN = loadBundledDoc("examples/najdorf-6-f4-white.pgn", "Najdorf 6.f4 White repertoire example");
107
- // Log every tool call in and out. Keeps args + response payloads together
108
- // with a per-call duration so we can trace what the LLM asked for and what
109
- // it got back on the same journalctl line. Response is JSON-stringified and
110
- // capped so the two doc-reading tools (~5-10 KB of static markdown each)
111
- // don't drown the log stream.
112
- const LOG_MAX_CHARS = 4096;
113
- // Convert a UCI move sequence into SAN by walking it move-by-move on
114
- // chess.js from the given starting FEN. LLMs reason far better in SAN
115
- // ("Nf3", "Bxc4") than UCI ("g1f3", "b5c4"), and matches how prep
116
- // discussion is written in the real world. If a move fails to parse
117
- // (illegal from the current position — bug or truncated PV), we
118
- // truncate cleanly rather than throwing so the response still carries
119
- // what we could convert.
120
- function uciLineToSAN(startFen, uciMoves) {
121
- const board = new Chess(startFen);
122
- const out = [];
123
- for (const uci of uciMoves) {
124
- if (uci.length < 4)
125
- break;
126
- try {
127
- const move = board.move({
128
- from: uci.slice(0, 2),
129
- to: uci.slice(2, 4),
130
- promotion: uci.length >= 5 ? uci[4] : undefined,
131
- });
132
- if (!move)
133
- break;
134
- out.push(move.san);
135
- }
136
- catch {
137
- break;
138
- }
139
- }
140
- return out;
141
- }
142
- function uciMoveToSAN(startFen, uci) {
143
- if (!uci || uci.length < 4)
144
- return uci;
145
- const board = new Chess(startFen);
146
- try {
147
- const move = board.move({
148
- from: uci.slice(0, 2),
149
- to: uci.slice(2, 4),
150
- promotion: uci.length >= 5 ? uci[4] : undefined,
151
- });
152
- return move ? move.san : uci;
153
- }
154
- catch {
155
- return uci;
156
- }
157
- }
158
- async function fetchCompactEval(fen) {
159
- try {
160
- const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
161
- const converted = convertCloudSnapshotResponse(raw, fen);
162
- const stored = analysisToStoredEval(converted);
163
- return storedEvalToCompact(stored, converted);
164
- }
165
- catch {
166
- return null;
167
- }
168
- }
169
- // Trim every PV in a converted cloud-analyse response to `maxPlies`
170
- // and mark each trimmed line with `pv_truncated: true` so the LLM
171
- // sees what happened. Applied ONLY to cloud_analyse (short synchronous
172
- // snapshot); deep_analyse is the explicit "give me the deep line"
173
- // tool and keeps its full PV.
174
- function capPvsInResponse(converted, maxPlies) {
175
- if (!converted || typeof converted !== "object")
176
- return;
177
- const r = converted;
178
- for (const eng of [r.stockfish, r.lc0]) {
179
- if (!eng || !Array.isArray(eng.lines))
180
- continue;
181
- for (const line of eng.lines) {
182
- if (Array.isArray(line.pv) && line.pv.length > maxPlies) {
183
- line.pv = line.pv.slice(0, maxPlies);
184
- line.pv_truncated = true;
185
- }
186
- }
187
- }
188
- converted.pv_max_plies = maxPlies;
189
- }
190
- function convertCloudSnapshotResponse(raw, startFen) {
191
- if (!raw || typeof raw !== "object")
192
- return raw;
193
- const r = raw;
194
- for (const eng of [r.stockfish, r.lc0]) {
195
- if (!eng)
196
- continue;
197
- if (Array.isArray(eng.lines)) {
198
- for (const line of eng.lines) {
199
- if (Array.isArray(line.pv))
200
- line.pv = uciLineToSAN(startFen, line.pv);
201
- }
202
- }
203
- if (typeof eng.bestMove === "string")
204
- eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
205
- }
206
- return raw;
207
- }
208
- // Extract a node id from the args. Accepts either `node_id` or a
209
- // `parent_id` alias for the add-style tools. Throws with a helpful
210
- // message if malformed.
211
- function argNodeId(args, key = "node_id") {
212
- const raw = args[key];
213
- if (typeof raw !== "string" || raw.length === 0) {
214
- throw new Error(`\`${key}\` is required (call read_prep_file to get valid node ids)`);
215
- }
216
- return raw.trim();
217
- }
218
- // Dispatch table for the batch tool: name → mutator that returns
219
- // { file, id } where id is the node the mutation touched. The batch
220
- // caller rebuilds the id → path index between ops so newly-created
221
- // nodes are addressable within the same batch.
222
- function dispatchMutation(file, idIndex, op) {
223
- const kind = String(op.op);
224
- // Small local helper — resolves a node_id (or parent_id) op field to
225
- // a path against the CURRENT tree state.
226
- const nodeIdField = (key) => {
227
- const raw = op[key];
228
- if (typeof raw !== "string" || raw.length === 0) {
229
- throw new Error(`\`${key}\` required on op ${kind}`);
230
- }
231
- return raw.trim();
232
- };
233
- const resolve = (id) => resolveNodeId(idIndex, id);
234
- switch (kind) {
235
- case "add_move": {
236
- const parentPath = resolve(nodeIdField("parent_id"));
237
- const parent = getNodeByPath(file.root, parentPath);
238
- const noStatsWarn = noStatsCheckWarning(parent);
239
- const step = addMove(file, parentPath, String(op.san));
240
- return { ...step, ...(noStatsWarn ? { warning: noStatsWarn } : {}) };
241
- }
242
- case "add_line": {
243
- const sans = Array.isArray(op.sans) ? op.sans.map(String) : [];
244
- const parentPath = resolve(nodeIdField("parent_id"));
245
- const parent = getNodeByPath(file.root, parentPath);
246
- const step = addLine(file, parentPath, sans);
247
- const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : nodeIdField("parent_id");
248
- // Same anti-pattern warnings as the standalone add_line case —
249
- // long unbranched line + no-stats-check parent are both bugs
250
- // whether they land solo or inside a batch.
251
- const longLineWarn = longLineWarning(sans.length);
252
- const noStatsWarn = noStatsCheckWarning(parent);
253
- const warnings = [longLineWarn, noStatsWarn].filter((s) => !!s);
254
- return { file: step.file, id: lastId, results: step.line, ...(warnings.length > 0 ? { warnings } : {}) };
255
- }
256
- case "set_comment": {
257
- const commentStr = typeof op.comment === "string" ? op.comment : "";
258
- const commentWarns = commentAntiPatterns(commentStr);
259
- const targetPath = resolve(nodeIdField("node_id"));
260
- const targetNode = getNodeByPath(file.root, targetPath);
261
- const describeWarn = noDescribeWarning(targetNode, commentStr);
262
- const step = setComment(file, targetPath, commentStr);
263
- const all = [...commentWarns, ...(describeWarn ? [describeWarn] : [])];
264
- return { ...step, ...(all.length > 0 ? { warnings: all } : {}) };
265
- }
266
- case "set_nags":
267
- return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
268
- case "set_annotations": {
269
- const arrows = Array.isArray(op.arrows) ? op.arrows : [];
270
- const highlights = Array.isArray(op.highlights) ? op.highlights : [];
271
- const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
272
- return setAnnotations(file, resolve(nodeIdField("node_id")), ann);
273
- }
274
- case "set_ceo_eval": {
275
- const ev = op.ceoEval;
276
- return setCeoEval(file, resolve(nodeIdField("node_id")), ev ?? null);
277
- }
278
- case "delete_subtree":
279
- return deleteSubtree(file, resolve(nodeIdField("node_id")));
280
- case "promote_variation":
281
- return promoteVariation(file, resolve(nodeIdField("node_id")));
282
- case "set_tag":
283
- return { file: setTag(file, String(op.key), String(op.value ?? "")), id: ROOT_ID };
284
- default:
285
- throw new Error(`unknown mutation op: ${kind}`);
286
- }
287
- }
288
- // Batch: load, parse, apply N mutations in order, export, save.
289
- // All-or-nothing — any error aborts and nothing is saved. The id index
290
- // is rebuilt after each op so nodes created earlier in the batch can be
291
- // addressed by later ops via their newly-derived node_id.
292
- async function applyBatchMutations(args) {
293
- const id = String(args.id);
294
- const mutations = Array.isArray(args.mutations) ? args.mutations : [];
295
- if (mutations.length === 0)
296
- throw new Error("mutations array required");
297
- const g = await fetchGame(id);
298
- let file = parsePGN(g.pgnContent);
299
- let idIndex = buildIdIndex(file.root);
300
- const results = [];
301
- for (let i = 0; i < mutations.length; i++) {
302
- const op = mutations[i];
303
- try {
304
- const step = dispatchMutation(file, idIndex, op);
305
- file = step.file;
306
- idIndex = buildIdIndex(file.root);
307
- results.push({
308
- node_id: step.id,
309
- ...(step.results !== undefined ? { line: step.results } : {}),
310
- ...(step.warning ? { warning: step.warning } : {}),
311
- ...(step.warnings && step.warnings.length > 0 ? { warnings: step.warnings } : {}),
312
- });
313
- }
314
- catch (err) {
315
- const msg = err instanceof Error ? err.message : String(err);
316
- throw new Error(`mutation #${i} (${String(op.op)}) failed: ${msg}`);
317
- }
318
- }
319
- const newPgn = exportPGN(file);
320
- const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
321
- const saved = await saveGame(id, newPgn, expected);
322
- return { ok: true, results, version: saved.version };
323
- }
324
- const evalJobs = new Map();
325
- // GC finished jobs after this long so status polling remains useful
326
- // for a while but the map doesn't grow unbounded across long uptimes.
327
- const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
328
- // Checkpoint interval — save progress every N successfully-evaluated
329
- // nodes so a mid-run kill leaves the tree partially populated. Small
330
- // enough that <15s of work is at risk per checkpoint on a slow combo,
331
- // large enough that the save overhead stays a small fraction of the
332
- // per-node cost.
333
- const SAVE_EVERY_N = 8;
334
- function newEvalJobId() {
335
- // 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
336
- const rand = Math.random().toString(16).slice(2, 8);
337
- return `evj_${Date.now().toString(16)}${rand}`;
338
- }
339
- // Sweep expired jobs on every start/status call — cheap, doesn't need
340
- // a background timer, keeps the map bounded to active + recent jobs.
341
- function reapExpiredEvalJobs() {
342
- const now = Date.now();
343
- for (const [k, j] of evalJobs) {
344
- if (j.finishedAt && now - j.finishedAt > EVAL_JOB_TTL_MS) {
345
- evalJobs.delete(k);
346
- }
347
- }
348
- }
349
- async function autoEvaluate(args) {
350
- reapExpiredEvalJobs();
351
- const id = String(args.id);
352
- const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
353
- ? String(args.node_id)
354
- : ROOT_ID;
355
- const onlyMissing = args.only_missing !== false; // default true
356
- const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
357
- const g = await fetchGame(id);
358
- const file = parsePGN(g.pgnContent);
359
- const idIndex = buildIdIndex(file.root);
360
- const startPath = resolveNodeId(idIndex, startNodeId);
361
- const targets = [];
362
- const walk = (node, isStartAndRoot) => {
363
- if (!isStartAndRoot) {
364
- if (!onlyMissing || !node.ceoEval) {
365
- targets.push({ nodeId: node.id, fen: node.fen });
366
- }
367
- }
368
- for (const child of node.children)
369
- walk(child, false);
370
- };
371
- const startNode = getNodeByPath(file.root, startPath);
372
- // If the caller anchored at the root, skip evaluating the root itself
373
- // (no move); otherwise the anchor node IS a real move and gets evaluated.
374
- walk(startNode, startNode.id === ROOT_ID);
375
- // Dedup transpositions: if two candidate targets share the same
376
- // 3-field FEN key, they're the same position reached by different
377
- // move orders. Analyse ONE of them — cloud_analyse auto-propagates
378
- // the resulting ceoEval to every other node with a matching key
379
- // (see storeEvalOnNode), so the twin ends up with the same eval
380
- // without a second engine call. Keep DFS-first (mainline-preferred)
381
- // occurrence.
382
- let skippedTranspositions = 0;
383
- {
384
- const seen = new Set();
385
- const deduped = [];
386
- for (const t of targets) {
387
- const key = positionKey(t.fen);
388
- if (seen.has(key)) {
389
- skippedTranspositions++;
390
- continue;
391
- }
392
- seen.add(key);
393
- deduped.push(t);
394
- }
395
- targets.length = 0;
396
- targets.push(...deduped);
397
- }
398
- // Nothing to do → return a done job synthetically so the caller doesn't
399
- // need to special-case the empty response.
400
- if (targets.length === 0) {
401
- const jobId = newEvalJobId();
402
- evalJobs.set(jobId, {
403
- id: jobId,
404
- fileId: id,
405
- status: "done",
406
- targetCount: 0,
407
- evaluated: 0,
408
- errored: 0,
409
- failedNodeIds: [],
410
- finalVersion: g.version,
411
- startedAt: Date.now(),
412
- finishedAt: Date.now(),
413
- cancelled: false,
414
- });
415
- return { job_id: jobId, target_count: 0, status: "done", version: g.version };
416
- }
417
- const jobId = newEvalJobId();
418
- const job = {
419
- id: jobId,
420
- fileId: id,
421
- status: "running",
422
- targetCount: targets.length,
423
- evaluated: 0,
424
- errored: 0,
425
- failedNodeIds: [],
426
- startedAt: Date.now(),
427
- cancelled: false,
428
- };
429
- evalJobs.set(jobId, job);
430
- // Unawaited — runs concurrently with the tool response. Any thrown
431
- // error gets recorded on the job so the LLM's status poll surfaces
432
- // it instead of the process seeing an unhandled rejection.
433
- void runEvalJob(job, id, targets, movetimeMs).catch(err => {
434
- job.status = "error";
435
- job.error = err instanceof Error ? err.message : String(err);
436
- job.finishedAt = Date.now();
437
- });
438
- return {
439
- job_id: jobId,
440
- target_count: targets.length,
441
- // Transpositions inside the walk that we skipped because they'll
442
- // pick up the eval via auto-propagation. Zero when there are none.
443
- skipped_transpositions: skippedTranspositions,
444
- status: "running",
445
- // Rough time estimate at the current default movetime. Serialization
446
- // on the per-combo semaphore means walltime ≈ target_count × movetime.
447
- estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
448
- };
449
- }
450
- // Worker body — walks targets sequentially (concurrency > 1 is a lie
451
- // against the per-combo semaphore in the backend anyway), checkpoints
452
- // every SAVE_EVERY_N successfully-evaluated nodes so partial progress
453
- // is durable, and re-anchors the version after each save.
454
- async function runEvalJob(job, fileId, targets, movetimeMs) {
455
- const pending = [];
456
- const flush = async () => {
457
- if (pending.length === 0)
458
- return;
459
- // No expected_version — auto_evaluate treats concurrent edits by
460
- // the LLM as last-write-wins on the ceoEval field specifically.
461
- // Safe because set_ceo_eval is idempotent per node and other
462
- // mutations (add_move / set_comment / etc.) don't touch ceoEval.
463
- const saved = await applyBatchMutations({
464
- id: fileId,
465
- mutations: pending,
466
- });
467
- const sr = saved;
468
- if (typeof sr.version === "number")
469
- job.finalVersion = sr.version;
470
- pending.length = 0;
471
- };
472
- // Consecutive-failure abort. If N cloud_analyse calls in a row error,
473
- // the engine is almost certainly dead (vanished contract, network to
474
- // VastAI down) and burning through the rest of the tree just wastes
475
- // time. Bail with an explicit reason so a targeted retry is possible.
476
- const MAX_CONSECUTIVE_FAILURES = 3;
477
- let consecutiveFailures = 0;
478
- let aborted = false;
479
- for (const t of targets) {
480
- if (job.cancelled || aborted)
481
- break;
482
- try {
483
- const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
484
- const ev = analysisToStoredEval(analysis);
485
- if (ev) {
486
- pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
487
- job.evaluated++;
488
- consecutiveFailures = 0;
489
- }
490
- else {
491
- job.errored++;
492
- job.failedNodeIds.push(t.nodeId);
493
- consecutiveFailures++;
494
- }
495
- }
496
- catch {
497
- // Per-node failure — record the node_id so the caller can retry
498
- // just those, and count consecutive failures for the abort check.
499
- job.errored++;
500
- job.failedNodeIds.push(t.nodeId);
501
- consecutiveFailures++;
502
- }
503
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
504
- aborted = true;
505
- job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
506
- break;
507
- }
508
- if (pending.length >= SAVE_EVERY_N) {
509
- try {
510
- await flush();
511
- }
512
- catch {
513
- // Save failure is bad but not fatal — try again on the next
514
- // checkpoint or at the end. Progress remains in `pending`
515
- // so nothing is lost as long as the process stays alive.
516
- }
517
- }
518
- }
519
- // Final flush regardless of cancellation — durably persist whatever
520
- // work was completed before the user asked to stop.
521
- try {
522
- await flush();
523
- }
524
- catch (err) {
525
- job.error = err instanceof Error ? err.message : String(err);
526
- job.status = "error";
527
- job.finishedAt = Date.now();
528
- return;
529
- }
530
- job.status = job.cancelled ? "cancelled" : "done";
531
- job.finishedAt = Date.now();
532
- }
533
- function autoEvaluateStatus(args) {
534
- reapExpiredEvalJobs();
535
- const jobId = String(args.job_id || "").trim();
536
- if (!jobId)
537
- throw new Error("`job_id` is required");
538
- const job = evalJobs.get(jobId);
539
- if (!job) {
540
- return {
541
- status: "not_found",
542
- note: "Job unknown — either expired (kept ~15 min after completion), never existed, or the MCP process restarted since it was created. Re-run auto_evaluate to start over; the `only_missing` default will skip nodes already evaluated in the prep file.",
543
- };
544
- }
545
- return {
546
- job_id: job.id,
547
- status: job.status,
548
- target_count: job.targetCount,
549
- evaluated: job.evaluated,
550
- errored: job.errored,
551
- failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
552
- aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
553
- remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
554
- done: job.status !== "running",
555
- error: job.error,
556
- version: job.finalVersion,
557
- started_at_ms: job.startedAt,
558
- finished_at_ms: job.finishedAt,
559
- };
560
- }
561
- function autoEvaluateCancel(args) {
562
- const jobId = String(args.job_id || "").trim();
563
- if (!jobId)
564
- throw new Error("`job_id` is required");
565
- const job = evalJobs.get(jobId);
566
- if (!job)
567
- return { status: "not_found" };
568
- if (job.status !== "running") {
569
- return { status: job.status, note: "Job already finished; nothing to cancel." };
570
- }
571
- job.cancelled = true;
572
- // status transitions to "cancelled" on the next per-node iteration
573
- // inside runEvalJob, after the final flush persists progress.
574
- return { status: "cancelling", evaluated_so_far: job.evaluated };
575
- }
576
- const deepJobs = new Map();
577
- const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
578
- function newDeepJobId() {
579
- const rand = Math.random().toString(16).slice(2, 8);
580
- return `deep_${Date.now().toString(16)}${rand}`;
581
- }
582
- function reapExpiredDeepJobs() {
583
- const now = Date.now();
584
- for (const [k, j] of deepJobs) {
585
- if (j.finishedAt && now - j.finishedAt > DEEP_JOB_TTL_MS) {
586
- deepJobs.delete(k);
587
- }
588
- }
589
- }
590
- async function deepAnalyseStart(args) {
591
- reapExpiredDeepJobs();
592
- const resolved = await resolveFromNodeOrFen(args);
593
- const fen = resolved.fen;
594
- const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
595
- // Default 2 — SF loses meaningful strength at higher multipv, so a
596
- // deep think is best spent on a tight candidate list. Matches the
597
- // cloud_analyse stockfish_multipv default.
598
- const multipv = typeof args.multipv === "number" ? args.multipv : 2;
599
- const jobId = newDeepJobId();
600
- const job = {
601
- id: jobId,
602
- status: "running",
603
- fileHandle: resolved.file,
604
- fen,
605
- movetimeMs,
606
- multipv,
607
- startedAt: Date.now(),
608
- cancelController: new AbortController(),
609
- };
610
- deepJobs.set(jobId, job);
611
- // Kick off the long HTTP call unawaited — resolves when the backend
612
- // returns the SF snapshot. authedRequest is a plain fetch under the
613
- // hood; abort signal flows via cancelController.
614
- void runDeepJob(job).catch(err => {
615
- job.status = "error";
616
- job.error = err instanceof Error ? err.message : String(err);
617
- job.finishedAt = Date.now();
618
- });
619
- return {
620
- job_id: jobId,
621
- status: "running",
622
- movetime_ms: movetimeMs,
623
- fen,
624
- };
625
- }
626
- async function runDeepJob(job) {
627
- const body = {
628
- fen: job.fen,
629
- movetime_ms: job.movetimeMs,
630
- stockfish_multipv: job.multipv,
631
- engines: ["stockfish"],
632
- };
633
- let raw;
634
- try {
635
- // TODO(future): plumb an AbortSignal through authedRequest for
636
- // real mid-flight cancellation. For now, cancel just marks the
637
- // job so the caller stops polling; the backend still runs the
638
- // engine to completion and the result is stored on the job
639
- // record but flagged cancelled.
640
- raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
641
- }
642
- catch (err) {
643
- job.status = "error";
644
- job.error = err instanceof Error ? err.message : String(err);
645
- job.finishedAt = Date.now();
646
- return;
647
- }
648
- const converted = convertCloudSnapshotResponse(raw, job.fen);
649
- const sf = converted.stockfish;
650
- if (job.cancelController.signal.aborted) {
651
- job.status = "cancelled";
652
- }
653
- else {
654
- job.status = "done";
655
- }
656
- job.result = sf ?? null;
657
- job.finishedAt = Date.now();
658
- // Same node-persistence as cloud_analyse: if the caller anchored on
659
- // file_id+node_id, store the SF-only eval as the node's ceoEval so
660
- // quote_engine_eval can cite it later. We build a StoredEval that has
661
- // only the sf leg — no Lc0 was run.
662
- if (job.fileHandle && sf) {
663
- const ev = analysisToStoredEval({ stockfish: sf });
664
- if (ev) {
665
- try {
666
- await storeEvalOnNode(job.fileHandle, ev);
667
- }
668
- catch {
669
- // best-effort — the analysis result is what the LLM asked for
670
- }
671
- }
672
- }
673
- }
674
- function deepAnalyseStatus(args) {
675
- reapExpiredDeepJobs();
676
- const jobId = String(args.job_id || "").trim();
677
- if (!jobId)
678
- throw new Error("`job_id` is required");
679
- const job = deepJobs.get(jobId);
680
- if (!job) {
681
- return {
682
- status: "not_found",
683
- note: "Job unknown — expired (kept ~15 min after completion), never existed, or the MCP process restarted.",
684
- };
685
- }
686
- return {
687
- job_id: job.id,
688
- status: job.status,
689
- movetime_ms: job.movetimeMs,
690
- elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
691
- fen: job.fen,
692
- result: job.result,
693
- error: job.error,
694
- started_at_ms: job.startedAt,
695
- finished_at_ms: job.finishedAt,
696
- };
697
- }
698
- function deepAnalyseCancel(args) {
699
- const jobId = String(args.job_id || "").trim();
700
- if (!jobId)
701
- throw new Error("`job_id` is required");
702
- const job = deepJobs.get(jobId);
703
- if (!job)
704
- return { status: "not_found" };
705
- if (job.status !== "running") {
706
- return { status: job.status, note: "Job already finished; nothing to cancel." };
707
- }
708
- job.cancelController.abort();
709
- // Status flips to "cancelled" when runDeepJob observes the abort on
710
- // completion. Backend keeps churning until movetime elapses (mid-
711
- // flight abort of the HTTP call is a follow-up).
712
- return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
713
- }
714
- // ── find_position_in_courses: fenfind subprocess wrapper ───────────
715
- //
716
- // fenfind is a small python tool that indexes chess PGN files by
717
- // polyglot Zobrist hash. Given a position it returns which of the
718
- // user's Chessable / PGN files cover it, ranked by how much annotated
719
- // material sits below that position in each course. Runs as a
720
- // subprocess of the MCP so we can reuse python-chess's polyglot
721
- // hashing (matching the pre-built positions.db) instead of porting
722
- // the hash function to TS.
723
- //
724
- // Path resolution order (`FENFIND_PATH` env var overrides):
725
- // 1. $FENFIND_PATH/fenfind
726
- // 2. <package-root>/tools/fenfind/fenfind (ships with the npm package)
727
- // The bash wrapper picks a python interpreter with python-chess
728
- // available (venv at $here/.venv/bin/python preferred, then falls back
729
- // to system python3). DB path is resolved inside fenfind.py itself
730
- // (FENFIND_DB env, then ~/positions.db).
731
- // Path resolution shared by both fenfind + readpgn. FENFIND_PATH env
732
- // overrides the bundled tools/fenfind/ directory.
733
- // Path to sf_eval helper (spawns local stockfish, parses its `eval`
734
- // verbose output). Uses the same resolution pattern as FENFIND_DIR.
735
- const SF_EVAL_SCRIPT = (() => {
736
- const envPath = process.env.SF_EVAL_PATH?.trim();
737
- if (envPath && existsSync(join(envPath, "sf_eval")))
738
- return join(envPath, "sf_eval");
739
- const here = dirname(fileURLToPath(import.meta.url));
740
- const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
741
- return existsSync(bundled) ? bundled : null;
742
- })();
743
- const SF_EVAL_TIMEOUT_MS = 12_000;
744
- async function runSfEval(fen) {
745
- if (!SF_EVAL_SCRIPT) {
746
- return {
747
- found: false,
748
- error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
749
- };
750
- }
751
- const stdout = await new Promise((resolve, reject) => {
752
- const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
753
- let out = "";
754
- let err = "";
755
- p.stdout.on("data", d => { out += d.toString("utf8"); });
756
- p.stderr.on("data", d => { err += d.toString("utf8"); });
757
- const to = setTimeout(() => {
758
- try {
759
- p.kill("SIGTERM");
760
- }
761
- catch { /* already dead */ }
762
- reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
763
- }, SF_EVAL_TIMEOUT_MS);
764
- p.on("error", e => { clearTimeout(to); reject(e); });
765
- p.on("close", code => {
766
- clearTimeout(to);
767
- if (code !== 0)
768
- reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
769
- else
770
- resolve(out);
771
- });
772
- });
773
- try {
774
- return JSON.parse(stdout);
775
- }
776
- catch (e) {
777
- throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
778
- }
779
- }
780
- const FENFIND_DIR = (() => {
781
- const envPath = process.env.FENFIND_PATH?.trim();
782
- if (envPath && existsSync(join(envPath, "fenfind")))
783
- return envPath;
784
- const here = dirname(fileURLToPath(import.meta.url));
785
- const bundled = join(here, "..", "tools", "fenfind");
786
- return existsSync(join(bundled, "fenfind")) ? bundled : null;
787
- })();
788
- // Cap on how long we let the subprocess run. SQLite hash lookup returns
789
- // sub-second; PGN read from a course file is O(chapter size) and rarely
790
- // exceeds a second. 15s is a stuck-process backstop, not a real limit.
791
- const FENFIND_TIMEOUT_MS = 15_000;
792
- async function runFenfindScript(scriptName, args) {
793
- if (!FENFIND_DIR) {
794
- throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
795
- }
796
- const script = join(FENFIND_DIR, scriptName);
797
- return new Promise((resolve, reject) => {
798
- const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
799
- let out = "";
800
- let err = "";
801
- p.stdout.on("data", d => { out += d.toString("utf8"); });
802
- p.stderr.on("data", d => { err += d.toString("utf8"); });
803
- const to = setTimeout(() => {
804
- try {
805
- p.kill("SIGTERM");
806
- }
807
- catch { /* already dead */ }
808
- reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
809
- }, FENFIND_TIMEOUT_MS);
810
- p.on("error", e => { clearTimeout(to); reject(e); });
811
- p.on("close", code => {
812
- clearTimeout(to);
813
- if (code !== 0)
814
- reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
815
- else
816
- resolve(out);
817
- });
818
- });
819
- }
820
- function parseFenfindJson(scriptName, stdout) {
821
- try {
822
- return JSON.parse(stdout);
823
- }
824
- catch (e) {
825
- throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
826
- }
827
- }
828
- async function findPositionInCourses(args) {
829
- if (!FENFIND_DIR) {
830
- return {
831
- status: "not_available",
832
- note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind` script and positions.db, or install the tools/fenfind bundle shipped in the npm package.",
833
- };
834
- }
835
- const resolved = await resolveFromNodeOrFen(args);
836
- const cliArgs = [resolved.fen, "--json"];
837
- if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
838
- cliArgs.push("--sort", args.sort);
839
- }
840
- if (args.include_games)
841
- cliArgs.push("--games");
842
- if (args.chapters_mode)
843
- cliArgs.push("--chapters");
844
- if (typeof args.min_notes_chars === "number")
845
- cliArgs.push("--min", String(args.min_notes_chars));
846
- if (typeof args.limit === "number")
847
- cliArgs.push("-n", String(args.limit));
848
- const stdout = await runFenfindScript("fenfind", cliArgs);
849
- return parseFenfindJson("fenfind", stdout);
850
- }
851
- async function readCourseAtPosition(args) {
852
- if (!FENFIND_DIR) {
853
- return {
854
- status: "not_available",
855
- note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
856
- };
857
- }
858
- const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
859
- if (!Number.isFinite(fileId) || fileId <= 0) {
860
- throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
861
- }
862
- const cliArgs = ["--file-id", String(fileId)];
863
- if (typeof args.fen === "string" && args.fen.trim() !== "")
864
- cliArgs.push("--fen", args.fen.trim());
865
- if (typeof args.moves === "string" && args.moves.trim() !== "")
866
- cliArgs.push("--moves", args.moves.trim());
867
- if (typeof args.chapter === "string" && args.chapter.trim() !== "")
868
- cliArgs.push("--chapter", args.chapter.trim());
869
- if (typeof args.max_plies_below === "number")
870
- cliArgs.push("--max-plies-below", String(args.max_plies_below));
871
- const stdout = await runFenfindScript("readpgn", cliArgs);
872
- return parseFenfindJson("readpgn", stdout);
873
- }
874
- // Walk chess.js-free: resolve a path against a tree, throw if invalid.
875
- function pathIntoTree(root, path) {
876
- let cur = root;
877
- for (let i = 0; i < path.length; i++) {
878
- if (path[i] < 0 || path[i] >= cur.children.length) {
879
- throw new Error(`path segment ${i}=${path[i]} out of bounds`);
880
- }
881
- cur = cur.children[path[i]];
882
- }
883
- return cur;
884
- }
885
- // Read the cloud analyse response and build a StoredEval (compact form —
886
- // no PVs, White-POV cp / mate, depth, and derived NAG). Returns null if
887
- // there's no usable Stockfish signal (SF is the source of truth for
888
- // the NAG per docs).
889
- function analysisToStoredEval(analysis) {
890
- if (!analysis || typeof analysis !== "object")
891
- return null;
892
- const r = analysis;
893
- // Backend returns White-POV cp/mate (engine-ws flips in ParseInfo
894
- // based on side-to-move, cloud_snapshot passes through). Pure
895
- // pass-through here — a previous sign-flip on black-to-move was
896
- // wrong and silently inverted every Black-to-move stored eval.
897
- const engineEval = (block) => {
898
- const line = block?.lines?.[0];
899
- if (!line)
900
- return undefined;
901
- const depth = line.depth ?? block?.depth;
902
- if (typeof line.mate === "number")
903
- return { mate: line.mate, depth };
904
- if (typeof line.scoreCp === "number")
905
- return { cp: line.scoreCp, depth };
906
- return undefined;
907
- };
908
- const sf = engineEval(r.stockfish);
909
- const lc0 = engineEval(r.lc0);
910
- if (!sf && !lc0)
911
- return null;
912
- const ev = {};
913
- if (sf)
914
- ev.sf = sf;
915
- if (lc0)
916
- ev.lc0 = lc0;
917
- ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
918
- return ev;
919
- }
920
- function nagFromCp(cp, mate) {
921
- let effective;
922
- if (typeof mate === "number")
923
- effective = mate > 0 ? 10000 : -10000;
924
- else if (typeof cp === "number")
925
- effective = cp;
926
- else
927
- return null;
928
- const abs = Math.abs(effective);
929
- if (abs < 25)
930
- return "$10";
931
- if (abs < 60)
932
- return effective > 0 ? "$14" : "$15";
933
- if (abs < 130)
934
- return effective > 0 ? "$16" : "$17";
935
- return effective > 0 ? "$18" : "$19";
936
- }
937
- // Adapter for the compact eval attached to live query responses. Same
938
- // derivation logic; different output shape (needs the .nag + summary
939
- // used by get_position_stats / prep_snapshot).
940
- function storedEvalToCompact(ev, analysis) {
941
- if (!ev)
942
- return null;
943
- const a = analysis;
944
- const compact = { nag: ev.nag ?? null };
945
- if (ev.sf) {
946
- compact.stockfish = {
947
- cp: ev.sf.cp,
948
- mate: ev.sf.mate,
949
- bestMove: a.stockfish?.bestMove,
950
- pv: a.stockfish?.lines?.[0]?.pv,
951
- };
952
- }
953
- if (ev.lc0) {
954
- compact.lc0 = {
955
- cp: ev.lc0.cp,
956
- mate: ev.lc0.mate,
957
- bestMove: a.lc0?.bestMove,
958
- pv: a.lc0?.lines?.[0]?.pv,
959
- };
960
- }
961
- return compact;
962
- }
963
- // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
964
- // save with optimistic lock. Auto-saves so every tool call is atomic;
965
- // the LLM never sees intermediate state. The mutator is called with
966
- // both the parsed file and its id → path index, so the mutation can
967
- // resolve node_ids without rebuilding the index itself.
968
- async function applyMutation(args, mutator) {
969
- const id = String(args.id);
970
- const g = await fetchGame(id);
971
- const file = parsePGN(g.pgnContent);
972
- const idIndex = buildIdIndex(file.root);
973
- let result;
974
- try {
975
- result = mutator(file, idIndex);
976
- }
977
- catch (err) {
978
- if (err instanceof MutationError || err instanceof PathError || err instanceof NodeIdError) {
979
- throw new Error(`mutation rejected: ${err.message}`);
980
- }
981
- throw err;
982
- }
983
- const newPgn = exportPGN(result.file);
984
- const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
985
- const saved = await saveGame(id, newPgn, expected);
986
- return {
987
- ok: true,
988
- node_id: result.id,
989
- ...(result.results !== undefined ? { line: result.results } : {}),
990
- ...(result.warning ? { warning: result.warning } : {}),
991
- ...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
992
- version: saved.version,
993
- };
994
- }
995
- async function loadPrepFile(id) {
996
- const g = await fetchGame(id);
997
- // Echo the composite id back so read_prep_file responses match the
998
- // exact id the LLM passed in. The backend returns the raw game_id;
999
- // recompose so the LLM never sees the split form.
1000
- return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: id, pgn: g.pgnContent };
1001
- }
1002
- // Recursively project a PrepNode into the requested view. `depthLeft`
1003
- // null → unlimited; 0 → just the node without children.
1004
- //
1005
- // `fenIndex` (optional) enables the `transposes_to` field — for each
1006
- // node whose position also appears elsewhere in the SAME file, we
1007
- // annotate it with the OTHER occurrences' ids. Pass null (the default)
1008
- // to skip the annotation entirely; passing the map costs one lookup
1009
- // per node projected.
1010
- function projectNode(node, view, depthLeft, fenIndex = null) {
1011
- const base = {
1012
- id: node.id,
1013
- san: node.san,
1014
- ply: node.ply,
1015
- };
1016
- if (node.nags && node.nags.length > 0)
1017
- base.nags = node.nags;
1018
- if (node.comment)
1019
- base.comment = node.comment;
1020
- if (node.ceoEval)
1021
- base.ceoEval = node.ceoEval;
1022
- if (view === "full") {
1023
- base.fen = node.fen;
1024
- if (node.annotations)
1025
- base.annotations = node.annotations;
1026
- }
1027
- if (fenIndex && node.id !== ROOT_ID) {
1028
- const group = fenIndex.get(positionKey(node.fen));
1029
- if (group && group.length > 1) {
1030
- const others = group.filter(n => n.id !== node.id).map(n => n.id);
1031
- if (others.length > 0)
1032
- base.transposes_to = others;
1033
- }
1034
- }
1035
- // Children handling depends on view + depth budget.
1036
- const showChildren = depthLeft === null || depthLeft > 0;
1037
- const childDepth = depthLeft === null ? null : depthLeft - 1;
1038
- if (showChildren && node.children.length > 0) {
1039
- if (view === "spine") {
1040
- // Only follow children[0] — collapses the tree to the mainline.
1041
- base.children = [projectNode(node.children[0], view, childDepth, fenIndex)];
1042
- }
1043
- else {
1044
- base.children = node.children.map(c => projectNode(c, view, childDepth, fenIndex));
1045
- }
1046
- }
1047
- else {
1048
- base.children = [];
1049
- }
1050
- return base;
1051
- }
1052
- async function listCollections(_args) {
1053
- const raw = await authedRequest("GET", PGN_BASE);
1054
- const collections = unwrap(raw) ?? [];
1055
- return {
1056
- collections: collections.map(c => ({
1057
- id: c.id,
1058
- title: c.title,
1059
- icon: c.icon,
1060
- folder_path: c.folderPath,
1061
- game_count: c.gameCount,
1062
- position_search_enabled: c.positionSearchEnabled,
1063
- updated_at: c.updatedAt,
1064
- })),
1065
- };
1066
- }
1067
- // Convert a browser-returned game list row into the LLM shape (composite
1068
- // id, cleaned field names).
1069
- function projectGameRow(row) {
1070
- const collId = row.collectionId ?? "";
1071
- return {
1072
- id: collId ? makeFileId(collId, row.id) : row.id,
1073
- collection_id: collId,
1074
- collection_title: row.collectionTitle,
1075
- event: row.event,
1076
- white: row.white_player,
1077
- black: row.black_player,
1078
- eco: row.eco,
1079
- opening: row.opening,
1080
- updated_at: row.updated_at,
1081
- ply: row.ply,
1082
- };
1083
- }
1084
- async function listPrepFiles(args) {
1085
- const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
1086
- if (!collectionId) {
1087
- 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");
1088
- }
1089
- // Browser handler at GET /me/pgns/{id}/games returns a paginated list.
1090
- const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games?page=1&limit=200`);
1091
- const data = unwrap(raw);
1092
- const games = data?.games ?? (Array.isArray(data) ? data : []);
1093
- return { collection_id: collectionId, prep_files: games.map(projectGameRow) };
1094
- }
1095
- async function searchPrepFiles(args) {
1096
- const q = typeof args.query === "string" ? args.query.trim() : "";
1097
- if (!q)
1098
- throw new Error("query required");
1099
- const raw = await authedRequest("GET", `${PGN_BASE}/games/search?q=${encodeURIComponent(q)}&limit=100`);
1100
- const data = unwrap(raw);
1101
- const games = data?.games ?? [];
1102
- return { query: q, prep_files: games.map(projectGameRow) };
1103
- }
1104
- async function findPositionInFiles(args) {
1105
- // FEN can come from a node handle OR a direct fen/moves/line. Reuse
1106
- // the same resolver everything else uses.
1107
- const resolved = await resolveFromNodeOrFen(args);
1108
- const fen = resolved.fen;
1109
- const raw = await authedRequest("GET", `${PGN_BASE}/games/search?position=${encodeURIComponent(fen)}&limit=100`);
1110
- const data = unwrap(raw);
1111
- const games = data?.games ?? [];
1112
- return {
1113
- fen,
1114
- match_count: games.length,
1115
- prep_files: games.map(projectGameRow),
1116
- };
1117
- }
1118
- async function createPrepFile(args) {
1119
- const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
1120
- if (!collectionId) {
1121
- 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).");
1122
- }
1123
- const name = String(args.name || "").trim();
1124
- if (!name)
1125
- throw new Error("name is required");
1126
- // Seed with a PGN carrying the LLM-chosen name as the Event tag so
1127
- // subsequent list_prep_files calls display something useful.
1128
- const seedPgn = `[Event "${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n\n*\n`;
1129
- const game = await createGame(collectionId, seedPgn);
1130
- return {
1131
- ok: true,
1132
- id: makeFileId(game.collectionId ?? collectionId, game.id),
1133
- collection_id: game.collectionId ?? collectionId,
1134
- version: game.version,
1135
- };
1136
- }
1137
- async function readPrepFile(args) {
1138
- const id = String(args.id);
1139
- const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
1140
- ? args.view
1141
- : "compact";
1142
- const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
1143
- const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
1144
- const { file, version, fileIdEcho, pgn } = await loadPrepFile(id);
1145
- const idIndex = buildIdIndex(file.root);
1146
- const path = resolveNodeId(idIndex, startNodeId);
1147
- const anchor = getNodeByPath(file.root, path);
1148
- const fenIndex = buildFenIndex(file.root);
1149
- // How many DISTINCT positions in the file appear more than once,
1150
- // and how many nodes are involved. Shown in the header so the LLM
1151
- // sees at a glance whether transpositions matter here before diving
1152
- // into the tree.
1153
- let transGroups = 0;
1154
- let transNodes = 0;
1155
- for (const arr of fenIndex.values()) {
1156
- if (arr.length > 1) {
1157
- transGroups++;
1158
- transNodes += arr.length;
1159
- }
1160
- }
1161
- const header = {
1162
- id: fileIdEcho ?? id,
1163
- version,
1164
- tags: file.tags,
1165
- view,
1166
- node_id: startNodeId,
1167
- max_depth: maxDepth,
1168
- transposition_groups: transGroups,
1169
- transposition_nodes: transNodes,
1170
- };
1171
- if (view === "pgn") {
1172
- // For the root, just return the file's actual PGN as-is. For a
1173
- // subtree, build a mini-Game from the anchor and export it. Keeps
1174
- // formatting identical to what the app renders.
1175
- if (startNodeId === ROOT_ID && (maxDepth === null || maxDepth >= 999)) {
1176
- return { ...header, pgn };
1177
- }
1178
- // Truncate to a subtree with max_depth. Simple: walk the anchor's
1179
- // subtree, produce a synthetic PGN starting from the anchor's FEN.
1180
- const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
1181
- return { ...header, pgn: subtreePgn };
1182
- }
1183
- return { ...header, tree: projectNode(anchor, view, maxDepth, fenIndex) };
1184
- }
1185
- // Produce a PGN string for a subtree rooted at `anchor`, truncated
1186
- // at `maxDepth` plies below (null = unlimited). Reuses the exporter
1187
- // by building a synthetic PrepFile whose root is a shallow clone of
1188
- // the anchor with its children trimmed to depth.
1189
- function exportSubtreePgn(file, anchor, maxDepth) {
1190
- const trim = (n, depthLeft) => {
1191
- if (depthLeft !== null && depthLeft <= 0)
1192
- return { ...n, children: [] };
1193
- const next = depthLeft === null ? null : depthLeft - 1;
1194
- return { ...n, children: n.children.map(c => trim(c, next)) };
1195
- };
1196
- const trimmedAnchor = trim(anchor, maxDepth);
1197
- // If the anchor IS the root, exporter handles it. If it's an inner
1198
- // node, we set the root's FEN to the anchor's position and hang the
1199
- // trimmed subtree off it. Tags carried over.
1200
- if (anchor.id === ROOT_ID) {
1201
- return exportPGN({ tags: file.tags, root: trimmedAnchor });
1202
- }
1203
- const syntheticRoot = {
1204
- id: ROOT_ID,
1205
- san: null,
1206
- fen: anchor.fen,
1207
- ply: 0,
1208
- children: trimmedAnchor.children,
1209
- };
1210
- const tags = { ...file.tags, FEN: anchor.fen, SetUp: "1" };
1211
- return exportPGN({ tags, root: syntheticRoot });
1212
- }
1213
- async function listNodes(args) {
1214
- const id = String(args.id);
1215
- const filter = String(args.filter || "");
1216
- const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
1217
- const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
1218
- const { file } = await loadPrepFile(id);
1219
- const idIndex = buildIdIndex(file.root);
1220
- const path = resolveNodeId(idIndex, startNodeId);
1221
- const anchor = getNodeByPath(file.root, path);
1222
- const fenIndex = filter === "transpositions" ? buildFenIndex(file.root) : null;
1223
- const hits = [];
1224
- const walk = (node, depthLeft, spineOnly) => {
1225
- // Root has no san — never emit it as a match. Everything else is fair game.
1226
- if (node.id !== ROOT_ID) {
1227
- let include = false;
1228
- let extra = {};
1229
- switch (filter) {
1230
- case "missing_eval":
1231
- include = !node.ceoEval;
1232
- break;
1233
- case "has_comment":
1234
- include = !!(node.comment && node.comment.length > 0);
1235
- if (include)
1236
- extra.comment_preview = (node.comment || "").slice(0, 80);
1237
- break;
1238
- case "has_annotations":
1239
- include = !!(node.annotations && (node.annotations.arrows.length > 0 || node.annotations.highlights.length > 0));
1240
- break;
1241
- case "novelties":
1242
- include = !!(node.nags && node.nags.includes("$146"));
1243
- break;
1244
- case "leaves":
1245
- include = node.children.length === 0;
1246
- break;
1247
- case "mainline":
1248
- include = spineOnly;
1249
- break;
1250
- case "transpositions": {
1251
- const group = fenIndex.get(positionKey(node.fen));
1252
- if (group && group.length > 1) {
1253
- include = true;
1254
- extra.transposes_to = group.filter(n => n.id !== node.id).map(n => n.id);
1255
- }
1256
- break;
1257
- }
1258
- case "all":
1259
- include = true;
1260
- break;
1261
- default:
1262
- throw new Error(`unknown filter: ${filter}`);
1263
- }
1264
- if (include) {
1265
- const hit = { node_id: node.id, san: node.san, ply: node.ply };
1266
- Object.assign(hit, extra);
1267
- hits.push(hit);
1268
- }
1269
- }
1270
- if (depthLeft !== null && depthLeft <= 0)
1271
- return;
1272
- const nextDepth = depthLeft === null ? null : depthLeft - 1;
1273
- if (filter === "mainline" && spineOnly) {
1274
- if (node.children.length > 0)
1275
- walk(node.children[0], nextDepth, true);
1276
- }
1277
- else {
1278
- for (const c of node.children)
1279
- walk(c, nextDepth, filter === "mainline");
1280
- }
1281
- };
1282
- const rootIsSpineForFilter = filter === "mainline";
1283
- walk(anchor, maxDepth, rootIsSpineForFilter);
1284
- return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
1285
- }
1286
- // list_transpositions — every position that occurs 2+ times in the
1287
- // file, so the LLM knows where its analysis / prose will double up.
1288
- async function listTranspositions(args) {
1289
- const id = String(args.id);
1290
- const { file } = await loadPrepFile(id);
1291
- const fenIndex = buildFenIndex(file.root);
1292
- const groups = [];
1293
- for (const [key, arr] of fenIndex.entries()) {
1294
- if (arr.length < 2)
1295
- continue;
1296
- groups.push({
1297
- position_key: key,
1298
- size: arr.length,
1299
- node_ids: arr.map(n => n.id),
1300
- sans: arr.map(n => n.san),
1301
- });
1302
- }
1303
- groups.sort((a, b) => b.size - a.size || a.position_key.localeCompare(b.position_key));
1304
- const nodeCount = groups.reduce((s, g) => s + g.size, 0);
1305
- return { file_id: id, group_count: groups.length, node_count: nodeCount, groups };
1306
- }
1307
- // Strip cruft the LLM doesn't need from the DB-position response.
1308
- // Called AFTER trimGamesMovetext so plyNumber survives long enough to
1309
- // slice each game's movetext. Also renames the `transpositions` field
1310
- // to something the LLM can parse without knowing chess-DB jargon.
1311
- function stripPositionResponse(r) {
1312
- if (!r || typeof r !== "object")
1313
- return;
1314
- const t = r;
1315
- delete t.hash; // internal zobrist string
1316
- delete t.source; // internal "database" marker; we overwrite with our own .source
1317
- delete t.totalGames; // duplicates statistics.totalCount often; hasMore covers pagination
1318
- if (Array.isArray(t.moves)) {
1319
- for (const m of t.moves) {
1320
- if (typeof m.transpositions === "number") {
1321
- m.reachedViaTransposition = m.transpositions;
1322
- delete m.transpositions;
1323
- }
1324
- // Backend calls it "hotness" — a 0-100 time-decayed popularity score
1325
- // (recent + played often = high). Rename to something an LLM can read
1326
- // without guessing it means "on a winning streak".
1327
- if (typeof m.hotness === "number") {
1328
- m.fashionScore = m.hotness;
1329
- delete m.hotness;
1330
- }
1331
- }
1332
- }
1333
- if (Array.isArray(t.games)) {
1334
- for (const g of t.games) {
1335
- delete g.gameId;
1336
- delete g.whiteTitle;
1337
- delete g.blackTitle;
1338
- delete g.whiteTeam;
1339
- delete g.blackTeam;
1340
- delete g.round;
1341
- delete g.plyNumber;
1342
- delete g.relevance;
1343
- delete g.site;
1344
- delete g.ply;
1345
- }
1346
- }
1347
- }
1348
- // Trim every game's `moves` field to just the plies AFTER the queried
1349
- // position, using each game's `plyNumber`. Massive token save — a game
1350
- // 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
1351
- // the frontend's GamesTable.getMoveDisplay() trim logic.
1352
- function trimGamesMovetext(response) {
1353
- if (!response || typeof response !== "object")
1354
- return;
1355
- const r = response;
1356
- if (!Array.isArray(r.games))
1357
- return;
1358
- for (const g of r.games) {
1359
- if (typeof g.moves === "string" && typeof g.plyNumber === "number" && g.plyNumber > 0) {
1360
- g.moves = trimMovesToPly(g.moves, g.plyNumber);
1361
- }
1362
- }
1363
- }
1364
- function trimMovesToPly(moves, plyNumber) {
1365
- // Split into plain SAN tokens, dropping standalone move-number tokens
1366
- // ("1.", "12...") and any glued number prefix on a SAN token ("1.e4").
1367
- // Result markers ("*", "1-0", "0-1", "1/2-1/2") are stripped so they
1368
- // don't get counted as plies.
1369
- const tokens = [];
1370
- for (const chunk of moves.split(/\s+/)) {
1371
- if (!chunk)
1372
- continue;
1373
- const cleaned = chunk.replace(/^\d+\.+/, "");
1374
- if (!cleaned)
1375
- continue;
1376
- if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(cleaned))
1377
- continue;
1378
- tokens.push(cleaned);
1379
- }
1380
- const remaining = tokens.slice(plyNumber);
1381
- if (remaining.length === 0)
1382
- return "";
1383
- // Reconstruct with move numbering. First move gets "N..." if it's
1384
- // Black's move (starting the slice mid-move-pair), so the reader knows
1385
- // moves were dropped.
1386
- const out = [];
1387
- let ply = plyNumber;
1388
- for (let i = 0; i < remaining.length; i++) {
1389
- const san = remaining[i];
1390
- const moveNumber = Math.floor(ply / 2) + 1;
1391
- if (ply % 2 === 0) {
1392
- out.push(`${moveNumber}. ${san}`);
1393
- }
1394
- else if (i === 0) {
1395
- out.push(`${moveNumber}... ${san}`);
1396
- }
1397
- else {
1398
- out.push(san);
1399
- }
1400
- ply++;
1401
- }
1402
- return out.join(" ");
1403
- }
1404
- // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
1405
- // endpoints return moves in UCI on the wire — same LLM-readability
1406
- // concern as engine PVs, and the same wrapper-only fix. Passes the
1407
- // response through unchanged if there's no availableMoves array.
1408
- function convertAvailableMovesToSAN(raw, fen) {
1409
- if (!raw || typeof raw !== "object")
1410
- return raw;
1411
- const r = raw;
1412
- if (!Array.isArray(r.availableMoves))
1413
- return raw;
1414
- for (const m of r.availableMoves) {
1415
- if (typeof m.move === "string" && m.move.length >= 4) {
1416
- m.move = uciMoveToSAN(fen, m.move);
1417
- }
1418
- }
1419
- return raw;
1420
- }
1421
- // Resolve a starting FEN from any combination of `fen`, `line`, and
1422
- // `moves` the tool received. Three modes, all valid:
1423
- //
1424
- // fen alone → use as-is
1425
- // line/moves alone → walk from startpos
1426
- // fen + moves (or line) → walk from that fen
1427
- //
1428
- // `line` is the historical field name from the backend's prep endpoint;
1429
- // `moves` is the flexible-input name we now surface for LLM ergonomics
1430
- // ("start from this FEN and play these moves next"). They're synonyms
1431
- // here — same SAN sequence, same chess.js walker. `moves` wins if both
1432
- // happen to be provided.
1433
- function resolveFenFromArgs(args) {
1434
- const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
1435
- const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
1436
- const lineArg = typeof args.line === "string" ? args.line.trim() : "";
1437
- const sequence = movesArg || lineArg;
1438
- const board = fenArg ? new Chess(fenArg) : new Chess();
1439
- if (sequence) {
1440
- for (const raw of sequence.split(/\s+/)) {
1441
- const san = raw.replace(/^\d+\.+/, "");
1442
- if (!san)
1443
- continue;
1444
- try {
1445
- board.move(san);
1446
- }
1447
- catch {
1448
- throw new Error(`bad SAN token '${raw}' in moves`);
1449
- }
1450
- }
1451
- }
1452
- return board.fen();
1453
- }
1454
- // Normalize one MCP `prepare_opponent` source into the shape the backend's
1455
- // /api/chess/prep/prepare-multi expects. Handles two impedance mismatches:
1456
- // - snake_case → camelCase (fide_id → fideId, start_month → startMonth, etc.)
1457
- // - the unified `time_control` string → per-source-type filter:
1458
- // * fide / chesscom → timeFormats: ["Classical" | "Rapid" | "Blitz"]
1459
- // * lichess → perfType: "classical" | "rapid" | "blitz" | "bullet"
1460
- // Backend validates required fields per source type, so we don't need to
1461
- // pre-reject missing username/fideId here — it'll come back as a 400 the
1462
- // LLM can act on.
1463
- function normalizeSourceForBackend(src, idx) {
1464
- const type = typeof src.type === "string" ? src.type : "";
1465
- if (type !== "fide" && type !== "chesscom" && type !== "lichess") {
1466
- throw new Error(`sources[${idx}].type must be one of fide|chesscom|lichess (got ${JSON.stringify(src.type)})`);
1467
- }
1468
- const out = { type };
1469
- if (typeof src.fide_id === "number")
1470
- out.fideId = src.fide_id;
1471
- if (typeof src.username === "string" && src.username.trim() !== "")
1472
- out.username = src.username.trim();
1473
- if (typeof src.color === "string" && (src.color === "white" || src.color === "black"))
1474
- out.color = src.color;
1475
- if (typeof src.start_month === "string" && src.start_month.trim() !== "")
1476
- out.startMonth = src.start_month.trim();
1477
- if (typeof src.end_month === "string" && src.end_month.trim() !== "")
1478
- out.endMonth = src.end_month.trim();
1479
- if (typeof src.exclude_online === "boolean")
1480
- out.excludeOnline = src.exclude_online;
1481
- const tc = typeof src.time_control === "string" ? src.time_control : "";
1482
- if (tc) {
1483
- if (type === "lichess") {
1484
- out.perfType = tc;
114
+ // v0.48: consolidated the five `read_*_guide` / `read_example_prep_files`
115
+ // tools into ONE `read_docs`. LLM lists what it wants; we return them
116
+ // in a single response. Also cleaner: enumerating available docs in one
117
+ // tool description (with per-doc "CALL WHEN" hints) beats surfacing
118
+ // five almost-identical tools in the tool listing.
119
+ const DOC_LIBRARY = {
120
+ "engine-usage": ENGINE_USAGE_DOC,
121
+ "opening-prep": PREP_STRATEGY_DOC,
122
+ "prep-files": PREP_FILES_DOC,
123
+ "pgn-authoring": PGN_AUTHORING_DOC,
124
+ "summary-authoring": SUMMARY_AUTHORING_DOC,
125
+ "examples/italian-fried-liver-overview": EXAMPLE_OVERVIEW_PGN,
126
+ "examples/najdorf-6-f4-repertoire": EXAMPLE_REPERTOIRE_PGN,
127
+ };
128
+ function readDocs(args) {
129
+ const raw = args.docs;
130
+ const requested = Array.isArray(raw)
131
+ ? raw.map(String).map(s => s.trim()).filter(s => s.length > 0)
132
+ : [];
133
+ if (requested.length === 0) {
134
+ throw new Error(`docs required — pass e.g. { docs: ["engine-usage", "pgn-authoring"] }. Available: ${Object.keys(DOC_LIBRARY).join(", ")}`);
135
+ }
136
+ const out = {};
137
+ const unknown = [];
138
+ for (const name of requested) {
139
+ if (name in DOC_LIBRARY) {
140
+ out[name] = DOC_LIBRARY[name];
1485
141
  }
1486
142
  else {
1487
- // fide + chesscom take a titlecased timeFormats array.
1488
- const titled = tc.charAt(0).toUpperCase() + tc.slice(1);
1489
- out.timeFormats = [titled];
1490
- }
1491
- }
1492
- return out;
1493
- }
1494
- // Async resolver used by every engine / DB tool. Three paths:
1495
- //
1496
- // 1. file_id + node_id → load file, resolve node, return FEN + handle
1497
- // to persist ceoEval later. Cheapest, most explicit.
1498
- //
1499
- // 2. file_id + (fen | moves | line) → load file, resolve FEN
1500
- // client-side, then scan the file's nodes for one matching that
1501
- // FEN. If found, return the same handle as (1) so cloud_analyse
1502
- // auto-stores on the matching node. Fixes the previous footgun
1503
- // where `cloud_analyse({file_id, moves})` silently dropped the
1504
- // eval because the server didn't try to match the resulting FEN
1505
- // back to a node.
1506
- //
1507
- // 3. Just fen | moves | line, no file_id → scratch mode, no
1508
- // persistence. Same as before.
1509
- async function resolveFromNodeOrFen(args) {
1510
- const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
1511
- const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
1512
- if (fileId && nodeId) {
1513
- const g = await fetchGame(fileId);
1514
- const parsedFile = parsePGN(g.pgnContent);
1515
- const idIndex = buildIdIndex(parsedFile.root);
1516
- const nodePath = resolveNodeId(idIndex, nodeId);
1517
- const node = getNodeByPath(parsedFile.root, nodePath);
1518
- return {
1519
- fen: node.fen,
1520
- file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath, fen: node.fen },
1521
- };
1522
- }
1523
- if (fileId) {
1524
- // file_id only — resolve FEN from fen/moves/line, then look it up
1525
- // in the file's nodes. If a node has that FEN, treat this as if
1526
- // node_id had been supplied (auto-persist on match).
1527
- const fen = resolveFenFromArgs(args);
1528
- try {
1529
- const g = await fetchGame(fileId);
1530
- const parsedFile = parsePGN(g.pgnContent);
1531
- const match = findNodeByFen(parsedFile.root, fen);
1532
- if (match) {
1533
- const idIndex = buildIdIndex(parsedFile.root);
1534
- return {
1535
- fen,
1536
- file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
1537
- };
1538
- }
1539
- }
1540
- catch {
1541
- // Best-effort: if the file load fails, fall through to scratch mode.
1542
- }
1543
- return { fen };
1544
- }
1545
- return { fen: resolveFenFromArgs(args) };
1546
- }
1547
- // Search the tree for a node whose FEN matches. Full-tree scan — trees
1548
- // max out ~1000 nodes so this is fine. FEN comparison is exact string
1549
- // match (both come from the same chessops normalisation).
1550
- function findNodeByFen(root, targetFen) {
1551
- const stack = [{ node: root, path: [] }];
1552
- while (stack.length > 0) {
1553
- const { node, path } = stack.pop();
1554
- if (node.fen === targetFen)
1555
- return { node, path };
1556
- for (let i = 0; i < node.children.length; i++) {
1557
- stack.push({ node: node.children[i], path: [...path, i] });
143
+ unknown.push(name);
1558
144
  }
1559
145
  }
1560
- return null;
1561
- }
1562
- // Local wrapper — the mutation module re-exports paths.getNode so this
1563
- // import stays consistent with the rest of the file's imports.
1564
- function getNodeByPath(root, path) {
1565
- let cur = root;
1566
- for (const idx of path) {
1567
- if (idx < 0 || idx >= cur.children.length)
1568
- throw new Error(`invalid node path segment ${idx}`);
1569
- cur = cur.children[idx];
1570
- }
1571
- return cur;
1572
- }
1573
- // Persist a fresh ceoEval on the node referenced by the file handle
1574
- // AND on every other node in the same file that transposes to the
1575
- // same position (matches on the frontend's 3-field FEN key: piece
1576
- // placement + side to move + castling). Best-effort — if the file
1577
- // version raced (another agent saved between our GET and our PUT),
1578
- // we silently drop the store rather than fail the analysis the LLM
1579
- // actually asked for. The eval is still returned in the response
1580
- // either way.
1581
- //
1582
- // Return: ids of every node the eval was stamped on (empty on error).
1583
- // The primary node's id is always first (if present).
1584
- async function storeEvalOnNode(handle, ev) {
1585
- try {
1586
- const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
1587
- const key = positionKey(anchor.fen);
1588
- const fenIndex = buildFenIndex(handle.parsedFile.root);
1589
- const group = fenIndex.get(key) ?? [anchor];
1590
- // Resolve every transposed node back to its path. cloneOnPath
1591
- // rebuilds the spine so we need paths, not references — the
1592
- // id index was built against the original tree and every id in
1593
- // `group` exists there.
1594
- const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
1595
- const paths = group.map(n => resolveNodeId(idIndex, n.id));
1596
- const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
1597
- const newPgn = exportPGN(newFile);
1598
- await saveGame(handle.id, newPgn, handle.version);
1599
- // Ensure the primary node (the one the LLM addressed) comes first.
1600
- const anchorId = anchor.id;
1601
- return [anchorId, ...ids.filter(x => x !== anchorId)];
1602
- }
1603
- catch {
1604
- return [];
146
+ const resp = { docs: out };
147
+ if (unknown.length > 0) {
148
+ resp.unknown = unknown;
149
+ resp.note = `unknown doc name(s): ${unknown.join(", ")}. Available: ${Object.keys(DOC_LIBRARY).join(", ")}`;
1605
150
  }
151
+ return resp;
1606
152
  }
153
+ // Log every tool call in and out. Keeps args + response payloads together
154
+ // with a per-call duration so we can trace what the LLM asked for and what
155
+ // it got back on the same journalctl line. Response is JSON-stringified and
156
+ // capped so the two doc-reading tools (~5-10 KB of static markdown each)
157
+ // don't drown the log stream.
158
+ const LOG_MAX_CHARS = 4096;
1607
159
  function stringifyForLog(v) {
1608
160
  let s;
1609
161
  try {
@@ -1949,7 +501,14 @@ async function callToolInner(name, args) {
1949
501
  });
1950
502
  }
1951
503
  case "set_nags":
1952
- return applyMutation(args, (file, idIndex) => setNags(file, resolveNodeId(idIndex, argNodeId(args)), Array.isArray(args.nags) ? args.nags.map(String) : []));
504
+ return applyMutation(args, (file, idIndex) => {
505
+ const nagsArg = Array.isArray(args.nags) ? args.nags.map(String) : [];
506
+ const targetPath = resolveNodeId(idIndex, argNodeId(args));
507
+ const targetNode = getNodeByPath(file.root, targetPath);
508
+ const nagWarn = positionalNagOnIntermediateWarning(targetNode, nagsArg);
509
+ const step = setNags(file, targetPath, nagsArg);
510
+ return { ...step, ...(nagWarn ? { warning: nagWarn } : {}) };
511
+ });
1953
512
  case "set_annotations": {
1954
513
  const arrowsRaw = Array.isArray(args.arrows) ? args.arrows : [];
1955
514
  const highlightsRaw = Array.isArray(args.highlights) ? args.highlights : [];
@@ -1970,7 +529,7 @@ async function callToolInner(name, args) {
1970
529
  case "apply_mutations":
1971
530
  return applyBatchMutations(args);
1972
531
  case "auto_evaluate":
1973
- return autoEvaluate(args);
532
+ return autoEvaluate(args, applyBatchMutations);
1974
533
  case "auto_evaluate_status":
1975
534
  return autoEvaluateStatus(args);
1976
535
  case "auto_evaluate_cancel":
@@ -1985,16 +544,8 @@ async function callToolInner(name, args) {
1985
544
  const node = getNodeByPath(file.root, path);
1986
545
  return { ceoEval: node.ceoEval ?? null };
1987
546
  }
1988
- case "read_engine_usage_guide":
1989
- return { guide: ENGINE_USAGE_DOC };
1990
- case "read_opening_prep_guide":
1991
- return { guide: PREP_STRATEGY_DOC };
1992
- case "read_prep_files_guide":
1993
- return { guide: PREP_FILES_DOC };
1994
- case "read_pgn_authoring_guide":
1995
- return { guide: PGN_AUTHORING_DOC };
1996
- case "read_example_prep_files":
1997
- return { overview: EXAMPLE_OVERVIEW_PGN, repertoire: EXAMPLE_REPERTOIRE_PGN };
547
+ case "read_docs":
548
+ return readDocs(args);
1998
549
  case "prep_snapshot": {
1999
550
  const me = Number(args.fide_id_me);
2000
551
  const opp = Number(args.fide_id_opponent);