@chessceo/mcp 0.44.0 → 0.48.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/auto.js +291 -0
- package/dist/analysis/deep.js +157 -0
- package/dist/analysis/file_handle.js +156 -0
- package/dist/analysis/response.js +181 -0
- package/dist/courses.js +167 -0
- package/dist/index.js +64 -1513
- package/dist/pgn/exporter.js +13 -2
- package/dist/pgn/parser.js +46 -7
- package/dist/pgn/paths.js +37 -14
- package/dist/pgn/types.js +8 -5
- package/dist/prep/library.js +92 -0
- package/dist/prep/mutations.js +176 -0
- package/dist/prep/read.js +242 -0
- package/dist/response_transforms.js +169 -0
- package/dist/tools.js +35 -28
- package/dist/warnings.js +44 -1
- package/docs/pgn-authoring.md +13 -1
- package/docs/summary-authoring.md +97 -0
- package/package.json +1 -1
package/dist/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,
|
|
19
|
-
import {
|
|
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,
|
|
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
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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) =>
|
|
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 "
|
|
1989
|
-
return
|
|
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);
|