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 +9 -0
- package/README.md +131 -30
- package/dist/engines/stockfish.js +271 -58
- package/dist/env.js +2 -1
- package/dist/eval.js +22 -4
- package/dist/games.js +40 -6
- package/dist/index.js +227 -116
- package/dist/intents.js +125 -39
- package/dist/maia3/inference.js +13 -5
- package/dist/maia3/tokenize.js +7 -7
- package/package.json +7 -2
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
|
|
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
|
|
14
|
-
runtime
|
|
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
|
-
| `
|
|
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
|
|
147
|
-
| `move_candidates` |
|
|
148
|
-
| `move_candidates_by_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
|
-
|
|
156
|
-
|
|
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` |
|
|
172
|
-
| `give_chance` |
|
|
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. `
|
|
178
|
-
3. `
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
196
|
-
`UofTCSSLab/Maia3-5M`
|
|
197
|
-
(`scripts/export_maia3.py`); the runtime
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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.
|
|
19
|
-
return this.ready;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
engine.listener = null;
|
|
45
|
-
resolve();
|
|
167
|
+
finish();
|
|
46
168
|
}
|
|
47
169
|
};
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
return this.enqueue(() =>
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
engine.listener = null;
|
|
93
|
-
resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
|
|
265
|
+
succeed();
|
|
94
266
|
}
|
|
95
267
|
};
|
|
96
|
-
|
|
97
|
-
engine.
|
|
98
|
-
engine.sendCommand(`go depth ${depth}`);
|
|
268
|
+
session.aborts.add(abort);
|
|
269
|
+
engine.listener = listener;
|
|
99
270
|
stopTimer = setTimeout(() => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
this.ready = null;
|
|
329
|
+
catch { }
|
|
120
330
|
}
|
|
121
331
|
async quit() {
|
|
122
|
-
this.
|
|
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
|
}
|