llm-chess-mcp 0.1.2 → 0.3.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/.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
@@ -13,14 +13,18 @@ judgment; the MCP server handles all the computation.
13
13
  | Engine | Role | Runtime |
14
14
  |---|---|---|
15
15
  | **Stockfish 18** (WASM) | Objective evaluation, best moves, multipv | In-process (npm `stockfish`) |
16
- | **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`) |
17
17
  | **Lichess explorer** | Real human game statistics | HTTP (needs token) |
18
18
 
19
19
  Everything runs inside the Node process — no external engine process or Python
20
- runtime is required at deploy time. Maia3 is exported to ONNX once at build time.
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.
21
23
 
22
24
  ## Install
23
25
 
26
+ Requires Node.js 20 or newer.
27
+
24
28
  No install needed — run it directly with `npx`:
25
29
 
26
30
  ```bash
@@ -41,6 +45,25 @@ npm install -g llm-chess-mcp
41
45
  ```bash
42
46
  pnpm install
43
47
  pnpm build
48
+ pnpm test
49
+ ```
50
+
51
+ `pnpm test:unit` runs the unit suite. `pnpm test:e2e` builds first, then runs
52
+ the MCP transport tests. `pnpm check` runs the full local gate; use
53
+ `pnpm release:check` before publishing.
54
+
55
+ ### Maintainers
56
+
57
+ [Architecture](docs/architecture.md) describes runtime and service boundaries;
58
+ [the changelog](CHANGELOG.md) records client-visible changes.
59
+
60
+ Local quality commands:
61
+
62
+ ```bash
63
+ pnpm typecheck
64
+ pnpm test:coverage
65
+ pnpm contract:check
66
+ pnpm check
44
67
  ```
45
68
 
46
69
  ### Export Maia3 to ONNX (build-time only)
@@ -51,7 +74,7 @@ the reimplementation against the original, and exports `models/maia3-5m.onnx`.
51
74
  ```bash
52
75
  uv venv .venv-maia3 --python 3.13
53
76
  uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
54
- uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3"
77
+ uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
55
78
  pnpm export:maia3 # -> models/maia3-5m.onnx
56
79
  ```
57
80
 
@@ -69,6 +92,13 @@ cp .env.example .env
69
92
 
70
93
  Without a token, `opening_explorer` returns a disabled notice; all other tools work.
71
94
 
95
+ Explorer filters are strict. Speeds are `ultraBullet`, `bullet`, `blitz`,
96
+ `rapid`, `classical`, and `correspondence`; rating buckets are `0`, `1000`,
97
+ `1200`, `1400`, `1600`, `1800`, `2000`, `2200`, and `2500`. `masters` accepts
98
+ neither filter. Invalid filters fail locally. Transient failures (network,
99
+ timeout, 429, and 5xx) are retried once within a 12-second total budget;
100
+ invalid requests and other 4xx responses are not retried.
101
+
72
102
  ## Configure in your MCP client
73
103
 
74
104
  ### opencode
@@ -84,8 +114,7 @@ Add to `opencode.json` (project) or `~/.config/opencode/opencode.json` (global):
84
114
  "command": ["npx", "-y", "llm-chess-mcp"],
85
115
  "enabled": true,
86
116
  "environment": {
87
- "LICHESS_TOKEN": "your-token",
88
- "MAIA3_MODEL": "5m"
117
+ "LICHESS_TOKEN": "your-token"
89
118
  }
90
119
  }
91
120
  }
@@ -107,8 +136,7 @@ claude mcp add llm-chess-mcp -- npx -y llm-chess-mcp
107
136
  "command": "npx",
108
137
  "args": ["-y", "llm-chess-mcp"],
109
138
  "env": {
110
- "LICHESS_TOKEN": "your-token",
111
- "MAIA3_MODEL": "5m"
139
+ "LICHESS_TOKEN": "your-token"
112
140
  }
113
141
  }
114
142
  }
@@ -126,7 +154,6 @@ args = ["-y", "llm-chess-mcp"]
126
154
 
