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 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.
@@ -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
- const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
111
- for (const [key, value] of Object.entries(chess.getHeaders())) {
112
- snapshot.setHeader(key, value);
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 !== undefined)
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
- return snapshot;
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.init;
78
- this.configuredFlavor = options.flavor;
79
- this.maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
80
- this.timeouts = { ...DEFAULT_TIMEOUTS, ...options.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.isFinite(timeout) || timeout < 1) {
86
- throw new Error(`stockfish ${name} timeout must be positive`);
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.terminate(initializedEngine);
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 (session.engine && session.engine !== initializedEngine) {
125
- this.terminate(session.engine);
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
- session.engine = engine;
149
- engine.listener = () => { };
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) {
@@ -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;
@@ -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;
@@ -1,3 +1,5 @@
1
+ import type { ExplorerError } from "./explorer-core.js";
2
+ export declare function isExplorerCooldownError(error: ExplorerError): boolean;
1
3
  export interface ExplorerLimiterOptions {
2
4
  callerSignal: AbortSignal | undefined;
3
5
  deadline: number;
@@ -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 = 0;
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
- this.#cooldownUntil = Math.max(this.#cooldownUntil, this.now() + ms);
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
- const delay = cooldownUntil - this.now();
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 explorerError("rate_limited");
102
+ throw cooldownError();
75
103
  }
76
104
  return awaitWithAbort(options.callerSignal, () => options.sleep(delay)).then(() => {
77
- if (this.#cooldownUntil === cooldownUntil)
78
- this.#cooldownUntil = 0;
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);
@@ -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
- if (value === null || value.trim() === "") {
104
- return EXPLORER_RATE_LIMIT_COOLDOWN_MS;
105
- }
106
- return parseRetryAfterMs(value, now) ?? EXPLORER_RATE_LIMIT_COOLDOWN_MS;
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 = outcome.error;
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
  }
@@ -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
- if (!Array.isArray(speeds) ||
48
- ![...speeds].every((speed) => speedSet.has(speed)) ||
49
- new Set(speeds).size !== speeds.length) {
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
- if (!Array.isArray(ratings) ||
53
- ![...ratings].every((rating) => ratingSet.has(rating)) ||
54
- new Set(ratings).size !== ratings.length) {
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" && (speeds.length > 0 || ratings.length > 0)) {
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 (speeds.length)
63
- params.set("speeds", speeds.join(","));
64
- if (ratings.length)
65
- params.set("ratings", ratings.join(","));
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: now() + EXPLORER_TOTAL_TIMEOUT_MS,
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());