@chessceo/mcp 0.49.11 → 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,22 +1,11 @@
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 { analyseRouting, resolveFromNodeOrFen, storeEvalOnNode, } from "./file_handle.js";
1
+ // deep_analyse: async background job for ONE long think on ONE position
2
+ // (up to 5 min movetime), on any engine (default Stockfish). Same
3
+ // start / status / cancel shape as auto_evaluate. The long call holds only
4
+ // that engine's slot, so the other engines stay usable through
5
+ // cloud_analyse while it runs. nginx proxy_read_timeout on /api/agent/ is
6
+ // 420s to cover a 5-min think plus the bestmove grace.
7
+ import { analysePositions, convertPositionResult, parseEngines, resultToStoredEval } from "./response.js";
8
+ import { resolveFromNodeOrFen, storeEvals } from "./file_handle.js";
20
9
  const deepJobs = new Map();
21
10
  const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
22
11
  function newDeepJobId() {
@@ -33,57 +22,43 @@ function reapExpiredDeepJobs() {
33
22
  }
34
23
  export async function deepAnalyseStart(args) {
35
24
  reapExpiredDeepJobs();
25
+ const [engine] = parseEngines(args.engine, ["stockfish"]);
36
26
  const resolved = await resolveFromNodeOrFen(args);
37
27
  const fen = resolved.fen;
38
28
  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;
29
+ // Default 2 for Stockfish (it loses strength at higher multipv), 8 for
30
+ // the NN engines (it costs them nothing).
31
+ const multipv = typeof args.multipv === "number" ? args.multipv : engine === "stockfish" ? 2 : 8;
32
+ const opts = { engines: [engine], movetime_ms: movetimeMs, multipv: { [engine]: multipv } };
33
+ if (typeof args.contempt === "number")
34
+ opts.contempt = args.contempt;
35
+ if (typeof args.rental === "string" && args.rental.trim())
36
+ opts.rentals = { [engine]: args.rental.trim() };
43
37
  const jobId = newDeepJobId();
44
38
  const job = {
45
39
  id: jobId,
46
- routing: analyseRouting(args),
40
+ engine,
41
+ opts,
47
42
  status: "running",
48
43
  fileHandle: resolved.file,
49
44
  fen,
50
45
  movetimeMs,
51
- multipv,
52
46
  startedAt: Date.now(),
53
47
  cancelController: new AbortController(),
54
48
  };
55
49
  deepJobs.set(jobId, job);
56
- // Kick off the long HTTP call unawaited — resolves when the backend
57
- // returns the SF snapshot. authedRequest is a plain fetch under the
58
- // hood; abort signal flows via cancelController.
59
50
  void runDeepJob(job).catch(err => {
60
51
  job.status = "error";
61
52
  job.error = err instanceof Error ? err.message : String(err);
62
53
  job.finishedAt = Date.now();
63
54
  });
64
- return {
65
- job_id: jobId,
66
- status: "running",
67
- movetime_ms: movetimeMs,
68
- fen,
69
- };
55
+ return { job_id: jobId, status: "running", engine, movetime_ms: movetimeMs, fen };
70
56
  }
71
57
  async function runDeepJob(job) {
72
- const body = {
73
- fen: job.fen,
74
- movetime_ms: job.movetimeMs,
75
- stockfish_multipv: job.multipv,
76
- engines: ["stockfish"],
77
- ...job.routing,
78
- };
79
58
  let raw;
80
59
  try {
81
- // TODO(future): plumb an AbortSignal through authedRequest for
82
- // real mid-flight cancellation. For now, cancel just marks the
83
- // job so the caller stops polling; the backend still runs the
84
- // engine to completion and the result is stored on the job
85
- // record but flagged cancelled.
86
- raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
60
+ // Cancel only marks the job; the backend runs the think to completion.
61
+ [raw] = await analysePositions([{ fen: job.fen }], job.opts);
87
62
  }
88
63
  catch (err) {
89
64
  job.status = "error";
@@ -91,31 +66,26 @@ async function runDeepJob(job) {
91
66
  job.finishedAt = Date.now();
92
67
  return;
93
68
  }
94
- const converted = convertCloudSnapshotResponse(raw, job.fen);
95
- const sf = converted.stockfish;
96
- if (job.cancelController.signal.aborted) {
97
- job.status = "cancelled";
98
- }
99
- else {
100
- job.status = "done";
101
- }
102
- job.result = sf ?? null;
103
- job.finishedAt = Date.now();
104
- // Same node-persistence as cloud_analyse: if the caller anchored on
105
- // file_id+node_id, store the SF-only eval as the node's ceoEval so
106
- // quote_engine_eval can cite it later. We build a StoredEval that has
107
- // only the sf leg — no Lc0 was run.
108
- if (job.fileHandle && sf) {
109
- const ev = analysisToStoredEval({ stockfish: sf });
69
+ const converted = raw ? convertPositionResult(raw) : undefined;
70
+ const block = converted?.engines[job.engine];
71
+ job.result = block ?? null;
72
+ if (block?.error)
73
+ job.error = block.error;
74
+ // Merge into the node's ceoEval (other engines' stored reads are kept)
75
+ // and stamp transpositions, same as cloud_analyse.
76
+ if (job.fileHandle && converted && !job.cancelController.signal.aborted) {
77
+ const ev = resultToStoredEval(converted);
110
78
  if (ev) {
111
79
  try {
112
- await storeEvalOnNode(job.fileHandle, ev);
80
+ [job.storedOn] = await storeEvals(job.fileHandle.id, [{ fen: job.fen, ev }]);
113
81
  }
114
- catch {
115
- // best-effort — the analysis result is what the LLM asked for
82
+ catch (err) {
83
+ job.storeError = `analysis done but saving to the file failed: ${err instanceof Error ? err.message : String(err)}`;
116
84
  }
117
85
  }
118
86
  }
87
+ job.status = job.cancelController.signal.aborted ? "cancelled" : block && !block.error ? "done" : "error";
88
+ job.finishedAt = Date.now();
119
89
  }
120
90
  export function deepAnalyseStatus(args) {
121
91
  reapExpiredDeepJobs();
@@ -134,8 +104,11 @@ export function deepAnalyseStatus(args) {
134
104
  status: job.status,
135
105
  movetime_ms: job.movetimeMs,
136
106
  elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
107
+ engine: job.engine,
137
108
  fen: job.fen,
138
109
  result: job.result,
110
+ stored_on: job.storedOn,
111
+ store_error: job.storeError,
139
112
  error: job.error,
140
113
  started_at_ms: job.startedAt,
141
114
  finished_at_ms: job.finishedAt,
@@ -120,48 +120,55 @@ export function getNodeByPath(root, path) {
120
120
  }
121
121
  return cur;
122
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.
123
+ // Merge fresh evals into a file and save once. Each entry is stamped on
124
+ // every node that reaches its position (the frontend's 3-field FEN key:
125
+ // piece placement + side to move + castling), so a transposition is never
126
+ // analysed twice. Merging per engine means a Stockfish-only result keeps a
127
+ // stored human or Lc0 read.
131
128
  //
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).
129
+ // The file is re-read right before the write, and a version race (someone
130
+ // saved between our GET and PUT) is retried once on a fresh copy; the merge
131
+ // makes re-applying safe. Returns, per entry, the ids it was stamped on
132
+ // (empty if that position is not in the file). Throws if the save fails
133
+ // twice, so callers can report it rather than silently lose evals.
134
+ export async function storeEvals(fileId, entries) {
135
+ let lastErr;
136
+ for (let attempt = 0; attempt < 2; attempt++) {
137
+ const g = await fetchGame(fileId);
138
+ const parsed = parsePGN(g.pgnContent);
139
+ const fenIndex = buildFenIndex(parsed.root);
140
+ const idIndex = buildIdIndex(parsed.root);
141
+ let file = parsed;
142
+ const stamped = [];
143
+ for (const { fen, ev } of entries) {
144
+ const group = fenIndex.get(positionKey(fen)) ?? [];
145
+ const paths = group.map(n => resolveNodeId(idIndex, n.id));
146
+ const step = setCeoEvalMany(file, paths, ev);
147
+ file = step.file;
148
+ stamped.push(step.ids);
149
+ }
150
+ if (stamped.every(ids => ids.length === 0))
151
+ return stamped;
152
+ try {
153
+ await saveGame(fileId, exportPGN(file), g.version ?? 0);
154
+ return stamped;
155
+ }
156
+ catch (err) {
157
+ lastErr = err;
158
+ }
159
+ }
160
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
161
+ }
162
+ // Single-node wrapper: stamp `ev` on the handle's node and its transpositions.
163
+ // Returns the ids, the addressed node first; empty on failure (the analysis
164
+ // result is still returned to the caller either way).
134
165
  export async function storeEvalOnNode(handle, ev) {
135
166
  try {
136
167
  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)];
168
+ const [ids] = await storeEvals(handle.id, [{ fen: anchor.fen, ev }]);
169
+ return [anchor.id, ...(ids ?? []).filter(x => x !== anchor.id)];
152
170
  }
153
171
  catch {
154
172
  return [];
155
173
  }
156
174
  }
157
- // Routing fields for POST /api/agent/cloud-engines/analyse: which rental to
158
- // run on and which engine legs to run. Omitted fields let the backend pick.
159
- export function analyseRouting(args) {
160
- const out = {};
161
- if (typeof args.contract_id === "string" && args.contract_id.trim()) {
162
- out.contract_id = args.contract_id.trim();
163
- }
164
- if (Array.isArray(args.engines))
165
- out.engines = args.engines;
166
- return out;
167
- }
@@ -1,18 +1,96 @@
1
- // Shared engine-response helpers used across cloud_analyse, auto_evaluate,
2
- // deep_analyse, and get_position_stats. All PGN-shape data comes back
3
- // from the backend in UCI ("g1f3") — this module converts to SAN, extracts
4
- // stored evals from the raw JSON, and derives the eval → NAG mapping.
5
- //
6
- // Extracted from src/index.ts in v0.44 as part of the file split.
1
+ // Shared engine helpers for cloud_analyse, auto_evaluate, deep_analyse and
2
+ // the compact eval attached to position tools. The backend returns one
3
+ // result per position with a block per engine (stockfish, lc0, human), PVs
4
+ // in UCI and WDL in per mille. This module calls it in chunks, converts PVs
5
+ // to SAN and WDL to percent, and turns a result into the stored ceoEval.
7
6
  import { Chess } from "chess.js";
8
7
  import { authedRequest } from "../http.js";
9
- // Convert a UCI move sequence into SAN by walking it move-by-move on
10
- // chess.js from the given starting FEN. LLMs reason far better in SAN
11
- // ("Nf3", "Bxc4") than UCI ("g1f3", "b5c4"), and matches how prep
12
- // discussion is written in the real world. If a move fails to parse
13
- // (illegal from the current position — bug or truncated PV), we
14
- // truncate cleanly rather than throwing so the response still carries
15
- // what we could convert.
8
+ export const ENGINES = ["stockfish", "lc0", "human"];
9
+ // Engine name → key in the stored ceoEval / [%ceo-eval] escape.
10
+ export const STORED_KEY = { stockfish: "sf", lc0: "lc0", human: "human" };
11
+ // Backend limits (handlers/agent.go): positions per call, and positions ×
12
+ // movetime per call. analysePositions chunks to stay inside both.
13
+ export const MAX_POSITIONS_PER_CALL = 10;
14
+ const MAX_CALL_BUDGET_MS = 150_000;
15
+ export const DEFAULT_MOVETIME_MS = 2000;
16
+ // Validate an `engines` argument. Unknown names throw (better than silently
17
+ // running fewer engines than the caller asked for).
18
+ export function parseEngines(v, fallback) {
19
+ if (v === undefined || v === null)
20
+ return fallback;
21
+ const list = Array.isArray(v) ? v : [v];
22
+ const out = [];
23
+ for (const e of list) {
24
+ const name = String(e).trim().toLowerCase();
25
+ if (!ENGINES.includes(name)) {
26
+ throw new Error(`unknown engine '${e}' (allowed: stockfish, lc0, human)`);
27
+ }
28
+ if (!out.includes(name))
29
+ out.push(name);
30
+ }
31
+ if (out.length === 0)
32
+ throw new Error("engines must list at least one of: stockfish, lc0, human");
33
+ return out;
34
+ }
35
+ // Pull the optional per-engine knobs (multipv, contempt, rentals) out of tool
36
+ // args. Shared by every analysis tool so they all accept the same shape.
37
+ export function analyseOptionsFromArgs(args, engines, movetimeMs) {
38
+ const opts = { engines, movetime_ms: movetimeMs };
39
+ const pickMap = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
40
+ const mpv = pickMap(args.multipv);
41
+ if (mpv) {
42
+ const out = {};
43
+ for (const e of ENGINES)
44
+ if (typeof mpv[e] === "number")
45
+ out[e] = mpv[e];
46
+ opts.multipv = out;
47
+ }
48
+ if (typeof args.contempt === "number")
49
+ opts.contempt = args.contempt;
50
+ const rentals = pickMap(args.rentals);
51
+ if (rentals) {
52
+ const out = {};
53
+ for (const e of ENGINES)
54
+ if (typeof rentals[e] === "string" && rentals[e].trim())
55
+ out[e] = rentals[e].trim();
56
+ opts.rentals = out;
57
+ }
58
+ return opts;
59
+ }
60
+ // Run positions through the backend, chunked to its per-call limits. Results
61
+ // come back in input order, raw (UCI PVs, per-mille WDL).
62
+ export async function analysePositions(positions, opts) {
63
+ const movetime = opts.movetime_ms ?? DEFAULT_MOVETIME_MS;
64
+ const perCall = Math.max(1, Math.min(MAX_POSITIONS_PER_CALL, Math.floor(MAX_CALL_BUDGET_MS / movetime)));
65
+ // Every position gets an id; results are matched back by id, not order.
66
+ const withIds = positions.map((p, i) => ({ id: p.id ?? `p${i + 1}`, fen: p.fen }));
67
+ const byId = new Map();
68
+ for (let i = 0; i < withIds.length; i += perCall) {
69
+ const body = {
70
+ positions: withIds.slice(i, i + perCall),
71
+ engines: opts.engines,
72
+ movetime_ms: movetime,
73
+ };
74
+ if (opts.multipv && Object.keys(opts.multipv).length > 0)
75
+ body.multipv = opts.multipv;
76
+ if (typeof opts.contempt === "number")
77
+ body.contempt = opts.contempt;
78
+ if (opts.rentals && Object.keys(opts.rentals).length > 0)
79
+ body.rentals = opts.rentals;
80
+ const raw = (await authedRequest("POST", "/api/agent/cloud-engines/analyse", body));
81
+ if (!raw || !Array.isArray(raw.positions))
82
+ throw new Error("unexpected response from the analyse endpoint");
83
+ for (const r of raw.positions)
84
+ if (r && typeof r.id === "string")
85
+ byId.set(r.id, r);
86
+ }
87
+ // A position the backend didn't answer comes back with no engines, so
88
+ // callers report it as failed rather than shifting other results.
89
+ return withIds.map(p => byId.get(p.id) ?? { id: p.id, fen: p.fen, engines: {} });
90
+ }
91
+ // Convert a UCI move sequence into SAN by walking it on chess.js from the
92
+ // given FEN. LLMs reason far better in SAN. If a move fails to parse
93
+ // (truncated PV), stop cleanly rather than throw.
16
94
  export function uciLineToSAN(startFen, uciMoves) {
17
95
  const board = new Chess(startFen);
18
96
  const out = [];
@@ -51,131 +129,105 @@ export function uciMoveToSAN(startFen, uci) {
51
129
  return uci;
52
130
  }
53
131
  }
54
- // Fetch a compact cloud eval for `fen`. Returns null on any error — no
55
- // running combo instance, engine failure, network timeout. Callers
56
- // attach the result to their response as `.eval` so the LLM has the
57
- // stockfish + lc0 read without a separate tool call.
58
- export async function fetchCompactEval(fen) {
59
- try {
60
- const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
61
- const converted = convertCloudSnapshotResponse(raw, fen);
62
- const stored = analysisToStoredEval(converted);
63
- return storedEvalToCompact(stored, converted);
64
- }
65
- catch {
66
- return null;
67
- }
68
- }
69
- // Trim every PV in a converted cloud-analyse response to `maxPlies`
70
- // and mark each trimmed line with `pv_truncated: true` so the LLM
71
- // sees what happened. Applied ONLY to cloud_analyse (short synchronous
72
- // snapshot); deep_analyse is the explicit "give me the deep line"
73
- // tool and keeps its full PV.
74
- export function capPvsInResponse(converted, maxPlies) {
75
- if (!converted || typeof converted !== "object")
76
- return;
77
- const r = converted;
78
- for (const eng of [r.stockfish, r.lc0]) {
79
- if (!eng || !Array.isArray(eng.lines))
132
+ // In place: PVs and bestMove to SAN, WDL from per mille to percent, and (if
133
+ // pvMaxPlies is set) PVs capped with `pv_truncated` on trimmed lines. PVs
134
+ // past ~6 plies are speculative, and long ones get pasted into add_line as
135
+ // if they were prep; the caller re-analyses the end position to see more.
136
+ export function convertPositionResult(r, pvMaxPlies) {
137
+ for (const e of ENGINES) {
138
+ const block = r.engines?.[e];
139
+ if (!block)
80
140
  continue;
81
- for (const line of eng.lines) {
82
- if (Array.isArray(line.pv) && line.pv.length > maxPlies) {
83
- line.pv = line.pv.slice(0, maxPlies);
84
- line.pv_truncated = true;
141
+ for (const line of block.lines ?? []) {
142
+ if (Array.isArray(line.pv)) {
143
+ line.pv = uciLineToSAN(r.fen, line.pv);
144
+ if (pvMaxPlies && line.pv.length > pvMaxPlies) {
145
+ line.pv = line.pv.slice(0, pvMaxPlies);
146
+ line.pv_truncated = true;
147
+ }
85
148
  }
149
+ if (line.wdl)
150
+ line.wdl = wdlPercent(line.wdl);
86
151
  }
152
+ if (typeof block.bestMove === "string")
153
+ block.bestMove = uciMoveToSAN(r.fen, block.bestMove);
87
154
  }
88
- converted.pv_max_plies = maxPlies;
155
+ return r;
89
156
  }
90
- export function convertCloudSnapshotResponse(raw, startFen) {
91
- if (!raw || typeof raw !== "object")
92
- return raw;
93
- const r = raw;
94
- for (const eng of [r.stockfish, r.lc0]) {
95
- if (!eng)
96
- continue;
97
- if (Array.isArray(eng.lines)) {
98
- for (const line of eng.lines) {
99
- if (Array.isArray(line.pv))
100
- line.pv = uciLineToSAN(startFen, line.pv);
101
- }
102
- }
103
- if (typeof eng.bestMove === "string")
104
- eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
157
+ // Per mille → whole percent summing to 100 (largest remainder), so a
158
+ // stored W/D/L never reads 101%.
159
+ function wdlPercent(m) {
160
+ const total = m.w + m.d + m.l || 1;
161
+ const exact = [m.w, m.d, m.l].map(v => (v * 100) / total);
162
+ const floor = exact.map(Math.floor);
163
+ let left = 100 - floor.reduce((a, b) => a + b, 0);
164
+ const order = exact.map((v, i) => [v - floor[i], i]).sort((a, b) => b[0] - a[0]);
165
+ for (const [, i] of order) {
166
+ if (left <= 0)
167
+ break;
168
+ floor[i]++;
169
+ left--;
105
170
  }
106
- return raw;
171
+ return { w: floor[0], d: floor[1], l: floor[2] };
107
172
  }
108
- export function analysisToStoredEval(analysis) {
109
- if (!analysis || typeof analysis !== "object")
110
- return null;
111
- const r = analysis;
112
- // Backend returns White-POV cp/mate (engine-ws flips in ParseInfo
113
- // based on side-to-move, cloud_snapshot passes through). Pure
114
- // pass-through here — a previous sign-flip on black-to-move was
115
- // wrong and silently inverted every Black-to-move stored eval.
116
- const engineEval = (block) => {
117
- const line = block?.lines?.[0];
118
- if (!line)
119
- return undefined;
120
- const depth = line.depth ?? block?.depth;
121
- if (typeof line.mate === "number")
122
- return { mate: line.mate, depth };
123
- if (typeof line.scoreCp === "number")
124
- return { cp: line.scoreCp, depth };
125
- return undefined;
126
- };
127
- const sf = engineEval(r.stockfish);
128
- const lc0 = engineEval(r.lc0);
129
- if (!sf && !lc0)
130
- return null;
173
+ // Stored eval from a CONVERTED result (WDL already in percent). Stockfish
174
+ // stores cp/mate; lc0 and human store WDL, falling back to cp/mate if an
175
+ // engine sent no WDL. Engines that errored or returned no line are left out,
176
+ // so a merge keeps whatever was stored for them before.
177
+ export function resultToStoredEval(r) {
131
178
  const ev = {};
132
- if (sf)
133
- ev.sf = sf;
134
- if (lc0)
135
- ev.lc0 = lc0;
136
- ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
137
- return ev;
179
+ for (const e of ENGINES) {
180
+ const block = r.engines?.[e];
181
+ const line = block?.lines?.[0];
182
+ if (!block || block.error || !line)
183
+ continue;
184
+ const depth = line.depth ?? block.depth;
185
+ let stored;
186
+ if (e !== "stockfish" && line.wdl) {
187
+ stored = { w: line.wdl.w, d: line.wdl.d, l: line.wdl.l, depth };
188
+ }
189
+ else if (typeof line.mate === "number") {
190
+ stored = { mate: line.mate, depth };
191
+ }
192
+ else if (typeof line.scoreCp === "number") {
193
+ stored = { cp: line.scoreCp, depth };
194
+ }
195
+ if (stored)
196
+ ev[STORED_KEY[e]] = stored;
197
+ }
198
+ return ev.sf || ev.lc0 || ev.human ? ev : null;
138
199
  }
139
- export function nagFromCp(cp, mate) {
140
- let effective;
141
- if (typeof mate === "number")
142
- effective = mate > 0 ? 10000 : -10000;
143
- else if (typeof cp === "number")
144
- effective = cp;
145
- else
146
- return null;
147
- const abs = Math.abs(effective);
148
- if (abs < 25)
149
- return "$10";
150
- if (abs < 60)
151
- return effective > 0 ? "$14" : "$15";
152
- if (abs < 130)
153
- return effective > 0 ? "$16" : "$17";
154
- return effective > 0 ? "$18" : "$19";
200
+ // Engines that produced no usable line for this position, with the reason.
201
+ export function engineFailures(r, engines) {
202
+ const out = {};
203
+ for (const e of engines) {
204
+ const block = r.engines?.[e];
205
+ if (!block)
206
+ out[e] = "no result";
207
+ else if (block.error)
208
+ out[e] = block.error;
209
+ else if (!block.lines || block.lines.length === 0)
210
+ out[e] = "no lines";
211
+ }
212
+ return out;
155
213
  }
156
- // Adapter for the compact eval attached to live query responses. Same
157
- // derivation logic; different output shape (needs the .nag + summary
158
- // used by get_position_stats / prep_snapshot).
159
- export function storedEvalToCompact(ev, analysis) {
160
- if (!ev)
161
- return null;
162
- const a = analysis;
163
- const compact = { nag: ev.nag ?? null };
164
- if (ev.sf) {
165
- compact.stockfish = {
166
- cp: ev.sf.cp,
167
- mate: ev.sf.mate,
168
- bestMove: a.stockfish?.bestMove,
169
- pv: a.stockfish?.lines?.[0]?.pv,
170
- };
214
+ export async function fetchCompactEval(fen) {
215
+ try {
216
+ const [raw] = await analysePositions([{ fen }], {
217
+ engines: ["stockfish"],
218
+ movetime_ms: 1500,
219
+ multipv: { stockfish: 1 },
220
+ });
221
+ if (!raw)
222
+ return null;
223
+ const r = convertPositionResult(raw, 6);
224
+ const sf = r.engines.stockfish;
225
+ const line = sf?.lines?.[0];
226
+ if (!sf || sf.error || !line)
227
+ return null;
228
+ return { stockfish: { cp: line.scoreCp, mate: line.mate, bestMove: sf.bestMove, pv: line.pv } };
171
229
  }
172
- if (ev.lc0) {
173
- compact.lc0 = {
174
- cp: ev.lc0.cp,
175
- mate: ev.lc0.mate,
176
- bestMove: a.lc0?.bestMove,
177
- pv: a.lc0?.lines?.[0]?.pv,
178
- };
230
+ catch {
231
+ return null;
179
232
  }
180
- return compact;
181
233
  }