@chessceo/mcp 0.31.2 → 0.34.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/dist/index.js +553 -107
- package/docs/engine-usage.md +38 -8
- package/docs/pgn-authoring.md +11 -9
- package/docs/prep-strategy.md +57 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -87,8 +87,17 @@ const AUTHED_TOOLS = new Set([
|
|
|
87
87
|
"set_tag",
|
|
88
88
|
"apply_mutations",
|
|
89
89
|
"auto_evaluate",
|
|
90
|
+
"auto_evaluate_status",
|
|
91
|
+
"auto_evaluate_cancel",
|
|
92
|
+
"deep_analyse",
|
|
93
|
+
"deep_analyse_status",
|
|
94
|
+
"deep_analyse_cancel",
|
|
90
95
|
"quote_engine_eval",
|
|
91
96
|
"predict_human_move",
|
|
97
|
+
"prepare_opponent",
|
|
98
|
+
"get_prep_position",
|
|
99
|
+
"list_prep_sessions",
|
|
100
|
+
"delete_prep_session",
|
|
92
101
|
]);
|
|
93
102
|
function isAuthedToolCall(body) {
|
|
94
103
|
if (!body || typeof body !== "object")
|
|
@@ -195,55 +204,80 @@ const TOOLS = [
|
|
|
195
204
|
},
|
|
196
205
|
},
|
|
197
206
|
{
|
|
198
|
-
name: "
|
|
199
|
-
description: "
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"• Surprise is a scalpel. Don't tell a lifelong 1.e4 player to switch to 1.d4 — meta-signal screams prep. Rare secondary lines within the user's existing repertoire (e.g. 6.Bc4 instead of usual 6.Bg5 vs the Najdorf) are where surprise is real.\n\n" +
|
|
208
|
-
"For the full guide call the `read_prep_strategy_guide` tool.",
|
|
207
|
+
name: "prepare_opponent",
|
|
208
|
+
description: "Create a prep SESSION combining games from one or more sources — FIDE database, Chess.com account, Lichess account — with optional filters (colour, date range, time control). Returns a session `token` you pass to `get_prep_position` to query stats at any position within that filtered corpus.\n\n" +
|
|
209
|
+
"This is the main opponent-prep tool. Use it whenever a user asks 'prep me against X' — call once with the right sources+filters, then walk the tree with `get_prep_position(session_token, ...)`. Sessions are cached on the server (list existing ones with `list_prep_sessions` to avoid rebuilding).\n\n" +
|
|
210
|
+
"SOURCES (1-10 per call, combined into one gameset):\n" +
|
|
211
|
+
"- `fide` — needs `fideId`. Optional filters: `color`, `startMonth`/`endMonth`, `timeControl` (`classical`|`rapid`|`blitz`), `excludeOnline`.\n" +
|
|
212
|
+
"- `chesscom` — needs `username`. Filters: `color`, `startMonth`/`endMonth` (**required** for chesscom/lichess), `timeControl`.\n" +
|
|
213
|
+
"- `lichess` — needs `username`. Same filters as chesscom; `timeControl` also accepts `bullet` on Lichess.\n\n" +
|
|
214
|
+
"Multi-source example: one player with both a FIDE ID and a Lichess account → two sources in one call, all their games combined into one session.\n\n" +
|
|
215
|
+
"GROUNDING: every claim about the opponent's repertoire must trace back to a `get_prep_position` call on this session. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at actual game counts / win rates in the response. Prep is a two-player game — see `read_prep_strategy_guide` before recommending an opening plan.",
|
|
209
216
|
inputSchema: {
|
|
210
217
|
type: "object",
|
|
211
218
|
properties: {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
description: "
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
type: "string",
|
|
232
|
-
description: "SAN moves to apply on top of `fen` (or on top of startpos if no fen). Same shape as `line`. Wins over `line` if both are set. Only used if `node_id` is not set.",
|
|
233
|
-
},
|
|
234
|
-
fen: {
|
|
235
|
-
type: "string",
|
|
236
|
-
description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone. Only used if `node_id` is not set.",
|
|
237
|
-
},
|
|
238
|
-
limit: {
|
|
239
|
-
type: "integer",
|
|
240
|
-
minimum: 1,
|
|
241
|
-
maximum: 10,
|
|
242
|
-
description: "Number of games to return (max 10 per request; page with offset).",
|
|
219
|
+
sources: {
|
|
220
|
+
type: "array",
|
|
221
|
+
minItems: 1,
|
|
222
|
+
maxItems: 10,
|
|
223
|
+
description: "1-10 game sources, all combined into one filtered session.",
|
|
224
|
+
items: {
|
|
225
|
+
type: "object",
|
|
226
|
+
properties: {
|
|
227
|
+
type: { type: "string", enum: ["fide", "chesscom", "lichess"], description: "Source type." },
|
|
228
|
+
fide_id: { type: "integer", description: "FIDE ID (required for type='fide')." },
|
|
229
|
+
username: { type: "string", description: "Platform username (required for type='chesscom'/'lichess')." },
|
|
230
|
+
color: { type: "string", enum: ["white", "black"], description: "Filter: only games where this side is played by the source player. Omit for both colours." },
|
|
231
|
+
start_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games from this month onwards, format 'YYYY/MM'. **Required** for chesscom/lichess." },
|
|
232
|
+
end_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games up to this month, format 'YYYY/MM'. **Required** for chesscom/lichess." },
|
|
233
|
+
time_control: { type: "string", enum: ["classical", "rapid", "blitz", "bullet"], description: "Filter: only this time control. `bullet` is Lichess-only." },
|
|
234
|
+
exclude_online: { type: "boolean", description: "FIDE-only: exclude online-flagged games (default false)." },
|
|
235
|
+
},
|
|
236
|
+
required: ["type"],
|
|
237
|
+
},
|
|
243
238
|
},
|
|
239
|
+
},
|
|
240
|
+
required: ["sources"],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "get_prep_position",
|
|
245
|
+
description: "Query one position within a prep session created by `prepare_opponent`. Returns move statistics (frequency + win rate + last-played date per move) plus the actual games played from that position, in one call.\n\n" +
|
|
246
|
+
"Position input: prefer `file_id`+`node_id` when inside a prep file (server derives FEN from the tree). Otherwise pass `fen`.\n\n" +
|
|
247
|
+
"AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` (Stockfish + Lc0 read at the position) so you don't need a separate cloud_analyse.\n\n" +
|
|
248
|
+
"Reading the response — CRITICAL:\n" +
|
|
249
|
+
"• Win % is one weight, not a verdict. Sample size matters (3 games at 66% is noise; 300 at 55% is signal).\n" +
|
|
250
|
+
"• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted.\n" +
|
|
251
|
+
"• Recency > career. The last 12-24 months dominate — filter your session with `start_month` if the player's repertoire shifted.\n" +
|
|
252
|
+
"• Opponent will deviate early. Prep is a tree — cover the 2 most likely replies at each real branching point, not one 20-move line.\n\n" +
|
|
253
|
+
"For the full guide call `read_prep_strategy_guide`.",
|
|
254
|
+
inputSchema: {
|
|
255
|
+
type: "object",
|
|
256
|
+
properties: {
|
|
257
|
+
session_token: { type: "string", description: "Session token from `prepare_opponent`." },
|
|
258
|
+
file_id: { type: "string", description: "Prep file id — combine with `node_id` for tree-addressed position lookup." },
|
|
259
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`." },
|
|
260
|
+
fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
|
|
261
|
+
limit: { type: "integer", minimum: 1, maximum: 50, description: "Games to return (default 10)." },
|
|
244
262
|
offset: { type: "integer", minimum: 0 },
|
|
245
263
|
},
|
|
246
|
-
required: ["
|
|
264
|
+
required: ["session_token"],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: "list_prep_sessions",
|
|
269
|
+
description: "List the caller's active prep sessions with their tokens and metadata. Call this BEFORE `prepare_opponent` to reuse an existing session instead of rebuilding — sessions cost real backend work for chesscom/lichess (downloading months of games), so re-using saves time. Response includes source description, game count, and creation time per session.",
|
|
270
|
+
inputSchema: { type: "object", properties: {} },
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "delete_prep_session",
|
|
274
|
+
description: "Delete one prep session by token. Free-form cleanup — sessions do expire automatically, but this is useful when you're done with one or when you want to force a rebuild after upstream data changed.",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: {
|
|
278
|
+
session_token: { type: "string", description: "Token from `list_prep_sessions` or the response of `prepare_opponent`." },
|
|
279
|
+
},
|
|
280
|
+
required: ["session_token"],
|
|
247
281
|
},
|
|
248
282
|
},
|
|
249
283
|
{
|
|
@@ -437,14 +471,14 @@ const TOOLS = [
|
|
|
437
471
|
},
|
|
438
472
|
{
|
|
439
473
|
name: "cloud_analyse",
|
|
440
|
-
description: "Runs a synchronous ~2s analysis on the user's running combo instance and returns both Stockfish and Lc0's final read for the FEN — depth, top-N candidate moves with scores (centipawns
|
|
474
|
+
description: "Runs a synchronous ~2s analysis on the user's running combo instance and returns both Stockfish and Lc0's final read for the FEN — depth, top-N candidate moves with scores (**scoreCp is White-POV centipawns**: +20 = White is +0.20 pawns better regardless of whose turn it is; matches the sign convention used everywhere else in this MCP, including the stored ceoEval). Mate is White-POV plies-to-mate (+5 = White mates in 5). Also returns each engine's principal variation.\n\n" +
|
|
441
475
|
"GROUNDING: every claim you make about a position must trace back to actual engine output from a call in THIS session. Don't invent evaluations, don't name 'best moves' you haven't seen the engine list, don't fabricate variations that 'look plausible.' Compute is cheap — call this 5-10 times while walking a tree rather than pattern-matching from your training data. When you don't have data for the position, either run the tool or say so; don't fill the gap with chess prose the user can't distinguish from measured output.\n\n" +
|
|
442
476
|
"Auto-picks the caller's only running combo instance; errors clearly if there are zero (start one first with start_cloud_engine) or more than one (destroy the extras first).\n\n" +
|
|
443
477
|
"How to read the response:\n" +
|
|
444
478
|
"• Stockfish is objective truth — trust it for 'does this line hold?' 'is there a tactic?' 'is this endgame drawn?' A Stockfish 0.00 means 'objectively equal', NOT 'trivial draw' — one side can still be much harder to play in practice.\n" +
|
|
445
479
|
"• Lc0 is practical eval — trust it for 'which side is easier?' 'which candidate is best when Stockfish shows several as equal?' Lc0 sees long-term positional factors Stockfish's fixed search can miss.\n" +
|
|
446
480
|
"• When they agree → high confidence. When they disagree → look at both scores and reason WHY (Stockfish sharply higher = tactic Lc0 missed; Lc0 higher = long-term positional edge past Stockfish's horizon). Never dismiss either — the disagreement is the signal.\n\n" +
|
|
447
|
-
"Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative).
|
|
481
|
+
"Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Signed 0-100 strength — same scale as the web UI's ContemptStrength slider (the server multiplies by 8 to produce Lc0's internal cp bias). Typical values: ±15 for a light nudge, ±30-60 for real fighting play, ±80-100 for maximum steer. Use it to find non-objective 'practical' ideas or when the user needs to lean toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
|
|
448
482
|
"Also useful: pass `moves` on top of `fen` to explore a variation without computing FENs yourself (e.g. fen='<tabiya>', moves='b4 a5 c3'). And the flip-side-to-move threat check documented in the guide is a great free trick.\n\n" +
|
|
449
483
|
"For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
|
|
450
484
|
"Not for casual questions — this costs real money per second. Use `get_position_stats` for anything that doesn't require deep prep.\n\n" +
|
|
@@ -475,7 +509,12 @@ const TOOLS = [
|
|
|
475
509
|
type: "integer",
|
|
476
510
|
minimum: -100,
|
|
477
511
|
maximum: 100,
|
|
478
|
-
description: "Lc0 contempt bias. 0 = objective (default). Positive favours White, negative favours Black
|
|
512
|
+
description: "Lc0 contempt bias. Signed 0-100 strength (same scale as the web UI's ContemptStrength slider — server multiplies by 8 to get the internal cp bias). 0 = objective (default). Positive favours White, negative favours Black. Typical: ±15 light nudge, ±30-60 real fighting play, ±80-100 maximum steer. Not applied to Stockfish. See engine_usage_primer for when to use.",
|
|
513
|
+
},
|
|
514
|
+
engines: {
|
|
515
|
+
type: "array",
|
|
516
|
+
items: { type: "string", enum: ["stockfish", "lc0"] },
|
|
517
|
+
description: "Which engines to run. Default = both. Use `[\"lc0\"]` to skip Stockfish (e.g. while a deep_analyse job is holding the SF slot on the same combo). Use `[\"stockfish\"]` when only the objective read matters. The skipped engine's field is omitted from the response.",
|
|
479
518
|
},
|
|
480
519
|
},
|
|
481
520
|
},
|
|
@@ -500,7 +539,7 @@ const TOOLS = [
|
|
|
500
539
|
name: "read_prep_file",
|
|
501
540
|
description: "Read one prep file. Returns a compact tree (each node carries a stable `id`, `san`, `fen`, `ply`, optional `comment`/`nags`/`annotations`/`ceoEval`, and `children`) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN — all edits go through the mutation tools (add_move / add_line / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
|
|
502
541
|
"**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash derived from its parent's id + its SAN. Sibling insertions, deletions and variation promotions do NOT change any other node's id. Pass this id as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
|
|
503
|
-
"**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot,
|
|
542
|
+
"**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_prep_position, quote_engine_eval). Use it whenever a file is open — the server derives the FEN from the tree, so you can't 'analyse the wrong position' by mis-typing a FEN.",
|
|
504
543
|
inputSchema: {
|
|
505
544
|
type: "object",
|
|
506
545
|
properties: {
|
|
@@ -693,10 +732,11 @@ const TOOLS = [
|
|
|
693
732
|
},
|
|
694
733
|
{
|
|
695
734
|
name: "auto_evaluate",
|
|
696
|
-
description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval`
|
|
697
|
-
"**
|
|
698
|
-
"
|
|
699
|
-
"
|
|
735
|
+
description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval` on every descendant via cloud_analyse. Requires a running cloud combo instance.\n\n" +
|
|
736
|
+
"**Async job — returns immediately.** Response: `{ job_id, target_count, status: 'running', estimated_seconds }`. Then poll `auto_evaluate_status(job_id)` until `done: true`. Cancel a run with `auto_evaluate_cancel(job_id)` — partial progress is preserved. Do useful other work between polls (write more of the tree, walk the opponent's repertoire) — the engine runs in the background.\n\n" +
|
|
737
|
+
"Progress is checkpointed to the prep file every 8 successfully-evaluated nodes, so a cancel / crash / MCP restart mid-run leaves the tree partially populated rather than losing everything. On MCP restart the job record disappears; re-run auto_evaluate and `only_missing=true` naturally skips what was already saved.\n\n" +
|
|
738
|
+
"**Does NOT set visible NAGs.** NAG placement is your call, not the engine's — an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move. Use quote_engine_eval on individual nodes before writing prose that references engine numbers.\n\n" +
|
|
739
|
+
"Costs real money — one cloud_analyse per node. A 200-node walk at default movetime is ~5 min of engine time (calls serialise on the per-combo semaphore in the backend).",
|
|
700
740
|
inputSchema: {
|
|
701
741
|
type: "object",
|
|
702
742
|
properties: {
|
|
@@ -704,11 +744,83 @@ const TOOLS = [
|
|
|
704
744
|
node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
|
|
705
745
|
only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
|
|
706
746
|
movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
|
|
707
|
-
expected_version: { type: "integer" },
|
|
708
747
|
},
|
|
709
748
|
required: ["id"],
|
|
710
749
|
},
|
|
711
750
|
},
|
|
751
|
+
{
|
|
752
|
+
name: "auto_evaluate_status",
|
|
753
|
+
description: "Poll the status of an auto_evaluate job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', target_count, evaluated, errored, remaining, done, error?, version? }`. When `status: 'not_found'` the job either expired (kept ~15 min after completion), never existed, or the MCP restarted since it was created — re-run auto_evaluate.\n\n" +
|
|
754
|
+
"Typical poll cadence: every 3-5 s for small walks, every 10-30 s for large ones. Don't hammer — status is a pure in-memory read but polling doesn't speed the engine up.",
|
|
755
|
+
inputSchema: {
|
|
756
|
+
type: "object",
|
|
757
|
+
properties: {
|
|
758
|
+
job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
|
|
759
|
+
},
|
|
760
|
+
required: ["job_id"],
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
name: "auto_evaluate_cancel",
|
|
765
|
+
description: "Ask a running auto_evaluate job to stop as soon as its current node finishes. Whatever progress was completed before cancellation is durably saved (checkpoint on cancel). Idempotent — cancelling an already-finished job is a no-op with a clear note in the response.",
|
|
766
|
+
inputSchema: {
|
|
767
|
+
type: "object",
|
|
768
|
+
properties: {
|
|
769
|
+
job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
|
|
770
|
+
},
|
|
771
|
+
required: ["job_id"],
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
name: "deep_analyse",
|
|
776
|
+
description: "Start a long Stockfish think on a single position (up to 5 min movetime). Returns a `job_id` immediately; poll `deep_analyse_status(job_id)` for the result, cancel with `deep_analyse_cancel(job_id)`. Runs SF only — Lc0 doesn't benefit from long thinks past a handful of seconds — and **holds only the SF engine slot on the combo, so `cloud_analyse(..., engines: [\"lc0\"])` stays available for other work in parallel**.\n\n" +
|
|
777
|
+
"Use this when a specific critical position deserves depth — a novelty candidate, a hairy tactical shot, a difficult endgame — and you want Stockfish at depth 35+ rather than the ~depth 22 you get from a 2s cloud_analyse. Movetime is in ms; typical: 30_000-60_000 for 'careful check', 120_000-300_000 for 'find the truth'.\n\n" +
|
|
778
|
+
"Result shape when done matches cloud_analyse's Stockfish leg (depth, top-N candidates with scoreCp/mate, best move, PV). Auto-stores the eval on `file_id`+`node_id` when both are supplied, same as cloud_analyse.",
|
|
779
|
+
inputSchema: {
|
|
780
|
+
type: "object",
|
|
781
|
+
properties: {
|
|
782
|
+
file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree AND persist the result on the node's ceoEval." },
|
|
783
|
+
node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
|
|
784
|
+
fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
|
|
785
|
+
moves: { type: "string", description: "Optional SAN moves on top of `fen`. Only used if `node_id` is not set." },
|
|
786
|
+
movetime_ms: {
|
|
787
|
+
type: "integer",
|
|
788
|
+
minimum: 5_000,
|
|
789
|
+
maximum: 300_000,
|
|
790
|
+
description: "Think time in ms. Default 60_000 (1 min). Max 300_000 (5 min).",
|
|
791
|
+
},
|
|
792
|
+
multipv: {
|
|
793
|
+
type: "integer",
|
|
794
|
+
minimum: 1,
|
|
795
|
+
maximum: 10,
|
|
796
|
+
description: "Number of candidate lines (default 3).",
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: "deep_analyse_status",
|
|
803
|
+
description: "Poll a deep_analyse job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', elapsed_ms, movetime_ms, result?, error? }`. `result` shape when done: `{ engine, depth, timeMs, bestMove, lines: [{rank, depth, scoreCp?, mate?, pv, nodes?}] }` — the SF leg of a cloud_analyse response.\n\n" +
|
|
804
|
+
"Poll cadence: every ~15-30s for long thinks; there's no penalty for polling more often but the engine progresses at its own pace.",
|
|
805
|
+
inputSchema: {
|
|
806
|
+
type: "object",
|
|
807
|
+
properties: {
|
|
808
|
+
job_id: { type: "string", description: "Job id from `deep_analyse`." },
|
|
809
|
+
},
|
|
810
|
+
required: ["job_id"],
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
name: "deep_analyse_cancel",
|
|
815
|
+
description: "Ask a running deep_analyse job to stop early. The engine returns whatever it's found so far as the final result. Useful when a partial result at depth 25 is enough and you don't want to wait for depth 40. Idempotent for already-finished jobs.",
|
|
816
|
+
inputSchema: {
|
|
817
|
+
type: "object",
|
|
818
|
+
properties: {
|
|
819
|
+
job_id: { type: "string", description: "Job id from `deep_analyse`." },
|
|
820
|
+
},
|
|
821
|
+
required: ["job_id"],
|
|
822
|
+
},
|
|
823
|
+
},
|
|
712
824
|
{
|
|
713
825
|
name: "quote_engine_eval",
|
|
714
826
|
description: "Return the stored engine eval for a node, or null if that node was never analysed. **Call this before writing prose or NAGs that quote engine numbers** — if it returns null, you have no measurement to cite. Do NOT infer an eval for the node from siblings or children; either analyse it (cloud_analyse with node_id) or omit the number from your prose.\n\n" +
|
|
@@ -968,12 +1080,33 @@ async function applyBatchMutations(args) {
|
|
|
968
1080
|
const savedRow = saved;
|
|
969
1081
|
return { ok: true, results, version: savedRow.version };
|
|
970
1082
|
}
|
|
971
|
-
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
|
|
975
|
-
//
|
|
1083
|
+
const evalJobs = new Map();
|
|
1084
|
+
// GC finished jobs after this long so status polling remains useful
|
|
1085
|
+
// for a while but the map doesn't grow unbounded across long uptimes.
|
|
1086
|
+
const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
|
|
1087
|
+
// Checkpoint interval — save progress every N successfully-evaluated
|
|
1088
|
+
// nodes so a mid-run kill leaves the tree partially populated. Small
|
|
1089
|
+
// enough that <15s of work is at risk per checkpoint on a slow combo,
|
|
1090
|
+
// large enough that the save overhead stays a small fraction of the
|
|
1091
|
+
// per-node cost.
|
|
1092
|
+
const SAVE_EVERY_N = 8;
|
|
1093
|
+
function newEvalJobId() {
|
|
1094
|
+
// 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
|
|
1095
|
+
const rand = Math.random().toString(16).slice(2, 8);
|
|
1096
|
+
return `evj_${Date.now().toString(16)}${rand}`;
|
|
1097
|
+
}
|
|
1098
|
+
// Sweep expired jobs on every start/status call — cheap, doesn't need
|
|
1099
|
+
// a background timer, keeps the map bounded to active + recent jobs.
|
|
1100
|
+
function reapExpiredEvalJobs() {
|
|
1101
|
+
const now = Date.now();
|
|
1102
|
+
for (const [k, j] of evalJobs) {
|
|
1103
|
+
if (j.finishedAt && now - j.finishedAt > EVAL_JOB_TTL_MS) {
|
|
1104
|
+
evalJobs.delete(k);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
976
1108
|
async function autoEvaluate(args) {
|
|
1109
|
+
reapExpiredEvalJobs();
|
|
977
1110
|
const id = String(args.id);
|
|
978
1111
|
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
|
|
979
1112
|
? String(args.node_id)
|
|
@@ -1001,45 +1134,294 @@ async function autoEvaluate(args) {
|
|
|
1001
1134
|
// If the caller anchored at the root, skip evaluating the root itself
|
|
1002
1135
|
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
1003
1136
|
walk(startNode, startNode.id === ROOT_ID);
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1137
|
+
// Nothing to do → return a done job synthetically so the caller doesn't
|
|
1138
|
+
// need to special-case the empty response.
|
|
1139
|
+
if (targets.length === 0) {
|
|
1140
|
+
const jobId = newEvalJobId();
|
|
1141
|
+
evalJobs.set(jobId, {
|
|
1142
|
+
id: jobId,
|
|
1143
|
+
fileId: id,
|
|
1144
|
+
status: "done",
|
|
1145
|
+
targetCount: 0,
|
|
1146
|
+
evaluated: 0,
|
|
1147
|
+
errored: 0,
|
|
1148
|
+
finalVersion: g.version,
|
|
1149
|
+
startedAt: Date.now(),
|
|
1150
|
+
finishedAt: Date.now(),
|
|
1151
|
+
cancelled: false,
|
|
1152
|
+
});
|
|
1153
|
+
return { job_id: jobId, target_count: 0, status: "done", version: g.version };
|
|
1154
|
+
}
|
|
1155
|
+
const jobId = newEvalJobId();
|
|
1156
|
+
const job = {
|
|
1157
|
+
id: jobId,
|
|
1158
|
+
fileId: id,
|
|
1159
|
+
status: "running",
|
|
1160
|
+
targetCount: targets.length,
|
|
1161
|
+
evaluated: 0,
|
|
1162
|
+
errored: 0,
|
|
1163
|
+
startedAt: Date.now(),
|
|
1164
|
+
cancelled: false,
|
|
1165
|
+
};
|
|
1166
|
+
evalJobs.set(jobId, job);
|
|
1167
|
+
// Unawaited — runs concurrently with the tool response. Any thrown
|
|
1168
|
+
// error gets recorded on the job so the LLM's status poll surfaces
|
|
1169
|
+
// it instead of the process seeing an unhandled rejection.
|
|
1170
|
+
void runEvalJob(job, id, targets, movetimeMs).catch(err => {
|
|
1171
|
+
job.status = "error";
|
|
1172
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
1173
|
+
job.finishedAt = Date.now();
|
|
1174
|
+
});
|
|
1175
|
+
return {
|
|
1176
|
+
job_id: jobId,
|
|
1177
|
+
target_count: targets.length,
|
|
1178
|
+
status: "running",
|
|
1179
|
+
// Rough time estimate at the current default movetime. Serialization
|
|
1180
|
+
// on the per-combo semaphore means walltime ≈ target_count × movetime.
|
|
1181
|
+
estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
// Worker body — walks targets sequentially (concurrency > 1 is a lie
|
|
1185
|
+
// against the per-combo semaphore in the backend anyway), checkpoints
|
|
1186
|
+
// every SAVE_EVERY_N successfully-evaluated nodes so partial progress
|
|
1187
|
+
// is durable, and re-anchors the version after each save.
|
|
1188
|
+
async function runEvalJob(job, fileId, targets, movetimeMs) {
|
|
1189
|
+
const pending = [];
|
|
1190
|
+
const flush = async () => {
|
|
1191
|
+
if (pending.length === 0)
|
|
1192
|
+
return;
|
|
1193
|
+
// No expected_version — auto_evaluate treats concurrent edits by
|
|
1194
|
+
// the LLM as last-write-wins on the ceoEval field specifically.
|
|
1195
|
+
// Safe because set_ceo_eval is idempotent per node and other
|
|
1196
|
+
// mutations (add_move / set_comment / etc.) don't touch ceoEval.
|
|
1197
|
+
const saved = await applyBatchMutations({
|
|
1198
|
+
id: fileId,
|
|
1199
|
+
mutations: pending,
|
|
1200
|
+
});
|
|
1201
|
+
const sr = saved;
|
|
1202
|
+
if (typeof sr.version === "number")
|
|
1203
|
+
job.finalVersion = sr.version;
|
|
1204
|
+
pending.length = 0;
|
|
1205
|
+
};
|
|
1206
|
+
for (const t of targets) {
|
|
1207
|
+
if (job.cancelled)
|
|
1208
|
+
break;
|
|
1209
|
+
try {
|
|
1210
|
+
const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
|
|
1211
|
+
const ev = analysisToStoredEval(analysis, t.fen);
|
|
1212
|
+
if (ev) {
|
|
1213
|
+
pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
|
|
1214
|
+
job.evaluated++;
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
job.errored++;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
// Per-node failure — record and continue; a bad FEN or a transient
|
|
1222
|
+
// engine hiccup on one node shouldn't kill the whole walk.
|
|
1223
|
+
job.errored++;
|
|
1224
|
+
}
|
|
1225
|
+
if (pending.length >= SAVE_EVERY_N) {
|
|
1015
1226
|
try {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1227
|
+
await flush();
|
|
1228
|
+
}
|
|
1229
|
+
catch {
|
|
1230
|
+
// Save failure is bad but not fatal — try again on the next
|
|
1231
|
+
// checkpoint or at the end. Progress remains in `pending`
|
|
1232
|
+
// so nothing is lost as long as the process stays alive.
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
// Final flush regardless of cancellation — durably persist whatever
|
|
1237
|
+
// work was completed before the user asked to stop.
|
|
1238
|
+
try {
|
|
1239
|
+
await flush();
|
|
1240
|
+
}
|
|
1241
|
+
catch (err) {
|
|
1242
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
1243
|
+
job.status = "error";
|
|
1244
|
+
job.finishedAt = Date.now();
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
job.status = job.cancelled ? "cancelled" : "done";
|
|
1248
|
+
job.finishedAt = Date.now();
|
|
1249
|
+
}
|
|
1250
|
+
function autoEvaluateStatus(args) {
|
|
1251
|
+
reapExpiredEvalJobs();
|
|
1252
|
+
const jobId = String(args.job_id || "").trim();
|
|
1253
|
+
if (!jobId)
|
|
1254
|
+
throw new Error("`job_id` is required");
|
|
1255
|
+
const job = evalJobs.get(jobId);
|
|
1256
|
+
if (!job) {
|
|
1257
|
+
return {
|
|
1258
|
+
status: "not_found",
|
|
1259
|
+
note: "Job unknown — either expired (kept ~15 min after completion), never existed, or the MCP process restarted since it was created. Re-run auto_evaluate to start over; the `only_missing` default will skip nodes already evaluated in the prep file.",
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
job_id: job.id,
|
|
1264
|
+
status: job.status,
|
|
1265
|
+
target_count: job.targetCount,
|
|
1266
|
+
evaluated: job.evaluated,
|
|
1267
|
+
errored: job.errored,
|
|
1268
|
+
remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
|
|
1269
|
+
done: job.status !== "running",
|
|
1270
|
+
error: job.error,
|
|
1271
|
+
version: job.finalVersion,
|
|
1272
|
+
started_at_ms: job.startedAt,
|
|
1273
|
+
finished_at_ms: job.finishedAt,
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
function autoEvaluateCancel(args) {
|
|
1277
|
+
const jobId = String(args.job_id || "").trim();
|
|
1278
|
+
if (!jobId)
|
|
1279
|
+
throw new Error("`job_id` is required");
|
|
1280
|
+
const job = evalJobs.get(jobId);
|
|
1281
|
+
if (!job)
|
|
1282
|
+
return { status: "not_found" };
|
|
1283
|
+
if (job.status !== "running") {
|
|
1284
|
+
return { status: job.status, note: "Job already finished; nothing to cancel." };
|
|
1285
|
+
}
|
|
1286
|
+
job.cancelled = true;
|
|
1287
|
+
// status transitions to "cancelled" on the next per-node iteration
|
|
1288
|
+
// inside runEvalJob, after the final flush persists progress.
|
|
1289
|
+
return { status: "cancelling", evaluated_so_far: job.evaluated };
|
|
1290
|
+
}
|
|
1291
|
+
const deepJobs = new Map();
|
|
1292
|
+
const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
|
|
1293
|
+
function newDeepJobId() {
|
|
1294
|
+
const rand = Math.random().toString(16).slice(2, 8);
|
|
1295
|
+
return `deep_${Date.now().toString(16)}${rand}`;
|
|
1296
|
+
}
|
|
1297
|
+
function reapExpiredDeepJobs() {
|
|
1298
|
+
const now = Date.now();
|
|
1299
|
+
for (const [k, j] of deepJobs) {
|
|
1300
|
+
if (j.finishedAt && now - j.finishedAt > DEEP_JOB_TTL_MS) {
|
|
1301
|
+
deepJobs.delete(k);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
async function deepAnalyseStart(args) {
|
|
1306
|
+
reapExpiredDeepJobs();
|
|
1307
|
+
const resolved = await resolveFromNodeOrFen(args);
|
|
1308
|
+
const fen = resolved.fen;
|
|
1309
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
|
|
1310
|
+
const multipv = typeof args.multipv === "number" ? args.multipv : 3;
|
|
1311
|
+
const jobId = newDeepJobId();
|
|
1312
|
+
const job = {
|
|
1313
|
+
id: jobId,
|
|
1314
|
+
status: "running",
|
|
1315
|
+
fileHandle: resolved.file,
|
|
1316
|
+
fen,
|
|
1317
|
+
movetimeMs,
|
|
1318
|
+
multipv,
|
|
1319
|
+
startedAt: Date.now(),
|
|
1320
|
+
cancelController: new AbortController(),
|
|
1321
|
+
};
|
|
1322
|
+
deepJobs.set(jobId, job);
|
|
1323
|
+
// Kick off the long HTTP call unawaited — resolves when the backend
|
|
1324
|
+
// returns the SF snapshot. authedRequest is a plain fetch under the
|
|
1325
|
+
// hood; abort signal flows via cancelController.
|
|
1326
|
+
void runDeepJob(job).catch(err => {
|
|
1327
|
+
job.status = "error";
|
|
1328
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
1329
|
+
job.finishedAt = Date.now();
|
|
1330
|
+
});
|
|
1331
|
+
return {
|
|
1332
|
+
job_id: jobId,
|
|
1333
|
+
status: "running",
|
|
1334
|
+
movetime_ms: movetimeMs,
|
|
1335
|
+
fen,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
async function runDeepJob(job) {
|
|
1339
|
+
const body = {
|
|
1340
|
+
fen: job.fen,
|
|
1341
|
+
movetime_ms: job.movetimeMs,
|
|
1342
|
+
multipv: job.multipv,
|
|
1343
|
+
engines: ["stockfish"],
|
|
1344
|
+
};
|
|
1345
|
+
let raw;
|
|
1346
|
+
try {
|
|
1347
|
+
// TODO(future): plumb an AbortSignal through authedRequest for
|
|
1348
|
+
// real mid-flight cancellation. For now, cancel just marks the
|
|
1349
|
+
// job so the caller stops polling; the backend still runs the
|
|
1350
|
+
// engine to completion and the result is stored on the job
|
|
1351
|
+
// record but flagged cancelled.
|
|
1352
|
+
raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
1353
|
+
}
|
|
1354
|
+
catch (err) {
|
|
1355
|
+
job.status = "error";
|
|
1356
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
1357
|
+
job.finishedAt = Date.now();
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
const converted = convertCloudSnapshotResponse(raw, job.fen);
|
|
1361
|
+
const sf = converted.stockfish;
|
|
1362
|
+
if (job.cancelController.signal.aborted) {
|
|
1363
|
+
job.status = "cancelled";
|
|
1364
|
+
}
|
|
1365
|
+
else {
|
|
1366
|
+
job.status = "done";
|
|
1367
|
+
}
|
|
1368
|
+
job.result = sf ?? null;
|
|
1369
|
+
job.finishedAt = Date.now();
|
|
1370
|
+
// Same node-persistence as cloud_analyse: if the caller anchored on
|
|
1371
|
+
// file_id+node_id, store the SF-only eval as the node's ceoEval so
|
|
1372
|
+
// quote_engine_eval can cite it later. We build a StoredEval that has
|
|
1373
|
+
// only the sf leg — no Lc0 was run.
|
|
1374
|
+
if (job.fileHandle && sf) {
|
|
1375
|
+
const ev = analysisToStoredEval({ stockfish: sf }, job.fen);
|
|
1376
|
+
if (ev) {
|
|
1377
|
+
try {
|
|
1378
|
+
await storeEvalOnNode(job.fileHandle, ev);
|
|
1020
1379
|
}
|
|
1021
1380
|
catch {
|
|
1022
|
-
// best-effort —
|
|
1381
|
+
// best-effort — the analysis result is what the LLM asked for
|
|
1023
1382
|
}
|
|
1024
1383
|
}
|
|
1025
1384
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
batchMutations.push({ op: "set_ceo_eval", node_id: s.nodeId, ceoEval: s.ev });
|
|
1385
|
+
}
|
|
1386
|
+
function deepAnalyseStatus(args) {
|
|
1387
|
+
reapExpiredDeepJobs();
|
|
1388
|
+
const jobId = String(args.job_id || "").trim();
|
|
1389
|
+
if (!jobId)
|
|
1390
|
+
throw new Error("`job_id` is required");
|
|
1391
|
+
const job = deepJobs.get(jobId);
|
|
1392
|
+
if (!job) {
|
|
1393
|
+
return {
|
|
1394
|
+
status: "not_found",
|
|
1395
|
+
note: "Job unknown — expired (kept ~15 min after completion), never existed, or the MCP process restarted.",
|
|
1396
|
+
};
|
|
1039
1397
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1398
|
+
return {
|
|
1399
|
+
job_id: job.id,
|
|
1400
|
+
status: job.status,
|
|
1401
|
+
movetime_ms: job.movetimeMs,
|
|
1402
|
+
elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
|
|
1403
|
+
fen: job.fen,
|
|
1404
|
+
result: job.result,
|
|
1405
|
+
error: job.error,
|
|
1406
|
+
started_at_ms: job.startedAt,
|
|
1407
|
+
finished_at_ms: job.finishedAt,
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
function deepAnalyseCancel(args) {
|
|
1411
|
+
const jobId = String(args.job_id || "").trim();
|
|
1412
|
+
if (!jobId)
|
|
1413
|
+
throw new Error("`job_id` is required");
|
|
1414
|
+
const job = deepJobs.get(jobId);
|
|
1415
|
+
if (!job)
|
|
1416
|
+
return { status: "not_found" };
|
|
1417
|
+
if (job.status !== "running") {
|
|
1418
|
+
return { status: job.status, note: "Job already finished; nothing to cancel." };
|
|
1419
|
+
}
|
|
1420
|
+
job.cancelController.abort();
|
|
1421
|
+
// Status flips to "cancelled" when runDeepJob observes the abort on
|
|
1422
|
+
// completion. Backend keeps churning until movetime elapses (mid-
|
|
1423
|
+
// flight abort of the HTTP call is a follow-up).
|
|
1424
|
+
return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
|
|
1043
1425
|
}
|
|
1044
1426
|
// Walk chess.js-free: resolve a path against a tree, throw if invalid.
|
|
1045
1427
|
function pathIntoTree(root, path) {
|
|
@@ -1312,6 +1694,46 @@ function resolveFenFromArgs(args) {
|
|
|
1312
1694
|
}
|
|
1313
1695
|
return board.fen();
|
|
1314
1696
|
}
|
|
1697
|
+
// Normalize one MCP `prepare_opponent` source into the shape the backend's
|
|
1698
|
+
// /api/chess/prep/prepare-multi expects. Handles two impedance mismatches:
|
|
1699
|
+
// - snake_case → camelCase (fide_id → fideId, start_month → startMonth, etc.)
|
|
1700
|
+
// - the unified `time_control` string → per-source-type filter:
|
|
1701
|
+
// * fide / chesscom → timeFormats: ["Classical" | "Rapid" | "Blitz"]
|
|
1702
|
+
// * lichess → perfType: "classical" | "rapid" | "blitz" | "bullet"
|
|
1703
|
+
// Backend validates required fields per source type, so we don't need to
|
|
1704
|
+
// pre-reject missing username/fideId here — it'll come back as a 400 the
|
|
1705
|
+
// LLM can act on.
|
|
1706
|
+
function normalizeSourceForBackend(src, idx) {
|
|
1707
|
+
const type = typeof src.type === "string" ? src.type : "";
|
|
1708
|
+
if (type !== "fide" && type !== "chesscom" && type !== "lichess") {
|
|
1709
|
+
throw new Error(`sources[${idx}].type must be one of fide|chesscom|lichess (got ${JSON.stringify(src.type)})`);
|
|
1710
|
+
}
|
|
1711
|
+
const out = { type };
|
|
1712
|
+
if (typeof src.fide_id === "number")
|
|
1713
|
+
out.fideId = src.fide_id;
|
|
1714
|
+
if (typeof src.username === "string" && src.username.trim() !== "")
|
|
1715
|
+
out.username = src.username.trim();
|
|
1716
|
+
if (typeof src.color === "string" && (src.color === "white" || src.color === "black"))
|
|
1717
|
+
out.color = src.color;
|
|
1718
|
+
if (typeof src.start_month === "string" && src.start_month.trim() !== "")
|
|
1719
|
+
out.startMonth = src.start_month.trim();
|
|
1720
|
+
if (typeof src.end_month === "string" && src.end_month.trim() !== "")
|
|
1721
|
+
out.endMonth = src.end_month.trim();
|
|
1722
|
+
if (typeof src.exclude_online === "boolean")
|
|
1723
|
+
out.excludeOnline = src.exclude_online;
|
|
1724
|
+
const tc = typeof src.time_control === "string" ? src.time_control : "";
|
|
1725
|
+
if (tc) {
|
|
1726
|
+
if (type === "lichess") {
|
|
1727
|
+
out.perfType = tc;
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
// fide + chesscom take a titlecased timeFormats array.
|
|
1731
|
+
const titled = tc.charAt(0).toUpperCase() + tc.slice(1);
|
|
1732
|
+
out.timeFormats = [titled];
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
return out;
|
|
1736
|
+
}
|
|
1315
1737
|
// Async resolver used by every engine / DB tool. If the caller supplied
|
|
1316
1738
|
// file_id + node_id (preferred), load the file, resolve the node, and
|
|
1317
1739
|
// return both the FEN and a handle we can use to persist ceoEval later.
|
|
@@ -1406,27 +1828,31 @@ async function callToolInner(name, args) {
|
|
|
1406
1828
|
return get("/api/chess/players/search/simple", { q: String(args.name), view: "llm" });
|
|
1407
1829
|
case "get_player_profile":
|
|
1408
1830
|
return get("/api/chess/players/profile", { fideId: Number(args.fide_id) });
|
|
1409
|
-
case "
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1831
|
+
case "prepare_opponent": {
|
|
1832
|
+
const rawSources = Array.isArray(args.sources) ? args.sources : [];
|
|
1833
|
+
if (rawSources.length === 0)
|
|
1834
|
+
throw new Error("`sources` array required");
|
|
1835
|
+
const sources = rawSources.map((s, i) => normalizeSourceForBackend(s, i));
|
|
1836
|
+
return authedRequest("POST", "/api/chess/prep/prepare-multi", { sources });
|
|
1837
|
+
}
|
|
1838
|
+
case "get_prep_position": {
|
|
1839
|
+
const token = String(args.session_token || "").trim();
|
|
1840
|
+
if (!token)
|
|
1841
|
+
throw new Error("`session_token` required — create one with prepare_opponent");
|
|
1413
1842
|
const resolved = await resolveFromNodeOrFen(args);
|
|
1414
|
-
const
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
fen: effectiveFen,
|
|
1843
|
+
const fen = resolved.fen;
|
|
1844
|
+
const qs = {
|
|
1845
|
+
token,
|
|
1846
|
+
fen,
|
|
1847
|
+
limit: typeof args.limit === "number" ? args.limit : 10,
|
|
1420
1848
|
};
|
|
1421
|
-
if (typeof args.limit === "number")
|
|
1422
|
-
params.limit = args.limit;
|
|
1423
1849
|
if (typeof args.offset === "number")
|
|
1424
|
-
|
|
1850
|
+
qs.offset = args.offset;
|
|
1425
1851
|
const [raw, ev] = await Promise.all([
|
|
1426
|
-
get("/api/chess/prep/
|
|
1427
|
-
fetchCompactEval(
|
|
1852
|
+
get("/api/chess/prep/unified", qs),
|
|
1853
|
+
fetchCompactEval(fen),
|
|
1428
1854
|
]);
|
|
1429
|
-
const converted = convertAvailableMovesToSAN(raw,
|
|
1855
|
+
const converted = convertAvailableMovesToSAN(raw, fen);
|
|
1430
1856
|
if (converted && typeof converted === "object") {
|
|
1431
1857
|
trimGamesMovetext(converted);
|
|
1432
1858
|
stripPositionResponse(converted);
|
|
@@ -1435,6 +1861,14 @@ async function callToolInner(name, args) {
|
|
|
1435
1861
|
}
|
|
1436
1862
|
return converted;
|
|
1437
1863
|
}
|
|
1864
|
+
case "list_prep_sessions":
|
|
1865
|
+
return authedRequest("GET", "/api/chess/prep/sessions");
|
|
1866
|
+
case "delete_prep_session": {
|
|
1867
|
+
const token = String(args.session_token || "").trim();
|
|
1868
|
+
if (!token)
|
|
1869
|
+
throw new Error("`session_token` required");
|
|
1870
|
+
return authedRequest("DELETE", `/api/chess/prep/sessions/${encodeURIComponent(token)}`);
|
|
1871
|
+
}
|
|
1438
1872
|
case "get_position_stats": {
|
|
1439
1873
|
const resolved = await resolveFromNodeOrFen(args);
|
|
1440
1874
|
const fen = resolved.fen;
|
|
@@ -1515,6 +1949,8 @@ async function callToolInner(name, args) {
|
|
|
1515
1949
|
body.multipv = args.multipv;
|
|
1516
1950
|
if (typeof args.contempt === "number")
|
|
1517
1951
|
body.contempt = args.contempt;
|
|
1952
|
+
if (Array.isArray(args.engines))
|
|
1953
|
+
body.engines = args.engines;
|
|
1518
1954
|
const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
1519
1955
|
const converted = convertCloudSnapshotResponse(raw, fen);
|
|
1520
1956
|
// Node-addressed calls: persist the result on the node's ceoEval
|
|
@@ -1530,6 +1966,12 @@ async function callToolInner(name, args) {
|
|
|
1530
1966
|
}
|
|
1531
1967
|
return converted;
|
|
1532
1968
|
}
|
|
1969
|
+
case "deep_analyse":
|
|
1970
|
+
return deepAnalyseStart(args);
|
|
1971
|
+
case "deep_analyse_status":
|
|
1972
|
+
return deepAnalyseStatus(args);
|
|
1973
|
+
case "deep_analyse_cancel":
|
|
1974
|
+
return deepAnalyseCancel(args);
|
|
1533
1975
|
case "list_prep_files":
|
|
1534
1976
|
return authedRequest("GET", "/api/agent/prep-files");
|
|
1535
1977
|
case "search_prep_files":
|
|
@@ -1588,6 +2030,10 @@ async function callToolInner(name, args) {
|
|
|
1588
2030
|
return applyBatchMutations(args);
|
|
1589
2031
|
case "auto_evaluate":
|
|
1590
2032
|
return autoEvaluate(args);
|
|
2033
|
+
case "auto_evaluate_status":
|
|
2034
|
+
return autoEvaluateStatus(args);
|
|
2035
|
+
case "auto_evaluate_cancel":
|
|
2036
|
+
return autoEvaluateCancel(args);
|
|
1591
2037
|
case "quote_engine_eval": {
|
|
1592
2038
|
const fileId = String(args.id);
|
|
1593
2039
|
const nodeId = argNodeId(args);
|
|
@@ -1714,7 +2160,7 @@ Preparation workflow — follow the steps in order and be explicit about which t
|
|
|
1714
2160
|
- Rapid and blitz reveal what they play under time pressure but may include experiments.
|
|
1715
2161
|
- Online games are useful but noisier (blitz gambits, alt accounts).
|
|
1716
2162
|
|
|
1717
|
-
4. **Walk the opponent's repertoire against ${me}'s color.** Call \`
|
|
2163
|
+
4. **Walk the opponent's repertoire against ${me}'s color.** Call \`prepare_opponent\` once with the opponent's FIDE ID (add \`chesscom\` / \`lichess\` sources if you know their handles), filtered to the color they'll have in this game — plus \`start_month\` (last 12-24 months) and \`time_control: "classical"\` if you want the strongest signal. That returns a session \`token\`. Then walk the tree with \`get_prep_position(session_token=token, node_id="r")\`, pick the opponent's most-played reply, call again as you descend. Look for:
|
|
1718
2164
|
- **Weak lines**: variations where the opponent scores below 40% as their side.
|
|
1719
2165
|
- **Shallow lines**: openings the opponent has played only a few times — probably less deeply prepared.
|
|
1720
2166
|
- **Abandoned lines**: openings they used to play but stopped. Something went wrong; may not want to revisit.
|
|
@@ -1756,7 +2202,7 @@ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
|
1756
2202
|
1. \`search_player\` to get their FIDE ID.
|
|
1757
2203
|
2. \`get_player_profile\` — pull rating history, career splits by color and time control, opening repertoire, opponent analysis, top events, notable wins and losses.
|
|
1758
2204
|
3. Weight the data: recent (last 12-24 months) > older, classical OTB > rapid/blitz > online.
|
|
1759
|
-
4. \`
|
|
2205
|
+
4. \`prepare_opponent\` twice (once per colour, or once with two sources), then \`get_prep_position(session_token, node_id="r")\` to summarise their opening choices with actual frequencies and win rates. Filter with \`start_month\` if you only care about their current repertoire.
|
|
1760
2206
|
5. Deliver: current strength, characteristic openings, one-sentence style read, biggest wins, biggest losses / recurring weakness. Cite the numbers.`;
|
|
1761
2207
|
break;
|
|
1762
2208
|
}
|
package/docs/engine-usage.md
CHANGED
|
@@ -70,19 +70,24 @@ Trust Lc0 for:
|
|
|
70
70
|
|
|
71
71
|
Contempt is an Lc0 option that skews its evaluation and move choice toward one side. Passing `contempt` to `cloud_analyse` sets it on the Lc0 leg only; Stockfish always analyzes objectively.
|
|
72
72
|
|
|
73
|
-
**
|
|
73
|
+
**Scale:** signed 0-100 strength — the same value the user's web UI shows on its ContemptStrength slider. The server multiplies by 8 to produce Lc0's internal centipawn bias, so `contempt=25` sends 200 cp, `contempt=50` sends 400 cp, `contempt=100` sends 800 cp. Positive favours White, negative favours Black, zero is objective.
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
-
|
|
75
|
+
**Practical magnitudes:**
|
|
76
|
+
- `±10-20` — a light nudge. Small preference shift, mostly for tie-breaking between candidates Lc0 already sees as near-equal.
|
|
77
|
+
- `±30-60` — real fighting play. Lc0 avoids simplifications, prefers keeping tension, favours the more ambitious side of a choice.
|
|
78
|
+
- `±80-100` — maximum steer. Use when the user needs Lc0 to surface *only* fighting ideas for a must-win, at the cost of ignoring objectively safer choices.
|
|
77
79
|
- **Zero** (default): pure objective Lc0.
|
|
78
80
|
|
|
81
|
+
- **Positive contempt** (e.g. `+30`): Lc0 assumes it's playing *from White's side* against equal or slightly weaker opposition. It picks more ambitious, complicated moves and avoids quick simplifications. The eval reads higher than pure objective. Concrete effect: with White, Lc0 will less often steer into the Exchange Slav or a symmetrical Berlin.
|
|
82
|
+
- **Negative contempt** (e.g. `-30`): Same but from Black's side. Lc0 will play sharper, more provocative lines with Black — more likely to pick a Sveshnikov over a Petroff, or a Grünfeld over a Slav.
|
|
83
|
+
|
|
79
84
|
The eval Lc0 returns when contempt is set is **not** the objective eval — it's Lc0's assessment under the contempt bias. If you also want the objective read, look at the Stockfish leg of the same response.
|
|
80
85
|
|
|
81
86
|
### When to set contempt
|
|
82
87
|
|
|
83
|
-
- **Finding new ideas** — set contempt in the direction you're preparing to see moves the objective engine wouldn't consider "safe enough." Lines Lc0 rejects at contempt=0 but likes at contempt=±
|
|
84
|
-
- **Building a solid repertoire** — small positive contempt for your color if the user wants to take positions seriously (real, competitive games) without gambling on unclear complications.
|
|
85
|
-
- **Playing for a win with the "worse" side** — negative contempt if the user needs to avoid drawing lines with Black in a must-win situation.
|
|
88
|
+
- **Finding new ideas** — set contempt in the direction you're preparing to see moves the objective engine wouldn't consider "safe enough." Lines Lc0 rejects at contempt=0 but likes at contempt=±30 are candidate surprises.
|
|
89
|
+
- **Building a solid repertoire** — small positive contempt (`+10` to `+20`) for your color if the user wants to take positions seriously (real, competitive games) without gambling on unclear complications.
|
|
90
|
+
- **Playing for a win with the "worse" side** — negative contempt (`-30` to `-60`) if the user needs to avoid drawing lines with Black in a must-win situation.
|
|
86
91
|
- **Never with zero direction** — always know *why* you're skewing before you set contempt. It's a specific tool for a specific question, not a default knob.
|
|
87
92
|
|
|
88
93
|
## Tricks worth knowing
|
|
@@ -98,6 +103,31 @@ The gap (1.4 - 0.2 = 1.2) is roughly the value of the tempo Black has to spend d
|
|
|
98
103
|
|
|
99
104
|
**Gotcha:** flipping side-to-move invalidates the en passant target field (if the last move was a two-square pawn push, the ep square is now stale). Also, both sides are counted as still having whichever castling rights are in the FEN — don't do this in the middle of a castling sequence. For opening / middlegame threat-checking it's a very useful idiom.
|
|
100
105
|
|
|
106
|
+
## Deep Stockfish thinks: `deep_analyse`
|
|
107
|
+
|
|
108
|
+
`cloud_analyse` runs both engines with a movetime cap of 10 s — deliberately fast because most opening-tree questions are answered in 2–3 s. For **one specific critical position** where you want depth 35+ instead of the usual depth 22, use `deep_analyse`:
|
|
109
|
+
|
|
110
|
+
- SF-only (Lc0 saturates in a handful of seconds — no benefit past ~5 s of movetime).
|
|
111
|
+
- Movetime up to 5 min.
|
|
112
|
+
- Async: returns a `job_id` immediately, poll `deep_analyse_status(job_id)`, cancel with `deep_analyse_cancel(job_id)` if you decide partial depth is enough.
|
|
113
|
+
- **Holds only the Stockfish slot on the combo.** Lc0 remains callable for other positions via `cloud_analyse({ engines: ["lc0"], … })` while the deep think runs. Use that during the wait — walk other branches, sanity-check candidates.
|
|
114
|
+
|
|
115
|
+
Typical movetimes:
|
|
116
|
+
- `30_000 – 60_000` (30–60 s) — careful check on a candidate move
|
|
117
|
+
- `120_000 – 300_000` (2–5 min) — "find the truth" on a novelty, tactical shot, or difficult endgame
|
|
118
|
+
|
|
119
|
+
Only use it when you actually need the depth. Regular `cloud_analyse` handles the ~5–10 branches of a typical prep walk perfectly well.
|
|
120
|
+
|
|
121
|
+
## Splitting engines on `cloud_analyse`
|
|
122
|
+
|
|
123
|
+
`cloud_analyse` accepts an `engines` list to run only one leg:
|
|
124
|
+
|
|
125
|
+
- `engines: ["lc0"]` — Lc0 only, useful while a `deep_analyse` is holding the Stockfish slot.
|
|
126
|
+
- `engines: ["stockfish"]` — SF only, when only the objective read matters and you want to skip the Lc0 latency.
|
|
127
|
+
- Default (omitted) — both engines. This is what you want for real prep decisions.
|
|
128
|
+
|
|
129
|
+
The skipped engine's field is omitted from the response (not present as an empty object).
|
|
130
|
+
|
|
101
131
|
## Worked example
|
|
102
132
|
|
|
103
133
|
User is preparing Black against a 2600 opponent who plays 1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be3 e5. You want to know if 7.Nb3 or 7.Nf3 is more testing.
|
|
@@ -106,10 +136,10 @@ Two calls:
|
|
|
106
136
|
1. `cloud_analyse(fen=<position after 6...e5>, multipv=2)` — get both engines' top choices with movetime=2000.
|
|
107
137
|
2. If Stockfish scores them equal but Lc0 prefers one by 0.10-0.20, that's your practical answer. The user will find that line harder to face.
|
|
108
138
|
|
|
109
|
-
If the user is specifically preparing to *play* the black side in a must-win, add `contempt=-
|
|
139
|
+
If the user is specifically preparing to *play* the black side in a must-win, add `contempt=-30` (or up to `-60` for a harder steer) on a follow-up call to see which lines Lc0 finds most fighting for Black. Compare against Stockfish's objective read to make sure the fighting choice isn't just losing.
|
|
110
140
|
|
|
111
141
|
## What NOT to do
|
|
112
142
|
|
|
113
|
-
- **Don't quote Lc0's contempt-biased eval as objective.** If you tell the user "Lc0 gives Black +0.30 here" without disclosing you set contempt=-
|
|
143
|
+
- **Don't quote Lc0's contempt-biased eval as objective.** If you tell the user "Lc0 gives Black +0.30 here" without disclosing you set contempt=-30, that's misleading.
|
|
114
144
|
- **Don't run cloud analysis just for casual questions.** `cloud_analyse` costs the user real money per second. If the question is "is 1.e4 or 1.d4 better?", the free `analyse` (single Stockfish, 2s) or `get_position_stats` (11.7M-game database) is enough.
|
|
115
145
|
- **Don't ignore the disagreement.** When Stockfish and Lc0 diverge sharply, that's exactly when you should explain *why* to the user — not paper over it.
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -23,7 +23,7 @@ Building (use these — one call for many ops):
|
|
|
23
23
|
|
|
24
24
|
- **`apply_mutations(id, mutations[])`** — batch. This is the primary build tool. Send 100 mutations in one call → one load-parse-mutate-save cycle instead of 100. When you're writing a repertoire from scratch, EVERYTHING should go through this. Individual mutation tools are for surgical follow-up edits, not for bulk work.
|
|
25
25
|
- **`add_line(id, parent_id, sans[])`** — a whole linear variation in one op. Cleaner than N `add_move` ops for straight lines. Overlapping prefixes with an existing branch **join** (same SAN from the same position IS the same move) — see below.
|
|
26
|
-
- **`auto_evaluate(id, node_id?)`** — walk from a node and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. **Does NOT set visible NAGs** — the numbers land in a hidden `[%ceo-eval]` tag so you can read them back later. NAG placement (`$1` `!`, `$14` `⩲`, `$16` `±`, `$18` `+−`, etc.) is your editorial call. Skip nodes that already have a stored eval by default.
|
|
26
|
+
- **`auto_evaluate(id, node_id?)`** — walk from a node and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every descendant via cloud_analyse. **Async job**: returns `{job_id, target_count, estimated_seconds}` immediately, does the work in the background. Poll with `auto_evaluate_status(job_id)` until `done:true`; cancel with `auto_evaluate_cancel(job_id)` if you change your mind (partial progress is checkpointed every 8 nodes so a cancel never loses more than a handful of evaluations). **Does NOT set visible NAGs** — the numbers land in a hidden `[%ceo-eval]` tag so you can read them back later. NAG placement (`$1` `!`, `$14` `⩲`, `$16` `±`, `$18` `+−`, etc.) is your editorial call. Skip nodes that already have a stored eval by default.
|
|
27
27
|
|
|
28
28
|
Per-op mutation tools (single-op calls, use for edits after the bulk build):
|
|
29
29
|
|
|
@@ -42,8 +42,8 @@ All mutations **auto-save** with optimistic locking. Response includes the new `
|
|
|
42
42
|
|
|
43
43
|
1. `read_prep_file` — see what's there. Every node has an `id` you'll pass to the mutation and engine/DB tools.
|
|
44
44
|
2. `apply_mutations([...])` — one call with your whole intended build (a mix of `add_move` / `add_line` for structure, plus any `set_comment`/`set_annotations` you already know at author time, plus any `set_nags` where you already have a clear judgment — novelty `$146`, `!?` speculative sac, obvious `?` blunder in a sideline you're rejecting).
|
|
45
|
-
3. `auto_evaluate(id)` —
|
|
46
|
-
4.
|
|
45
|
+
3. `auto_evaluate(id)` — spawns a background job that PERSISTS engine numbers on every node. Does not touch visible NAGs. Cheap way to get every position's Stockfish + Lc0 read baked into the file for later reference. Grab the returned `job_id` and either (a) poll `auto_evaluate_status(job_id)` every ~10-30s until done, or (b) fire and do useful work meanwhile (write more of the tree, walk the opponent's repertoire) and check back later — engine walk-time serialises on the per-combo semaphore, so it takes roughly `target_count × movetime_ms` in wall time.
|
|
46
|
+
4. Once the job reports `done:true`, re-read the file and add NAGs where they carry real signal (see NAG discipline below). Use individual mutation tools for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
|
|
47
47
|
|
|
48
48
|
The build-cost math: a 200-move file via individual `add_move` calls is 200 saves ≈ 100+ seconds of tool overhead. The same file via one `apply_mutations` call is one save ≈ 500ms. Use batch by default.
|
|
49
49
|
|
|
@@ -194,7 +194,7 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
|
|
|
194
194
|
| `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
|
|
195
195
|
| Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
|
|
196
196
|
|
|
197
|
-
**NAGs are editorial, not automatic.** `auto_evaluate` persists engine numbers on every node but does NOT set visible NAGs — because a repertoire full of 0.00 opening theory does not want a `$10` (=) glyph plastered on every move. That's noise, not signal. NAGs are your call: mark moves that carry a judgment a reader can't derive by looking at the board.
|
|
197
|
+
**NAGs are editorial, not automatic.** `auto_evaluate` (and its `auto_evaluate_status` poll) persists engine numbers on every node but does NOT set visible NAGs — because a repertoire full of 0.00 opening theory does not want a `$10` (=) glyph plastered on every move. That's noise, not signal. NAGs are your call: mark moves that carry a judgment a reader can't derive by looking at the board.
|
|
198
198
|
|
|
199
199
|
Where NAGs actually earn their place:
|
|
200
200
|
|
|
@@ -210,7 +210,7 @@ Where NAGs are noise: every `$10` "=" glyph on every equal position. If the whol
|
|
|
210
210
|
|
|
211
211
|
When reading back a file, `node.ceoEval.nag` carries the threshold-derived NAG (`$10` / `$14` / `$16` / `$18` etc.) — treat it as a *suggestion*, not an automatic write. Promote it to a visible NAG only when a glyph on that move actually helps the reader.
|
|
212
212
|
|
|
213
|
-
**Persisted evals travel with the file.** Every node with an eval gets `ceoEval: { sf: {cp: 25, depth: 32}, lc0: {cp: 30, depth: 18}, nag: "$14" }` on subsequent `read_prep_file` calls. Stored as `[%ceo-eval sf=+0.25/32 lc0=+0.30/18 nag=$14]` inside the PGN comment (an escape tag the app hides from the board view, just like [%cal] / [%csl]). So a re-read after auto_evaluate gives you every position's number without re-running cloud_analyse. Query tools (get_position_stats,
|
|
213
|
+
**Persisted evals travel with the file.** Every node with an eval gets `ceoEval: { sf: {cp: 25, depth: 32}, lc0: {cp: 30, depth: 18}, nag: "$14" }` on subsequent `read_prep_file` calls. Stored as `[%ceo-eval sf=+0.25/32 lc0=+0.30/18 nag=$14]` inside the PGN comment (an escape tag the app hides from the board view, just like [%cal] / [%csl]). So a re-read after auto_evaluate gives you every position's number without re-running cloud_analyse. Query tools (get_position_stats, get_prep_position, prep_snapshot) also auto-attach a live `eval` at the request position when a cloud engine is running.
|
|
214
214
|
|
|
215
215
|
**Engine numbers in prose: brief only.** If you must reference a number, keep it compact:
|
|
216
216
|
|
|
@@ -221,12 +221,14 @@ When reading back a file, `node.ceoEval.nag` carries the threshold-derived NAG (
|
|
|
221
221
|
|
|
222
222
|
**Only quote engine numbers for positions you actually called `cloud_analyse` on** (or that carry a `ceoEval` from an earlier auto_evaluate, or an auto-attached `.eval` from a query tool). Do NOT infer an eval for a parent position from its children and then write "both engines 0.00" on the parent — that reads as a measurement but is a guess. If the position wasn't analysed, either analyse it or omit the number: prose can say "both continuations run to 0.00, so this looks balanced" without claiming an eval that wasn't taken.
|
|
223
223
|
|
|
224
|
-
**When you set `contempt`, attribute it to Lc0 only.** Contempt affects Lc0 exclusively — Stockfish always analyses objectively. So don't write "both engines at contempt −
|
|
224
|
+
**When you set `contempt`, attribute it to Lc0 only.** Contempt affects Lc0 exclusively — Stockfish always analyses objectively. So don't write "both engines at contempt −30" — that misleads the reader into thinking SF was biased too. Correct phrasings:
|
|
225
225
|
|
|
226
|
-
- `{Lc0 (contempt −
|
|
227
|
-
- `{With Lc0 nudged toward Black (contempt −
|
|
226
|
+
- `{Lc0 (contempt −30): 0.00 — even biased toward Black, still balanced. SF objective: 0.00.}`
|
|
227
|
+
- `{With Lc0 nudged toward Black (contempt −30) it still picks Qg6 at 0.00; SF's objective read also 0.00.}`
|
|
228
228
|
|
|
229
|
-
If you only quote the Lc0 number, disclose the bias in the same sentence — `{Lc0 gives Black +0.30}` with an undisclosed `contempt=-
|
|
229
|
+
If you only quote the Lc0 number, disclose the bias in the same sentence — `{Lc0 gives Black +0.30}` with an undisclosed `contempt=-30` is a false objectivity claim.
|
|
230
|
+
|
|
231
|
+
Contempt scale is signed 0-100 (same as the web UI's ContemptStrength slider). Typical: `±10-20` a light nudge, `±30-60` real fighting play, `±80-100` maximum steer.
|
|
230
232
|
|
|
231
233
|
### Before you write any commentary: describe_position
|
|
232
234
|
|
package/docs/prep-strategy.md
CHANGED
|
@@ -4,20 +4,20 @@ Opening prep is not a monologue with the position-stats and player-prep endpoint
|
|
|
4
4
|
|
|
5
5
|
## Grounding: cite tools, don't fill gaps with chess prose
|
|
6
6
|
|
|
7
|
-
Every recommendation you make must trace back to an actual tool call in this session — engine output from `cloud_analyse`
|
|
7
|
+
Every recommendation you make must trace back to an actual tool call in this session — engine output from `cloud_analyse` for evaluations and lines, `prepare_opponent` + `get_prep_position` for opponent statistics, `get_position_stats` for the general database, `get_player_profile` / `get_head_to_head` for style and history. **Compute is cheap.** When you don't have the data to justify a claim, run the tool — do not paper over the gap with generic chess wisdom you didn't verify.
|
|
8
8
|
|
|
9
9
|
**Concrete failure modes to catch yourself doing:**
|
|
10
10
|
|
|
11
11
|
- "This is a good line because Black gets the two bishops." → Did Lc0 or Stockfish score it that way? If not, drop the claim.
|
|
12
|
-
- "Your opponent hates isolated queen pawn positions." → Did you actually look at their IQP games
|
|
12
|
+
- "Your opponent hates isolated queen pawn positions." → Did you actually look at their IQP games via a `prepare_opponent` session? Or is this a training-data pattern?
|
|
13
13
|
- "The Petroff is a solid choice here." → Did you check the opponent's score as White vs the Petroff, or against Black openings in general? If not, drop it.
|
|
14
14
|
- "Aim for a Catalan setup, they historically struggle there." → Point at concrete games where they lost in Catalan structures. If you can't, don't say it.
|
|
15
|
-
- Inventing move sequences that "look like typical prep" without walking the actual tree via `
|
|
15
|
+
- Inventing move sequences that "look like typical prep" without walking the actual tree via `get_prep_position` / `prep_snapshot`.
|
|
16
16
|
- Asserting an opponent's style ("sharp tactician", "endgame grinder") without pointing at their profile data or specific games.
|
|
17
17
|
|
|
18
18
|
The failure mode is *authoritative-sounding recipe = 30% real tool output + 70% chess-book filler*. The user cannot tell which is which; from their side it all looks like analysis. That's worse than saying "I don't have data on this yet — should I run X?"
|
|
19
19
|
|
|
20
|
-
**Do this instead:** cite the specific numbers ("opponent scores 32% as White vs the Sveshnikov over 47 games, 2023-2025"), the tool that produced them ("via `
|
|
20
|
+
**Do this instead:** cite the specific numbers ("opponent scores 32% as White vs the Sveshnikov over 47 games, 2023-2025"), the tool that produced them ("via a `prepare_opponent` session filtered to classical since 2024"), and reason from there. If the reasoning wants to extend beyond what the data supports, either run more tools or flag it as your read, not the data's.
|
|
21
21
|
|
|
22
22
|
## Numbers are inputs, not verdicts
|
|
23
23
|
|
|
@@ -27,6 +27,58 @@ The move statistics endpoints return win %, game counts, and (in the big DB) a `
|
|
|
27
27
|
- **Score doesn't automatically indicate a level gap.** A 60% variation isn't necessarily "stronger players crushing weaker ones." Look at the per-move `avgWhite` / `avgBlack` fields (returned on every move statistic) before drawing conclusions about who is playing whom.
|
|
28
28
|
- **Don't recommend the higher-percentage move just because it's higher.** If 1.b3 scores 60% and 1.d4 scores 50% against a given opponent, that is *not* on its own a case for playing 1.b3 — style, prep depth, transposition risk, and the practical questions below all matter more.
|
|
29
29
|
|
|
30
|
+
## Which DB source: signal vs population
|
|
31
|
+
|
|
32
|
+
Two shards on `get_position_stats`, answering different questions:
|
|
33
|
+
|
|
34
|
+
- **`gm-classical`** (default): pre-aggregated GM classical games (both players ~2500+, real thinking time). Every move is signal, no noise. Sample sizes are smaller. Answers: *what do good players actually play here, and how well does each option score at that level?*
|
|
35
|
+
- **`main`**: the full 11.7M-game database. Wider coverage, cheaper positions included (blitz, weak opponents, blunder-fests). Noisier. Answers: *has this position/move been reached at all, and by whom?*
|
|
36
|
+
|
|
37
|
+
They're not either-or. Reasonable to hit both on the same position for different questions:
|
|
38
|
+
|
|
39
|
+
- **What GMs play here** → `gm-classical`. Even a small count (5-10 games at avgRating 2600) is often the honest answer for a specific tabiya — that's just how many GMs have gone there. Don't dismiss small numbers if the rating context supports them.
|
|
40
|
+
- **Does anyone play this at all** → `main`. Especially useful for a move you're considering that hasn't appeared in `gm-classical`. Zero in the GM DB doesn't automatically mean bad — it might just mean unfashionable. Check `main`: if it has some volume at reasonable avgRating (~2200+), the move has real support even without top-level play; if it's only 1400-rated games or bots, it doesn't.
|
|
41
|
+
- **Sanity-checking a candidate** → both. If a move you're considering shows 3 games in `gm-classical` but 800 in `main` at avgRating 2100, that's a coherent picture (unfashionable at the very top but played by strong players); if `main` shows the same 3 games and nothing else, the move is genuinely rare and you're pioneering.
|
|
42
|
+
|
|
43
|
+
When you do read a `main` result, always eyeball `avgRating` per move before quoting the win%. A 60% score across 200 games at avgRating 1400 is not a recommendation — it's a trap that catches beginners. The same 60% across 200 games at avgRating 2400 is a real finding.
|
|
44
|
+
|
|
45
|
+
Cross-reference tip: if `main` shows a specific move dominating that `gm-classical` doesn't touch, look at *who* — correspondence engines, blitz-only players, and bot accounts skew the `main` totals. A move with 5000 games in `main` at avgRating 1900 and zero in `gm-classical` is probably not something to recommend as GM prep.
|
|
46
|
+
|
|
47
|
+
## Which prep source: match data density to what you're preparing
|
|
48
|
+
|
|
49
|
+
`prepare_opponent` accepts fide, chesscom, lichess sources — each with per-source colour, date-range, time-control filters. Which sources to combine depends on how much data the opponent actually has and what specific question you're answering:
|
|
50
|
+
|
|
51
|
+
- **Well-known player, well-known question** ("what does Firouzja play against 1.e4?"): FIDE, `time_control: "classical"`, `start_month` = 12–24 months ago. Cleanest signal, big enough sample. Don't dilute with online.
|
|
52
|
+
- **Well-known player, sideline question** ("what would he play in this obscure 20-ply position?"): FIDE all-time-controls first — classical is unlikely to have games in a rare position. If still nothing, add rapid; add online only if you must.
|
|
53
|
+
- **Junior / sub-2400 / comeback player**: FIDE classical alone might give you 5 games. Add FIDE rapid + chesscom + lichess (all filtered to the last 12 months). Signal density improves, but recognize online games are a mixed signal — see next section.
|
|
54
|
+
- **Multiple accounts / handles**: pass them all as sources in one call — `[{type:"fide",fide_id:X}, {type:"chesscom",username:"..."}, {type:"lichess",username:"..."}]`. Same session, more games, no rebuild, all combined into one filtered corpus.
|
|
55
|
+
|
|
56
|
+
The trade-off is always the same: signal quality vs data density. When density is high, prefer quality; when density is low, take what you can get and lower your confidence in the read explicitly ("only 12 games at this branch — the 66% is noisier than the 55% we saw at move 2").
|
|
57
|
+
|
|
58
|
+
## Reading a chess.com / lichess profile: three shapes
|
|
59
|
+
|
|
60
|
+
Online games are a *mixed* signal. Before treating chesscom/lichess data the same weight as FIDE, spot which of these three profiles the opponent fits — misclassifying is worse than not using online data at all.
|
|
61
|
+
|
|
62
|
+
1. **Consistent** — plays roughly the same repertoire online and OTB. Gold. Suddenly you have thousands of extra games to see reactions against sidelines and to see where he makes mistakes in the early middlegame under time pressure. Weight online data almost the same as classical FIDE.
|
|
63
|
+
|
|
64
|
+
2. **Eclectic** — online they play everything under the sun; OTB they have a real narrow repertoire. Still usable, but for a *different* question. Online tells you: (a) which openings he has *seen* and roughly *knows*, (b) which moves he *actually recalls under pressure*, (c) where he makes early mistakes. Online does NOT reliably tell you what he'll play in the game.
|
|
65
|
+
|
|
66
|
+
3. **Split personality** — online repertoire genuinely differs from OTB. Classic example: a 1.e4 Ruy Lopez player over the board who plays only the King's Gambit on lichess blitz because it's fun. Online is the playground; OTB is serious. **Recognize this and discount the online games entirely** for opening-choice prediction — he's not playing his lichess repertoire in a rated classical game. Online may still surface middlegame tendencies, but "what will he play" belongs to FIDE only.
|
|
67
|
+
|
|
68
|
+
How to distinguish, quickly: compare the top openings from `get_player_profile` (FIDE-based) against the moves he plays from the starting position in an online-only `prepare_opponent` session. High overlap → shape 1. Modest overlap with FIDE openings as a subset of online → shape 2. Different openings entirely → shape 3.
|
|
69
|
+
|
|
70
|
+
Write the profile-shape into the prep file's overview comment as soon as you decide it — future re-reads (and the user) benefit from knowing you already made the call.
|
|
71
|
+
|
|
72
|
+
## Reversed colours as a scarcity trick
|
|
73
|
+
|
|
74
|
+
For a rare position where the opponent has 0–2 games as their side, it's often worth also checking what they've done in the same position from the OTHER colour. This doesn't tell you what they'd play — it tells you what they've SEEN. Useful when you have no direct data:
|
|
75
|
+
|
|
76
|
+
- **Surprise calculus**: "he's had this position from the White side against people playing it against him — so seeing it from the Black side isn't a real surprise, he knows the ideas."
|
|
77
|
+
- **Structural familiarity**: "he's played this pawn structure before, just from the other colour — his over-the-board understanding will carry over."
|
|
78
|
+
- **Cuts both ways**: if the opponent has played it a lot with the OTHER colour AND had good results, assume he knows both sides thoroughly.
|
|
79
|
+
|
|
80
|
+
Do this by dropping the `color` filter on `prepare_opponent`, or making a second session with the opposite colour — then compare game counts. When density is scarce, information from the "wrong" colour is worth more than no information at all. Just be honest about what it is: knowledge, not prediction.
|
|
81
|
+
|
|
30
82
|
## Prep is symmetric — both sides know the same things
|
|
31
83
|
|
|
32
84
|
The single biggest LLM error in prep is treating it like writing a book: "here's what you should play against this opponent's weakness." A real chess opponent:
|
|
@@ -41,7 +93,7 @@ Every recommendation should be filtered through: "does this survive the fact tha
|
|
|
41
93
|
|
|
42
94
|
The opponent's last 12-24 months matter far more than a 10-year career average. Repertoires evolve — a lifelong Najdorf player might have quietly become a Petroff player last year, and their old career stats will lie to you if you skim.
|
|
43
95
|
|
|
44
|
-
**Related product note
|
|
96
|
+
**Related product note:** `get_prep_position` (from a `prepare_opponent` session) deliberately strips the per-move `fashionScore` field. At the individual-player level, fashion is trailing noise — the opponent's opening trend is already captured in game dates. `fashionScore` stays on the general-DB endpoint (`get_position_stats`), where it's genuinely useful: it's what the whole top field is playing this month.
|
|
45
97
|
|
|
46
98
|
## Prep is a tree, not a line
|
|
47
99
|
|
package/package.json
CHANGED