llm-chess-mcp 0.1.1 → 0.1.3

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/.env.example ADDED
@@ -0,0 +1,9 @@
1
+ # Lichess opening explorer (optional; explorer tools disabled if unset)
2
+ # Generate at https://lichess.org/account/oauth/token/create
3
+ LICHESS_TOKEN=
4
+
5
+ # The published package bundles only Maia3 5m (~20 MB). Leave at 5m.
6
+ MAIA3_MODEL=5m
7
+
8
+ # Stockfish flavor: lite-single (default, ~7MB) | lite | single | full
9
+ STOCKFISH_FLAVOR=lite-single
package/README.md CHANGED
@@ -1,20 +1,30 @@
1
1
  # llm-chess-mcp
2
2
 
3
- An MCP server for truly enjoying chess games with your LLM. It adds a feature that, rather than simply making the best move, allows it to think and judge on its own and make moves. the MCP server handles all the computation (Stockfish evaluation, Maia3 human-move prediction, Lichess opening statistics).
3
+ An MCP chess runtime that lets LLMs play, analyze, and adapt their strength
4
+ without outsourcing every decision to an engine.
5
+
6
+ Rather than returning a single best move, it exposes objective strength
7
+ (Stockfish), human move likelihood (Maia3), and real-game statistics (Lichess)
8
+ so the LLM can choose how it wants to play. The LLM does the strategy and
9
+ judgment; the MCP server handles all the computation.
4
10
 
5
11
  ## Engines
6
12
 
7
13
  | Engine | Role | Runtime |
8
14
  |---|---|---|
9
15
  | **Stockfish 18** (WASM) | Objective evaluation, best moves, multipv | In-process (npm `stockfish`) |
10
- | **Maia3** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
16
+ | **Maia3 5M** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
11
17
  | **Lichess explorer** | Real human game statistics | HTTP (needs token) |
12
18
 
13
- Everything runs inside the Node process — no separate engine binaries or Python
14
- runtime needed at deploy time. Maia3 is exported to ONNX once at build time.
19
+ Everything runs inside the Node process — no external engine process or Python
20
+ runtime is required at deploy time. The published package bundles the Maia3 5M
21
+ model; other export variants are not runtime options unless their ONNX files are
22
+ provided separately.
15
23
 
16
24
  ## Install
17
25
 
26
+ Requires Node.js 20 or newer.
27
+
18
28
  No install needed — run it directly with `npx`:
19
29
 
20
30
  ```bash
@@ -35,6 +45,7 @@ npm install -g llm-chess-mcp
35
45
  ```bash
36
46
  pnpm install
37
47
  pnpm build
48
+ pnpm test
38
49
  ```
39
50
 
40
51
  ### Export Maia3 to ONNX (build-time only)
@@ -45,7 +56,7 @@ the reimplementation against the original, and exports `models/maia3-5m.onnx`.
45
56
  ```bash
46
57
  uv venv .venv-maia3 --python 3.13
47
58
  uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
48
- uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3"
59
+ uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
49
60
  pnpm export:maia3 # -> models/maia3-5m.onnx
50
61
  ```
51
62
 
@@ -78,8 +89,7 @@ Add to `opencode.json` (project) or `~/.config/opencode/opencode.json` (global):
78
89
  "command": ["npx", "-y", "llm-chess-mcp"],
79
90
  "enabled": true,
80
91
  "environment": {
81
- "LICHESS_TOKEN": "your-token",
82
- "MAIA3_MODEL": "5m"
92
+ "LICHESS_TOKEN": "your-token"
83
93
  }
84
94
  }
85
95
  }
@@ -101,8 +111,7 @@ claude mcp add llm-chess-mcp -- npx -y llm-chess-mcp
101
111
  "command": "npx",
102
112
  "args": ["-y", "llm-chess-mcp"],
103
113
  "env": {
104
- "LICHESS_TOKEN": "your-token",
105
- "MAIA3_MODEL": "5m"
114
+ "LICHESS_TOKEN": "your-token"
106
115
  }
107
116
  }
108
117
  }
@@ -120,7 +129,6 @@ args = ["-y", "llm-chess-mcp"]
120
129
 
121
130
  [mcp_servers.llm-chess-mcp.env]
122
131
  LICHESS_TOKEN = "your-token"
123
- MAIA3_MODEL = "5m"
124
132
  ```
