llm-chess-mcp 0.4.8 → 0.4.10
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 +1 -1
- package/README.md +4 -1
- package/dist/chess-copy.js +122 -5
- package/dist/chess.d.ts +1 -1
- package/dist/chess.js +1 -1
- package/dist/engines/stockfish.js +55 -12
- package/dist/explorer-core.d.ts +1 -0
- package/dist/explorer-core.js +1 -0
- package/dist/explorer-limiter.d.ts +2 -0
- package/dist/explorer-limiter.js +48 -7
- package/dist/explorer-response.js +3 -0
- package/dist/explorer-retry.js +13 -7
- package/dist/explorer.d.ts +2 -2
- package/dist/explorer.js +45 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/intents.js +74 -9
- package/dist/pgn.d.ts +2 -0
- package/dist/pgn.js +768 -21
- package/dist/services.js +13 -12
- package/dist/tool-inputs.js +36 -6
- package/dist/tool-schemas.js +100 -35
- package/docs/architecture.md +6 -1
- package/package.json +3 -3
package/.env.example
CHANGED
|
@@ -5,5 +5,5 @@ LICHESS_TOKEN=
|
|
|
5
5
|
# The published package bundles only Maia3 5m (~20 MB). Leave at 5m.
|
|
6
6
|
MAIA3_MODEL=5m
|
|
7
7
|
|
|
8
|
-
# Stockfish flavor: lite-single (default, ~7MB) | lite | single | full
|
|
8
|
+
# Stockfish flavor: lite-single (default, ~7MB) | single-lite | lite | single | full | asm
|
|
9
9
|
STOCKFISH_FLAVOR=lite-single
|
package/README.md
CHANGED
|
@@ -264,7 +264,10 @@ rejected:
|
|
|
264
264
|
|
|
265
265
|
- Up to 1,000 games are retained per process; idle games expire after one hour.
|
|
266
266
|
- `move_evaluate` accepts at most 10 moves per call.
|
|
267
|
-
- Imported PGNs are limited to 1 MiB and 4,096 plies
|
|
267
|
+
- Imported PGNs are limited to 1 MiB, 256 headers, and 4,096 plies across the
|
|
268
|
+
mainline and variations, plus 32,768 structural elements and 16 KiB per
|
|
269
|
+
lexical token. Every variation is legality-checked; game state retains the
|
|
270
|
+
mainline. UTF-8 BOMs and standard escaped header values are supported.
|
|
268
271
|
- Custom FENs reject inconsistent castling/en-passant metadata and impossible
|
|
269
272
|
pawn or promotion material.
|
|
270
273
|
- Stockfish accepts up to 32 active or queued analyses.
|
package/dist/chess-copy.js
CHANGED
|
@@ -5,9 +5,75 @@ const ORIGINAL_PIECES = {
|
|
|
5
5
|
r: 2,
|
|
6
6
|
n: 2,
|
|
7
7
|
};
|
|
8
|
+
const CANONICAL_PGN_HEADERS = new Map([
|
|
9
|
+
"Event",
|
|
10
|
+
"Site",
|
|
11
|
+
"Date",
|
|
12
|
+
"Round",
|
|
13
|
+
"White",
|
|
14
|
+
"Black",
|
|
15
|
+
"Result",
|
|
16
|
+
"SetUp",
|
|
17
|
+
"FEN",
|
|
18
|
+
].map((name) => [name.toLowerCase(), name]));
|
|
19
|
+
function restoreHeaders(chess, sourceHeaders) {
|
|
20
|
+
const sourceNames = new Set(sourceHeaders.map(([key]) => key.toLowerCase()));
|
|
21
|
+
for (const key of Object.keys(chess.getHeaders())) {
|
|
22
|
+
if (!sourceNames.has(key.toLowerCase()))
|
|
23
|
+
chess.removeHeader(key);
|
|
24
|
+
}
|
|
25
|
+
const names = new Map();
|
|
26
|
+
for (const key of Object.keys(chess.getHeaders())) {
|
|
27
|
+
const existing = names.get(key.toLowerCase());
|
|
28
|
+
if (existing)
|
|
29
|
+
existing.push(key);
|
|
30
|
+
else
|
|
31
|
+
names.set(key.toLowerCase(), [key]);
|
|
32
|
+
}
|
|
33
|
+
for (const [key, value] of sourceHeaders) {
|
|
34
|
+
const lower = key.toLowerCase();
|
|
35
|
+
const canonical = CANONICAL_PGN_HEADERS.get(lower) ?? key;
|
|
36
|
+
for (const existing of names.get(lower) ?? []) {
|
|
37
|
+
if (existing !== canonical)
|
|
38
|
+
chess.removeHeader(existing);
|
|
39
|
+
}
|
|
40
|
+
chess.setHeader(canonical, value);
|
|
41
|
+
names.set(lower, [canonical]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
8
44
|
function squareColor(square) {
|
|
9
45
|
return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
|
|
10
46
|
}
|
|
47
|
+
function minimumPawnCaptures(chess, color) {
|
|
48
|
+
const pawns = chess
|
|
49
|
+
.findPiece({ type: "p", color })
|
|
50
|
+
.map((square) => ({
|
|
51
|
+
advances: color === "w" ? Number(square[1]) - 2 : 7 - Number(square[1]),
|
|
52
|
+
file: square.charCodeAt(0) - 97,
|
|
53
|
+
}))
|
|
54
|
+
.sort((left, right) => left.file - right.file);
|
|
55
|
+
let costs = new Map([[0, 0]]);
|
|
56
|
+
for (const pawn of pawns) {
|
|
57
|
+
const next = new Map();
|
|
58
|
+
for (const [mask, cost] of costs) {
|
|
59
|
+
for (let original = 0; original < 8; original += 1) {
|
|
60
|
+
const bit = 1 << original;
|
|
61
|
+
if (mask & bit)
|
|
62
|
+
continue;
|
|
63
|
+
const captures = Math.abs(original - pawn.file);
|
|
64
|
+
if (captures > pawn.advances)
|
|
65
|
+
continue;
|
|
66
|
+
const nextMask = mask | bit;
|
|
67
|
+
next.set(nextMask, Math.min(next.get(nextMask) ?? Infinity, cost + captures));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
costs = next;
|
|
71
|
+
}
|
|
72
|
+
return Math.min(...costs.values());
|
|
73
|
+
}
|
|
74
|
+
function nonKingMaterial(chess, color) {
|
|
75
|
+
return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
|
|
76
|
+
}
|
|
11
77
|
function hasPiece(chess, square, type, color) {
|
|
12
78
|
const piece = chess.get(square);
|
|
13
79
|
return piece?.type === type && piece.color === color;
|
|
@@ -92,6 +158,14 @@ export function assertLegalPosition(chess) {
|
|
|
92
158
|
throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
|
|
93
159
|
}
|
|
94
160
|
assertCastlingPosition(chess, color);
|
|
161
|
+
const opponent = color === "w" ? "b" : "w";
|
|
162
|
+
const opponentPawns = chess.findPiece({ type: "p", color: opponent }).length;
|
|
163
|
+
const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
|
|
164
|
+
const possibleOpponentPromotions = 8 - opponentPawns;
|
|
165
|
+
if (minimumPawnCaptures(chess, color) >
|
|
166
|
+
missingOpponentMaterial + possibleOpponentPromotions) {
|
|
167
|
+
throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
|
|
168
|
+
}
|
|
95
169
|
}
|
|
96
170
|
assertEnPassantPosition(chess);
|
|
97
171
|
const turn = chess.turn();
|
|
@@ -107,14 +181,31 @@ export function snapshotChess(chess) {
|
|
|
107
181
|
const initialFen = history[0]?.before ?? chess.fen();
|
|
108
182
|
assertSafeFenCounters(initialFen);
|
|
109
183
|
const snapshot = new Chess(initialFen);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
184
|
+
assertLegalPosition(snapshot);
|
|
185
|
+
const comments = new Map(chess.getComments().map(({ fen, comment }) => [
|
|
186
|
+
fen,
|
|
187
|
+
/[{}]/.test(comment) ? comment.replace(/[\r\n]+/g, " ") : comment,
|
|
188
|
+
]));
|
|
189
|
+
const sourceHeaders = Object.entries(chess.getHeaders());
|
|
190
|
+
const unsafeComments = [...comments.values()].some((comment) => /[{}]/.test(comment));
|
|
191
|
+
let markerPrefix = "\uE000";
|
|
192
|
+
if (unsafeComments) {
|
|
193
|
+
const occupied = [...sourceHeaders.flat(), ...comments.values()].join("\u0000");
|
|
194
|
+
while (occupied.includes(markerPrefix))
|
|
195
|
+
markerPrefix += "\uE001";
|
|
113
196
|
}
|
|
197
|
+
const markerComments = [];
|
|
114
198
|
const restoreComment = () => {
|
|
115
199
|
const comment = comments.get(snapshot.fen());
|
|
116
|
-
if (comment
|
|
200
|
+
if (comment === undefined)
|
|
201
|
+
return;
|
|
202
|
+
if (!unsafeComments || !/[{}]/.test(comment)) {
|
|
117
203
|
snapshot.setComment(comment);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const marker = `${markerPrefix}${markerComments.length}${markerPrefix}`;
|
|
207
|
+
markerComments.push(comment);
|
|
208
|
+
snapshot.setComment(marker);
|
|
118
209
|
};
|
|
119
210
|
restoreComment();
|
|
120
211
|
for (const move of history) {
|
|
@@ -122,5 +213,31 @@ export function snapshotChess(chess) {
|
|
|
122
213
|
restoreComment();
|
|
123
214
|
}
|
|
124
215
|
assertSafeFenCounters(snapshot.fen());
|
|
125
|
-
|
|
216
|
+
if (!unsafeComments) {
|
|
217
|
+
restoreHeaders(snapshot, sourceHeaders);
|
|
218
|
+
return snapshot;
|
|
219
|
+
}
|
|
220
|
+
const escapedPrefix = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
221
|
+
const marker = new RegExp(`\\{${escapedPrefix}(\\d+)${escapedPrefix}\\}`, "g");
|
|
222
|
+
const pgn = snapshot.pgn().replace(marker, (_match, index) => {
|
|
223
|
+
return `;${markerComments[Number(index)]}\n`;
|
|
224
|
+
});
|
|
225
|
+
const restored = new Chess();
|
|
226
|
+
restored.loadPgn(pgn);
|
|
227
|
+
const restoredHistory = restored.history({ verbose: true });
|
|
228
|
+
while (restored.undo()) { }
|
|
229
|
+
const restoreSafeComment = () => {
|
|
230
|
+
const comment = comments.get(restored.fen());
|
|
231
|
+
if (comment !== undefined && !/[{}]/.test(comment)) {
|
|
232
|
+
restored.setComment(comment);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
restoreSafeComment();
|
|
236
|
+
for (const move of restoredHistory) {
|
|
237
|
+
restored.move(moveDescriptor(move));
|
|
238
|
+
restoreSafeComment();
|
|
239
|
+
}
|
|
240
|
+
restoreHeaders(restored, sourceHeaders);
|
|
241
|
+
assertSafeFenCounters(restored.fen());
|
|
242
|
+
return restored;
|
|
126
243
|
}
|
package/dist/chess.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Chess } from "chess.js";
|
|
2
2
|
import type { Move } from "chess.js";
|
|
3
3
|
export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
|
|
4
|
-
export { MAX_PGN_BYTES, MAX_PGN_PLIES, parseImportedPgn, pgnOf } from "./pgn.js";
|
|
4
|
+
export { MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, pgnOf, } from "./pgn.js";
|
|
5
5
|
import type { ChessState, DrawResult } from "./domain.js";
|
|
6
6
|
export declare const MAX_EVALUATED_MOVES = 10;
|
|
7
7
|
export declare function drawResult(chess: Chess): DrawResult | null;
|
package/dist/chess.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Chess } from "chess.js";
|
|
2
2
|
export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
|
|
3
|
-
export { MAX_PGN_BYTES, MAX_PGN_PLIES, parseImportedPgn, pgnOf } from "./pgn.js";
|
|
3
|
+
export { MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, pgnOf, } from "./pgn.js";
|
|
4
4
|
import { ChessError } from "./errors.js";
|
|
5
5
|
export const MAX_EVALUATED_MOVES = 10;
|
|
6
6
|
function moveDescriptor(move) {
|
|
@@ -19,6 +19,7 @@ const DEFAULT_TIMEOUTS = {
|
|
|
19
19
|
stopGrace: 2000,
|
|
20
20
|
};
|
|
21
21
|
const DEFAULT_MAX_QUEUE = 32;
|
|
22
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
22
23
|
export function resolveStockfishFlavor(value) {
|
|
23
24
|
const normalized = (value || DEFAULT_FLAVOR).toLowerCase();
|
|
24
25
|
if (!FLAVORS.has(normalized)) {
|
|
@@ -56,6 +57,22 @@ function asError(error) {
|
|
|
56
57
|
function abortError(signal) {
|
|
57
58
|
return asError(signal.reason ?? "stockfish request cancelled");
|
|
58
59
|
}
|
|
60
|
+
function ownOption(options, name) {
|
|
61
|
+
return Object.hasOwn(options, name) ? options[name] : undefined;
|
|
62
|
+
}
|
|
63
|
+
function mergeTimeouts(value) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return { ...DEFAULT_TIMEOUTS };
|
|
66
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
67
|
+
throw new Error("stockfish timeouts must be an object");
|
|
68
|
+
}
|
|
69
|
+
const timeouts = { ...DEFAULT_TIMEOUTS };
|
|
70
|
+
for (const name of Object.keys(DEFAULT_TIMEOUTS)) {
|
|
71
|
+
if (Object.hasOwn(value, name))
|
|
72
|
+
timeouts[name] = value[name];
|
|
73
|
+
}
|
|
74
|
+
return timeouts;
|
|
75
|
+
}
|
|
59
76
|
export class Stockfish {
|
|
60
77
|
session = null;
|
|
61
78
|
queue = [];
|
|
@@ -74,16 +91,18 @@ export class Stockfish {
|
|
|
74
91
|
maxQueue;
|
|
75
92
|
timeouts;
|
|
76
93
|
constructor(options = {}) {
|
|
77
|
-
this.initEngine = options
|
|
78
|
-
this.configuredFlavor = options
|
|
79
|
-
this.maxQueue = options
|
|
80
|
-
this.timeouts =
|
|
94
|
+
this.initEngine = ownOption(options, "init");
|
|
95
|
+
this.configuredFlavor = ownOption(options, "flavor");
|
|
96
|
+
this.maxQueue = ownOption(options, "maxQueue") ?? DEFAULT_MAX_QUEUE;
|
|
97
|
+
this.timeouts = mergeTimeouts(ownOption(options, "timeouts"));
|
|
81
98
|
if (!Number.isInteger(this.maxQueue) || this.maxQueue < 1) {
|
|
82
99
|
throw new Error("stockfish maxQueue must be a positive integer");
|
|
83
100
|
}
|
|
84
101
|
for (const [name, timeout] of Object.entries(this.timeouts)) {
|
|
85
|
-
if (!Number.
|
|
86
|
-
|
|
102
|
+
if (!Number.isSafeInteger(timeout) ||
|
|
103
|
+
timeout < 1 ||
|
|
104
|
+
timeout > MAX_TIMER_DELAY_MS) {
|
|
105
|
+
throw new Error(`stockfish ${name} timeout must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
87
106
|
}
|
|
88
107
|
}
|
|
89
108
|
}
|
|
@@ -113,7 +132,9 @@ export class Stockfish {
|
|
|
113
132
|
const engine = (this.initEngine ?? loadStockfish())(selectedFlavor, (error, initializedEngine) => {
|
|
114
133
|
callbackCalled = true;
|
|
115
134
|
if (this.session !== session) {
|
|
116
|
-
this.
|
|
135
|
+
if (initializedEngine !== this.session?.engine) {
|
|
136
|
+
this.terminate(initializedEngine);
|
|
137
|
+
}
|
|
117
138
|
return;
|
|
118
139
|
}
|
|
119
140
|
if (session.readySettled) {
|
|
@@ -121,8 +142,21 @@ export class Stockfish {
|
|
|
121
142
|
this.terminate(initializedEngine);
|
|
122
143
|
return;
|
|
123
144
|
}
|
|
124
|
-
if (
|
|
125
|
-
this.
|
|
145
|
+
if (this.terminations.has(initializedEngine)) {
|
|
146
|
+
this.failSession(session, new Error("stockfish initializer reused a terminated engine"));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const replacedEngine = session.engine;
|
|
150
|
+
if (replacedEngine && replacedEngine !== initializedEngine) {
|
|
151
|
+
this.terminate(replacedEngine);
|
|
152
|
+
if (this.session !== session ||
|
|
153
|
+
session.readySettled ||
|
|
154
|
+
session.engine !== replacedEngine) {
|
|
155
|
+
if (initializedEngine !== this.session?.engine) {
|
|
156
|
+
this.terminate(initializedEngine);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
126
160
|
}
|
|
127
161
|
session.engine = initializedEngine;
|
|
128
162
|
if (error) {
|
|
@@ -141,12 +175,21 @@ export class Stockfish {
|
|
|
141
175
|
}, (handshakeError) => this.failSession(session, asError(handshakeError)));
|
|
142
176
|
});
|
|
143
177
|
if (callbackCalled) {
|
|
144
|
-
if (engine !== session.engine)
|
|
178
|
+
if (engine !== session.engine && engine !== this.session?.engine) {
|
|
145
179
|
this.terminate(engine);
|
|
180
|
+
}
|
|
146
181
|
}
|
|
147
182
|
else if (this.session === session && !session.readySettled) {
|
|
148
|
-
|
|
149
|
-
|
|
183
|
+
if (this.terminations.has(engine)) {
|
|
184
|
+
this.failSession(session, new Error("stockfish initializer reused a terminated engine"));
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
session.engine = engine;
|
|
188
|
+
engine.listener = () => { };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (engine !== this.session?.engine) {
|
|
192
|
+
this.terminate(engine);
|
|
150
193
|
}
|
|
151
194
|
}
|
|
152
195
|
catch (error) {
|
package/dist/explorer-core.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare const EXPLORER_RATE_LIMIT_COOLDOWN_MS = 60000;
|
|
|
10
10
|
export declare const EXPLORER_MAX_RESPONSE_BYTES: number;
|
|
11
11
|
export declare const EXPLORER_MAX_MOVES = 256;
|
|
12
12
|
export declare const EXPLORER_MAX_STRING_LENGTH = 256;
|
|
13
|
+
export declare const EXPLORER_MAX_COOLDOWN_MS = 2147483647;
|
|
13
14
|
export declare class ExplorerError extends Error {
|
|
14
15
|
readonly kind: ExplorerErrorKind;
|
|
15
16
|
readonly status?: number | undefined;
|
package/dist/explorer-core.js
CHANGED
|
@@ -8,6 +8,7 @@ export const EXPLORER_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
|
8
8
|
export const EXPLORER_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
9
9
|
export const EXPLORER_MAX_MOVES = 256;
|
|
10
10
|
export const EXPLORER_MAX_STRING_LENGTH = 256;
|
|
11
|
+
export const EXPLORER_MAX_COOLDOWN_MS = 2_147_483_647;
|
|
11
12
|
export class ExplorerError extends Error {
|
|
12
13
|
kind;
|
|
13
14
|
status;
|
package/dist/explorer-limiter.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
import { awaitWithAbort, explorerError, throwIfAborted, } from "./explorer-core.js";
|
|
1
|
+
import { awaitWithAbort, explorerError, EXPLORER_MAX_COOLDOWN_MS, throwIfAborted, } from "./explorer-core.js";
|
|
2
|
+
const cooldownErrors = new WeakSet();
|
|
3
|
+
function cooldownError() {
|
|
4
|
+
const error = explorerError("rate_limited");
|
|
5
|
+
cooldownErrors.add(error);
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
export function isExplorerCooldownError(error) {
|
|
9
|
+
return cooldownErrors.has(error);
|
|
10
|
+
}
|
|
2
11
|
class RequestLimiter {
|
|
3
12
|
now;
|
|
4
13
|
#active = false;
|
|
5
|
-
#cooldownUntil
|
|
14
|
+
#cooldownUntil;
|
|
15
|
+
#lastNow;
|
|
6
16
|
#queue = [];
|
|
7
17
|
constructor(now) {
|
|
8
18
|
this.now = now;
|
|
@@ -11,7 +21,22 @@ class RequestLimiter {
|
|
|
11
21
|
return this.#queue.length;
|
|
12
22
|
}
|
|
13
23
|
cooldown(ms, _now) {
|
|
14
|
-
|
|
24
|
+
if (!Number.isSafeInteger(ms) ||
|
|
25
|
+
ms < 0 ||
|
|
26
|
+
ms > EXPLORER_MAX_COOLDOWN_MS) {
|
|
27
|
+
throw explorerError("invalid_input");
|
|
28
|
+
}
|
|
29
|
+
const now = this.#readNow();
|
|
30
|
+
const cooldownUntil = now + ms;
|
|
31
|
+
if (!Number.isFinite(cooldownUntil) ||
|
|
32
|
+
Math.abs(cooldownUntil) > Number.MAX_SAFE_INTEGER ||
|
|
33
|
+
(ms > 0 && cooldownUntil <= now)) {
|
|
34
|
+
throw explorerError("invalid_input");
|
|
35
|
+
}
|
|
36
|
+
this.#cooldownUntil =
|
|
37
|
+
this.#cooldownUntil === undefined
|
|
38
|
+
? cooldownUntil
|
|
39
|
+
: Math.max(this.#cooldownUntil, cooldownUntil);
|
|
15
40
|
}
|
|
16
41
|
run(options, request) {
|
|
17
42
|
throwIfAborted(options.callerSignal);
|
|
@@ -66,18 +91,34 @@ class RequestLimiter {
|
|
|
66
91
|
waiter.start();
|
|
67
92
|
}
|
|
68
93
|
#waitForCooldown(options) {
|
|
94
|
+
const now = this.#readNow();
|
|
69
95
|
const cooldownUntil = this.#cooldownUntil;
|
|
70
|
-
|
|
96
|
+
if (cooldownUntil === undefined)
|
|
97
|
+
return;
|
|
98
|
+
const delay = Math.ceil(cooldownUntil - now);
|
|
71
99
|
if (delay <= 0)
|
|
72
100
|
return;
|
|
73
101
|
if (delay >= options.deadline - options.now()) {
|
|
74
|
-
throw
|
|
102
|
+
throw cooldownError();
|
|
75
103
|
}
|
|
76
104
|
return awaitWithAbort(options.callerSignal, () => options.sleep(delay)).then(() => {
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
const now = this.#readNow();
|
|
106
|
+
if (this.#cooldownUntil !== undefined && this.#cooldownUntil > now) {
|
|
107
|
+
throw cooldownError();
|
|
108
|
+
}
|
|
109
|
+
this.#cooldownUntil = undefined;
|
|
79
110
|
});
|
|
80
111
|
}
|
|
112
|
+
#readNow() {
|
|
113
|
+
const now = this.now();
|
|
114
|
+
if (!Number.isFinite(now) ||
|
|
115
|
+
Math.abs(now) > Number.MAX_SAFE_INTEGER ||
|
|
116
|
+
(this.#lastNow !== undefined && now < this.#lastNow)) {
|
|
117
|
+
throw explorerError("invalid_input");
|
|
118
|
+
}
|
|
119
|
+
this.#lastNow = now;
|
|
120
|
+
return now;
|
|
121
|
+
}
|
|
81
122
|
}
|
|
82
123
|
export function createExplorerLimiter(now = () => performance.now()) {
|
|
83
124
|
return new RequestLimiter(now);
|
|
@@ -118,6 +118,9 @@ export async function normalizeExplorerResponse(response, signal, options) {
|
|
|
118
118
|
if (ucis.has(move.uci) || options.legalMoves.get(move.uci) !== move.san) {
|
|
119
119
|
throw explorerError("invalid_response");
|
|
120
120
|
}
|
|
121
|
+
if (sumCounts(move.white, move.draws, move.black) === 0) {
|
|
122
|
+
throw explorerError("invalid_response");
|
|
123
|
+
}
|
|
121
124
|
ucis.add(move.uci);
|
|
122
125
|
white = sumCounts(white, move.white);
|
|
123
126
|
draws = sumCounts(draws, move.draws);
|
package/dist/explorer-retry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { awaitWithAbort, explorerError, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_RATE_LIMIT_COOLDOWN_MS, } from "./explorer-core.js";
|
|
1
|
+
import { awaitWithAbort, explorerError, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_MAX_COOLDOWN_MS, EXPLORER_RATE_LIMIT_COOLDOWN_MS, } from "./explorer-core.js";
|
|
2
|
+
import { isExplorerCooldownError } from "./explorer-limiter.js";
|
|
2
3
|
const SHORT_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
3
4
|
const LONG_WEEKDAYS = [
|
|
4
5
|
"Sunday",
|
|
@@ -25,7 +26,7 @@ const MONTHS = [
|
|
|
25
26
|
];
|
|
26
27
|
const IMF_DATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
27
28
|
const RFC850_DATE = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
28
|
-
const ASCTIME_DATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( [1-9]|[12]\d|3[01]) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
|
29
|
+
const ASCTIME_DATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( [1-9]|0[1-9]|[12]\d|3[01]) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
|
29
30
|
function httpDate(weekday, year, month, day, hour, minute, second) {
|
|
30
31
|
const date = new Date(0);
|
|
31
32
|
date.setUTCFullYear(year, month, day);
|
|
@@ -100,10 +101,10 @@ export function retryAfterMs(value, now) {
|
|
|
100
101
|
return parseRetryAfterMs(value, now) ?? EXPLORER_DEFAULT_RETRY_DELAY_MS;
|
|
101
102
|
}
|
|
102
103
|
export function rateLimitCooldownMs(value, now) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return
|
|
104
|
+
const delay = value === null || value.trim() === ""
|
|
105
|
+
? EXPLORER_RATE_LIMIT_COOLDOWN_MS
|
|
106
|
+
: (parseRetryAfterMs(value, now) ?? EXPLORER_RATE_LIMIT_COOLDOWN_MS);
|
|
107
|
+
return Math.min(delay, EXPLORER_MAX_COOLDOWN_MS);
|
|
107
108
|
}
|
|
108
109
|
async function waitForRetry(options, delay, originalError) {
|
|
109
110
|
if (delay >= options.deadline - options.now())
|
|
@@ -118,7 +119,12 @@ export async function retryExplorer(options, attemptRequest) {
|
|
|
118
119
|
const outcome = await attemptRequest();
|
|
119
120
|
if (outcome.type === "success")
|
|
120
121
|
return outcome.result;
|
|
121
|
-
lastError =
|
|
122
|
+
lastError =
|
|
123
|
+
isExplorerCooldownError(outcome.error) &&
|
|
124
|
+
lastError?.kind === "rate_limited" &&
|
|
125
|
+
lastError.status !== undefined
|
|
126
|
+
? lastError
|
|
127
|
+
: outcome.error;
|
|
122
128
|
if (outcome.retry === "stop" || attempt + 1 >= options.maxAttempts) {
|
|
123
129
|
throw lastError;
|
|
124
130
|
}
|
package/dist/explorer.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Chess } from "chess.js";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
|
-
import { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError } from "./explorer-core.js";
|
|
3
|
+
import { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_COOLDOWN_MS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError } from "./explorer-core.js";
|
|
4
4
|
import type { ExplorerErrorKind, ExplorerFetch, ExplorerResult } from "./explorer-core.js";
|
|
5
5
|
import { createExplorerLimiter } from "./explorer-limiter.js";
|
|
6
6
|
import type { ExplorerLimiter, ExplorerLimiterOptions } from "./explorer-limiter.js";
|
|
7
|
-
export { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError, createExplorerLimiter, };
|
|
7
|
+
export { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_COOLDOWN_MS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError, createExplorerLimiter, };
|
|
8
8
|
export type { ExplorerErrorKind, ExplorerFetch, ExplorerLimiter, ExplorerLimiterOptions, ExplorerResult, };
|
|
9
9
|
export declare const LICHESS_SPEEDS: readonly ["ultraBullet", "bullet", "blitz", "rapid", "classical", "correspondence"];
|
|
10
10
|
export declare const LICHESS_RATINGS: readonly [0, 1000, 1200, 1400, 1600, 1800, 2000, 2200, 2500];
|
package/dist/explorer.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
|
-
import { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError, explorerError, throwIfAborted, } from "./explorer-core.js";
|
|
2
|
+
import { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_COOLDOWN_MS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError, explorerError, throwIfAborted, } from "./explorer-core.js";
|
|
3
3
|
import { createExplorerLimiter } from "./explorer-limiter.js";
|
|
4
4
|
import { normalizeExplorerResponse } from "./explorer-response.js";
|
|
5
5
|
import { rateLimitCooldownMs, retryAfterMs, retryExplorer, } from "./explorer-retry.js";
|
|
6
6
|
import { requestExplorerTransport } from "./explorer-transport.js";
|
|
7
7
|
const BASE = "https://explorer.lichess.org";
|
|
8
|
-
export { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError, createExplorerLimiter, };
|
|
8
|
+
export { EXPLORER_ATTEMPT_TIMEOUT_MS, EXPLORER_DEFAULT_RETRY_DELAY_MS, EXPLORER_ERROR_KINDS, EXPLORER_MAX_ATTEMPTS, EXPLORER_MAX_COOLDOWN_MS, EXPLORER_MAX_MOVES, EXPLORER_MAX_RESPONSE_BYTES, EXPLORER_MAX_STRING_LENGTH, EXPLORER_RATE_LIMIT_COOLDOWN_MS, EXPLORER_TOTAL_TIMEOUT_MS, ExplorerError, createExplorerLimiter, };
|
|
9
9
|
export const LICHESS_SPEEDS = [
|
|
10
10
|
"ultraBullet",
|
|
11
11
|
"bullet",
|
|
@@ -32,6 +32,29 @@ export const lichessRatingSchema = z.union([
|
|
|
32
32
|
const speedSet = new Set(LICHESS_SPEEDS);
|
|
33
33
|
const ratingSet = new Set(LICHESS_RATINGS);
|
|
34
34
|
const dbSet = new Set(["lichess", "masters"]);
|
|
35
|
+
function snapshotArray(value) {
|
|
36
|
+
if (!Array.isArray(value))
|
|
37
|
+
throw explorerError("invalid_input");
|
|
38
|
+
try {
|
|
39
|
+
return Array.from(value);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw explorerError("invalid_input");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function requestClock(now) {
|
|
46
|
+
let previous;
|
|
47
|
+
return () => {
|
|
48
|
+
const current = now();
|
|
49
|
+
if (!Number.isFinite(current) ||
|
|
50
|
+
Math.abs(current) > Number.MAX_SAFE_INTEGER ||
|
|
51
|
+
(previous !== undefined && current < previous)) {
|
|
52
|
+
throw explorerError("invalid_input");
|
|
53
|
+
}
|
|
54
|
+
previous = current;
|
|
55
|
+
return current;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
35
58
|
export function explorerEnabled() {
|
|
36
59
|
return (process.env.LICHESS_TOKEN || "").length > 0;
|
|
37
60
|
}
|
|
@@ -44,30 +67,37 @@ function setupExplorerRequest(chess, db, speeds, ratings, options) {
|
|
|
44
67
|
throw explorerError("disabled");
|
|
45
68
|
if (!dbSet.has(db))
|
|
46
69
|
throw explorerError("invalid_input");
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
new Set(
|
|
70
|
+
const speedValues = snapshotArray(speeds);
|
|
71
|
+
if (!speedValues.every((speed) => speedSet.has(speed)) ||
|
|
72
|
+
new Set(speedValues).size !== speedValues.length) {
|
|
50
73
|
throw explorerError("invalid_input");
|
|
51
74
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
new Set(
|
|
75
|
+
const ratingValues = snapshotArray(ratings);
|
|
76
|
+
if (!ratingValues.every((rating) => ratingSet.has(rating)) ||
|
|
77
|
+
new Set(ratingValues).size !== ratingValues.length) {
|
|
55
78
|
throw explorerError("invalid_input");
|
|
56
79
|
}
|
|
57
|
-
if (db === "masters" && (
|
|
80
|
+
if (db === "masters" && (speedValues.length > 0 || ratingValues.length > 0)) {
|
|
58
81
|
throw explorerError("invalid_input");
|
|
59
82
|
}
|
|
60
83
|
const params = new URLSearchParams();
|
|
61
84
|
params.set("fen", chess.fen());
|
|
62
|
-
if (
|
|
63
|
-
params.set("speeds",
|
|
64
|
-
if (
|
|
65
|
-
params.set("ratings",
|
|
66
|
-
const now = options.now ?? (() => performance.now());
|
|
85
|
+
if (speedValues.length)
|
|
86
|
+
params.set("speeds", speedValues.join(","));
|
|
87
|
+
if (ratingValues.length)
|
|
88
|
+
params.set("ratings", ratingValues.join(","));
|
|
89
|
+
const now = requestClock(options.now ?? (() => performance.now()));
|
|
90
|
+
const startedAt = now();
|
|
91
|
+
const deadline = startedAt + EXPLORER_TOTAL_TIMEOUT_MS;
|
|
92
|
+
if (!Number.isFinite(deadline) ||
|
|
93
|
+
Math.abs(deadline) > Number.MAX_SAFE_INTEGER ||
|
|
94
|
+
deadline <= startedAt) {
|
|
95
|
+
throw explorerError("invalid_input");
|
|
96
|
+
}
|
|
67
97
|
return {
|
|
68
98
|
callerSignal,
|
|
69
99
|
db,
|
|
70
|
-
deadline
|
|
100
|
+
deadline,
|
|
71
101
|
legalMoves: new Map(chess.moves({ verbose: true }).map((move) => [move.lan, move.san])),
|
|
72
102
|
limiter: options.limiter ?? processExplorerLimiter,
|
|
73
103
|
now,
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export { buildServer } from "./server.js";
|
|
|
3
3
|
export { serveHttp } from "./http.js";
|
|
4
4
|
export type { HttpServerHandle, HttpServerOptions } from "./http.js";
|
|
5
5
|
export type { AnalysisServices, AppServices, CandidateServices, ExplorerServices, GameServices, LifecycleServices, } from "./services.js";
|
|
6
|
-
export { drawResult, MAX_EVALUATED_MOVES, MAX_PGN_BYTES, MAX_PGN_PLIES, parseImportedPgn, snapshotChess, } from "./chess.js";
|
|
6
|
+
export { drawResult, MAX_EVALUATED_MOVES, MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, snapshotChess, } from "./chess.js";
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { serveHttp } from "./http.js";
|
|
|
8
8
|
import { buildServer } from "./server.js";
|
|
9
9
|
export { buildServer } from "./server.js";
|
|
10
10
|
export { serveHttp } from "./http.js";
|
|
11
|
-
export { drawResult, MAX_EVALUATED_MOVES, MAX_PGN_BYTES, MAX_PGN_PLIES, parseImportedPgn, snapshotChess, } from "./chess.js";
|
|
11
|
+
export { drawResult, MAX_EVALUATED_MOVES, MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, snapshotChess, } from "./chess.js";
|
|
12
12
|
function installShutdown(closeTransport) {
|
|
13
13
|
let shutdown;
|
|
14
14
|
const close = () => (shutdown ??= closeTransport());
|