@chessceo/mcp 0.49.10 → 0.50.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.
@@ -1,49 +1,35 @@
1
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:
2
+ // stores engine evals on every node. The job lifecycle (start, run, status,
3
+ // cancel) is one self-contained unit.
10
4
  //
11
5
  // 1. `auto_evaluate` collects targets, spawns an unawaited worker,
12
6
  // 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.
7
+ // 2. `auto_evaluate_status(job_id)` returns live progress per engine.
8
+ // 3. `auto_evaluate_cancel(job_id)` stops the job; everything analysed
9
+ // up to the last saved batch stays in the file.
17
10
  //
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).
11
+ // Each node gets only the requested engines it is missing, so re-running
12
+ // with another engine (e.g. adding `human` after a Stockfish pass) analyses
13
+ // just that engine. Transpositions are analysed once and the eval is stamped
14
+ // on every node that reaches the position. Results are saved after every
15
+ // backend batch (up to 10 positions), merged per engine.
22
16
  //
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 { analyseRouting } from "./file_handle.js";
17
+ // The MCP server is long-lived (chessceo-mcp.service), so in-memory job
18
+ // state survives across HTTP requests. On restart jobs disappear; re-running
19
+ // skips what is already stored.
20
+ import { fetchGame } from "../http.js";
21
+ import { analysePositions, convertPositionResult, ENGINES, MAX_POSITIONS_PER_CALL, parseEngines, resultToStoredEval, STORED_KEY, } from "./response.js";
22
+ import { storeEvals } from "./file_handle.js";
28
23
  import { parsePGN } from "../pgn/parser.js";
29
24
  import { buildIdIndex, positionKey, resolveNodeId, ROOT_ID } from "../pgn/paths.js";
30
25
  const evalJobs = new Map();
31
26
  // GC finished jobs after this long so status polling remains useful
32
27
  // for a while but the map doesn't grow unbounded across long uptimes.
33
28
  const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
34
- // Checkpoint interval — save progress every N successfully-evaluated
35
- // nodes so a mid-run kill leaves the tree partially populated. Small
36
- // enough that <15s of work is at risk per checkpoint on a slow combo,
37
- // large enough that the save overhead stays a small fraction of the
38
- // per-node cost.
39
- const SAVE_EVERY_N = 8;
40
29
  function newEvalJobId() {
41
- // 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
42
30
  const rand = Math.random().toString(16).slice(2, 8);
43
31
  return `evj_${Date.now().toString(16)}${rand}`;
44
32
  }
45
- // Sweep expired jobs on every start/status call — cheap, doesn't need
46
- // a background timer, keeps the map bounded to active + recent jobs.
47
33
  function reapExpiredEvalJobs() {
48
34
  const now = Date.now();
49
35
  for (const [k, j] of evalJobs) {
@@ -52,8 +38,6 @@ function reapExpiredEvalJobs() {
52
38
  }
53
39
  }
54
40
  }