125
133
 
126
134
  Or via the CLI:
@@ -135,32 +143,100 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
135
143
  |---|---|
136
144
  | `create_game` | Create a game (optionally from a FEN), returns `game_id` |
137
145
  | `delete_game` | Delete a game and free its session |
138
- | `game_state` | Authoritative state: FEN, turn, check/mate/draw flags, history, last move, castling |
139
- | `game_state_ascii` | ASCII board diagram |
140
- | `game_play_move` | Play a move (SAN or UCI) — the only mutating tool |
146
+ | `game_state` | Authoritative state: FEN, turn, revision, check/mate/draw flags, history, last move, castling (optional ASCII) |
147
+ | `game_play_move` | Play a move (SAN or UCI) — the only mutating tool, with stale-position guard |
141
148
  | `game_legal_moves` | All legal moves with metadata |
142
149
  | `game_pgn` | Export the game as PGN |
143
150
  | `game_import_pgn` | Import a PGN into a new game |
144
- | `position_analyze` | Stockfish multipv lines (cp/mate + PV) |
151
+ | `position_analyze` | Stockfish multipv lines (cp/mate/WDL + PV), `analysis_level` preset |
145
152
  | `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
146
- | `move_evaluate` | Score a move + cpLoss + classification |
147
- | `move_candidates` | Unified candidates (SF eval + cpLoss + Maia3 prob + Lichess stats) |
148
- | `move_candidates_by_intent` | Candidates ranked for a strategic intent |
153
+ | `move_evaluate` | Score one or more moves + cpLoss + classification |
154
+ | `move_candidates` | **Primary tool**: unified candidates (objective + human + opening) |
155
+ | `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
149
156
  | `opening_explorer` | Lichess human game statistics |
150
157
 
151
158
  ## Score conventions
152
159
 
153
160
  - Stockfish scores are **side-to-move perspective**: positive cp = side to move is
154
- better; `mate N` = side to move mates in N.
155
- - `move_evaluate` reports the score **from the mover's perspective** (negated after
156
- the move), plus `cpLoss` (centipawns lost vs the best move) and a classification:
161
+ better; `mate N` = side to move mates in N. `wdl` is `[win, draw, loss]` in
162
+ permille for the side to move.
163
+ - `move_candidates` gives `moverCp` (the mover's perspective higher is better
164
+ for the player choosing the move) and `whiteCp` (fixed white perspective) so
165
+ the sign never flips on you.
166
+ - `move_evaluate` reports the score **from the mover's perspective**, plus `cpLoss`
167
+ (centipawns lost vs the best move) and a classification:
157
168
  `best / excellent / good / inaccuracy / mistake / blunder`.
158
169
  - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
159
170
  can still be objectively bad.
160
171
 