127
155
  [mcp_servers.llm-chess-mcp.env]
128
156
  LICHESS_TOKEN = "your-token"
129
- MAIA3_MODEL = "5m"
130
157
  ```
131
158
 
132
159
  Or via the CLI:
@@ -153,6 +180,20 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
153
180
  | `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
154
181
  | `opening_explorer` | Lichess human game statistics |
155
182
 
183
+ ## Result format and 0.1.x migration
184
+
185
+ `structuredContent` is the canonical successful result. Handler-level failures
186
+ set `isError` and provide `structuredContent.error`. Input-schema failures are
187
+ generated by the MCP SDK before the handler and use its standard `isError` text
188
+ result without `structuredContent`. Otherwise, `content` is only a short
189
+ human-readable summary and must not be parsed as data.
190
+
191
+ Clients upgrading from 0.1.x should stop parsing `content` and consume
192
+ `structuredContent` instead. Check `isError` and the structured error code when
193
+ a tool fails. `move_evaluate` now always returns
194
+ `{ game_id, revision, results }`; its former single-move top-level duplicates
195
+ are removed.
196
+
156
197
  ## Score conventions
157
198
 
158
199
  - Stockfish scores are **side-to-move perspective**: positive cp = side to move is
@@ -223,6 +264,13 @@ rejected:
223
264
  { "error": { "code": "STALE_POSITION", "message": "position changed: expected revision 2, current 3" } }