55
- // Path->node helper duplicated here so auto.ts doesn't depend on index.ts.
56
- // Same body as index.ts:getNodeByPath.
57
41
  function getNodeByPath(root, path) {
58
42
  let cur = root;
59
43
  for (const idx of path) {
@@ -63,187 +47,189 @@ function getNodeByPath(root, path) {
63
47
  }
64
48
  return cur;
65
49
  }
66
- export async function autoEvaluate(args, applyBatchMutations) {
50
+ export async function autoEvaluate(args) {
67
51
  reapExpiredEvalJobs();
68
52
  const id = String(args.id);
69
53
  const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
70
54
  ? String(args.node_id)
71
55
  : ROOT_ID;
72
56
  const onlyMissing = args.only_missing !== false; // default true
73
- const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
57
+ const engines = parseEngines(args.engines, ["stockfish"]);
58
+ const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 2000;
59
+ const baseOpts = { engines, movetime_ms: movetimeMs };
60
+ if (typeof args.contempt === "number")
61
+ baseOpts.contempt = args.contempt;
62
+ if (args.rentals && typeof args.rentals === "object")
63
+ baseOpts.rentals = args.rentals;
74
64
  const g = await fetchGame(id);
75
65
  const file = parsePGN(g.pgnContent);
76
66
  const idIndex = buildIdIndex(file.root);
77
67
  const startPath = resolveNodeId(idIndex, startNodeId);
78
- const targets = [];
68
+ const startNode = getNodeByPath(file.root, startPath);
69
+ // Every node in the subtree (the root itself has no move to evaluate
70
+ // when the walk starts there). With only_missing, a node needs just the
71
+ // requested engines it has no stored eval for. One target per position:
72
+ // transpositions are analysed once and the eval is stamped on every twin
73
+ // at save time, and a twin's stored engines count for the position.
74
+ const byKey = new Map();
75
+ // Stored evals per position, merged across twins, and whether some twin
76
+ // lacks an engine another twin already has (then we copy, not re-run).
77
+ const known = new Map();
78
+ let skippedTranspositions = 0;
79
+ let alreadyComplete = 0;
79
80
  const walk = (node, isStartAndRoot) => {
80
81
  if (!isStartAndRoot) {
81
- if (!onlyMissing || !node.ceoEval) {
82
- targets.push({ nodeId: node.id, fen: node.fen });
82
+ const key = positionKey(node.fen);
83
+ const have = (e) => Boolean(node.ceoEval?.[STORED_KEY[e]]);
84
+ const missing = onlyMissing ? engines.filter(e => !have(e)) : [...engines];
85
+ const prev = byKey.get(key);
86
+ if (prev) {
87
+ skippedTranspositions++;
88
+ // Only engines missing on every twin need running.
89
+ prev.engines = prev.engines.filter(e => missing.includes(e));
90
+ }
91
+ else {
92
+ byKey.set(key, { nodeId: node.id, fen: node.fen, engines: missing });
93
+ }
94
+ const k = known.get(key) ?? { fen: node.fen, ev: {}, nodes: 0, has: {} };
95
+ k.nodes++;
96
+ for (const e of engines) {
97
+ const mine = node.ceoEval?.[STORED_KEY[e]];
98
+ if (!mine)
99
+ continue;
100
+ k.has[e] = (k.has[e] ?? 0) + 1;
101
+ if (!k.ev[STORED_KEY[e]])
102
+ k.ev[STORED_KEY[e]] = mine;
83
103
  }
104
+ known.set(key, k);
84
105
  }
85
106
  for (const child of node.children)
86
107
  walk(child, false);
87
108
  };
88
- const startNode = getNodeByPath(file.root, startPath);
89
- // If the caller anchored at the root, skip evaluating the root itself
90
- // (no move); otherwise the anchor node IS a real move and gets evaluated.
91
109
  walk(startNode, startNode.id === ROOT_ID);
92
- // Dedup transpositions: if two candidate targets share the same
93
- // 3-field FEN key, they're the same position reached by different
94
- // move orders. Analyse ONE of them — cloud_analyse auto-propagates
95
- // the resulting ceoEval to every other node with a matching key
96
- // (see storeEvalOnNode), so the twin ends up with the same eval
97
- // without a second engine call. Keep DFS-first (mainline-preferred)
98
- // occurrence.
99
- let skippedTranspositions = 0;
100
- {
101
- const seen = new Set();
102
- const deduped = [];
103
- for (const t of targets) {
104
- const key = positionKey(t.fen);
105
- if (seen.has(key)) {
106
- skippedTranspositions++;
107
- continue;
108
- }
109
- seen.add(key);
110
- deduped.push(t);
110
+ // Twins that lack an engine another twin has: copy it over now (merge,
111
+ // no engine time).
112
+ const copies = onlyMissing
113
+ ? [...known.values()]
114
+ .filter(k => engines.some(e => (k.has[e] ?? 0) > 0 && (k.has[e] ?? 0) < k.nodes))
115
+ .map(k => ({ fen: k.fen, ev: k.ev }))
116
+ : [];
117
+ if (copies.length > 0)
118
+ await storeEvals(id, copies);
119
+ const targets = [...byKey.values()].filter(t => {
120
+ if (t.engines.length === 0) {
121
+ alreadyComplete++;
122
+ return false;
111
123
  }
112
- targets.length = 0;
113
- targets.push(...deduped);
114
- }
115
- // Nothing to do → return a done job synthetically so the caller doesn't
116
- // need to special-case the empty response.
117
- if (targets.length === 0) {
118
- const jobId = newEvalJobId();
119
- evalJobs.set(jobId, {
120
- id: jobId,
121
- fileId: id,
122
- status: "done",
123
- targetCount: 0,
124
- evaluated: 0,
125
- errored: 0,
126
- failedNodeIds: [],
127
- finalVersion: g.version,
128
- startedAt: Date.now(),
129
- finishedAt: Date.now(),
130
- cancelled: false,
131
- });
132
- return { job_id: jobId, target_count: 0, status: "done", version: g.version };
133
- }
124
+ return true;
125
+ });
134
126
  const jobId = newEvalJobId();
135
127
  const job = {
136
128
  id: jobId,
137
129
  fileId: id,
138
- status: "running",
130
+ status: targets.length === 0 ? "done" : "running",
131
+ engines,
139
132
  targetCount: targets.length,
140
- evaluated: 0,
141
- errored: 0,
142
- failedNodeIds: [],
133
+ processed: 0,
134
+ evaluated: {},
135
+ failed: {},
143
136
  startedAt: Date.now(),
144
137
  cancelled: false,
145
138
  };
146
- evalJobs.set(jobId, job);
147
- // Unawaited — runs concurrently with the tool response. Any thrown
148
- // error gets recorded on the job so the LLM's status poll surfaces
149
- // it instead of the process seeing an unhandled rejection.
150
- void runEvalJob(job, id, targets, movetimeMs, applyBatchMutations, analyseRouting(args)).catch(err => {
151
- job.status = "error";
152
- job.error = err instanceof Error ? err.message : String(err);
139
+ if (targets.length === 0)
153
140
  job.finishedAt = Date.now();
154
- });
141
+ evalJobs.set(jobId, job);
142
+ if (targets.length > 0) {
143
+ void runEvalJob(job, targets, baseOpts).catch(err => {
144
+ job.status = "error";
145
+ job.error = err instanceof Error ? err.message : String(err);
146
+ job.finishedAt = Date.now();
147
+ });
148
+ }
149
+ // Engines run in parallel per position, positions one after another.
150
+ const estimatedSeconds = Math.round((targets.length * movetimeMs) / 1000);
155
151
  return {
156
152
  job_id: jobId,
153
+ status: job.status,
154
+ engines,
157
155
  target_count: targets.length,
158
- // Transpositions inside the walk that we skipped because they'll
159
- // pick up the eval via auto-propagation. Zero when there are none.
156
+ already_complete: alreadyComplete,
160
157
  skipped_transpositions: skippedTranspositions,
161
- status: "running",
162
- // Rough time estimate at the current default movetime. Serialization
163
- // on the per-combo semaphore means walltime ≈ target_count × movetime.
164
- estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
158
+ copied_to_transpositions: copies.length,
159
+ estimated_seconds: estimatedSeconds,
165
160
  };
166
161
  }
167
- // Worker body — walks targets sequentially (concurrency > 1 is a lie
168
- // against the per-combo semaphore in the backend anyway), checkpoints
169
- // every SAVE_EVERY_N successfully-evaluated nodes so partial progress
170
- // is durable, and re-anchors the version after each save.
171
- async function runEvalJob(job, fileId, targets, movetimeMs, applyBatchMutations, routing) {
172
- const pending = [];
173
- const flush = async () => {
174
- if (pending.length === 0)
175
- return;
176
- // No expected_version — auto_evaluate treats concurrent edits by
177
- // the LLM as last-write-wins on the ceoEval field specifically.
178
- // Safe because set_ceo_eval is idempotent per node and other
179
- // mutations (add_move / set_comment / etc.) don't touch ceoEval.
180
- const saved = await applyBatchMutations({
181
- id: fileId,
182
- mutations: pending,
183
- });
184
- const sr = saved;
185
- if (typeof sr.version === "number")
186
- job.finalVersion = sr.version;
187
- pending.length = 0;
188
- };
189
- // Consecutive-failure abort. If N cloud_analyse calls in a row error,
190
- // the engine is almost certainly dead (vanished contract, network to
191
- // VastAI down) and burning through the rest of the tree just wastes
192
- // time. Bail with an explicit reason so a targeted retry is possible.
193
- const MAX_CONSECUTIVE_FAILURES = 3;
194
- let consecutiveFailures = 0;
195
- let aborted = false;
162
+ // Worker: groups targets by which engines they need (so a node that only
163
+ // lacks `human` doesn't re-run Stockfish), sends batches of up to 10
164
+ // positions, and saves each batch's evals right away.
165
+ async function runEvalJob(job, targets, baseOpts) {
166
+ const groups = new Map();
196
167
  for (const t of targets) {
197
- if (job.cancelled || aborted)
198
- break;
199
- try {
200
- const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1, ...routing });
201
- const ev = analysisToStoredEval(analysis);
202
- if (ev) {
203
- pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
204
- job.evaluated++;
168
+ const sig = t.engines.join(",");
169
+ groups.set(sig, [...(groups.get(sig) ?? []), t]);
170
+ }
171
+ // Two failed batches in a row means the engine or rental is gone; stop
172
+ // with a reason instead of burning through the rest of the tree.
173
+ const MAX_CONSECUTIVE_FAILURES = 2;
174
+ let consecutiveFailures = 0;
175
+ const markFailed = (t, engine) => {
176
+ (job.failed[engine] ??= []).push(t.nodeId);
177
+ };
178
+ outer: for (const groupTargets of groups.values()) {
179
+ const engines = groupTargets[0].engines;
180
+ for (let i = 0; i < groupTargets.length; i += MAX_POSITIONS_PER_CALL) {
181
+ if (job.cancelled)
182
+ break outer;
183
+ const batch = groupTargets.slice(i, i + MAX_POSITIONS_PER_CALL);
184
+ let results;
185
+ try {
186
+ results = await analysePositions(batch.map(t => ({ id: t.nodeId, fen: t.fen })), { ...baseOpts, engines });
205
187
  consecutiveFailures = 0;
206
188
  }
207
- else {
208
- job.errored++;
209
- job.failedNodeIds.push(t.nodeId);
210
- consecutiveFailures++;
211
- }
212
- }
213
- catch {
214
- // Per-node failure — record the node_id so the caller can retry
215
- // just those, and count consecutive failures for the abort check.
216
- job.errored++;
217
- job.failedNodeIds.push(t.nodeId);
218
- consecutiveFailures++;
219
- }
220
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
221
- aborted = true;
222
- job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
223
- break;
224
- }
225
- if (pending.length >= SAVE_EVERY_N) {
226
- try {
227
- await flush();
189
+ catch (err) {
190
+ job.lastError = err instanceof Error ? err.message : String(err);
191
+ for (const t of batch)
192
+ for (const e of engines)
193
+ markFailed(t, e);
194
+ job.processed += batch.length;
195
+ if (++consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
196
+ job.abortedReason = `stopped after ${MAX_CONSECUTIVE_FAILURES} failed batches in a row: ${job.lastError}. Check list_cloud_engines.`;
197
+ break outer;
198
+ }
199
+ continue;
228
200
  }
229
- catch {
230
- // Save failure is bad but not fatal — try again on the next
231
- // checkpoint or at the end. Progress remains in `pending`
232
- // so nothing is lost as long as the process stays alive.
201
+ const entries = [];
202
+ batch.forEach((t, idx) => {
203
+ const r = results[idx];
204
+ const ev = r ? resultToStoredEval(convertPositionResult(r)) : null;
205
+ for (const e of engines) {
206
+ if (ev?.[STORED_KEY[e]])
207
+ job.evaluated[e] = (job.evaluated[e] ?? 0) + 1;
208
+ else {
209
+ markFailed(t, e);
210
+ const msg = r?.engines?.[e]?.error;
211
+ if (msg)
212
+ job.lastError = `${e}: ${msg}`;
213
+ }
214
+ }
215
+ if (ev)
216
+ entries.push({ fen: t.fen, ev });
217
+ });
218
+ job.processed += batch.length;
219
+ if (entries.length > 0) {
220
+ try {
221
+ await storeEvals(job.fileId, entries);
222
+ }
223
+ catch (err) {
224
+ // The analysis is lost for this batch; record and keep going.
225
+ job.lastError = `save failed: ${err instanceof Error ? err.message : String(err)}`;
226
+ for (const t of batch)
227
+ for (const e of engines)
228
+ markFailed(t, e);
229
+ }
233
230
  }
234
231
  }
235
232
  }
236
- // Final flush regardless of cancellation — durably persist whatever
237
- // work was completed before the user asked to stop.
238
- try {
239
- await flush();
240
- }
241
- catch (err) {
242
- job.error = err instanceof Error ? err.message : String(err);
243
- job.status = "error";
244
- job.finishedAt = Date.now();
245
- return;
246
- }
247
233
  job.status = job.cancelled ? "cancelled" : "done";
248
234
  job.finishedAt = Date.now();
249
235
  }
@@ -256,21 +242,27 @@ export function autoEvaluateStatus(args) {
256
242
  if (!job) {
257
243
  return {
258
244
  status: "not_found",
259
- 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.",
245
+ 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; the `only_missing` default skips engines already stored in the prep file.",
260
246
  };
261
247
  }
248
+ const failedCount = {};
249
+ for (const e of ENGINES)
250
+ if (job.failed[e]?.length)
251
+ failedCount[e] = job.failed[e].length;
262
252
  return {
263
253
  job_id: job.id,
264
254
  status: job.status,
255
+ engines: job.engines,
265
256
  target_count: job.targetCount,
266
- evaluated: job.evaluated,
267
- errored: job.errored,
268
- failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
269
- aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
270
- remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
257
+ processed: job.processed,
258
+ remaining: Math.max(0, job.targetCount - job.processed),
259
+ evaluated: job.evaluated, // positions stored, per engine
260
+ failed: failedCount, // positions without a result, per engine
261
+ failed_node_ids: job.failed, // per engine, for a targeted retry with node_id
262
+ last_error: job.lastError,
263
+ aborted_reason: job.abortedReason,
271
264
  done: job.status !== "running",
272
265
  error: job.error,
273
- version: job.finalVersion,
274
266
  started_at_ms: job.startedAt,
275
267
  finished_at_ms: job.finishedAt,
276
268
  };
@@ -286,7 +278,6 @@ export function autoEvaluateCancel(args) {
286
278
  return { status: job.status, note: "Job already finished; nothing to cancel." };
287
279
  }
288
280
  job.cancelled = true;
289
- // status transitions to "cancelled" on the next per-node iteration
290
- // inside runEvalJob, after the final flush persists progress.
291
- return { status: "cancelling", evaluated_so_far: job.evaluated };
281
+ // status turns "cancelled" once the batch in flight is saved.
282
+ return { status: "cancelling", processed_so_far: job.processed };
292
283
  }
@@ -0,0 +1,182 @@
1
+ // cloud_analyse and ensure_engines: the synchronous analysis tool and the
2
+ // rental planner. cloud_analyse runs up to 10 positions on any engines the
3
+ // caller has running (stockfish, lc0, human), each engine on whichever
4
+ // rental provides it, and stores the evals on matching nodes when a file is
5
+ // given. ensure_engines tells the caller what is running, what it would
6
+ // need to start, and what a job would cost, without starting anything.
7
+ import { Chess } from "chess.js";
8
+ import { authedRequest, fetchGame } from "../http.js";
9
+ import { parsePGN } from "../pgn/parser.js";
10
+ import { buildIdIndex, resolveNodeId } from "../pgn/paths.js";
11
+ import { analyseOptionsFromArgs, analysePositions, convertPositionResult, DEFAULT_MOVETIME_MS, engineFailures, MAX_POSITIONS_PER_CALL, parseEngines, resultToStoredEval, } from "./response.js";
12
+ import { getNodeByPath, resolveFenFromArgs, storeEvals } from "./file_handle.js";
13
+ function stringList(v) {
14
+ if (!Array.isArray(v))
15
+ return [];
16
+ return v.map(x => String(x).trim()).filter(x => x.length > 0);
17
+ }
18
+ // Collect the positions to analyse from the tool args. Accepts lists
19
+ // (`node_ids` with `file_id`, `fens`, `lines`) and the single-position
20
+ // inputs (`node_id`, `fen`, `moves`), in any combination.
21
+ async function collectPositions(args) {
22
+ const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
23
+ const nodeIds = stringList(args.node_ids);
24
+ if (typeof args.node_id === "string" && args.node_id.trim())
25
+ nodeIds.push(args.node_id.trim());
26
+ const positions = [];
27
+ if (nodeIds.length > 0) {
28
+ if (!fileId)
29
+ throw new Error("node_id / node_ids need file_id");
30
+ const g = await fetchGame(fileId);
31
+ const file = parsePGN(g.pgnContent);
32
+ const idIndex = buildIdIndex(file.root);
33
+ for (const nid of nodeIds) {
34
+ const node = getNodeByPath(file.root, resolveNodeId(idIndex, nid));
35
+ positions.push({ id: nid, node_id: nid, fen: node.fen });
36
+ }
37
+ }
38
+ const checkFen = (fen) => {
39
+ try {
40
+ return new Chess(fen).fen();
41
+ }
42
+ catch {
43
+ throw new Error(`invalid FEN: ${fen}`);
44
+ }
45
+ };
46
+ for (const fen of stringList(args.fens)) {
47
+ positions.push({ id: `p${positions.length + 1}`, fen: checkFen(fen) });
48
+ }
49
+ // `lines`: SAN move sequences, each from `fen` (or the start position).
50
+ for (const line of stringList(args.lines)) {
51
+ positions.push({ id: `p${positions.length + 1}`, fen: resolveFenFromArgs({ fen: args.fen, moves: line }) });
52
+ }
53
+ if (positions.length === 0 && (typeof args.fen === "string" || typeof args.moves === "string")) {
54
+ positions.push({ id: "p1", fen: resolveFenFromArgs(args) });
55
+ }
56
+ if (positions.length === 0) {
57
+ throw new Error("nothing to analyse: pass fens, lines, fen/moves, or file_id with node_ids");
58
+ }
59
+ return { positions, fileId };
60
+ }
61
+ export async function cloudAnalyse(args, evalScale) {
62
+ const engines = parseEngines(args.engines, ["stockfish"]);
63
+ const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : DEFAULT_MOVETIME_MS;
64
+ // PVs past ~6 plies are speculative and get pasted into add_line as if
65
+ // they were prep. Raise only to verify a forcing line.
66
+ const pvMaxPlies = typeof args.pv_max_plies === "number" && args.pv_max_plies > 0
67
+ ? Math.min(args.pv_max_plies, 40)
68
+ : 6;
69
+ const { positions, fileId } = await collectPositions(args);
70
+ if (positions.length > MAX_POSITIONS_PER_CALL) {
71
+ throw new Error(`${positions.length} positions; cloud_analyse takes up to ${MAX_POSITIONS_PER_CALL} per call. ` +
72
+ "Split the list, or use auto_evaluate for a whole file or subtree.");
73
+ }
74
+ const results = await analysePositions(positions.map(p => ({ id: p.id, fen: p.fen })), analyseOptionsFromArgs(args, engines, movetimeMs));
75
+ const out = positions.map((p, i) => {
76
+ const r = results[i] ? convertPositionResult(results[i], pvMaxPlies) : { fen: p.fen, engines: {} };
77
+ const failed = engineFailures(r, engines);
78
+ return {
79
+ id: p.id,
80
+ ...(p.node_id ? { node_id: p.node_id } : {}),
81
+ fen: p.fen,
82
+ engines: r.engines,
83
+ ...(Object.keys(failed).length > 0 ? { failed } : {}),
84
+ };
85
+ });
86
+ // Inside a file: merge each eval into every node that reaches the
87
+ // position (node-addressed or matched by FEN), in one save. This is what
88
+ // lets quote_engine_eval cite a measurement later.
89
+ let storeError;
90
+ if (fileId) {
91
+ const entries = [];
92
+ out.forEach((o, idx) => {
93
+ const r = results[idx];
94
+ const ev = r ? resultToStoredEval(r) : null;
95
+ if (ev)
96
+ entries.push({ fen: positions[idx].fen, ev, idx });
97
+ });
98
+ if (entries.length > 0) {
99
+ try {
100
+ const stamped = await storeEvals(fileId, entries);
101
+ entries.forEach((e, k) => {
102
+ if (stamped[k]?.length)
103
+ out[e.idx].stored_on = stamped[k];
104
+ });
105
+ }
106
+ catch (err) {
107
+ storeError = `analysis done but saving to the file failed: ${err instanceof Error ? err.message : String(err)}`;
108
+ }
109
+ }
110
+ }
111
+ return {
112
+ engines,
113
+ movetime_ms: movetimeMs,
114
+ positions: out,
115
+ ...(storeError ? { store_error: storeError } : {}),
116
+ notes: "scoreCp/mate are White's point of view. wdl is win/draw/loss percent, White's point of view.",
117
+ eval_scale: evalScale,
118
+ };
119
+ }
120
+ // Read-only rental plan: for each requested engine, the running rental(s)
121
+ // that provide it, or the cheapest available SKU to start. With `positions`,
122
+ // adds a time and cost estimate for analysing that many positions.
123
+ export async function ensureEngines(args) {
124
+ const engines = parseEngines(args.engines, ["stockfish"]);
125
+ const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : DEFAULT_MOVETIME_MS;
126
+ const positions = typeof args.positions === "number" && args.positions > 0 ? Math.floor(args.positions) : 0;
127
+ const [rentalsRaw, optionsRaw] = await Promise.all([
128
+ authedRequest("GET", "/api/agent/cloud-engines"),
129
+ authedRequest("GET", "/api/agent/cloud-engines/options"),
130
+ ]);
131
+ const rentals = (Array.isArray(rentalsRaw) ? rentalsRaw : []);
132
+ const options = (optionsRaw?.options ?? []);
133
+ // Engines run in parallel per position, so wall time is per position,
134
+ // not per engine.
135
+ const minutes = positions > 0 ? (positions * movetimeMs) / 60000 : 0;
136
+ const plan = {};
137
+ const toStart = [];
138
+ let estCost = 0;
139
+ for (const e of engines) {
140
+ const running = rentals.filter(r => (r.engines ?? []).includes(e));
141
+ if (running.length > 0) {
142
+ plan[e] = {
143
+ status: running.length === 1 ? "running" : "several running (pass rentals." + e + " to choose)",
144
+ rentals: running.map(r => ({ contract_id: r.contractId, name: r.displayName || r.instanceType, cost_per_minute: r.costPerMinute, state: r.instanceStatus || "running" })),
145
+ };
146
+ estCost += (running[0].costPerMinute ?? 0) * minutes;
147
+ continue;
148
+ }
149
+ const skus = options
150
+ .filter(o => o.engine === e && o.available !== false)
151
+ .sort((a, b) => (a.costPerHour ?? Infinity) - (b.costPerHour ?? Infinity));
152
+ if (skus.length === 0) {
153
+ plan[e] = { status: "not available on this account right now" };
154
+ continue;
155
+ }
156
+ toStart.push(e);
157
+ plan[e] = {
158
+ status: "not running",
159
+ cheapest: { machine_type: skus[0].machineType, name: skus[0].displayName, cost_per_hour: skus[0].costPerHour },
160
+ other_skus: skus.slice(1).map(o => ({ machine_type: o.machineType, name: o.displayName, cost_per_hour: o.costPerHour })),
161
+ };
162
+ estCost += (skus[0].costPerMinute ?? (skus[0].costPerHour ?? 0) / 60) * minutes;
163
+ }
164
+ return {
165
+ engines,
166
+ plan,
167
+ ...(toStart.length > 0
168
+ ? { next_step: `Show the user the price for ${toStart.join(", ")} and get a yes, then start_cloud_engine with the machine_type. A new rental takes ~1-5 min to become ready.` }
169
+ : { next_step: "Everything requested is running. Analyse, then stop_cloud_engine when done." }),
170
+ ...(positions > 0
171
+ ? {
172
+ estimate: {
173
+ positions,
174
+ movetime_ms: movetimeMs,
175
+ minutes: Math.round(minutes * 10) / 10,
176
+ engine_cost: Math.round(estCost * 100) / 100,
177
+ note: "Engine time only. Rentals bill per minute from start to stop, so startup and idle time add to this.",
178
+ },
179
+ }
180
+ : {}),
181
+ };
182
+ }