llm-chess-mcp 0.4.8 → 0.4.9

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.
@@ -8,6 +8,36 @@ const ORIGINAL_PIECES = {
8
8
  function squareColor(square) {
9
9
  return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
10
10
  }
11
+ function minimumPawnCaptures(chess, color) {
12
+ const pawns = chess
13
+ .findPiece({ type: "p", color })
14
+ .map((square) => ({
15
+ advances: color === "w" ? Number(square[1]) - 2 : 7 - Number(square[1]),
16
+ file: square.charCodeAt(0) - 97,
17
+ }))
18
+ .sort((left, right) => left.file - right.file);
19
+ let costs = new Map([[0, 0]]);
20
+ for (const pawn of pawns) {
21
+ const next = new Map();
22
+ for (const [mask, cost] of costs) {
23
+ for (let original = 0; original < 8; original += 1) {
24
+ const bit = 1 << original;
25
+ if (mask & bit)
26
+ continue;
27
+ const captures = Math.abs(original - pawn.file);
28
+ if (captures > pawn.advances)
29
+ continue;
30
+ const nextMask = mask | bit;
31
+ next.set(nextMask, Math.min(next.get(nextMask) ?? Infinity, cost + captures));
32
+ }
33
+ }
34
+ costs = next;
35
+ }
36
+ return Math.min(...costs.values());
37
+ }
38
+ function nonKingMaterial(chess, color) {
39
+ return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
40
+ }
11
41
  function hasPiece(chess, square, type, color) {
12
42
  const piece = chess.get(square);
13
43
  return piece?.type === type && piece.color === color;
@@ -92,6 +122,14 @@ export function assertLegalPosition(chess) {
92
122
  throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
93
123
  }
94
124
  assertCastlingPosition(chess, color);
125
+ const opponent = color === "w" ? "b" : "w";
126
+ const opponentPawns = chess.findPiece({ type: "p", color: opponent }).length;
127
+ const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
128
+ const possibleOpponentPromotions = 8 - opponentPawns;
129
+ if (minimumPawnCaptures(chess, color) >
130
+ missingOpponentMaterial + possibleOpponentPromotions) {
131
+ throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
132
+ }
95
133
  }
96
134
  assertEnPassantPosition(chess);
97
135
  const turn = chess.turn();
@@ -107,6 +145,7 @@ export function snapshotChess(chess) {
107
145
  const initialFen = history[0]?.before ?? chess.fen();
108
146
  assertSafeFenCounters(initialFen);
109
147
  const snapshot = new Chess(initialFen);
148
+ assertLegalPosition(snapshot);
110
149
  const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
111
150
  for (const [key, value] of Object.entries(chess.getHeaders())) {
112
151
  snapshot.setHeader(key, value);
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());
package/dist/intents.js CHANGED
@@ -8,8 +8,8 @@ function safeDifference(left, right) {
8
8
  return result;
9
9
  }
10
10
  function safeSum(values) {
11
- if (!values.every(Number.isSafeInteger)) {
12
- throw new RangeError("chess count must be a safe integer");
11
+ if (!values.every((value) => Number.isSafeInteger(value) && value >= 0)) {
12
+ throw new RangeError("chess count must be a non-negative safe integer");
13
13
  }
14
14
  const result = values.reduce((sum, value) => sum + value, 0);
15
15
  if (!Number.isSafeInteger(result)) {
@@ -17,6 +17,18 @@ function safeSum(values) {
17
17
  }
18
18
  return result;
19
19
  }
20
+ function assertLichessMove(move) {
21
+ if (move.count < 1) {
22
+ throw new RangeError("chess move count must be positive");
23
+ }
24
+ if (move.count !== safeSum([move.white, move.draws, move.black])) {
25
+ throw new RangeError("derived chess move count is inconsistent");
26
+ }
27
+ if (move.averageRating !== null &&
28
+ (!Number.isSafeInteger(move.averageRating) || move.averageRating < 0)) {
29
+ throw new RangeError("chess average rating must be a non-negative safe integer");
30
+ }
31
+ }
20
32
  function toSan(chess, uci) {
21
33
  const m = chess.moves({ verbose: true }).find((x) => x.lan === uci);
22
34
  return m ? m.san : uci;
@@ -49,42 +61,93 @@ function objectiveFromLine(line, turn, bestCp) {
49
61
  };
50
62
  }
51
63
  export function explorerCandidateData(result) {
64
+ const totals = [result.white, result.draws, result.black];
65
+ let white = 0;
66
+ let draws = 0;
67
+ let black = 0;
68
+ const ucis = new Set();
52
69
  for (const move of result.moves) {
53
- if (move.count !== safeSum([move.white, move.draws, move.black])) {
54
- throw new RangeError("derived chess move count is inconsistent");
70
+ if (ucis.has(move.uci)) {
71
+ throw new RangeError("duplicate chess move");
55
72
  }
73
+ ucis.add(move.uci);
74
+ assertLichessMove(move);
75
+ white = safeSum([white, move.white]);
76
+ draws = safeSum([draws, move.draws]);
77
+ black = safeSum([black, move.black]);
78
+ }
79
+ if (white > result.white || draws > result.draws || black > result.black) {
80
+ throw new RangeError("chess move totals exceed explorer totals");
56
81
  }
57
82
  return {
58
83
  status: result.moves.length > 0 ? "available" : "no_data",
59
- totalGames: safeSum([result.white, result.draws, result.black]),
84
+ totalGames: safeSum(totals),
60
85
  moves: result.moves,
61
86
  };
62
87
  }
63
88
  export function candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResult) {
89
+ if ((lichessResult.status === "disabled" ||
90
+ lichessResult.status === "unavailable") &&
91
+ lichessResult.moves.length > 0) {
92
+ throw new RangeError(`${lichessResult.status} explorer data cannot contain moves`);
93
+ }
94
+ if ((lichessResult.status === "available") !==
95
+ (lichessResult.moves.length > 0)) {
96
+ throw new RangeError(`${lichessResult.status} explorer data has inconsistent moves`);
97
+ }
64
98
  if (chess.isGameOver()) {
65
99
  return {
66
100
  candidates: [],
67
101
  moveSensitivity: { level: "low", topMoveSpreadCp: null },
68
102
  };
69
103
  }
70
- const turn = chess.turn();
71
- const maiaByUci = new Map(maiaMoves.map((move) => [move.uci, move.prob]));
72
104
  const legalUcis = new Set(chess.moves({ verbose: true }).map((move) => move.lan));
105
+ const turn = chess.turn();
106
+ const maiaByUci = new Map();
107
+ const maiaUcis = new Set();
108
+ for (const move of maiaMoves) {
109
+ if (maiaUcis.has(move.uci))
110
+ throw new RangeError("duplicate Maia move");
111
+ maiaUcis.add(move.uci);
112
+ if (!Number.isFinite(move.prob) || move.prob < 0 || move.prob > 1) {
113
+ throw new RangeError("Maia move probability must be between 0 and 1");
114
+ }
115
+ if (!legalUcis.has(move.uci))
116
+ continue;
117
+ maiaByUci.set(move.uci, move.prob);
118
+ }
73
119
  const sfByUci = new Map();
74
120
  for (const line of sfLines) {
75
121
  const uci = line.pv[0];
76
122
  const evaluation = toEval(line);
77
123
  if (uci !== undefined && legalUcis.has(uci) && evaluation !== null) {
124
+ if (sfByUci.has(uci))
125
+ throw new RangeError("duplicate Stockfish move");
78
126
  sfByUci.set(uci, { line, evaluation });
79
127
  }
80
128
  }
81
- const lichessByUci = new Map(lichessResult.moves.map((move) => [move.uci, move]));
129
+ const totalGames = lichessResult.totalGames ?? 0;
130
+ safeSum([totalGames]);
131
+ const lichessByUci = new Map();
132
+ const lichessUcis = new Set();
133
+ for (const move of lichessResult.moves) {
134
+ if (lichessUcis.has(move.uci)) {
135
+ throw new RangeError("duplicate explorer move");
136
+ }
137
+ lichessUcis.add(move.uci);
138
+ assertLichessMove(move);
139
+ if (move.count > totalGames) {
140
+ throw new RangeError("chess move count exceeds explorer total");
141
+ }
142
+ if (!legalUcis.has(move.uci))
143
+ continue;
144
+ lichessByUci.set(move.uci, move);
145
+ }
82
146
  const normalizedSfLines = [...sfByUci.values()];
83
147
  const evals = normalizedSfLines.map(({ evaluation }) => evaluation);
84
148
  const bestCp = evals.length
85
149
  ? Math.max(...evals.map((value) => evalToCp(value)))
86
150
  : null;
87
- const totalGames = lichessResult.totalGames ?? 0;
88
151
  const ucis = new Set([
89
152
  ...sfByUci.keys(),
90
153
  ...maiaByUci.keys(),
@@ -167,6 +230,7 @@ export function createCandidateComputation(dependencies) {
167
230
  return await work();
168
231
  }
169
232
  catch (error) {
233
+ workSignal.throwIfAborted();
170
234
  controller.abort(error);
171
235
  throw error;
172
236
  }
@@ -194,6 +258,7 @@ export function createCandidateComputation(dependencies) {
194
258
  fatal(() => dependencies.humanMoveDistribution(chess, elo, elo, maiaTopN, workSignal)),
195
259
  fatal(explorer),
196
260
  ]);
261
+ workSignal.throwIfAborted();
197
262
  return candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResult);
198
263
  };
199
264
  }