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 +9 -0
- package/README.md +70 -11
- package/dist/chess.js +83 -0
- package/dist/engines/stockfish.js +276 -69
- package/dist/env.js +2 -1
- package/dist/errors.js +1 -1
- package/dist/explorer.js +220 -25
- package/dist/games.js +88 -34
- package/dist/index.js +16 -363
- package/dist/intents.js +74 -50
- package/dist/maia3/inference.js +73 -19
- package/dist/maia3/mirror.js +11 -2
- package/dist/maia3/tokenize.js +16 -13
- package/dist/server.js +16 -0
- package/dist/services.js +15 -0
- package/dist/tool-inputs.js +117 -0
- package/dist/tool-meta.js +92 -0
- package/dist/tool-result.js +34 -0
- package/dist/tool-schemas.js +226 -0
- package/dist/tools/analysis.js +121 -0
- package/dist/tools/candidates.js +64 -0
- package/dist/tools/explorer.js +21 -0
- package/dist/tools/game.js +101 -0
- package/dist/types.js +8 -1
- package/package.json +22 -4
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.
|
|
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
|
|
287
|
-
`UofTCSSLab/Maia3-5M`
|
|
288
|
-
(`scripts/export_maia3.py`); the runtime
|
|
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
|
-
|
|
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"))
|
|
@@ -11,62 +38,182 @@ function parseScore(token) {
|
|
|
11
38
|
return { cp: null, mate: null };
|
|
12
39
|
}
|
|
13
40
|
function parseWdl(line) {
|
|
14
|
-
const
|
|
15
|
-
if (!
|
|
41
|
+
const groups = line.match(/ wdl (?<wins>\d+) (?<draws>\d+) (?<losses>\d+)/)?.groups;
|
|
42
|
+
if (!groups)
|
|
16
43
|
return null;
|
|
17
|
-
return [Number(
|
|
44
|
+
return [Number(groups.wins), Number(groups.draws), Number(groups.losses)];
|
|
18
45
|
}
|
|
19
46
|
export class Stockfish {
|
|
20
|
-
|
|
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.
|
|
25
|
-
return this.ready;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
engine.listener = null;
|
|
51
|
-
resolve();
|
|
167
|
+
finish();
|
|
52
168
|
}
|
|
53
169
|
};
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
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
|
+
});
|
|
65
207
|
}
|
|
66
|
-
doAnalyze(fen, depth, multipv) {
|
|
208
|
+
doAnalyze(session, fen, depth, multipv) {
|
|
67
209
|
return new Promise((resolve, reject) => {
|
|
68
|
-
const 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
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
88
|
-
const
|
|
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
|
|
263
|
+
pv: pv ? pv.split(" ") : [],
|
|
95
264
|
});
|
|
96
265
|
}
|
|
97
266
|
else if (line.startsWith("bestmove")) {
|
|
98
|
-
|
|
99
|
-
engine.listener = null;
|
|
100
|
-
resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
|
|
267
|
+
succeed();
|
|
101
268
|
}
|
|
102
269
|
};
|
|
103
|
-
|
|
104
|
-
engine.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
this.ready = null;
|
|
331
|
+
catch { }
|
|
128
332
|
}
|
|
129
333
|
async quit() {
|
|
130
|
-
this.
|
|
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
|
}
|