172
+ ## Candidate structure
173
+
174
+ `move_candidates` returns each candidate with three independent facets:
175
+
176
+ ```json
177
+ {
178
+ "uci": "g1f3",
179
+ "san": "Nf3",
180
+ "objective": { "rank": 1, "moverCp": 55, "whiteCp": 55, "cpLoss": 0, "moverMate": null, "wdl": [153, 844, 3] },
181
+ "human": { "maia3Prob": 0.62, "selfElo": 1500, "opponentElo": 1500 },
182
+ "opening": { "status": "available", "games": 18421, "frequency": 0.31 }
183
+ }
184
+ ```
185
+
186
+ - `objective` — Stockfish: engine strength, never conflated with human-likeness.
187
+ `moverCp` is from the mover's perspective (higher = better for the chooser).
188
+ - `human` — Maia3 conditional probability at a target Elo.
189
+ - `opening` — Lichess empirical frequency (a different signal from Maia3).
190
+
191
+ `opening.status` is `available`, `no_data` (API ok but no games in this
192
+ position), `unavailable` (timeout/429/401), or `disabled` (no token).
193
+ Stockfish + Maia3 results are always returned regardless.
194
+
195
+ `move_candidates` also returns `moveSensitivity`, describing how sharply the
196
+ evaluation changes across the top engine lines:
197
+
198
+ ```json
199
+ { "moveSensitivity": { "level": "high", "topMoveSpreadCp": 245 } }
200
+ ```
201
+
202
+ `level` is `low` (<80cp spread), `medium` (80–200cp), or `high` (≥200cp). High
203
+ sensitivity means choosing among plausible alternatives can materially change
204
+ the evaluation — useful for deciding whether to ease off or play precisely.
205
+
206
+ ## Analysis levels
207
+
208
+ Stockfish tools accept an `analysis_level` preset instead of raw UCI knobs:
209
+
210
+ | Level | Depth | MultiPV |
211
+ |---|---|---|
212
+ | `fast` | 8 | 5 |
213
+ | `normal` | 15 | 8 |
214
+ | `deep` | 22 | 10 |
215
+
216
+ Explicit `depth`/`multipv` overrides are still available for advanced use.
217
+
218
+ ## Stale-position guard
219
+
220
+ Every state read returns a `revision`. `game_play_move` **requires**
221
+ `expected_revision`; if the game has advanced since your last read, the move is
222
+ rejected:
223
+
224
+ ```json
225
+ { "error": { "code": "STALE_POSITION", "message": "position changed: expected revision 2, current 3" } }
226
+ ```
227
+
228
+ ## Runtime limits
229
+
230
+ - Up to 1,000 game sessions are retained; idle sessions expire after one hour.
231
+ - `move_evaluate` accepts at most 10 moves per call.
232
+ - Imported PGNs are limited to 1 MiB and 4,096 plies.
233
+ - Stockfish accepts up to 32 active or queued analyses.
234
+
161
235
  ## Intents
162
236
 
163
- `move_candidates_by_intent` ranks candidates for a chosen intent:
237
+ `move_candidates_by_intent` ranks candidates for a chosen intent. It is a
238
+ convenience layer over `move_candidates`; the fixed thresholds below are
239
+ heuristic defaults, not the source of truth:
164
240
 
165
241
  | Intent | Meaning |
166
242
  |---|---|
@@ -168,16 +244,40 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
168
244
  | `strong` | Engine-strong but human-plausible |
169
245
  | `natural` | Most human-typical at the target Elo |
170
246
  | `balanced` | Blend of strength and human-likeness |
171
- | `ease_off` | Slightly weaker (−30..−80cp) but human |
172
- | `give_chance` | Clearly weaker (−80..−150cp), gives the opponent chances |
247
+ | `ease_off` | Human-plausible moves that modestly reduce advantage without changing the expected result |
248
+ | `give_chance` | Human-plausible inaccuracies that meaningfully improve the opponent's chances |
249
+
250
+ This tool ranks candidates but does not choose a move. Use the returned signals
251
+ and conversation context to make the final decision — do not map user skill
252
+ mechanically to an intent.
173
253
 
174
254
  ## Example flow
175
255
 
256
+ The normal play loop is three calls:
257
+
176
258
  1. `create_game` → `game_id`
177
- 2. `position_analyze` to see the objective best lines
178
- 3. `human_move_distribution` to see what a human of a given Elo would play
179
- 4. `move_candidates_by_intent` with the intent that fits the situation
180
- 5. `game_play_move` to commit the chosen move
259
+ 2. `move_candidates` pick a move
260
+ 3. `game_play_move` (with `expected_revision`) commit it
261
+
262
+ Go deeper only when you need to:
263
+
264
+ - `position_analyze` — objective best lines
265
+ - `human_move_distribution` — what a human of a given Elo would play
266
+ - `opening_explorer` — real-game statistics
267
+ - `move_evaluate` — score a specific move (or compare several)
268
+
269
+ ## Maia3 ONNX verification
270
+
271
+ The exported ONNX model is regression-tested against the upstream Maia3
272
+ implementation across fixed positions and Elo pairs:
273
+
274
+ ```bash
275
+ .venv-maia3/bin/python scripts/verify_maia3.py --model 5m
276
+ ```
277
+
278
+ It checks top-1/top-k move agreement and max probability error to detect
279
+ export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
280
+ and top-5 agreement and max probability error < 1e-4.
181
281
 