224
265
  ```
225
266
 
267
+ ## Runtime limits
268
+
269
+ - Up to 1,000 game sessions are retained; idle sessions expire after one hour.
270
+ - `move_evaluate` accepts at most 10 moves per call.
271
+ - Imported PGNs are limited to 1 MiB and 4,096 plies.
272
+ - Stockfish accepts up to 32 active or queued analyses.
273
+
226
274
  ## Intents
227
275
 
228
276
  `move_candidates_by_intent` ranks candidates for a chosen intent. It is a
@@ -270,6 +318,16 @@ It checks top-1/top-k move agreement and max probability error to detect
270
318
  export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
271
319
  and top-5 agreement and max probability error < 1e-4.
272
320
 
321
+ ## Releases
322
+
323
+ Releases are verified locally; this project intentionally has no hosted CI
324
+ release workflow.
325
+
326
+ For `0.2.0`, run `pnpm release:check`, pack the tarball, and smoke-test a clean
327
+ install of that tarball with `llm-chess-mcp`. Publish only after that succeeds.
328
+ Use the same local gate and clean-install smoke test before promoting the proven
329
+ `0.2.x` release process to `1.0.0`.
330
+
273
331
  ## License & attribution
274
332
 
275
333
  This project is licensed under the **AGPL-3.0** (see `LICENSE`).
@@ -283,6 +341,7 @@ It bundles and depends on third-party components:
283
341
  | [onnxruntime-node](https://github.com/microsoft/onnxruntime) | MIT | Microsoft |
284
342
  | [chess.js](https://github.com/jhlywa/chess.js) | BSD-2-Clause | Jeff Hlywa |
285
343
 
286
- The Maia3 model weights (`models/maia3-5m.onnx`) are derived from the
287
- `UofTCSSLab/Maia3-5M` checkpoint. The ONNX export is a build-time step
288
- (`scripts/export_maia3.py`); the runtime does not execute any Maia3 Python code.
344
+ The bundled Maia3 model (`models/maia3-5m.onnx`) is derived from
345
+ [`UofTCSSLab/Maia3-5M` at `b6559de2398d7140b985f28fd2c19fb5e47ddabe`](https://huggingface.co/UofTCSSLab/Maia3-5M/tree/b6559de2398d7140b985f28fd2c19fb5e47ddabe).
346
+ The ONNX export is a build-time step (`scripts/export_maia3.py`); the runtime
347
+ does not execute any Maia3 Python code.
package/dist/chess.js ADDED
@@ -0,0 +1,83 @@
1
+ import { Chess } from "chess.js";
2
+ import { ChessError } from "./errors.js";
3
+ export const MAX_EVALUATED_MOVES = 10;
4
+ export const MAX_PGN_BYTES = 1024 * 1024;
5
+ export const MAX_PGN_PLIES = 4096;
6
+ function moveDescriptor(move) {
7
+ const base = { from: move.from, to: move.to };
8
+ return move.promotion ? { ...base, promotion: move.promotion } : base;
9
+ }
10
+ export function snapshotChess(chess) {
11
+ const history = chess.history({ verbose: true });
12
+ const snapshot = new Chess(history[0]?.before ?? chess.fen());
13
+ for (const move of history)
14
+ snapshot.move(moveDescriptor(move));
15
+ return snapshot;
16
+ }
17
+ export function drawResult(chess) {
18
+ if (!chess.isDraw())
19
+ return null;
20
+ if (chess.isStalemate())
21
+ return "stalemate";
22
+ if (chess.isInsufficientMaterial())
23
+ return "insufficient_material";
24
+ if (chess.isThreefoldRepetition())
25
+ return "threefold_repetition";
26
+ if (chess.isDrawByFiftyMoves())
27
+ return "fifty_move_rule";
28
+ return "draw";
29
+ }
30
+ export function parseImportedPgn(pgn) {
31
+ if (Buffer.byteLength(pgn, "utf8") > MAX_PGN_BYTES) {
32
+ throw new ChessError("PGN_TOO_LARGE", `PGN exceeds the ${MAX_PGN_BYTES}-byte limit`);
33
+ }
34
+ let chess;
35
+ try {
36
+ chess = new Chess();
37
+ chess.loadPgn(pgn);
38
+ }
39
+ catch {
40
+ throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
41
+ }
42
+ if (chess.history().length > MAX_PGN_PLIES) {
43
+ throw new ChessError("PGN_TOO_MANY_MOVES", `PGN exceeds the ${MAX_PGN_PLIES}-ply limit`);
44
+ }
45
+ return chess;
46
+ }
47
+ export function stateOf(chess, revision) {
48
+ const last = chess.history({ verbose: true }).at(-1);
49
+ return {
50
+ fen: chess.fen(),
51
+ turn: chess.turn(),
52
+ revision,
53
+ isCheck: chess.isCheck(),
54
+ isCheckmate: chess.isCheckmate(),
55
+ isStalemate: chess.isStalemate(),
56
+ isDraw: chess.isDraw(),
57
+ isGameOver: chess.isGameOver(),
58
+ isInsufficientMaterial: chess.isInsufficientMaterial(),
59
+ isThreefoldRepetition: chess.isThreefoldRepetition(),
60
+ isDrawByFiftyMoves: chess.isDrawByFiftyMoves(),
61
+ moveNumber: chess.moveNumber(),
62
+ history: chess.history(),
63
+ lastMove: last ? { san: last.san, uci: last.lan } : null,
64
+ castling: {
65
+ whiteKingside: chess.getCastlingRights("w").k,
66
+ whiteQueenside: chess.getCastlingRights("w").q,
67
+ blackKingside: chess.getCastlingRights("b").k,
68
+ blackQueenside: chess.getCastlingRights("b").q,
69
+ },
70
+ };
71
+ }
72
+ export function parseMove(chess, move) {
73
+ const legal = chess.moves({ verbose: true });
74
+ const san = move.replace(/[+#]$/, "");
75
+ const found = legal.find((candidate) => candidate.san.replace(/[+#]$/, "") === san) ??
76
+ legal.find((candidate) => candidate.lan === move);
77
+ if (!found)
78
+ throw new ChessError("ILLEGAL_MOVE", `illegal move: ${move}`);
79
+ return found;
80
+ }
81
+ export function playParsedMove(chess, move) {
82
+ return chess.move(moveDescriptor(move));
83
+ }
@@ -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"))
@@ -11,62 +38,182 @@ function parseScore(token) {
11
38
  return { cp: null, mate: null };
12
39
  }
13
40
  function parseWdl(line) {
14
- const m = line.match(/ wdl (\d+) (\d+) (\d+)/);
15
- if (!m)
41
+ const groups = line.match(/ wdl (?<wins>\d+) (?<draws>\d+) (?<losses>\d+)/)?.groups;
42
+ if (!groups)
16
43
  return null;
17
- return [Number(m[1]), Number(m[2]), Number(m[3])];
44
+ return [Number(groups.wins), Number(groups.draws), Number(groups.losses)];
18
45
  }
19
46
  export class Stockfish {
20
- engine = null;
21
- ready = null;
47
+ session = null;
22
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
+ }
23
70
  init() {
24
- if (this.ready)
25
- return this.ready;
26
- this.ready = new Promise((resolve, reject) => {
27
- const init = require("stockfish");
28
- const engine = init(flavor(), (err) => {
29
- if (err)
30
- return reject(err);
31
- this.handshake().then(resolve, reject);
32
- });
33
- this.engine = engine;
34
- 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;
35
78
  });
36
- 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;
37
132
  }
38
- handshake() {
133
+ handshake(session) {
39
134
  return new Promise((resolve, reject) => {
40
- const engine = this.engine;
135
+ const engine = session.engine;
136
+ if (!engine) {
137
+ reject(new Error("stockfish initialized without an engine"));
138
+ return;
139
+ }
41
140
  let stage = 0;
42
- const timer = setTimeout(() => reject(new Error("stockfish handshake timeout")), 15000);
43
- 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) => {
44
157
  if (stage === 0 && line === "uciok") {
45
158
  stage = 1;
46
- engine.sendCommand("isready");
159
+ try {
160
+ engine.sendCommand("isready");
161
+ }
162
+ catch (error) {
163
+ finish(asError(error));
164
+ }
47
165
  }
48
166
  else if (stage === 1 && line === "readyok") {
49
- clearTimeout(timer);
50
- engine.listener = null;
51
- resolve();
167
+ finish();
52
168
  }
53
169
  };
54
- 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
+ }
55
179
  });
56
180
  }
57
181
  enqueue(fn) {
58
- const run = this.queue.then(fn, fn);
59
- this.queue = run.catch(() => { });
60
- 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
+ });
61
191
  }
62
- async analyze(fen, depth, multipv) {
63
- await this.init();
64
- 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
+ });
65
207
  }
66
- doAnalyze(fen, depth, multipv) {
208
+ doAnalyze(session, fen, depth, multipv) {
67
209
  return new Promise((resolve, reject) => {
68
- const engine = this.engine;
210
+ const engine = session.engine;
211
+ if (!engine) {
212
+ reject(new Error("stockfish engine unavailable"));
213
+ return;
214
+ }
69
215
  const byPv = new Map();
216
+ let settled = false;
70
217
  let stopTimer = null;
71
218
  let failTimer = null;
72
219
  const cleanup = () => {
@@ -76,58 +223,118 @@ export class Stockfish {
76
223
  clearTimeout(failTimer);
77
224
  stopTimer = null;
78
225
  failTimer = null;
226
+ session.aborts.delete(abort);
227
+ if (engine.listener === listener)
228
+ engine.listener = null;
79
229
  };
80
- 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) => {
81
248
  if (line.startsWith("info") && line.includes(" multipv ")) {
82
- const m = line.match(/multipv (\d+)/);
83
- const s = line.match(/ score (cp -?\d+|mate -?\d+)/);
84
- const pv = line.match(/ pv (.+)$/);
85
- if (!m)
249
+ const multipv = line.match(/multipv (?<value>\d+)/)?.groups?.value;
250
+ if (!multipv)
86
251
  return;
87
- const n = Number(m[1]);
88
- const score = s ? parseScore(s[1]) : { cp: null, mate: null };
252
+ const scoreToken = line.match(/ score (?<value>cp -?\d+|mate -?\d+)/)?.groups?.value;
253
+ const pv = line.match(/ pv (?<value>.+)$/)?.groups?.value;
254
+ const n = Number(multipv);
255
+ const score = scoreToken
256
+ ? parseScore(scoreToken)
257
+ : { cp: null, mate: null };
89
258
  byPv.set(n, {
90
259
  multipv: n,
91
260
  scoreCp: score.cp,
92
261
  scoreMate: score.mate,
93
262
  wdl: parseWdl(line),
94
- pv: pv ? pv[1].split(" ") : [],
263
+ pv: pv ? pv.split(" ") : [],
95
264
  });
96
265
  }
97
266
  else if (line.startsWith("bestmove")) {
98
- cleanup();
99
- engine.listener = null;
100
- resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
267
+ succeed();
101
268
  }
102
269
  };
103
- engine.sendCommand("position fen " + fen);
104
- engine.sendCommand(`setoption name MultiPV value ${multipv}`);
105
- engine.sendCommand("setoption name UCI_ShowWDL value true");
106
- engine.sendCommand(`go depth ${depth}`);
270
+ session.aborts.add(abort);
271
+ engine.listener = listener;
107
272
  stopTimer = setTimeout(() => {
108
- engine.sendCommand("stop");
109
- failTimer = setTimeout(() => {
110
- if (engine.listener) {
111
- engine.listener = null;
112
- this.reset();
113
- reject(new Error("stockfish analyze timeout"));
114
- }
115
- }, 2000);
116
- }, 30000);
117
- });
118
- }
119
- reset() {
120
- if (this.engine) {
273
+ failTimer = setTimeout(() => fail(new Error("stockfish analyze timeout"), true), this.timeouts.stopGrace);
274
+ try {
275
+ engine.sendCommand("stop");
276
+ }
277
+ catch (error) {
278
+ fail(asError(error), true);
279
+ }
280
+ }, this.timeouts.analyze);
121
281
  try {
122
- this.engine.terminate();
282
+ engine.sendCommand("position fen " + fen);
283
+ engine.sendCommand(`setoption name MultiPV value ${multipv}`);
284
+ engine.sendCommand("setoption name UCI_ShowWDL value true");
285
+ engine.sendCommand(`go depth ${depth}`);
286
+ }
287
+ catch (error) {
288
+ fail(asError(error), true);
289
+ return;
123
290
  }
124
- catch { }
291
+ });
292
+ }
293
+ failSession(session, error) {
294
+ if (session.initSettled)
295
+ return;
296
+ session.initSettled = true;
297
+ if (session.initTimer)
298
+ clearTimeout(session.initTimer);
299
+ session.initTimer = null;
300
+ this.invalidateSession(session, error);
301
+ session.reject(error);
302
+ }
303
+ invalidateSession(session, error) {
304
+ if (this.session !== session)
305
+ return;
306
+ this.session = null;
307
+ if (session.initTimer)
308
+ clearTimeout(session.initTimer);
309
+ session.initTimer = null;
310
+ if (!session.initSettled) {
311
+ session.initSettled = true;
312
+ session.reject(error);
313
+ }
314
+ const aborts = [...session.aborts];
315
+ session.aborts.clear();
316
+ if (session.engine) {
317
+ session.engine.listener = null;
318
+ this.terminate(session.engine);
319
+ }
320
+ for (const abort of aborts)
321
+ abort(error);
322
+ }
323
+ terminate(engine) {
324
+ if (this.terminated.has(engine))
325
+ return;
326
+ this.terminated.add(engine);
327
+ engine.listener = null;
328
+ try {
329
+ engine.terminate();
125
330
  }
126
- this.engine = null;
127
- this.ready = null;
331
+ catch { }
128
332
  }
129
333
  async quit() {
130
- this.reset();
334
+ this.quitGeneration++;
335
+ const session = this.session;
336
+ if (session)
337
+ this.invalidateSession(session, new Error("stockfish quit"));
131
338
  }
132
339
  }
133
340
  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
  }
package/dist/errors.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export class ChessError extends Error {
2
2
  code;
3
3
  constructor(code, message) {
4
- super(`${code}: ${message}`);
4
+ super(message);
5
5
  this.code = code;
6
6
  this.name = "ChessError";
7
7
  }