@chessceo/mcp 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +13 -1501
- 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 +170 -0
- package/dist/prep/read.js +242 -0
- package/dist/response_transforms.js +169 -0
- package/package.json +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// auto_evaluate: async background job that walks a prep-file subtree and
|
|
2
|
+
// stores an engine eval on every node via cloud_analyse. Kept as its own
|
|
3
|
+
// module in v0.44 (was 300 LOC in the middle of src/index.ts) — the job
|
|
4
|
+
// lifecycle (start, run, status, cancel) is one self-contained unit.
|
|
5
|
+
//
|
|
6
|
+
// The naive walk-and-await approach held one HTTP request open for the
|
|
7
|
+
// full duration of the walk (200 nodes × ~1.5s serialized on the
|
|
8
|
+
// per-combo engine semaphore = ~5 min). MCP hosts vary in their
|
|
9
|
+
// tolerance for that. Switched to a background-job model:
|
|
10
|
+
//
|
|
11
|
+
// 1. `auto_evaluate` collects targets, spawns an unawaited worker,
|
|
12
|
+
// returns `{ job_id, target_count }` immediately.
|
|
13
|
+
// 2. `auto_evaluate_status(job_id)` returns live progress; the LLM
|
|
14
|
+
// can poll while doing other work on the tree.
|
|
15
|
+
// 3. `auto_evaluate_cancel(job_id)` aborts a running job cleanly;
|
|
16
|
+
// partial progress up to the last checkpoint is preserved.
|
|
17
|
+
//
|
|
18
|
+
// The MCP server is long-lived (chessceo-mcp.service under systemd), so
|
|
19
|
+
// in-memory job state survives across HTTP requests. On process restart
|
|
20
|
+
// jobs disappear — polling returns `not_found` and the LLM re-runs (the
|
|
21
|
+
// `only_missing` default naturally skips already-evaluated nodes).
|
|
22
|
+
//
|
|
23
|
+
// Progress is checkpointed to the prep file every SAVE_EVERY_N nodes so
|
|
24
|
+
// a mid-run crash / cancellation doesn't lose the whole walk.
|
|
25
|
+
import { authedRequest, fetchGame } from "../http.js";
|
|
26
|
+
import { analysisToStoredEval } from "./response.js";
|
|
27
|
+
import { parsePGN } from "../pgn/parser.js";
|
|
28
|
+
import { buildIdIndex, positionKey, resolveNodeId, ROOT_ID } from "../pgn/paths.js";
|
|
29
|
+
const evalJobs = new Map();
|
|
30
|
+
// GC finished jobs after this long so status polling remains useful
|
|
31
|
+
// for a while but the map doesn't grow unbounded across long uptimes.
|
|
32
|
+
const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
|
|
33
|
+
// Checkpoint interval — save progress every N successfully-evaluated
|
|
34
|
+
// nodes so a mid-run kill leaves the tree partially populated. Small
|
|
35
|
+
// enough that <15s of work is at risk per checkpoint on a slow combo,
|
|
36
|
+
// large enough that the save overhead stays a small fraction of the
|
|
37
|
+
// per-node cost.
|
|
38
|
+
const SAVE_EVERY_N = 8;
|
|
39
|
+
function newEvalJobId() {
|
|
40
|
+
// 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
|
|
41
|
+
const rand = Math.random().toString(16).slice(2, 8);
|
|
42
|
+
return `evj_${Date.now().toString(16)}${rand}`;
|
|
43
|
+
}
|
|
44
|
+
// Sweep expired jobs on every start/status call — cheap, doesn't need
|
|
45
|
+
// a background timer, keeps the map bounded to active + recent jobs.
|
|
46
|
+
function reapExpiredEvalJobs() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
for (const [k, j] of evalJobs) {
|
|
49
|
+
if (j.finishedAt && now - j.finishedAt > EVAL_JOB_TTL_MS) {
|
|
50
|
+
evalJobs.delete(k);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Path->node helper duplicated here so auto.ts doesn't depend on index.ts.
|
|
55
|
+
// Same body as index.ts:getNodeByPath.
|
|
56
|
+
function getNodeByPath(root, path) {
|
|
57
|
+
let cur = root;
|
|
58
|
+
for (const idx of path) {
|
|
59
|
+
if (idx < 0 || idx >= cur.children.length)
|
|
60
|
+
throw new Error(`invalid node path segment ${idx}`);
|
|
61
|
+
cur = cur.children[idx];
|
|
62
|
+
}
|
|
63
|
+
return cur;
|
|
64
|
+
}
|
|
65
|
+
export async function autoEvaluate(args, applyBatchMutations) {
|
|
66
|
+
reapExpiredEvalJobs();
|
|
67
|
+
const id = String(args.id);
|
|
68
|
+
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
|
|
69
|
+
? String(args.node_id)
|
|
70
|
+
: ROOT_ID;
|
|
71
|
+
const onlyMissing = args.only_missing !== false; // default true
|
|
72
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
|
|
73
|
+
const g = await fetchGame(id);
|
|
74
|
+
const file = parsePGN(g.pgnContent);
|
|
75
|
+
const idIndex = buildIdIndex(file.root);
|
|
76
|
+
const startPath = resolveNodeId(idIndex, startNodeId);
|
|
77
|
+
const targets = [];
|
|
78
|
+
const walk = (node, isStartAndRoot) => {
|
|
79
|
+
if (!isStartAndRoot) {
|
|
80
|
+
if (!onlyMissing || !node.ceoEval) {
|
|
81
|
+
targets.push({ nodeId: node.id, fen: node.fen });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const child of node.children)
|
|
85
|
+
walk(child, false);
|
|
86
|
+
};
|
|
87
|
+
const startNode = getNodeByPath(file.root, startPath);
|
|
88
|
+
// If the caller anchored at the root, skip evaluating the root itself
|
|
89
|
+
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
90
|
+
walk(startNode, startNode.id === ROOT_ID);
|
|
91
|
+
// Dedup transpositions: if two candidate targets share the same
|
|
92
|
+
// 3-field FEN key, they're the same position reached by different
|
|
93
|
+
// move orders. Analyse ONE of them — cloud_analyse auto-propagates
|
|
94
|
+
// the resulting ceoEval to every other node with a matching key
|
|
95
|
+
// (see storeEvalOnNode), so the twin ends up with the same eval
|
|
96
|
+
// without a second engine call. Keep DFS-first (mainline-preferred)
|
|
97
|
+
// occurrence.
|
|
98
|
+
let skippedTranspositions = 0;
|
|
99
|
+
{
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const deduped = [];
|
|
102
|
+
for (const t of targets) {
|
|
103
|
+
const key = positionKey(t.fen);
|
|
104
|
+
if (seen.has(key)) {
|
|
105
|
+
skippedTranspositions++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
seen.add(key);
|
|
109
|
+
deduped.push(t);
|
|
110
|
+
}
|
|
111
|
+
targets.length = 0;
|
|
112
|
+
targets.push(...deduped);
|
|
113
|
+
}
|
|
114
|
+
// Nothing to do → return a done job synthetically so the caller doesn't
|
|
115
|
+
// need to special-case the empty response.
|
|
116
|
+
if (targets.length === 0) {
|
|
117
|
+
const jobId = newEvalJobId();
|
|
118
|
+
evalJobs.set(jobId, {
|
|
119
|
+
id: jobId,
|
|
120
|
+
fileId: id,
|
|
121
|
+
status: "done",
|
|
122
|
+
targetCount: 0,
|
|
123
|
+
evaluated: 0,
|
|
124
|
+
errored: 0,
|
|
125
|
+
failedNodeIds: [],
|
|
126
|
+
finalVersion: g.version,
|
|
127
|
+
startedAt: Date.now(),
|
|
128
|
+
finishedAt: Date.now(),
|
|
129
|
+
cancelled: false,
|
|
130
|
+
});
|
|
131
|
+
return { job_id: jobId, target_count: 0, status: "done", version: g.version };
|
|
132
|
+
}
|
|
133
|
+
const jobId = newEvalJobId();
|
|
134
|
+
const job = {
|
|
135
|
+
id: jobId,
|
|
136
|
+
fileId: id,
|
|
137
|
+
status: "running",
|
|
138
|
+
targetCount: targets.length,
|
|
139
|
+
evaluated: 0,
|
|
140
|
+
errored: 0,
|
|
141
|
+
failedNodeIds: [],
|
|
142
|
+
startedAt: Date.now(),
|
|
143
|
+
cancelled: false,
|
|
144
|
+
};
|
|
145
|
+
evalJobs.set(jobId, job);
|
|
146
|
+
// Unawaited — runs concurrently with the tool response. Any thrown
|
|
147
|
+
// error gets recorded on the job so the LLM's status poll surfaces
|
|
148
|
+
// it instead of the process seeing an unhandled rejection.
|
|
149
|
+
void runEvalJob(job, id, targets, movetimeMs, applyBatchMutations).catch(err => {
|
|
150
|
+
job.status = "error";
|
|
151
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
152
|
+
job.finishedAt = Date.now();
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
job_id: jobId,
|
|
156
|
+
target_count: targets.length,
|
|
157
|
+
// Transpositions inside the walk that we skipped because they'll
|
|
158
|
+
// pick up the eval via auto-propagation. Zero when there are none.
|
|
159
|
+
skipped_transpositions: skippedTranspositions,
|
|
160
|
+
status: "running",
|
|
161
|
+
// Rough time estimate at the current default movetime. Serialization
|
|
162
|
+
// on the per-combo semaphore means walltime ≈ target_count × movetime.
|
|
163
|
+
estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// Worker body — walks targets sequentially (concurrency > 1 is a lie
|
|
167
|
+
// against the per-combo semaphore in the backend anyway), checkpoints
|
|
168
|
+
// every SAVE_EVERY_N successfully-evaluated nodes so partial progress
|
|
169
|
+
// is durable, and re-anchors the version after each save.
|
|
170
|
+
async function runEvalJob(job, fileId, targets, movetimeMs, applyBatchMutations) {
|
|
171
|
+
const pending = [];
|
|
172
|
+
const flush = async () => {
|
|
173
|
+
if (pending.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
// No expected_version — auto_evaluate treats concurrent edits by
|
|
176
|
+
// the LLM as last-write-wins on the ceoEval field specifically.
|
|
177
|
+
// Safe because set_ceo_eval is idempotent per node and other
|
|
178
|
+
// mutations (add_move / set_comment / etc.) don't touch ceoEval.
|
|
179
|
+
const saved = await applyBatchMutations({
|
|
180
|
+
id: fileId,
|
|
181
|
+
mutations: pending,
|
|
182
|
+
});
|
|
183
|
+
const sr = saved;
|
|
184
|
+
if (typeof sr.version === "number")
|
|
185
|
+
job.finalVersion = sr.version;
|
|
186
|
+
pending.length = 0;
|
|
187
|
+
};
|
|
188
|
+
// Consecutive-failure abort. If N cloud_analyse calls in a row error,
|
|
189
|
+
// the engine is almost certainly dead (vanished contract, network to
|
|
190
|
+
// VastAI down) and burning through the rest of the tree just wastes
|
|
191
|
+
// time. Bail with an explicit reason so a targeted retry is possible.
|
|
192
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
193
|
+
let consecutiveFailures = 0;
|
|
194
|
+
let aborted = false;
|
|
195
|
+
for (const t of targets) {
|
|
196
|
+
if (job.cancelled || aborted)
|
|
197
|
+
break;
|
|
198
|
+
try {
|
|
199
|
+
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
200
|
+
const ev = analysisToStoredEval(analysis);
|
|
201
|
+
if (ev) {
|
|
202
|
+
pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
|
|
203
|
+
job.evaluated++;
|
|
204
|
+
consecutiveFailures = 0;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
job.errored++;
|
|
208
|
+
job.failedNodeIds.push(t.nodeId);
|
|
209
|
+
consecutiveFailures++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Per-node failure — record the node_id so the caller can retry
|
|
214
|
+
// just those, and count consecutive failures for the abort check.
|
|
215
|
+
job.errored++;
|
|
216
|
+
job.failedNodeIds.push(t.nodeId);
|
|
217
|
+
consecutiveFailures++;
|
|
218
|
+
}
|
|
219
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
220
|
+
aborted = true;
|
|
221
|
+
job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
if (pending.length >= SAVE_EVERY_N) {
|
|
225
|
+
try {
|
|
226
|
+
await flush();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Save failure is bad but not fatal — try again on the next
|
|
230
|
+
// checkpoint or at the end. Progress remains in `pending`
|
|
231
|
+
// so nothing is lost as long as the process stays alive.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Final flush regardless of cancellation — durably persist whatever
|
|
236
|
+
// work was completed before the user asked to stop.
|
|
237
|
+
try {
|
|
238
|
+
await flush();
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
242
|
+
job.status = "error";
|
|
243
|
+
job.finishedAt = Date.now();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
job.status = job.cancelled ? "cancelled" : "done";
|
|
247
|
+
job.finishedAt = Date.now();
|
|
248
|
+
}
|
|
249
|
+
export function autoEvaluateStatus(args) {
|
|
250
|
+
reapExpiredEvalJobs();
|
|
251
|
+
const jobId = String(args.job_id || "").trim();
|
|
252
|
+
if (!jobId)
|
|
253
|
+
throw new Error("`job_id` is required");
|
|
254
|
+
const job = evalJobs.get(jobId);
|
|
255
|
+
if (!job) {
|
|
256
|
+
return {
|
|
257
|
+
status: "not_found",
|
|
258
|
+
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.",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
job_id: job.id,
|
|
263
|
+
status: job.status,
|
|
264
|
+
target_count: job.targetCount,
|
|
265
|
+
evaluated: job.evaluated,
|
|
266
|
+
errored: job.errored,
|
|
267
|
+
failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
|
|
268
|
+
aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
|
|
269
|
+
remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
|
|
270
|
+
done: job.status !== "running",
|
|
271
|
+
error: job.error,
|
|
272
|
+
version: job.finalVersion,
|
|
273
|
+
started_at_ms: job.startedAt,
|
|
274
|
+
finished_at_ms: job.finishedAt,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
export function autoEvaluateCancel(args) {
|
|
278
|
+
const jobId = String(args.job_id || "").trim();
|
|
279
|
+
if (!jobId)
|
|
280
|
+
throw new Error("`job_id` is required");
|
|
281
|
+
const job = evalJobs.get(jobId);
|
|
282
|
+
if (!job)
|
|
283
|
+
return { status: "not_found" };
|
|
284
|
+
if (job.status !== "running") {
|
|
285
|
+
return { status: job.status, note: "Job already finished; nothing to cancel." };
|
|
286
|
+
}
|
|
287
|
+
job.cancelled = true;
|
|
288
|
+
// status transitions to "cancelled" on the next per-node iteration
|
|
289
|
+
// inside runEvalJob, after the final flush persists progress.
|
|
290
|
+
return { status: "cancelling", evaluated_so_far: job.evaluated };
|
|
291
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// deep_analyse: async background job for a SINGLE long Stockfish think
|
|
2
|
+
// on ONE position (up to 5 min movetime). Same start / status / cancel
|
|
3
|
+
// shape as auto_evaluate, but different intent — the point is to free
|
|
4
|
+
// the tool response path from a 5-minute wait AND to keep the Lc0 slot
|
|
5
|
+
// free on the combo so the LLM can keep calling
|
|
6
|
+
// `cloud_analyse({engines: ["lc0"]})` for other positions while the
|
|
7
|
+
// deep SF think runs.
|
|
8
|
+
//
|
|
9
|
+
// Concretely: the job fires an unawaited authedRequest to the backend
|
|
10
|
+
// with engines=["stockfish"] + long movetime; the backend's per-engine
|
|
11
|
+
// semaphore lets that hold only the SF slot for the duration. The
|
|
12
|
+
// MCP-side promise resolves when the long HTTP call returns (nginx
|
|
13
|
+
// proxy_read_timeout is bumped to 420s on /api/agent/ to cover 5-min
|
|
14
|
+
// movetime + engine bestmove grace).
|
|
15
|
+
//
|
|
16
|
+
// Extracted from index.ts in v0.44 as part of the file split.
|
|
17
|
+
import { authedRequest } from "../http.js";
|
|
18
|
+
import { analysisToStoredEval, convertCloudSnapshotResponse } from "./response.js";
|
|
19
|
+
import { resolveFromNodeOrFen, storeEvalOnNode, } from "./file_handle.js";
|
|
20
|
+
const deepJobs = new Map();
|
|
21
|
+
const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
|
|
22
|
+
function newDeepJobId() {
|
|
23
|
+
const rand = Math.random().toString(16).slice(2, 8);
|
|
24
|
+
return `deep_${Date.now().toString(16)}${rand}`;
|
|
25
|
+
}
|
|
26
|
+
function reapExpiredDeepJobs() {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [k, j] of deepJobs) {
|
|
29
|
+
if (j.finishedAt && now - j.finishedAt > DEEP_JOB_TTL_MS) {
|
|
30
|
+
deepJobs.delete(k);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function deepAnalyseStart(args) {
|
|
35
|
+
reapExpiredDeepJobs();
|
|
36
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
37
|
+
const fen = resolved.fen;
|
|
38
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
|
|
39
|
+
// Default 2 — SF loses meaningful strength at higher multipv, so a
|
|
40
|
+
// deep think is best spent on a tight candidate list. Matches the
|
|
41
|
+
// cloud_analyse stockfish_multipv default.
|
|
42
|
+
const multipv = typeof args.multipv === "number" ? args.multipv : 2;
|
|
43
|
+
const jobId = newDeepJobId();
|
|
44
|
+
const job = {
|
|
45
|
+
id: jobId,
|
|
46
|
+
status: "running",
|
|
47
|
+
fileHandle: resolved.file,
|
|
48
|
+
fen,
|
|
49
|
+
movetimeMs,
|
|
50
|
+
multipv,
|
|
51
|
+
startedAt: Date.now(),
|
|
52
|
+
cancelController: new AbortController(),
|
|
53
|
+
};
|
|
54
|
+
deepJobs.set(jobId, job);
|
|
55
|
+
// Kick off the long HTTP call unawaited — resolves when the backend
|
|
56
|
+
// returns the SF snapshot. authedRequest is a plain fetch under the
|
|
57
|
+
// hood; abort signal flows via cancelController.
|
|
58
|
+
void runDeepJob(job).catch(err => {
|
|
59
|
+
job.status = "error";
|
|
60
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
61
|
+
job.finishedAt = Date.now();
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
job_id: jobId,
|
|
65
|
+
status: "running",
|
|
66
|
+
movetime_ms: movetimeMs,
|
|
67
|
+
fen,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function runDeepJob(job) {
|
|
71
|
+
const body = {
|
|
72
|
+
fen: job.fen,
|
|
73
|
+
movetime_ms: job.movetimeMs,
|
|
74
|
+
stockfish_multipv: job.multipv,
|
|
75
|
+
engines: ["stockfish"],
|
|
76
|
+
};
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
// TODO(future): plumb an AbortSignal through authedRequest for
|
|
80
|
+
// real mid-flight cancellation. For now, cancel just marks the
|
|
81
|
+
// job so the caller stops polling; the backend still runs the
|
|
82
|
+
// engine to completion and the result is stored on the job
|
|
83
|
+
// record but flagged cancelled.
|
|
84
|
+
raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
job.status = "error";
|
|
88
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
89
|
+
job.finishedAt = Date.now();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const converted = convertCloudSnapshotResponse(raw, job.fen);
|
|
93
|
+
const sf = converted.stockfish;
|
|
94
|
+
if (job.cancelController.signal.aborted) {
|
|
95
|
+
job.status = "cancelled";
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
job.status = "done";
|
|
99
|
+
}
|
|
100
|
+
job.result = sf ?? null;
|
|
101
|
+
job.finishedAt = Date.now();
|
|
102
|
+
// Same node-persistence as cloud_analyse: if the caller anchored on
|
|
103
|
+
// file_id+node_id, store the SF-only eval as the node's ceoEval so
|
|
104
|
+
// quote_engine_eval can cite it later. We build a StoredEval that has
|
|
105
|
+
// only the sf leg — no Lc0 was run.
|
|
106
|
+
if (job.fileHandle && sf) {
|
|
107
|
+
const ev = analysisToStoredEval({ stockfish: sf });
|
|
108
|
+
if (ev) {
|
|
109
|
+
try {
|
|
110
|
+
await storeEvalOnNode(job.fileHandle, ev);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// best-effort — the analysis result is what the LLM asked for
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function deepAnalyseStatus(args) {
|
|
119
|
+
reapExpiredDeepJobs();
|
|
120
|
+
const jobId = String(args.job_id || "").trim();
|
|
121
|
+
if (!jobId)
|
|
122
|
+
throw new Error("`job_id` is required");
|
|
123
|
+
const job = deepJobs.get(jobId);
|
|
124
|
+
if (!job) {
|
|
125
|
+
return {
|
|
126
|
+
status: "not_found",
|
|
127
|
+
note: "Job unknown — expired (kept ~15 min after completion), never existed, or the MCP process restarted.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
job_id: job.id,
|
|
132
|
+
status: job.status,
|
|
133
|
+
movetime_ms: job.movetimeMs,
|
|
134
|
+
elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
|
|
135
|
+
fen: job.fen,
|
|
136
|
+
result: job.result,
|
|
137
|
+
error: job.error,
|
|
138
|
+
started_at_ms: job.startedAt,
|
|
139
|
+
finished_at_ms: job.finishedAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function deepAnalyseCancel(args) {
|
|
143
|
+
const jobId = String(args.job_id || "").trim();
|
|
144
|
+
if (!jobId)
|
|
145
|
+
throw new Error("`job_id` is required");
|
|
146
|
+
const job = deepJobs.get(jobId);
|
|
147
|
+
if (!job)
|
|
148
|
+
return { status: "not_found" };
|
|
149
|
+
if (job.status !== "running") {
|
|
150
|
+
return { status: job.status, note: "Job already finished; nothing to cancel." };
|
|
151
|
+
}
|
|
152
|
+
job.cancelController.abort();
|
|
153
|
+
// Status flips to "cancelled" when runDeepJob observes the abort on
|
|
154
|
+
// completion. Backend keeps churning until movetime elapses (mid-
|
|
155
|
+
// flight abort of the HTTP call is a follow-up).
|
|
156
|
+
return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
|
|
157
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Shared address resolution for engine + DB tools. Every position-taking
|
|
2
|
+
// tool (cloud_analyse, describe_position, predict_human_move,
|
|
3
|
+
// prep_snapshot, get_prep_position, find_position_in_files,
|
|
4
|
+
// find_position_in_courses, deep_analyse, quote_engine_eval) accepts the
|
|
5
|
+
// same set of position inputs — this module normalises them into a FEN
|
|
6
|
+
// (and, when the caller was inside a prep file, a FileHandle that lets
|
|
7
|
+
// the downstream write ceoEval back on the resolved node).
|
|
8
|
+
//
|
|
9
|
+
// Extracted from index.ts in v0.44 as part of the file split.
|
|
10
|
+
import { Chess } from "chess.js";
|
|
11
|
+
import { fetchGame, saveGame } from "../http.js";
|
|
12
|
+
import { parsePGN } from "../pgn/parser.js";
|
|
13
|
+
import { exportPGN } from "../pgn/exporter.js";
|
|
14
|
+
import { buildFenIndex, buildIdIndex, positionKey, resolveNodeId, } from "../pgn/paths.js";
|
|
15
|
+
import { setCeoEvalMany } from "../pgn/mutations.js";
|
|
16
|
+
// Async resolver used by every engine / DB tool. Three paths:
|
|
17
|
+
//
|
|
18
|
+
// 1. file_id + node_id → load file, resolve node, return FEN + handle
|
|
19
|
+
// to persist ceoEval later. Cheapest, most explicit.
|
|
20
|
+
//
|
|
21
|
+
// 2. file_id + (fen | moves | line) → load file, resolve FEN
|
|
22
|
+
// client-side, then scan the file's nodes for one matching that
|
|
23
|
+
// FEN. If found, return the same handle as (1) so cloud_analyse
|
|
24
|
+
// auto-stores on the matching node. Fixes the previous footgun
|
|
25
|
+
// where `cloud_analyse({file_id, moves})` silently dropped the
|
|
26
|
+
// eval because the server didn't try to match the resulting FEN
|
|
27
|
+
// back to a node.
|
|
28
|
+
//
|
|
29
|
+
// 3. Just fen | moves | line, no file_id → scratch mode, no
|
|
30
|
+
// persistence. Same as before.
|
|
31
|
+
export async function resolveFromNodeOrFen(args) {
|
|
32
|
+
const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
|
|
33
|
+
const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
|
|
34
|
+
if (fileId && nodeId) {
|
|
35
|
+
const g = await fetchGame(fileId);
|
|
36
|
+
const parsedFile = parsePGN(g.pgnContent);
|
|
37
|
+
const idIndex = buildIdIndex(parsedFile.root);
|
|
38
|
+
const nodePath = resolveNodeId(idIndex, nodeId);
|
|
39
|
+
const node = getNodeByPath(parsedFile.root, nodePath);
|
|
40
|
+
return {
|
|
41
|
+
fen: node.fen,
|
|
42
|
+
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath, fen: node.fen },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (fileId) {
|
|
46
|
+
// file_id only — resolve FEN from fen/moves/line, then look it up
|
|
47
|
+
// in the file's nodes. If a node has that FEN, treat this as if
|
|
48
|
+
// node_id had been supplied (auto-persist on match).
|
|
49
|
+
const fen = resolveFenFromArgs(args);
|
|
50
|
+
try {
|
|
51
|
+
const g = await fetchGame(fileId);
|
|
52
|
+
const parsedFile = parsePGN(g.pgnContent);
|
|
53
|
+
const match = findNodeByFen(parsedFile.root, fen);
|
|
54
|
+
if (match) {
|
|
55
|
+
const idIndex = buildIdIndex(parsedFile.root);
|
|
56
|
+
return {
|
|
57
|
+
fen,
|
|
58
|
+
file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Best-effort: if the file load fails, fall through to scratch mode.
|
|
64
|
+
}
|
|
65
|
+
return { fen };
|
|
66
|
+
}
|
|
67
|
+
return { fen: resolveFenFromArgs(args) };
|
|
68
|
+
}
|
|
69
|
+
// Normalize the LLM's position inputs into a single FEN. Used both by
|
|
70
|
+
// resolveFromNodeOrFen and directly by tools that don't need a file
|
|
71
|
+
// handle. Accepts three shapes: bare `fen`, `moves` from startpos (SAN
|
|
72
|
+
// space-separated, with optional `1.` / `1...` move-number prefixes
|
|
73
|
+
// stripped), or `fen`+`moves` layered together. `line` is a legacy
|
|
74
|
+
// alias for `moves`.
|
|
75
|
+
export function resolveFenFromArgs(args) {
|
|
76
|
+
const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
|
|
77
|
+
const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
|
|
78
|
+
const lineArg = typeof args.line === "string" ? args.line.trim() : "";
|
|
79
|
+
const sequence = movesArg || lineArg;
|
|
80
|
+
const board = fenArg ? new Chess(fenArg) : new Chess();
|
|
81
|
+
if (sequence) {
|
|
82
|
+
for (const raw of sequence.split(/\s+/)) {
|
|
83
|
+
const san = raw.replace(/^\d+\.+/, "");
|
|
84
|
+
if (!san)
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
board.move(san);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error(`bad SAN token '${raw}' in moves`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return board.fen();
|
|
95
|
+
}
|
|
96
|
+
// Search the tree for a node whose FEN matches. Full-tree scan — trees
|
|
97
|
+
// max out ~1000 nodes so this is fine. FEN comparison is exact string
|
|
98
|
+
// match (both come from the same chessops normalisation).
|
|
99
|
+
export function findNodeByFen(root, targetFen) {
|
|
100
|
+
const stack = [{ node: root, path: [] }];
|
|
101
|
+
while (stack.length > 0) {
|
|
102
|
+
const { node, path } = stack.pop();
|
|
103
|
+
if (node.fen === targetFen)
|
|
104
|
+
return { node, path };
|
|
105
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
106
|
+
stack.push({ node: node.children[i], path: [...path, i] });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
// Local wrapper for path→node navigation — kept here so callers of the
|
|
112
|
+
// file_handle module don't have to import pgn/paths.js just for one
|
|
113
|
+
// walk. Body identical to paths.getNode.
|
|
114
|
+
export function getNodeByPath(root, path) {
|
|
115
|
+
let cur = root;
|
|
116
|
+
for (const idx of path) {
|
|
117
|
+
if (idx < 0 || idx >= cur.children.length)
|
|
118
|
+
throw new Error(`invalid node path segment ${idx}`);
|
|
119
|
+
cur = cur.children[idx];
|
|
120
|
+
}
|
|
121
|
+
return cur;
|
|
122
|
+
}
|
|
123
|
+
// Persist a fresh ceoEval on the node referenced by the file handle
|
|
124
|
+
// AND on every other node in the same file that transposes to the
|
|
125
|
+
// same position (matches on the frontend's 3-field FEN key: piece
|
|
126
|
+
// placement + side to move + castling). Best-effort — if the file
|
|
127
|
+
// version raced (another agent saved between our GET and our PUT),
|
|
128
|
+
// we silently drop the store rather than fail the analysis the LLM
|
|
129
|
+
// actually asked for. The eval is still returned in the response
|
|
130
|
+
// either way.
|
|
131
|
+
//
|
|
132
|
+
// Return: ids of every node the eval was stamped on (empty on error).
|
|
133
|
+
// The primary node's id is always first (if present).
|
|
134
|
+
export async function storeEvalOnNode(handle, ev) {
|
|
135
|
+
try {
|
|
136
|
+
const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
|
|
137
|
+
const key = positionKey(anchor.fen);
|
|
138
|
+
const fenIndex = buildFenIndex(handle.parsedFile.root);
|
|
139
|
+
const group = fenIndex.get(key) ?? [anchor];
|
|
140
|
+
// Resolve every transposed node back to its path. cloneOnPath
|
|
141
|
+
// rebuilds the spine so we need paths, not references — the
|
|
142
|
+
// id index was built against the original tree and every id in
|
|
143
|
+
// `group` exists there.
|
|
144
|
+
const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
|
|
145
|
+
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
146
|
+
const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
|
|
147
|
+
const newPgn = exportPGN(newFile);
|
|
148
|
+
await saveGame(handle.id, newPgn, handle.version);
|
|
149
|
+
// Ensure the primary node (the one the LLM addressed) comes first.
|
|
150
|
+
const anchorId = anchor.id;
|
|
151
|
+
return [anchorId, ...ids.filter(x => x !== anchorId)];
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|