182
282
  ## License & attribution
183
283
 
@@ -192,6 +292,7 @@ It bundles and depends on third-party components:
192
292
  | [onnxruntime-node](https://github.com/microsoft/onnxruntime) | MIT | Microsoft |
193
293
  | [chess.js](https://github.com/jhlywa/chess.js) | BSD-2-Clause | Jeff Hlywa |
194
294
 
195
- The Maia3 model weights (`models/maia3-5m.onnx`) are derived from the
196
- `UofTCSSLab/Maia3-5M` checkpoint. The ONNX export is a build-time step
197
- (`scripts/export_maia3.py`); the runtime does not execute any Maia3 Python code.
295
+ The bundled Maia3 model (`models/maia3-5m.onnx`) is derived from
296
+ [`UofTCSSLab/Maia3-5M` at `b6559de2398d7140b985f28fd2c19fb5e47ddabe`](https://huggingface.co/UofTCSSLab/Maia3-5M/tree/b6559de2398d7140b985f28fd2c19fb5e47ddabe).
297
+ The ONNX export is a build-time step (`scripts/export_maia3.py`); the runtime
298
+ does not execute any Maia3 Python code.
@@ -1,7 +1,34 @@
1
1
  import { createRequire } from "node:module";
2
2
  const require = createRequire(import.meta.url);
3
- function flavor() {
4
- return process.env.STOCKFISH_FLAVOR || "lite-single";
3
+ export const STOCKFISH_FLAVORS = [
4
+ "full",
5
+ "lite",
6
+ "single",
7
+ "lite-single",
8
+ "single-lite",
9
+ "asm",
10
+ ];
11
+ const DEFAULT_FLAVOR = "lite-single";
12
+ const FLAVORS = new Set(STOCKFISH_FLAVORS);
13
+ const DEFAULT_TIMEOUTS = {
14
+ init: 15000,
15
+ handshake: 15000,
16
+ analyze: 30000,
17
+ stopGrace: 2000,
18
+ };
19
+ const DEFAULT_MAX_QUEUE = 32;
20
+ export function resolveStockfishFlavor(value) {
21
+ const normalized = (value || DEFAULT_FLAVOR).toLowerCase();
22
+ if (!FLAVORS.has(normalized)) {
23
+ throw new Error(`invalid STOCKFISH_FLAVOR: ${JSON.stringify(value)}; expected one of ${STOCKFISH_FLAVORS.join(", ")}`);
24
+ }
25
+ return normalized;
26
+ }
27
+ function loadStockfish() {
28
+ return require("stockfish");
29
+ }
30
+ function asError(error) {
31
+ return error instanceof Error ? error : new Error(String(error));
5
32
  }
6
33
  function parseScore(token) {
7
34
  if (token.startsWith("cp"))
@@ -10,57 +37,183 @@ function parseScore(token) {
10
37
  return { cp: null, mate: Number(token.slice(4)) };
11
38
  return { cp: null, mate: null };
12
39
  }
40
+ function parseWdl(line) {
41
+ const m = line.match(/ wdl (\d+) (\d+) (\d+)/);
42
+ if (!m)
43
+ return null;
44
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
45
+ }
13
46
  export class Stockfish {
14
- engine = null;
15
- ready = null;
47
+ session = null;
16
48
  queue = Promise.resolve();
49
+ queued = 0;
50
+ quitGeneration = 0;
51
+ terminated = new WeakSet();
52
+ initEngine;
53
+ configuredFlavor;
54
+ maxQueue;
55
+ timeouts;
56
+ constructor(options = {}) {
57
+ this.initEngine = options.init;
58
+ this.configuredFlavor = options.flavor;
59
+ this.maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
60
+ this.timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
61
+ if (!Number.isInteger(this.maxQueue) || this.maxQueue < 1) {
62
+ throw new Error("stockfish maxQueue must be a positive integer");
63
+ }
64
+ for (const [name, timeout] of Object.entries(this.timeouts)) {
65
+ if (!Number.isFinite(timeout) || timeout < 1) {
66
+ throw new Error(`stockfish ${name} timeout must be positive`);
67
+ }
68
+ }
69
+ }
17
70
  init() {
18
- if (this.ready)
19
- return this.ready;
20
- this.ready = new Promise((resolve, reject) => {
21
- const init = require("stockfish");
22
- const engine = init(flavor(), (err) => {
23
- if (err)
24
- return reject(err);
25
- this.handshake().then(resolve, reject);
26
- });
27
- this.engine = engine;
28
- engine.listener = () => { };
71
+ if (this.session)
72
+ return this.session.ready;
73
+ let resolve;
74
+ let reject;
75
+ const ready = new Promise((res, rej) => {
76
+ resolve = res;
77
+ reject = rej;
29
78
  });
30
- return this.ready;
79
+ const session = {
80
+ engine: null,
81
+ ready,
82
+ initSettled: false,
83
+ initTimer: null,
84
+ resolve,
85
+ reject,
86
+ aborts: new Set(),
87
+ };
88
+ this.session = session;
89
+ session.initTimer = setTimeout(() => this.failSession(session, new Error("stockfish init timeout")), this.timeouts.init);
90
+ let callbackCalled = false;
91
+ try {
92
+ const selectedFlavor = resolveStockfishFlavor(this.configuredFlavor ?? process.env.STOCKFISH_FLAVOR);
93
+ const engine = (this.initEngine ?? loadStockfish())(selectedFlavor, (error, initializedEngine) => {
94
+ callbackCalled = true;
95
+ if (this.session !== session) {
96
+ this.terminate(initializedEngine);
97
+ return;
98
+ }
99
+ if (session.initSettled) {
100
+ if (initializedEngine !== session.engine)
101
+ this.terminate(initializedEngine);
102
+ return;
103
+ }
104
+ if (session.engine && session.engine !== initializedEngine) {
105
+ this.terminate(session.engine);
106
+ }
107
+ session.engine = initializedEngine;
108
+ if (error) {
109
+ this.failSession(session, asError(error));
110
+ return;
111
+ }
112
+ initializedEngine.listener = () => { };
113
+ if (session.initTimer)
114
+ clearTimeout(session.initTimer);
115
+ session.initTimer = null;
116
+ this.handshake(session).then(() => {
117
+ if (this.session !== session || session.initSettled)
118
+ return;
119
+ session.initSettled = true;
120
+ session.resolve();
121
+ }, (handshakeError) => this.failSession(session, asError(handshakeError)));
122
+ });
123
+ if (!callbackCalled && this.session === session && !session.initSettled) {
124
+ session.engine = engine;
125
+ engine.listener = () => { };
126
+ }
127
+ }
128
+ catch (error) {
129
+ this.failSession(session, asError(error));
130
+ }
131
+ return ready;
31
132
  }
32
- handshake() {
133
+ handshake(session) {
33
134
  return new Promise((resolve, reject) => {
34
- const engine = this.engine;
135
+ const engine = session.engine;
136
+ if (!engine) {
137
+ reject(new Error("stockfish initialized without an engine"));
138
+ return;
139
+ }
35
140
  let stage = 0;
36
- const timer = setTimeout(() => reject(new Error("stockfish handshake timeout")), 15000);
37
- engine.listener = (line) => {
141
+ let settled = false;
142
+ const finish = (error) => {
143
+ if (settled)
144
+ return;
145
+ settled = true;
146
+ clearTimeout(timer);
147
+ session.aborts.delete(abort);
148
+ if (engine.listener === listener)
149
+ engine.listener = null;
150
+ if (error)
151
+ reject(error);
152
+ else
153
+ resolve();
154
+ };
155
+ const abort = (error) => finish(error);
156
+ const listener = (line) => {
38
157
  if (stage === 0 && line === "uciok") {
39
158
  stage = 1;
40
- engine.sendCommand("isready");
159
+ try {
160
+ engine.sendCommand("isready");
161
+ }
162
+ catch (error) {
163
+ finish(asError(error));
164
+ }
41
165
  }
42
166
  else if (stage === 1 && line === "readyok") {
43
- clearTimeout(timer);
44
- engine.listener = null;
45
- resolve();
167
+ finish();
46
168
  }
47
169
  };
48
- engine.sendCommand("uci");
170
+ const timer = setTimeout(() => finish(new Error("stockfish handshake timeout")), this.timeouts.handshake);
171
+ session.aborts.add(abort);
172
+ engine.listener = listener;
173
+ try {
174
+ engine.sendCommand("uci");
175
+ }
176
+ catch (error) {
177
+ finish(asError(error));
178
+ }
49
179
  });
50
180
  }
51
181
  enqueue(fn) {
52
- const run = this.queue.then(fn, fn);
53
- this.queue = run.catch(() => { });
54
- return run;
182
+ if (this.queued >= this.maxQueue) {
183
+ return Promise.reject(new Error("stockfish queue full"));
184
+ }
185
+ this.queued++;
186
+ const run = this.queue.then(fn);
187
+ this.queue = run.then(() => { }, () => { });
188
+ return run.finally(() => {
189
+ this.queued--;
190
+ });
55
191
  }
56
- async analyze(fen, depth, multipv) {
57
- await this.init();
58
- return this.enqueue(() => this.doAnalyze(fen, depth, multipv));
192
+ analyze(fen, depth, multipv) {
193
+ const quitGeneration = this.quitGeneration;
194
+ return this.enqueue(async () => {
195
+ if (quitGeneration !== this.quitGeneration) {
196
+ throw new Error("stockfish request cancelled");
197
+ }
198
+ await this.init();
199
+ if (quitGeneration !== this.quitGeneration) {
200
+ throw new Error("stockfish request cancelled");
201
+ }
202
+ const session = this.session;
203
+ if (!session)
204
+ throw new Error("stockfish unavailable after initialization");
205
+ return this.doAnalyze(session, fen, depth, multipv);
206
+ });
59
207
  }
60
- doAnalyze(fen, depth, multipv) {
208
+ doAnalyze(session, fen, depth, multipv) {
61
209
  return new Promise((resolve, reject) => {
62
- const engine = this.engine;
210
+ const engine = session.engine;
211
+ if (!engine) {
212
+ reject(new Error("stockfish engine unavailable"));
213
+ return;
214
+ }
63
215
  const byPv = new Map();
216
+ let settled = false;
64
217
  let stopTimer = null;
65
218
  let failTimer = null;
66
219
  const cleanup = () => {
@@ -70,8 +223,28 @@ export class Stockfish {
70
223
  clearTimeout(failTimer);
71
224
  stopTimer = null;
72
225
  failTimer = null;
226
+ session.aborts.delete(abort);
227
+ if (engine.listener === listener)
228
+ engine.listener = null;
73
229
  };
74
- engine.listener = (line) => {
230
+ const succeed = () => {
231
+ if (settled)
232
+ return;
233
+ settled = true;
234
+ cleanup();
235
+ resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
236
+ };
237
+ const fail = (error, reset) => {
238
+ if (settled)
239
+ return;
240
+ settled = true;
241
+ cleanup();
242
+ if (reset)
243
+ this.invalidateSession(session, error);
244
+ reject(error);
245
+ };
246
+ const abort = (error) => fail(error, false);
247
+ const listener = (line) => {
75
248
  if (line.startsWith("info") && line.includes(" multipv ")) {
76
249
  const m = line.match(/multipv (\d+)/);
77
250
  const s = line.match(/ score (cp -?\d+|mate -?\d+)/);
@@ -84,42 +257,82 @@ export class Stockfish {
84
257
  multipv: n,
85
258
  scoreCp: score.cp,
86
259
  scoreMate: score.mate,
260
+ wdl: parseWdl(line),
87
261
  pv: pv ? pv[1].split(" ") : [],
88
262
  });
89
263
  }
90
264
  else if (line.startsWith("bestmove")) {
91
- cleanup();
92
- engine.listener = null;
93
- resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
265
+ succeed();
94
266
  }
95
267
  };
96
- engine.sendCommand("position fen " + fen);
97
- engine.sendCommand(`setoption name MultiPV value ${multipv}`);
98
- engine.sendCommand(`go depth ${depth}`);
268
+ session.aborts.add(abort);
269
+ engine.listener = listener;
99
270
  stopTimer = setTimeout(() => {
100
- engine.sendCommand("stop");
101
- failTimer = setTimeout(() => {
102
- if (engine.listener) {
103
- engine.listener = null;
104
- this.reset();
105
- reject(new Error("stockfish analyze timeout"));
106
- }
107
- }, 2000);
108
- }, 30000);
109
- });
110
- }
111
- reset() {
112
- if (this.engine) {
271
+ failTimer = setTimeout(() => fail(new Error("stockfish analyze timeout"), true), this.timeouts.stopGrace);
272
+ try {
273
+ engine.sendCommand("stop");
274
+ }
275
+ catch (error) {
276
+ fail(asError(error), true);
277
+ }
278
+ }, this.timeouts.analyze);
113
279
  try {
114
- this.engine.terminate();
280
+ engine.sendCommand("position fen " + fen);
281
+ engine.sendCommand(`setoption name MultiPV value ${multipv}`);
282
+ engine.sendCommand("setoption name UCI_ShowWDL value true");
283
+ engine.sendCommand(`go depth ${depth}`);
284
+ }
285
+ catch (error) {
286
+ fail(asError(error), true);
287
+ return;
115
288
  }
116
- catch { }
289
+ });
290
+ }
291
+ failSession(session, error) {
292
+ if (session.initSettled)
293
+ return;
294
+ session.initSettled = true;
295
+ if (session.initTimer)
296
+ clearTimeout(session.initTimer);
297
+ session.initTimer = null;
298
+ this.invalidateSession(session, error);
299
+ session.reject(error);
300
+ }
301
+ invalidateSession(session, error) {
302
+ if (this.session !== session)
303
+ return;
304
+ this.session = null;
305
+ if (session.initTimer)
306
+ clearTimeout(session.initTimer);
307
+ session.initTimer = null;
308
+ if (!session.initSettled) {
309
+ session.initSettled = true;
310
+ session.reject(error);
311
+ }
312
+ const aborts = [...session.aborts];
313
+ session.aborts.clear();
314
+ if (session.engine) {
315
+ session.engine.listener = null;
316
+ this.terminate(session.engine);
317
+ }
318
+ for (const abort of aborts)
319
+ abort(error);
320
+ }
321
+ terminate(engine) {
322
+ if (this.terminated.has(engine))
323
+ return;
324
+ this.terminated.add(engine);
325
+ engine.listener = null;
326
+ try {
327
+ engine.terminate();
117
328
  }
118
- this.engine = null;
119
- this.ready = null;
329
+ catch { }
120
330
  }
121
331
  async quit() {
122
- this.reset();
332
+ this.quitGeneration++;
333
+ const session = this.session;
334
+ if (session)
335
+ this.invalidateSession(session, new Error("stockfish quit"));
123
336
  }
124
337
  }
125
338
  export const stockfish = new Stockfish();
package/dist/env.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ const ALLOWED_KEYS = new Set(["LICHESS_TOKEN", "MAIA3_MODEL", "STOCKFISH_FLAVOR"]);
3
4
  export function loadEnv(path = ".env") {
4
5
  let text;
5
6
  try {
@@ -17,7 +18,7 @@ export function loadEnv(path = ".env") {
17
18
  continue;
18
19
  const key = trimmed.slice(0, eq).trim();
19
20
  const value = trimmed.slice(eq + 1).trim();
20
- if (key && process.env[key] === undefined) {
21
+ if (ALLOWED_KEYS.has(key) && process.env[key] === undefined) {
21
22
  process.env[key] = value.replace(/^["']|["']$/g, "");
22
23
  }
23
24
  }